messages implemented

This commit is contained in:
2026-02-26 21:12:32 +01:00
parent d0aefc5ddc
commit 15b7b77d20
168 changed files with 14728 additions and 6786 deletions

View File

@@ -69,15 +69,21 @@ class ArtworkAwardService
/**
* Remove an award for a user/artwork pair.
* Uses model-level delete so the ArtworkAwardObserver fires.
*/
public function removeAward(Artwork $artwork, User $user): void
{
ArtworkAward::where('artwork_id', $artwork->id)
$award = ArtworkAward::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->delete();
->first();
$this->recalcStats($artwork->id);
$this->syncToSearch($artwork);
if ($award) {
$award->delete(); // fires ArtworkAwardObserver::deleted
} else {
// Nothing to remove, but still sync stats to be safe.
$this->recalcStats($artwork->id);
$this->syncToSearch($artwork);
}
}
/**

View File

@@ -172,6 +172,73 @@ final class ArtworkSearchService
});
}
// ── Discover section helpers ───────────────────────────────────────────────
/**
* Trending: most viewed artworks, weighted toward recent uploads.
* Uses views:desc + recency via created_at:desc as tiebreaker.
*/
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.trending.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['views:desc', 'created_at:desc'],
])
->paginate($perPage);
});
}
/**
* Fresh: newest uploads first.
*/
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.fresh.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['created_at:desc'],
])
->paginate($perPage);
});
}
/**
* Top rated: highest number of favourites/likes.
*/
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.top-rated.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['likes:desc', 'views:desc'],
])
->paginate($perPage);
});
}
/**
* Most downloaded: highest download count.
*/
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.most-downloaded.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['downloads:desc', 'views:desc'],
])
->paginate($perPage);
});
}
// -------------------------------------------------------------------------
private function parseSort(string $sort): array

View File

@@ -1,9 +1,13 @@
<?php
namespace App\Services;
use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Throwable;
/**
* ArtworkStatsService
*
@@ -14,147 +18,200 @@ use Illuminate\Support\Facades\Redis;
*/
class ArtworkStatsService
{
protected string $redisKey = 'artwork_stats:deltas';
protected string $redisKey = 'artwork_stats:deltas';
/**
* Increment views for an artwork.
* Set $defer=true to push to Redis for async processing when available.
*/
/**
* Increment views for an artwork.
* Set $defer=true to push to Redis for async processing when available.
*/
public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'views', $by);
return;
$this->applyDelta($artworkId, ['views' => $by]);
}
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'views', $by);
return;
}
$this->applyDelta($artworkId, ['views' => $by]);
}
/**
* Increment downloads for an artwork.
*/
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'downloads', $by);
return;
/**
* Increment downloads for an artwork.
*/
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'downloads', $by);
return;
}
$this->applyDelta($artworkId, ['downloads' => $by]);
}
/**
* Increment views using an Artwork model. Preferred API-first signature.
*/
public function incrementViewsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementViews((int) $artwork->id, $by, $defer);
}
}
$this->applyDelta($artworkId, ['downloads' => $by]);
}
/**
* Increment views using an Artwork model.
*/
public function incrementViewsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementViews((int) $artwork->id, $by, $defer);
}
/**
* Apply a set of deltas to the artwork_stats row inside a transaction.
* This method is safe to call from jobs or synchronously.
*
* @param int $artworkId
* @param array<string,int> $deltas
*/
/**
* Increment downloads using an Artwork model.
*/
public function incrementDownloadsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementDownloads((int) $artwork->id, $by, $defer);
}
/**
* Apply a set of deltas to the artwork_stats row inside a transaction.
* After updating artwork-level stats, forwards view/download counts to
* UserStatsService so creator-level counters stay current.
*
* @param int $artworkId
* @param array<string,int> $deltas
*/
public function applyDelta(int $artworkId, array $deltas): void
{
try {
DB::transaction(function () use ($artworkId, $deltas) {
// Ensure a stats row exists. Insert default zeros if missing.
DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artworkId,
try {
DB::transaction(function () use ($artworkId, $deltas) {
// Ensure a stats row exists — insert default zeros if missing.
DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artworkId,
'views' => 0,
'downloads' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
]);
/**
* Increment downloads using an Artwork model. Preferred API-first signature.
*/
public function incrementDownloadsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementDownloads((int) $artwork->id, $by, $defer);
}
'views' => 0,
'downloads' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
]);
foreach ($deltas as $column => $value) {
// Only allow known columns to avoid SQL injection.
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
continue;
}
foreach ($deltas as $column => $value) {
// Only allow known columns to avoid SQL injection
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
continue;
}
DB::table('artwork_stats')
->where('artwork_id', $artworkId)
->increment($column, (int) $value);
}
});
DB::table('artwork_stats')
->where('artwork_id', $artworkId)
->increment($column, (int) $value);
}
});
} catch (Throwable $e) {
Log::error('Failed to apply artwork stats delta', ['artwork_id' => $artworkId, 'deltas' => $deltas, 'error' => $e->getMessage()]);
}
}
// Forward creator-level counters outside the transaction.
$this->forwardCreatorStats($artworkId, $deltas);
} catch (Throwable $e) {
Log::error('Failed to apply artwork stats delta', [
'artwork_id' => $artworkId,
'deltas' => $deltas,
'error' => $e->getMessage(),
]);
}
}
/**
* Push a delta to Redis queue for async processing.
*/
protected function pushDelta(int $artworkId, string $field, int $value): void
{
$payload = json_encode([
'artwork_id' => $artworkId,
'field' => $field,
'value' => $value,
'ts' => time(),
]);
/**
* After applying artwork-level deltas, forward relevant totals to the
* creator's user_statistics row via UserStatsService.
* Views skip Meilisearch reindex (high frequency covered by recompute).
*
* @param int $artworkId
* @param array<string,int> $deltas
*/
protected function forwardCreatorStats(int $artworkId, array $deltas): void
{
$viewDelta = (int) ($deltas['views'] ?? 0);
$downloadDelta = (int) ($deltas['downloads'] ?? 0);
try {
Redis::rpush($this->redisKey, $payload);
} catch (Throwable $e) {
// If Redis is unavailable, fallback to immediate apply to avoid data loss
Log::warning('Redis unavailable for artwork stats; applying immediately', ['error' => $e->getMessage()]);
$this->applyDelta($artworkId, [$field => $value]);
}
}
if ($viewDelta <= 0 && $downloadDelta <= 0) {
return;
}
/**
* Drain and apply queued deltas from Redis. Returns number processed.
* Designed to be invoked by a queued job or artisan command.
*/
try {
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if (! $creatorId) {
return;
}
/** @var UserStatsService $svc */
$svc = app(UserStatsService::class);
if ($viewDelta > 0) {
// High-frequency: increment counter but skip Meilisearch reindex.
$svc->incrementArtworkViewsReceived($creatorId, $viewDelta);
}
if ($downloadDelta > 0) {
$svc->incrementDownloadsReceived($creatorId, $downloadDelta);
}
} catch (Throwable $e) {
Log::warning('Failed to forward creator stats from artwork delta', [
'artwork_id' => $artworkId,
'error' => $e->getMessage(),
]);
}
}
/**
* Push a delta to Redis queue for async processing.
*/
protected function pushDelta(int $artworkId, string $field, int $value): void
{
$payload = json_encode([
'artwork_id' => $artworkId,
'field' => $field,
'value' => $value,
'ts' => time(),
]);
try {
Redis::rpush($this->redisKey, $payload);
} catch (Throwable $e) {
// If Redis is unavailable, fall back to immediate apply to avoid data loss.
Log::warning('Redis unavailable for artwork stats; applying immediately', [
'error' => $e->getMessage(),
]);
$this->applyDelta($artworkId, [$field => $value]);
}
}
/**
* Drain and apply queued deltas from Redis. Returns number processed.
* Designed to be invoked by a queued job or artisan command.
*/
public function processPendingFromRedis(int $max = 1000): int
{
if (! $this->redisAvailable()) {
return 0;
}
$processed = 0;
if (! $this->redisAvailable()) {
return 0;
}
try {
while ($processed < $max) {
$item = Redis::lpop($this->redisKey);
if (! $item) {
break;
}
$processed = 0;
$decoded = json_decode($item, true);
if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) {
continue;
$this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]);
$processed++;
}
} catch (Throwable $e) {
Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]);
}
try {
while ($processed < $max) {
$item = Redis::lpop($this->redisKey);
if (! $item) {
break;
}
return $processed;
}
$decoded = json_decode($item, true);
if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) {
continue;
}
protected function redisAvailable(): bool
{
try {
// Redis facade may throw if not configured
$pong = Redis::connection()->ping();
return (bool) $pong;
} catch (Throwable $e) {
return false;
}
}
$this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]);
$processed++;
}
} catch (Throwable $e) {
Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]);
}
return $processed;
}
protected function redisAvailable(): bool
{
try {
$pong = Redis::connection()->ping();
return (bool) $pong;
} catch (Throwable $e) {
return false;
}
}
}

View File

@@ -0,0 +1,323 @@
<?php
namespace App\Services;
use App\Services\LegacySmileyMapper;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\MarkdownConverter;
/**
* Sanitizes and renders user-submitted content.
*
* Pipeline:
* 1. Strip any raw HTML tags from input (we don't allow HTML)
* 2. Convert legacy <br> / <b> / <i> hints from really old legacy content
* 3. Parse subset of Markdown (bold, italic, code, links, line breaks)
* 4. Sanitize the rendered HTML: whitelist-only tags, strip attributes
* 5. Return safe HTML ready for storage or display
*/
class ContentSanitizer
{
/** Maximum number of emoji allowed before triggering a flood error. */
public const EMOJI_COUNT_MAX = 50;
/**
* Maximum ratio of emoji-to-total-characters before content is considered
* an emoji flood (applies only when emoji count > 5 to avoid false positives
* on very short strings like a single reaction comment).
*/
public const EMOJI_DENSITY_MAX = 0.40;
// HTML tags we allow in the final rendered output
private const ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'code', 'pre',
'a', 'ul', 'ol', 'li', 'blockquote', 'del',
];
// Allowed attributes per tag
private const ALLOWED_ATTRS = [
'a' => ['href', 'title', 'rel', 'target'],
];
private static ?MarkdownConverter $converter = null;
// ─────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert raw user input (legacy or new) to sanitized HTML.
*
* @param string|null $raw
* @return string Safe HTML
*/
public static function render(?string $raw): string
{
if ($raw === null || trim($raw) === '') {
return '';
}
// 1. Convert legacy HTML fragments to Markdown-friendly text
$text = static::legacyHtmlToMarkdown($raw);
// 2. Parse Markdown → HTML
$html = static::parseMarkdown($text);
// 3. Sanitize HTML (strip disallowed tags / attrs)
$html = static::sanitizeHtml($html);
return $html;
}
/**
* Strip ALL HTML from input, returning plain text with newlines preserved.
*/
public static function stripToPlain(?string $html): string
{
if ($html === null) {
return '';
}
// Convert <br> and <p> to line breaks before stripping
$text = preg_replace(['/<br\s*\/?>/i', '/<\/p>/i'], "\n", $html);
$text = strip_tags($text ?? '');
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim($text);
}
/**
* Validate that a Markdown-lite string does not contain disallowed patterns.
* Returns an array of validation errors (empty = OK).
*/
public static function validate(string $raw): array
{
$errors = [];
if (mb_strlen($raw) > 10_000) {
$errors[] = 'Content exceeds maximum length of 10,000 characters.';
}
// Detect raw HTML tags (we forbid them)
if (preg_match('/<[a-z][^>]*>/i', $raw)) {
$errors[] = 'HTML tags are not allowed. Use Markdown formatting instead.';
}
// Count emoji to prevent absolute spam
$emojiCount = static::countEmoji($raw);
if ($emojiCount > self::EMOJI_COUNT_MAX) {
$errors[] = 'Too many emoji. Please limit emoji usage.';
}
// Reject emoji-flood content: density guard catches e.g. 15 emoji in a
// 20-char string even when the absolute count is below EMOJI_COUNT_MAX.
if ($emojiCount > 5) {
$totalChars = mb_strlen($raw);
if ($totalChars > 0 && ($emojiCount / $totalChars) > self::EMOJI_DENSITY_MAX) {
$errors[] = 'Content is mostly emoji. Please add some text.';
}
}
return $errors;
}
/**
* Collapse consecutive runs of the same emoji in $text.
*
* Delegates to LegacySmileyMapper::collapseFlood() so the behaviour is
* consistent between new submissions and migrated legacy content.
*
* Example: "🍺 🍺 🍺 🍺 🍺 🍺 🍺" (7×) "🍺 🍺 🍺 🍺 🍺 ×7"
*
* @param int $maxRun Keep at most this many consecutive identical emoji.
*/
public static function collapseFlood(string $text, int $maxRun = 5): string
{
return LegacySmileyMapper::collapseFlood($text, $maxRun);
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert legacy HTML-style formatting to Markdown equivalents.
* This runs BEFORE Markdown parsing to handle old content gracefully.
*/
private static function legacyHtmlToMarkdown(string $html): string
{
$replacements = [
// Bold
'/<b>(.*?)<\/b>/is' => '**$1**',
'/<strong>(.*?)<\/strong>/is' => '**$1**',
// Italic
'/<i>(.*?)<\/i>/is' => '*$1*',
'/<em>(.*?)<\/em>/is' => '*$1*',
// Line breaks → actual newlines
'/<br\s*\/?>/i' => "\n",
// Paragraphs
'/<p>(.*?)<\/p>/is' => "$1\n\n",
// Strip remaining tags
'/<[^>]+>/' => '',
];
$result = $html;
foreach ($replacements as $pattern => $replacement) {
$result = preg_replace($pattern, $replacement, $result) ?? $result;
}
// Decode HTML entities (e.g. &amp; → &)
$result = html_entity_decode($result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $result;
}
/**
* Parse Markdown-lite subset to HTML.
*/
private static function parseMarkdown(string $text): string
{
$converter = static::getConverter();
$result = $converter->convert($text);
return (string) $result->getContent();
}
/**
* Whitelist-based HTML sanitizer.
* Removes all tags not in ALLOWED_TAGS, and strips disallowed attributes.
*/
private static function sanitizeHtml(string $html): string
{
// Parse with DOMDocument
$doc = new \DOMDocument('1.0', 'UTF-8');
// Suppress warnings from malformed fragments
libxml_use_internal_errors(true);
$doc->loadHTML(
'<html><body>' . $html . '</body></html>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
static::cleanNode($doc->getElementsByTagName('body')->item(0));
// Serialize back, removing the wrapping html/body
$body = $doc->getElementsByTagName('body')->item(0);
$inner = '';
foreach ($body->childNodes as $child) {
$inner .= $doc->saveHTML($child);
}
// Fix self-closing <a></a> etc.
return trim($inner);
}
/**
* Recursively clean a DOMNode strip forbidden tags/attributes.
*/
private static function cleanNode(\DOMNode $node): void
{
$toRemove = [];
$toUnwrap = [];
foreach ($node->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$tag = strtolower($child->nodeName);
if (! in_array($tag, self::ALLOWED_TAGS, true)) {
// Replace element with its text content
$toUnwrap[] = $child;
} else {
// Strip disallowed attributes
$allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? [];
$attrsToRemove = [];
foreach ($child->attributes as $attr) {
if (! in_array($attr->nodeName, $allowedAttrs, true)) {
$attrsToRemove[] = $attr->nodeName;
}
}
foreach ($attrsToRemove as $attrName) {
$child->removeAttribute($attrName);
}
// Force external links to be safe
if ($tag === 'a') {
$href = $child->getAttribute('href');
if ($href && ! static::isSafeUrl($href)) {
$toUnwrap[] = $child;
continue;
}
$child->setAttribute('rel', 'noopener noreferrer nofollow');
$child->setAttribute('target', '_blank');
}
// Recurse
static::cleanNode($child);
}
}
}
// Unwrap forbidden elements (replace with their children)
foreach ($toUnwrap as $el) {
while ($el->firstChild) {
$node->insertBefore($el->firstChild, $el);
}
$node->removeChild($el);
}
}
/**
* Very conservative URL whitelist.
*/
private static function isSafeUrl(string $url): bool
{
$lower = strtolower(trim($url));
// Allow relative paths and anchors
if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
return true;
}
// Only allow http(s)
return str_starts_with($lower, 'http://') || str_starts_with($lower, 'https://');
}
/**
* Count Unicode emoji in a string (basic heuristic).
*/
private static function countEmoji(string $text): int
{
// Match common emoji ranges
preg_match_all(
'/[\x{1F300}-\x{1FAD6}\x{2600}-\x{27BF}\x{FE00}-\x{FEFF}]/u',
$text,
$matches
);
return count($matches[0]);
}
/**
* Lazy-load and cache the Markdown converter.
*/
private static function getConverter(): MarkdownConverter
{
if (static::$converter === null) {
$env = new Environment([
'html_input' => 'strip',
'allow_unsafe_links' => false,
'max_nesting_level' => 10,
]);
$env->addExtension(new CommonMarkCoreExtension());
$env->addExtension(new AutolinkExtension());
$env->addExtension(new StrikethroughExtension());
static::$converter = new MarkdownConverter($env);
}
return static::$converter;
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
/**
* FollowService
*
* Manages follow / unfollow operations on the user_followers table.
* Convention:
* follower_id = the user doing the following
* user_id = the user being followed
*
* Counters in user_statistics are kept in sync atomically inside a transaction.
*/
final class FollowService
{
/**
* Follow $targetId on behalf of $actorId.
*
* @return bool true if a new follow was created, false if already following
*
* @throws \InvalidArgumentException if self-follow attempted
*/
public function follow(int $actorId, int $targetId): bool
{
if ($actorId === $targetId) {
throw new \InvalidArgumentException('Cannot follow yourself.');
}
$inserted = false;
DB::transaction(function () use ($actorId, $targetId, &$inserted) {
$rows = DB::table('user_followers')->insertOrIgnore([
'user_id' => $targetId,
'follower_id' => $actorId,
'created_at' => now(),
]);
if ($rows === 0) {
// Already following nothing to do
return;
}
$inserted = true;
// Increment following_count for actor, followers_count for target
$this->incrementCounter($actorId, 'following_count');
$this->incrementCounter($targetId, 'followers_count');
});
return $inserted;
}
/**
* Unfollow $targetId on behalf of $actorId.
*
* @return bool true if a follow row was removed, false if wasn't following
*/
public function unfollow(int $actorId, int $targetId): bool
{
if ($actorId === $targetId) {
return false;
}
$deleted = false;
DB::transaction(function () use ($actorId, $targetId, &$deleted) {
$rows = DB::table('user_followers')
->where('user_id', $targetId)
->where('follower_id', $actorId)
->delete();
if ($rows === 0) {
return;
}
$deleted = true;
$this->decrementCounter($actorId, 'following_count');
$this->decrementCounter($targetId, 'followers_count');
});
return $deleted;
}
/**
* Toggle follow state. Returns the new following state.
*/
public function toggle(int $actorId, int $targetId): bool
{
if ($this->isFollowing($actorId, $targetId)) {
$this->unfollow($actorId, $targetId);
return false;
}
$this->follow($actorId, $targetId);
return true;
}
public function isFollowing(int $actorId, int $targetId): bool
{
return DB::table('user_followers')
->where('user_id', $targetId)
->where('follower_id', $actorId)
->exists();
}
/**
* Current followers_count for a user (from cached column, not live count).
*/
public function followersCount(int $userId): int
{
return (int) DB::table('user_statistics')
->where('user_id', $userId)
->value('followers_count');
}
// ─── Private helpers ─────────────────────────────────────────────────────
private function incrementCounter(int $userId, string $column): void
{
DB::table('user_statistics')->updateOrInsert(
['user_id' => $userId],
[
$column => DB::raw("COALESCE({$column}, 0) + 1"),
'updated_at' => now(),
'created_at' => now(), // ignored on update
]
);
}
private function decrementCounter(int $userId, string $column): void
{
DB::table('user_statistics')
->where('user_id', $userId)
->where($column, '>', 0)
->update([
$column => DB::raw("{$column} - 1"),
'updated_at' => now(),
]);
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Services;
/**
* Centralized mapping from legacy GIF smiley codes to Unicode emoji.
*
* Usage:
* $result = LegacySmileyMapper::convert($text);
* $map = LegacySmileyMapper::getMap();
*/
class LegacySmileyMapper
{
/**
* The canonical smiley-code emoji map.
* Keys must be unique; variants are listed via aliases.
*/
private static array $map = [
// Core
':beer' => '🍺',
':clap' => '👏',
':coffee' => '☕',
':cry' => '😢',
':lol' => '😂',
':love' => '❤️',
':HB' => '🎂',
':wow' => '😮',
// Extended legacy codes
':smile' => '😊',
':grin' => '😁',
':wink' => '😉',
':tongue' => '😛',
':cool' => '😎',
':angry' => '😠',
':sad' => '😞',
':laugh' => '😆',
':hug' => '🤗',
':thumb' => '👍',
':thumbs' => '👍',
':thumbsup' => '👍',
':fire' => '🔥',
':star' => '⭐',
':heart' => '❤️',
':broken' => '💔',
':music' => '🎵',
':note' => '🎶',
':art' => '🎨',
':camera' => '📷',
':gift' => '🎁',
':cake' => '🎂',
':wave' => '👋',
':ok' => '👌',
':pray' => '🙏',
':think' => '🤔',
':eyes' => '👀',
':rainbow' => '🌈',
':sun' => '☀️',
':moon' => '🌙',
':party' => '🎉',
':bomb' => '💣',
':skull' => '💀',
':alien' => '👽',
':robot' => '🤖',
':poop' => '💩',
':money' => '💰',
':bulb' => '💡',
':check' => '✅',
':x' => '❌',
':warning' => '⚠️',
':question' => '❓',
':exclamation' => '❗',
':100' => '💯',
];
/**
* Convert all legacy smiley codes in $text to Unicode emoji.
* Only replaces codes that are surrounded by whitespace or start/end of string.
*
* @return string
*/
public static function convert(string $text): string
{
if (empty($text)) {
return $text;
}
foreach (static::$map as $code => $emoji) {
// Use word-boundary-style: the code must be followed by whitespace,
// end of string, or punctuation — not part of a word.
$escaped = preg_quote($code, '/');
$text = preg_replace(
'/(?<=\s|^)' . $escaped . '(?=\s|$|[.,!?;])/um',
$emoji,
$text
);
}
return $text;
}
/**
* Returns all codes that are present in the given text (for reporting).
*
* @return string[]
*/
public static function detect(string $text): array
{
$found = [];
foreach (array_keys(static::$map) as $code) {
$escaped = preg_quote($code, '/');
if (preg_match('/(?<=\s|^)' . $escaped . '(?=\s|$|[.,!?;])/um', $text)) {
$found[] = $code;
}
}
return $found;
}
/**
* Collapse consecutive runs of the same emoji that exceed $maxRun repetitions.
*
* Transforms e.g. "🍺 🍺 🍺 🍺 🍺 🍺 🍺 🍺" (8×) "🍺 🍺 🍺 🍺 🍺 ×8"
* so that spam/flood content is stored compactly and rendered readably.
*
* Both whitespace-separated ("🍺 🍺 🍺") and run-together ("🍺🍺🍺") forms
* are collapsed. Only emoji from the common Unicode blocks are affected;
* regular text is never touched.
*
* @param int $maxRun Maximum number of identical emoji to keep (default 5).
*/
public static function collapseFlood(string $text, int $maxRun = 5): string
{
if (empty($text)) {
return $text;
}
$limit = max(1, $maxRun);
// Match one emoji "unit" (codepoint from common ranges + optional variation
// selector U+FE0E / U+FE0F), followed by $limit or more repetitions of
// (optional horizontal whitespace + the same unit).
// The \1 backreference works byte-for-byte in UTF-8, so it correctly
// matches the same multi-byte sequence each time.
$pattern = '/([\x{1F000}-\x{1FFFF}\x{2600}-\x{27EF}][\x{FE0E}\x{FE0F}]?)'
. '([ \t]*\1){' . $limit . ',}/u';
return preg_replace_callback(
$pattern,
static function (array $m) use ($limit): string {
$unit = $m[1];
// substr_count is byte-safe and correct for multi-byte sequences.
$count = substr_count($m[0], $unit);
return str_repeat($unit . ' ', $limit - 1) . $unit . ' ×' . $count;
},
$text
) ?? $text;
}
/**
* Get the full mapping array.
*
* @return array<string, string>
*/
public static function getMap(): array
{
return static::$map;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Services\Messaging;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class MessageNotificationService
{
public function notifyNewMessage(Conversation $conversation, Message $message, User $sender): void
{
if (! DB::getSchemaBuilder()->hasTable('notifications')) {
return;
}
$recipientIds = ConversationParticipant::query()
->where('conversation_id', $conversation->id)
->whereNull('left_at')
->where('user_id', '!=', $sender->id)
->where('is_muted', false)
->where('is_archived', false)
->pluck('user_id')
->all();
if (empty($recipientIds)) {
return;
}
$recipientRows = User::query()
->whereIn('id', $recipientIds)
->get()
->filter(fn (User $recipient) => $recipient->allowsMessagesFrom($sender))
->pluck('id')
->map(fn ($id) => (int) $id)
->values()
->all();
if (empty($recipientRows)) {
return;
}
$preview = Str::limit((string) $message->body, 120, '…');
$now = now();
$rows = array_map(static fn (int $recipientId) => [
'user_id' => $recipientId,
'type' => 'message',
'data' => json_encode([
'conversation_id' => $conversation->id,
'sender_id' => $sender->id,
'sender_name' => $sender->username,
'preview' => $preview,
'message_id' => $message->id,
], JSON_UNESCAPED_UNICODE),
'read_at' => null,
'created_at' => $now,
'updated_at' => $now,
], $recipientRows);
DB::table('notifications')->insert($rows);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Services\Messaging;
use App\Jobs\DeleteMessageFromIndexJob;
use App\Jobs\IndexMessageJob;
use App\Models\Message;
class MessageSearchIndexer
{
public function indexMessage(Message $message): void
{
IndexMessageJob::dispatch($message->id);
}
public function updateMessage(Message $message): void
{
IndexMessageJob::dispatch($message->id);
}
public function deleteMessage(Message $message): void
{
DeleteMessageFromIndexJob::dispatch($message->id);
}
public function rebuildConversation(int $conversationId): void
{
Message::query()
->where('conversation_id', $conversationId)
->whereNull('deleted_at')
->select('id')
->chunkById(200, function ($messages): void {
foreach ($messages as $message) {
IndexMessageJob::dispatch((int) $message->id);
}
});
}
public function rebuildAll(): void
{
Message::query()
->whereNull('deleted_at')
->select('id')
->chunkById(500, function ($messages): void {
foreach ($messages as $message) {
IndexMessageJob::dispatch((int) $message->id);
}
});
}
}

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\IndexUserJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* UserStatsService single source of truth for user_statistics counters.
*
* All counter updates MUST go through this service.
* No direct increments in controllers or jobs.
*
* Design:
* - Atomic SQL increments (no read-modify-write races).
* - Negative counters are prevented at the SQL level (WHERE col > 0).
* - ensureRow() upserts the row before any counter touch.
* - recomputeUser() rebuilds all columns from authoritative tables.
*/
final class UserStatsService
{
// ─── Row management ──────────────────────────────────────────────────────
/**
* Guarantee a user_statistics row exists for the given user.
* Safe to call before every increment.
*/
public function ensureRow(int $userId): void
{
DB::table('user_statistics')->insertOrIgnore([
'user_id' => $userId,
'created_at' => now(),
'updated_at' => now(),
]);
}
// ─── Increment helpers ────────────────────────────────────────────────────
public function incrementUploads(int $userId, int $by = 1): void
{
$this->ensureRow($userId);
$this->inc($userId, 'uploads_count', $by);
$this->touchActive($userId);
$this->reindex($userId);
}
public function decrementUploads(int $userId, int $by = 1): void
{
$this->dec($userId, 'uploads_count', $by);
$this->reindex($userId);
}
public function incrementDownloadsReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'downloads_received_count', $by);
$this->reindex($creatorUserId);
}
public function incrementArtworkViewsReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'artwork_views_received_count', $by);
// Views are high-frequency do NOT reindex on every view.
}
public function incrementAwardsReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'awards_received_count', $by);
$this->reindex($creatorUserId);
}
public function decrementAwardsReceived(int $creatorUserId, int $by = 1): void
{
$this->dec($creatorUserId, 'awards_received_count', $by);
$this->reindex($creatorUserId);
}
public function incrementFavoritesReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'favorites_received_count', $by);
$this->reindex($creatorUserId);
}
public function decrementFavoritesReceived(int $creatorUserId, int $by = 1): void
{
$this->dec($creatorUserId, 'favorites_received_count', $by);
$this->reindex($creatorUserId);
}
public function incrementCommentsReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'comments_received_count', $by);
$this->reindex($creatorUserId);
}
public function decrementCommentsReceived(int $creatorUserId, int $by = 1): void
{
$this->dec($creatorUserId, 'comments_received_count', $by);
$this->reindex($creatorUserId);
}
public function incrementReactionsReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'reactions_received_count', $by);
$this->reindex($creatorUserId);
}
public function decrementReactionsReceived(int $creatorUserId, int $by = 1): void
{
$this->dec($creatorUserId, 'reactions_received_count', $by);
$this->reindex($creatorUserId);
}
public function incrementProfileViews(int $userId, int $by = 1): void
{
$this->ensureRow($userId);
$this->inc($userId, 'profile_views_count', $by);
}
// ─── Timestamp helpers ────────────────────────────────────────────────────
public function setLastUploadAt(int $userId, ?Carbon $timestamp = null): void
{
$this->ensureRow($userId);
DB::table('user_statistics')
->where('user_id', $userId)
->update([
'last_upload_at' => ($timestamp ?? now())->toDateTimeString(),
'updated_at' => now(),
]);
}
public function setLastActiveAt(int $userId, ?Carbon $timestamp = null): void
{
$this->ensureRow($userId);
DB::table('user_statistics')
->where('user_id', $userId)
->update([
'last_active_at' => ($timestamp ?? now())->toDateTimeString(),
'updated_at' => now(),
]);
}
// ─── Recompute ────────────────────────────────────────────────────────────
/**
* Recompute all counters for a single user from authoritative tables.
* Returns the computed values (array) without writing when $dryRun=true.
*
* @return array<string, int|string|null>
*/
public function recomputeUser(int $userId, bool $dryRun = false): array
{
$computed = [
'uploads_count' => (int) DB::table('artworks')
->where('user_id', $userId)
->whereNull('deleted_at')
->count(),
'downloads_received_count' => (int) DB::table('artwork_downloads as d')
->join('artworks as a', 'a.id', '=', 'd.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->count(),
'artwork_views_received_count' => (int) DB::table('artwork_stats as s')
->join('artworks as a', 'a.id', '=', 's.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->sum('s.views'),
'awards_received_count' => (int) DB::table('artwork_awards as aw')
->join('artworks as a', 'a.id', '=', 'aw.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->count(),
'favorites_received_count' => (int) DB::table('artwork_favourites as f')
->join('artworks as a', 'a.id', '=', 'f.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->count(),
'comments_received_count' => (int) DB::table('artwork_comments as c')
->join('artworks as a', 'a.id', '=', 'c.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->whereNull('c.deleted_at')
->count(),
'reactions_received_count' => (int) DB::table('artwork_reactions as r')
->join('artworks as a', 'a.id', '=', 'r.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->count(),
'followers_count' => (int) DB::table('user_followers')
->where('user_id', $userId)
->count(),
'following_count' => (int) DB::table('user_followers')
->where('follower_id', $userId)
->count(),
'last_upload_at' => DB::table('artworks')
->where('user_id', $userId)
->whereNull('deleted_at')
->max('created_at'),
];
if (! $dryRun) {
$this->ensureRow($userId);
DB::table('user_statistics')
->where('user_id', $userId)
->update(array_merge($computed, ['updated_at' => now()]));
$this->reindex($userId);
}
return $computed;
}
/**
* Recompute stats for all users in chunks.
*
* @param int $chunk Users per chunk.
*/
public function recomputeAll(int $chunk = 1000): void
{
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunk($chunk, function ($users) {
foreach ($users as $user) {
$this->recomputeUser($user->id);
}
});
}
// ─── Private helpers ──────────────────────────────────────────────────────
private function inc(int $userId, string $column, int $by = 1): void
{
DB::table('user_statistics')
->where('user_id', $userId)
->update([
$column => DB::raw("MAX(0, COALESCE({$column}, 0) + {$by})"),
'updated_at' => now(),
]);
}
private function dec(int $userId, string $column, int $by = 1): void
{
DB::table('user_statistics')
->where('user_id', $userId)
->where($column, '>', 0)
->update([
$column => DB::raw("MAX(0, COALESCE({$column}, 0) - {$by})"),
'updated_at' => now(),
]);
}
private function touchActive(int $userId): void
{
DB::table('user_statistics')
->where('user_id', $userId)
->update([
'last_active_at' => now(),
'updated_at' => now(),
]);
}
/**
* Queue a Meilisearch reindex for the user.
* Uses IndexUserJob to avoid blocking the request.
*/
private function reindex(int $userId): void
{
IndexUserJob::dispatch($userId);
}
}