diff --git a/app/Console/Commands/FlushRedisStatsCommand.php b/app/Console/Commands/FlushRedisStatsCommand.php new file mode 100644 index 00000000..a07cc417 --- /dev/null +++ b/app/Console/Commands/FlushRedisStatsCommand.php @@ -0,0 +1,44 @@ +option('max'); + + $processed = $service->processPendingFromRedis($max); + + if ($this->getOutput()->isVerbose()) { + $this->info("Processed {$processed} artwork-stat delta(s) from Redis."); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PruneViewEventsCommand.php b/app/Console/Commands/PruneViewEventsCommand.php new file mode 100644 index 00000000..4d2579cc --- /dev/null +++ b/app/Console/Commands/PruneViewEventsCommand.php @@ -0,0 +1,42 @@ +option('days'); + $cutoff = now()->subDays($days); + + $deleted = DB::table('artwork_view_events') + ->where('viewed_at', '<', $cutoff) + ->delete(); + + $this->info("Pruned {$deleted} view event(s) older than {$days} days (cutoff: {$cutoff})."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecalculateTrendingCommand.php b/app/Console/Commands/RecalculateTrendingCommand.php new file mode 100644 index 00000000..98ebda20 --- /dev/null +++ b/app/Console/Commands/RecalculateTrendingCommand.php @@ -0,0 +1,57 @@ +option('period'); + $chunkSize = (int) $this->option('chunk'); + $skipIndex = (bool) $this->option('skip-index'); + + $periods = $period === 'all' ? ['24h', '7d'] : [$period]; + + foreach ($periods as $p) { + if (! in_array($p, ['24h', '7d'], true)) { + $this->error("Invalid period '{$p}'. Use 24h, 7d, or all."); + return self::FAILURE; + } + + $this->info("Recalculating trending ({$p}) …"); + $start = microtime(true); + $updated = $this->trending->recalculate($p, $chunkSize); + $elapsed = round(microtime(true) - $start, 2); + + $this->info(" ✓ {$updated} artworks updated in {$elapsed}s"); + + if (! $skipIndex) { + $this->info(" Dispatching Meilisearch index jobs …"); + $this->trending->syncToSearchIndex($p); + $this->info(" ✓ Index jobs dispatched"); + } + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ResetWindowedStatsCommand.php b/app/Console/Commands/ResetWindowedStatsCommand.php new file mode 100644 index 00000000..13e29135 --- /dev/null +++ b/app/Console/Commands/ResetWindowedStatsCommand.php @@ -0,0 +1,97 @@ +option('period'); + + if (! in_array($period, ['24h', '7d'], true)) { + $this->error("Invalid period '{$period}'. Use 24h or 7d."); + return self::FAILURE; + } + + [$viewsCol, $downloadsCol, $cutoff] = match ($period) { + '24h' => ['views_24h', 'downloads_24h', now()->subDay()], + default => ['views_7d', 'downloads_7d', now()->subDays(7)], + }; + + $start = microtime(true); + + // ── 1. Zero the views window column ────────────────────────────────── + // We have no per-view event log, so we reset the accumulator. + $viewsReset = DB::table('artwork_stats')->update([$viewsCol => 0]); + + // ── 2. Recompute downloads window from the event log ───────────────── + // artwork_downloads has created_at, so each row's window is accurate. + // Chunked PHP loop avoids MySQL-only functions (GREATEST, INTERVAL) + // so this command works in both MySQL (production) and SQLite (tests). + $downloadsRecomputed = 0; + + DB::table('artwork_stats') + ->orderBy('artwork_id') + ->chunk(1000, function ($rows) use ($downloadsCol, $cutoff, &$downloadsRecomputed): void { + foreach ($rows as $row) { + $count = DB::table('artwork_downloads') + ->where('artwork_id', $row->artwork_id) + ->where('created_at', '>=', $cutoff) + ->count(); + + DB::table('artwork_stats') + ->where('artwork_id', $row->artwork_id) + ->update([$downloadsCol => max(0, $count)]); + + $downloadsRecomputed++; + } + }); + + $elapsed = round(microtime(true) - $start, 2); + + $this->info("Period: {$period}"); + $this->info(" {$viewsCol}: zeroed {$viewsReset} rows"); + $this->info(" {$downloadsCol}: recomputed {$downloadsRecomputed} rows ({$elapsed}s)"); + + Log::info('ResetWindowedStats complete', [ + 'period' => $period, + 'views_col' => $viewsCol, + 'views_rows_reset' => $viewsReset, + 'downloads_col' => $downloadsCol, + 'downloads_recomputed' => $downloadsRecomputed, + 'elapsed_s' => $elapsed, + ]); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 1d08be83..dddcba35 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -12,6 +12,7 @@ use App\Console\Commands\AggregateFeedAnalyticsCommand; use App\Console\Commands\EvaluateFeedWeightsCommand; use App\Console\Commands\AiTagArtworksCommand; use App\Console\Commands\CompareFeedAbCommand; +use App\Console\Commands\RecalculateTrendingCommand; use App\Uploads\Commands\CleanupUploadsCommand; class Kernel extends ConsoleKernel @@ -36,6 +37,7 @@ class Kernel extends ConsoleKernel CompareFeedAbCommand::class, AiTagArtworksCommand::class, \App\Console\Commands\MigrateFollows::class, + RecalculateTrendingCommand::class, ]; /** @@ -46,6 +48,9 @@ class Kernel extends ConsoleKernel $schedule->command('uploads:cleanup')->dailyAt('03:00'); $schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10'); $schedule->command('analytics:aggregate-feed')->dailyAt('03:20'); + // Recalculate trending scores every 30 minutes (staggered to reduce peak load) + $schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes(); + $schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground(); } /** diff --git a/app/Http/Controllers/Api/ArtworkAwardController.php b/app/Http/Controllers/Api/ArtworkAwardController.php index 36d1b2cb..b7353b69 100644 --- a/app/Http/Controllers/Api/ArtworkAwardController.php +++ b/app/Http/Controllers/Api/ArtworkAwardController.php @@ -34,6 +34,17 @@ final class ArtworkAwardController extends Controller $award = $this->service->award($artwork, $user, $data['medal']); + // Record activity event + try { + \App\Models\ActivityEvent::record( + actorId: $user->id, + type: \App\Models\ActivityEvent::TYPE_AWARD, + targetType: \App\Models\ActivityEvent::TARGET_ARTWORK, + targetId: $artwork->id, + meta: ['medal' => $data['medal']], + ); + } catch (\Throwable) {} + return response()->json( $this->buildPayload($artwork->id, $user->id), 201 diff --git a/app/Http/Controllers/Api/ArtworkCommentController.php b/app/Http/Controllers/Api/ArtworkCommentController.php index ba08ad62..c1ae32ca 100644 --- a/app/Http/Controllers/Api/ArtworkCommentController.php +++ b/app/Http/Controllers/Api/ArtworkCommentController.php @@ -93,6 +93,16 @@ class ArtworkCommentController extends Controller $comment->load(['user', 'user.profile']); + // Record activity event (fire-and-forget; never break the response) + try { + \App\Models\ActivityEvent::record( + actorId: $request->user()->id, + type: \App\Models\ActivityEvent::TYPE_COMMENT, + targetType: \App\Models\ActivityEvent::TARGET_ARTWORK, + targetId: $artwork->id, + ); + } catch (\Throwable) {} + return response()->json(['data' => $this->formatComment($comment, $request->user()->id)], 201); } diff --git a/app/Http/Controllers/Api/ArtworkDownloadController.php b/app/Http/Controllers/Api/ArtworkDownloadController.php new file mode 100644 index 00000000..5748a09a --- /dev/null +++ b/app/Http/Controllers/Api/ArtworkDownloadController.php @@ -0,0 +1,96 @@ +"} so the frontend can + * trigger the actual browser download. + * + * The frontend fires this POST on click, then uses the returned URL to + * trigger the file download (or falls back to the pre-resolved URL it + * already has). + */ +final class ArtworkDownloadController extends Controller +{ + public function __construct(private readonly ArtworkStatsService $stats) {} + + public function __invoke(Request $request, int $id): JsonResponse + { + $artwork = Artwork::public() + ->published() + ->with(['user:id']) + ->where('id', $id) + ->first(); + + if (! $artwork) { + return response()->json(['error' => 'Not found'], 404); + } + + // Record the download event — non-blocking, errors are swallowed. + $this->recordDownload($request, $artwork); + + // Increment counters — deferred via Redis when available. + $this->stats->incrementDownloads((int) $artwork->id, 1, defer: true); + + // Resolve the highest-resolution download URL available. + $url = $this->resolveDownloadUrl($artwork); + + return response()->json(['ok' => true, 'url' => $url]); + } + + /** + * Insert a row in artwork_downloads. + * Uses a raw insert for the binary(16) IP column. + * Silently ignores failures (analytics should never break user flow). + */ + private function recordDownload(Request $request, Artwork $artwork): void + { + try { + $ip = $request->ip() ?? '0.0.0.0'; + $bin = @inet_pton($ip); + + DB::table('artwork_downloads')->insert([ + 'artwork_id' => $artwork->id, + 'user_id' => $request->user()?->id, + 'ip' => $bin !== false ? $bin : null, + 'user_agent' => mb_substr((string) $request->userAgent(), 0, 512), + 'created_at' => now(), + ]); + } catch (\Throwable) { + // Analytics failure must never interrupt the download. + } + } + + /** + * Resolve the best available download URL: XL → LG → MD. + * Returns an empty string if no thumbnail can be resolved. + */ + private function resolveDownloadUrl(Artwork $artwork): string + { + foreach (['xl', 'lg', 'md'] as $size) { + $thumb = ThumbnailPresenter::present($artwork, $size); + if (! empty($thumb['url'])) { + return (string) $thumb['url']; + } + } + return ''; + } +} diff --git a/app/Http/Controllers/Api/ArtworkInteractionController.php b/app/Http/Controllers/Api/ArtworkInteractionController.php index f118a2c8..bc8a6d26 100644 --- a/app/Http/Controllers/Api/ArtworkInteractionController.php +++ b/app/Http/Controllers/Api/ArtworkInteractionController.php @@ -36,6 +36,16 @@ final class ArtworkInteractionController extends Controller if ($state) { $svc->incrementFavoritesReceived($creatorId); $svc->setLastActiveAt((int) $request->user()->id); + + // Record activity event (new favourite only) + try { + \App\Models\ActivityEvent::record( + actorId: (int) $request->user()->id, + type: \App\Models\ActivityEvent::TYPE_FAVORITE, + targetType: \App\Models\ActivityEvent::TARGET_ARTWORK, + targetId: $artworkId, + ); + } catch (\Throwable) {} } else { $svc->decrementFavoritesReceived($creatorId); } diff --git a/app/Http/Controllers/Api/ArtworkViewController.php b/app/Http/Controllers/Api/ArtworkViewController.php new file mode 100644 index 00000000..bb9e1fca --- /dev/null +++ b/app/Http/Controllers/Api/ArtworkViewController.php @@ -0,0 +1,62 @@ +published() + ->where('id', $id) + ->first(); + + if (! $artwork) { + return response()->json(['error' => 'Not found'], 404); + } + + $sessionKey = 'art_viewed.' . $id; + + // Already counted this session — return early without touching the DB. + if ($request->hasSession() && $request->session()->has($sessionKey)) { + return response()->json(['ok' => true, 'counted' => false]); + } + + // Write persistent event log (auth user_id or null for guests). + $this->stats->logViewEvent((int) $artwork->id, $request->user()?->id); + + // Defer to Redis when available, fall back to direct DB increment. + $this->stats->incrementViews((int) $artwork->id, 1, defer: true); + + // Mark this session so the artwork is not counted again. + if ($request->hasSession()) { + $request->session()->put($sessionKey, true); + } + + return response()->json(['ok' => true, 'counted' => true]); + } +} diff --git a/app/Http/Controllers/Api/Messaging/MessagingSettingsController.php b/app/Http/Controllers/Api/Messaging/MessagingSettingsController.php index 4cba0b4c..368ce4d8 100644 --- a/app/Http/Controllers/Api/Messaging/MessagingSettingsController.php +++ b/app/Http/Controllers/Api/Messaging/MessagingSettingsController.php @@ -18,6 +18,7 @@ class MessagingSettingsController extends Controller { return response()->json([ 'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone', + 'realtime_enabled' => (bool) config('messaging.realtime', false), ]); } @@ -31,6 +32,7 @@ class MessagingSettingsController extends Controller return response()->json([ 'allow_messages_from' => $request->user()->allow_messages_from, + 'realtime_enabled' => (bool) config('messaging.realtime', false), ]); } } diff --git a/app/Http/Controllers/Api/SimilarArtworksController.php b/app/Http/Controllers/Api/SimilarArtworksController.php new file mode 100644 index 00000000..48dc91ed --- /dev/null +++ b/app/Http/Controllers/Api/SimilarArtworksController.php @@ -0,0 +1,121 @@ +published() + ->with(['tags:id,slug', 'categories:id,slug']) + ->find($id); + + if (! $artwork) { + return response()->json(['error' => 'Artwork not found'], 404); + } + + $cacheKey = "api.similar.{$artwork->id}"; + + $items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) { + return $this->findSimilar($artwork); + }); + + return response()->json(['data' => $items]); + } + + private function findSimilar(Artwork $artwork): array + { + $tagSlugs = $artwork->tags->pluck('slug')->values()->all(); + $categorySlugs = $artwork->categories->pluck('slug')->values()->all(); + $orientation = $this->orientation($artwork); + + // Build Meilisearch filter: exclude self and same creator + $filterParts = [ + 'is_public = true', + 'is_approved = true', + 'id != ' . $artwork->id, + 'author_id != ' . $artwork->user_id, + ]; + + // Filter by same orientation (landscape/portrait) — improves visual coherence + if ($orientation !== 'square') { + $filterParts[] = 'orientation = "' . $orientation . '"'; + } + + // Priority 1: tag overlap (OR match across tags) + if ($tagSlugs !== []) { + $tagFilter = implode(' OR ', array_map( + fn (string $t): string => 'tags = "' . addslashes($t) . '"', + $tagSlugs + )); + $filterParts[] = '(' . $tagFilter . ')'; + } elseif ($categorySlugs !== []) { + // Fallback to category if no tags + $catFilter = implode(' OR ', array_map( + fn (string $c): string => 'category = "' . addslashes($c) . '"', + $categorySlugs + )); + $filterParts[] = '(' . $catFilter . ')'; + } + + $results = Artwork::search('') + ->options([ + 'filter' => implode(' AND ', $filterParts), + 'sort' => ['trending_score_7d:desc', 'likes:desc'], + ]) + ->paginate(self::LIMIT); + + return $results->getCollection() + ->map(fn (Artwork $a): array => [ + 'id' => $a->id, + 'title' => $a->title, + 'slug' => $a->slug, + 'thumb' => $a->thumbUrl('md'), + 'url' => '/art/' . $a->id . '/' . $a->slug, + 'author_id' => $a->user_id, + 'orientation' => $this->orientation($a), + 'width' => $a->width, + 'height' => $a->height, + ]) + ->values() + ->all(); + } + + private function orientation(Artwork $artwork): string + { + if (! $artwork->width || ! $artwork->height) { + return 'square'; + } + + return match (true) { + $artwork->width > $artwork->height => 'landscape', + $artwork->height > $artwork->width => 'portrait', + default => 'square', + }; + } +} diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index fdbf5ac8..6b0baf88 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -518,6 +518,16 @@ final class UploadController extends Controller $artwork->published_at = now(); $artwork->save(); + // Record upload activity event + try { + \App\Models\ActivityEvent::record( + actorId: (int) $user->id, + type: \App\Models\ActivityEvent::TYPE_UPLOAD, + targetType: \App\Models\ActivityEvent::TARGET_ARTWORK, + targetId: (int) $artwork->id, + ); + } catch (\Throwable) {} + return response()->json([ 'success' => true, 'artwork_id' => (int) $artwork->id, diff --git a/app/Http/Controllers/Web/CommunityActivityController.php b/app/Http/Controllers/Web/CommunityActivityController.php new file mode 100644 index 00000000..e79d629f --- /dev/null +++ b/app/Http/Controllers/Web/CommunityActivityController.php @@ -0,0 +1,124 @@ +user(); + $type = $request->query('type', 'global'); // global | following + $perPage = self::PER_PAGE; + + $query = ActivityEvent::query() + ->orderByDesc('created_at') + ->with(['actor:id,name,username']); + + if ($type === 'following' && $user) { + // Show only events from followed users + $followingIds = DB::table('user_followers') + ->where('follower_id', $user->id) + ->pluck('user_id') + ->all(); + + if (empty($followingIds)) { + $query->whereRaw('0 = 1'); // empty result set + } else { + $query->whereIn('actor_id', $followingIds); + } + } + + $events = $query->paginate($perPage)->withQueryString(); + $enriched = $this->enrich($events->getCollection()); + + return view('web.community.activity', [ + 'events' => $events, + 'enriched' => $enriched, + 'active_tab' => $type, + 'page_title' => 'Community Activity', + ]); + } + + /** + * Attach target object data to each event for display. + */ + private function enrich(\Illuminate\Support\Collection $events): \Illuminate\Support\Collection + { + // Collect artwork IDs and user IDs to eager-load + $artworkIds = $events + ->where('target_type', ActivityEvent::TARGET_ARTWORK) + ->pluck('target_id') + ->unique() + ->values() + ->all(); + + $userIds = $events + ->where('target_type', ActivityEvent::TARGET_USER) + ->pluck('target_id') + ->unique() + ->values() + ->all(); + + $artworks = Artwork::whereIn('id', $artworkIds) + ->with('user:id,name,username') + ->get(['id', 'title', 'slug', 'user_id', 'hash', 'thumb_ext']) + ->keyBy('id'); + + $users = User::whereIn('id', $userIds) + ->with('profile:user_id,avatar_hash') + ->get(['id', 'name', 'username']) + ->keyBy('id'); + + return $events->map(function (ActivityEvent $event) use ($artworks, $users): array { + $target = null; + + if ($event->target_type === ActivityEvent::TARGET_ARTWORK) { + $artwork = $artworks->get($event->target_id); + $target = $artwork ? [ + 'id' => $artwork->id, + 'title' => $artwork->title, + 'url' => '/art/' . $artwork->id . '/' . $artwork->slug, + 'thumb' => $artwork->thumbUrl('sm'), + ] : null; + } elseif ($event->target_type === ActivityEvent::TARGET_USER) { + $u = $users->get($event->target_id); + $target = $u ? [ + 'id' => $u->id, + 'name' => $u->name, + 'username' => $u->username, + 'url' => '/@' . $u->username, + ] : null; + } + + return [ + 'id' => $event->id, + 'type' => $event->type, + 'target_type' => $event->target_type, + 'actor' => [ + 'id' => $event->actor?->id, + 'name' => $event->actor?->name, + 'username' => $event->actor?->username, + 'url' => '/@' . $event->actor?->username, + ], + 'target' => $target, + 'created_at' => $event->created_at?->toIso8601String(), + ]; + }); + } +} diff --git a/app/Http/Controllers/Web/DiscoverController.php b/app/Http/Controllers/Web/DiscoverController.php index 08f3b68b..58b6aa4c 100644 --- a/app/Http/Controllers/Web/DiscoverController.php +++ b/app/Http/Controllers/Web/DiscoverController.php @@ -8,6 +8,7 @@ use App\Services\ArtworkSearchService; use App\Services\ArtworkService; use App\Services\ThumbnailPresenter; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -190,26 +191,56 @@ final class DiscoverController extends Controller ->pluck('user_id'); if ($followingIds->isEmpty()) { - $artworks = Artwork::query()->paginate(0); + // Trending fallback: show popular artworks so the page isn't blank + try { + $fallbackResults = $this->searchService->discoverTrending(12); + $fallbackArtworks = $fallbackResults->getCollection() + ->transform(fn ($a) => $this->presentArtwork($a)); + } catch (\Throwable) { + $fallbackArtworks = collect(); + } + + // Suggested creators: most-followed users the viewer doesn't follow yet + $suggestedCreators = DB::table('users') + ->join('user_statistics', 'users.id', '=', 'user_statistics.user_id') + ->where('users.id', '!=', $user->id) + ->whereNotNull('users.email_verified_at') + ->where('users.is_active', true) + ->orderByDesc('user_statistics.followers_count') + ->limit(8) + ->select( + 'users.id', + 'users.name', + 'users.username', + 'user_statistics.followers_count', + ) + ->get(); return view('web.discover.index', [ - 'artworks' => $artworks, - 'page_title' => 'Following Feed', - 'section' => 'following', - 'description' => 'Follow some creators to see their work here.', - 'icon' => 'fa-user-group', - 'empty' => true, + 'artworks' => collect(), + 'page_title' => 'Following Feed', + 'section' => 'following', + 'description' => 'Follow some creators to see their work here.', + 'icon' => 'fa-user-group', + 'empty' => true, + 'fallback_trending' => $fallbackArtworks, + 'fallback_creators' => $suggestedCreators, ]); } - $artworks = Artwork::query() - ->public() - ->published() - ->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order']) - ->whereIn('user_id', $followingIds) - ->orderByDesc('published_at') - ->paginate($perPage) - ->withQueryString(); + $page = (int) request()->get('page', 1); + $cacheKey = "discover.following.{$user->id}.p{$page}"; + + $artworks = Cache::remember($cacheKey, 60, function () use ($user, $followingIds, $perPage): \Illuminate\Pagination\LengthAwarePaginator { + return Artwork::query() + ->public() + ->published() + ->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order']) + ->whereIn('user_id', $followingIds) + ->orderByDesc('published_at') + ->paginate($perPage) + ->withQueryString(); + }); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); diff --git a/app/Http/Controllers/Web/HomeController.php b/app/Http/Controllers/Web/HomeController.php index fef2ddbb..173105b0 100644 --- a/app/Http/Controllers/Web/HomeController.php +++ b/app/Http/Controllers/Web/HomeController.php @@ -14,7 +14,10 @@ final class HomeController extends Controller public function index(Request $request): \Illuminate\View\View { - $sections = $this->homepage->all(); + $user = $request->user(); + $sections = $user + ? $this->homepage->allForUser($user) + : $this->homepage->all(); $hero = $sections['hero']; @@ -27,8 +30,9 @@ final class HomeController extends Controller ]; return view('web.home', [ - 'meta' => $meta, - 'props' => $sections, + 'meta' => $meta, + 'props' => $sections, + 'is_logged_in' => (bool) $user, ]); } } diff --git a/app/Models/ActivityEvent.php b/app/Models/ActivityEvent.php new file mode 100644 index 00000000..4540f966 --- /dev/null +++ b/app/Models/ActivityEvent.php @@ -0,0 +1,93 @@ + 'integer', + 'target_id' => 'integer', + 'meta' => 'array', + 'created_at' => 'datetime', + ]; + + // ── Event type constants ────────────────────────────────────────────────── + + const TYPE_UPLOAD = 'upload'; + const TYPE_COMMENT = 'comment'; + const TYPE_FAVORITE = 'favorite'; + const TYPE_AWARD = 'award'; + const TYPE_FOLLOW = 'follow'; + + const TARGET_ARTWORK = 'artwork'; + const TARGET_USER = 'user'; + + // ── Relations ───────────────────────────────────────────────────────────── + + /** The user who performed the action */ + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } + + // ── Factory helpers ─────────────────────────────────────────────────────── + + public static function record( + int $actorId, + string $type, + string $targetType, + int $targetId, + array $meta = [] + ): static { + $event = static::create([ + 'actor_id' => $actorId, + 'type' => $type, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'meta' => $meta ?: null, + 'created_at' => now(), + ]); + + // Ensure created_at is available on the returned instance + // ($timestamps = false means Eloquent doesn't auto-populate it) + if ($event->created_at === null) { + $event->created_at = now(); + } + + return $event; + } +} diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index 8384c7cd..0e2f2b80 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -250,6 +250,12 @@ class Artwork extends Model 'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '', 'is_public' => (bool) $this->is_public, 'is_approved' => (bool) $this->is_approved, + // ── Trending / discovery fields ──────────────────────────────────── + 'trending_score_24h' => (float) ($this->trending_score_24h ?? 0), + 'trending_score_7d' => (float) ($this->trending_score_7d ?? 0), + 'favorites_count' => (int) ($stat?->favorites ?? 0), + 'awards_received_count' => (int) ($awardStat?->score_total ?? 0), + 'downloads_count' => (int) ($stat?->downloads ?? 0), 'awards' => [ 'gold' => $awardStat?->gold_count ?? 0, 'silver' => $awardStat?->silver_count ?? 0, diff --git a/app/Services/ArtworkSearchService.php b/app/Services/ArtworkSearchService.php index ff5aeedc..78018b2b 100644 --- a/app/Services/ArtworkSearchService.php +++ b/app/Services/ArtworkSearchService.php @@ -175,8 +175,8 @@ final class ArtworkSearchService // ── Discover section helpers ─────────────────────────────────────────────── /** - * Trending: most viewed artworks, weighted toward recent uploads. - * Uses views:desc + recency via created_at:desc as tiebreaker. + * Trending: sorted by pre-computed trending_score_24h (recalculated every 30 min). + * Falls back to views:desc if the column is not yet populated. */ public function discoverTrending(int $perPage = 24): LengthAwarePaginator { @@ -185,7 +185,7 @@ final class ArtworkSearchService return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER, - 'sort' => ['views:desc', 'created_at:desc'], + 'sort' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'views:desc', 'created_at:desc'], ]) ->paginate($perPage); }); @@ -239,6 +239,64 @@ final class ArtworkSearchService }); } + /** + * Artworks matching any of the given tag slugs, sorted by trending score. + * Used for personalized "Because you like {tags}" homepage section. + * + * @param string[] $tagSlugs + */ + public function discoverByTags(array $tagSlugs, int $limit = 12): LengthAwarePaginator + { + if (empty($tagSlugs)) { + return $this->popular($limit); + } + + $tagFilter = implode(' OR ', array_map( + fn (string $t): string => 'tags = "' . addslashes($t) . '"', + array_slice($tagSlugs, 0, 5) + )); + + $cacheKey = 'discover.by-tags.' . md5(implode(',', $tagSlugs)); + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) { + return Artwork::search('') + ->options([ + 'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')', + 'sort' => ['trending_score_7d:desc', 'likes:desc'], + ]) + ->paginate($limit); + }); + } + + /** + * Fresh artworks in given categories, sorted by created_at desc. + * Used for personalized "Fresh in your favourite categories" section. + * + * @param string[] $categorySlugs + */ + public function discoverByCategories(array $categorySlugs, int $limit = 12): LengthAwarePaginator + { + if (empty($categorySlugs)) { + return $this->recent($limit); + } + + $catFilter = implode(' OR ', array_map( + fn (string $c): string => 'category = "' . addslashes($c) . '"', + array_slice($categorySlugs, 0, 3) + )); + + $cacheKey = 'discover.by-cats.' . md5(implode(',', $categorySlugs)); + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) { + return Artwork::search('') + ->options([ + 'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')', + 'sort' => ['created_at:desc'], + ]) + ->paginate($limit); + }); + } + // ------------------------------------------------------------------------- private function parseSort(string $sort): array diff --git a/app/Services/ArtworkStatsService.php b/app/Services/ArtworkStatsService.php index ec815f9d..118a5a16 100644 --- a/app/Services/ArtworkStatsService.php +++ b/app/Services/ArtworkStatsService.php @@ -23,26 +23,56 @@ class ArtworkStatsService /** * Increment views for an artwork. * Set $defer=true to push to Redis for async processing when available. + * Both all-time (views) and windowed (views_24h, views_7d) are updated. */ public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void { if ($defer && $this->redisAvailable()) { - $this->pushDelta($artworkId, 'views', $by); + $this->pushDelta($artworkId, 'views', $by); + $this->pushDelta($artworkId, 'views_24h', $by); + $this->pushDelta($artworkId, 'views_7d', $by); return; } - $this->applyDelta($artworkId, ['views' => $by]); + $this->applyDelta($artworkId, ['views' => $by, 'views_24h' => $by, 'views_7d' => $by]); } /** * Increment downloads for an artwork. + * Both all-time (downloads) and windowed (downloads_24h, downloads_7d) are updated. */ public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void { if ($defer && $this->redisAvailable()) { - $this->pushDelta($artworkId, 'downloads', $by); + $this->pushDelta($artworkId, 'downloads', $by); + $this->pushDelta($artworkId, 'downloads_24h', $by); + $this->pushDelta($artworkId, 'downloads_7d', $by); return; } - $this->applyDelta($artworkId, ['downloads' => $by]); + $this->applyDelta($artworkId, ['downloads' => $by, 'downloads_24h' => $by, 'downloads_7d' => $by]); + } + + /** + * Write one row to artwork_view_events (the persistent event log). + * + * Called from ArtworkViewController after session dedup passes. + * Guests (unauthenticated) are recorded with user_id = null. + * Rows are pruned after 90 days by skinbase:prune-view-events. + */ + public function logViewEvent(int $artworkId, ?int $userId): void + { + try { + DB::table('artwork_view_events')->insert([ + 'artwork_id' => $artworkId, + 'user_id' => $userId, + 'viewed_at' => now(), + ]); + } catch (Throwable $e) { + Log::warning('Failed to write artwork_view_events row', [ + 'artwork_id' => $artworkId, + 'user_id' => $userId, + 'error' => $e->getMessage(), + ]); + } } /** @@ -75,17 +105,21 @@ class ArtworkStatsService 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, + 'artwork_id' => $artworkId, + 'views' => 0, + 'views_24h' => 0, + 'views_7d' => 0, + 'downloads' => 0, + 'downloads_24h' => 0, + 'downloads_7d' => 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)) { + if (! in_array($column, ['views', 'views_24h', 'views_7d', 'downloads', 'downloads_24h', 'downloads_7d', 'favorites', 'rating_count'], true)) { continue; } diff --git a/app/Services/FollowService.php b/app/Services/FollowService.php index 3a216ab7..cd29cdbf 100644 --- a/app/Services/FollowService.php +++ b/app/Services/FollowService.php @@ -50,6 +50,18 @@ final class FollowService $this->incrementCounter($targetId, 'followers_count'); }); + // Record activity event outside the transaction to avoid deadlocks + if ($inserted) { + try { + \App\Models\ActivityEvent::record( + actorId: $actorId, + type: \App\Models\ActivityEvent::TYPE_FOLLOW, + targetType: \App\Models\ActivityEvent::TARGET_USER, + targetId: $targetId, + ); + } catch (\Throwable) {} + } + return $inserted; } diff --git a/app/Services/HomepageService.php b/app/Services/HomepageService.php index 57c309aa..2b5687c2 100644 --- a/app/Services/HomepageService.php +++ b/app/Services/HomepageService.php @@ -6,6 +6,8 @@ namespace App\Services; use App\Models\Artwork; use App\Models\Tag; +use App\Services\ArtworkSearchService; +use App\Services\UserPreferenceService; use App\Support\AvatarUrl; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -23,7 +25,11 @@ final class HomepageService { private const CACHE_TTL = 300; // 5 minutes - public function __construct(private readonly ArtworkService $artworks) {} + public function __construct( + private readonly ArtworkService $artworks, + private readonly ArtworkSearchService $search, + private readonly UserPreferenceService $prefs, + ) {} // ───────────────────────────────────────────────────────────────────────── // Public aggregator @@ -44,6 +50,36 @@ final class HomepageService ]; } + /** + * Personalized homepage data for an authenticated user. + * + * Sections: + * 1. from_following – artworks from creators you follow + * 2. trending – same trending feed as guests + * 3. by_tags – artworks matching user's top tags + * 4. by_categories – fresh uploads in user's favourite categories + * 5. tags / creators / news – shared with guest homepage + */ + public function allForUser(\App\Models\User $user): array + { + $prefs = $this->prefs->build($user); + + return [ + 'hero' => $this->getHeroArtwork(), + 'from_following' => $this->getFollowingFeed($user, $prefs), + 'trending' => $this->getTrending(), + 'by_tags' => $this->getByTags($prefs['top_tags'] ?? []), + 'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []), + 'tags' => $this->getPopularTags(), + 'creators' => $this->getCreatorSpotlight(), + 'news' => $this->getNews(), + 'preferences' => [ + 'top_tags' => $prefs['top_tags'] ?? [], + 'top_categories' => $prefs['top_categories'] ?? [], + ], + ]; + } + // ───────────────────────────────────────────────────────────────────────── // Sections // ───────────────────────────────────────────────────────────────────────── @@ -72,54 +108,61 @@ final class HomepageService } /** - * Trending: up to 12 artworks ordered by award score, views, downloads, recent activity. + * Trending: up to 12 artworks sorted by pre-computed trending_score_7d. * - * Award score = SUM(weight × medal_value) where gold=3, silver=2, bronze=1. - * Uses correlated subqueries to avoid GROUP BY issues with MySQL strict mode. + * Uses Meilisearch sorted by the pre-computed score (updated every 30 min). + * Falls back to DB ORDER BY trending_score_7d if Meilisearch is unavailable. + * Spec: no heavy joins in the hot path. */ public function getTrending(int $limit = 12): array { return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array { - $ids = DB::table('artworks') - ->select('id') - ->selectRaw( - '(SELECT COALESCE(SUM(weight * CASE medal' - . ' WHEN \'gold\' THEN 3' - . ' WHEN \'silver\' THEN 2' - . ' ELSE 1 END), 0)' - . ' FROM artwork_awards WHERE artwork_awards.artwork_id = artworks.id) AS award_score' - ) - ->selectRaw('COALESCE((SELECT views FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_views') - ->selectRaw('COALESCE((SELECT downloads FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_downloads') - ->where('is_public', true) - ->where('is_approved', true) - ->whereNull('deleted_at') - ->whereNotNull('published_at') - ->where('published_at', '>=', now()->subDays(30)) - ->orderByDesc('award_score') - ->orderByDesc('stat_views') - ->orderByDesc('stat_downloads') - ->orderByDesc('published_at') - ->limit($limit) - ->pluck('id'); + try { + $results = Artwork::search('') + ->options([ + 'filter' => 'is_public = true AND is_approved = true', + 'sort' => ['trending_score_7d:desc', 'trending_score_24h:desc', 'views:desc'], + ]) + ->paginate($limit, 'page', 1); - if ($ids->isEmpty()) { - return []; + $results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']); + + if ($results->isEmpty()) { + return $this->getTrendingFromDb($limit); + } + + return $results->getCollection() + ->map(fn ($a) => $this->serializeArtwork($a)) + ->values() + ->all(); + } catch (\Throwable $e) { + Log::warning('HomepageService::getTrending Meilisearch unavailable, DB fallback', [ + 'error' => $e->getMessage(), + ]); + + return $this->getTrendingFromDb($limit); } - - $indexed = Artwork::with(['user:id,name,username', 'user.profile:user_id,avatar_hash']) - ->whereIn('id', $ids) - ->get() - ->keyBy('id'); - - return $ids - ->filter(fn ($id) => $indexed->has($id)) - ->map(fn ($id) => $this->serializeArtwork($indexed[$id])) - ->values() - ->all(); }); } + /** + * DB-only fallback for trending (Meilisearch unavailable). + * Uses pre-computed trending_score_7d column — no correlated subqueries. + */ + private function getTrendingFromDb(int $limit): array + { + return Artwork::public() + ->published() + ->with(['user:id,name,username', 'user.profile:user_id,avatar_hash']) + ->orderByDesc('trending_score_7d') + ->orderByDesc('trending_score_24h') + ->limit($limit) + ->get() + ->map(fn ($a) => $this->serializeArtwork($a)) + ->values() + ->all(); + } + /** * Fresh uploads: latest 12 approved public artworks. */ @@ -268,6 +311,84 @@ final class HomepageService }); } + // ───────────────────────────────────────────────────────────────────────── + // Personalized sections (auth only) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Latest artworks from creators the user follows (max 12). + */ + public function getFollowingFeed(\App\Models\User $user, array $prefs): array + { + $followingIds = $prefs['followed_creators'] ?? []; + + if (empty($followingIds)) { + return []; + } + + return Cache::remember( + "homepage.following.{$user->id}", + 60, // short TTL – personal data + function () use ($followingIds): array { + $artworks = Artwork::public() + ->published() + ->with(['user:id,name,username', 'user.profile:user_id,avatar_hash']) + ->whereIn('user_id', $followingIds) + ->orderByDesc('published_at') + ->limit(12) + ->get(); + + return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all(); + } + ); + } + + /** + * Artworks matching the user's top tags (max 12). + * Powered by Meilisearch. + */ + public function getByTags(array $tagSlugs): array + { + if (empty($tagSlugs)) { + return []; + } + + try { + $results = $this->search->discoverByTags($tagSlugs, 12); + + return $results->getCollection() + ->map(fn ($a) => $this->serializeArtwork($a)) + ->values() + ->all(); + } catch (\Throwable $e) { + Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]); + return []; + } + } + + /** + * Fresh artworks in the user's favourite categories (max 12). + * Powered by Meilisearch. + */ + public function getByCategories(array $categorySlugs): array + { + if (empty($categorySlugs)) { + return []; + } + + try { + $results = $this->search->discoverByCategories($categorySlugs, 12); + + return $results->getCollection() + ->map(fn ($a) => $this->serializeArtwork($a)) + ->values() + ->all(); + } catch (\Throwable $e) { + Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]); + return []; + } + } + // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── diff --git a/app/Services/TrendingService.php b/app/Services/TrendingService.php new file mode 100644 index 00000000..2b6266ec --- /dev/null +++ b/app/Services/TrendingService.php @@ -0,0 +1,132 @@ + ['trending_score_24h', 7], + default => ['trending_score_7d', 30], + }; + + // Use the windowed counters: views_24h/views_7d and downloads_24h/downloads_7d + // instead of all-time totals so trending reflects recent activity. + [$viewCol, $dlCol] = match ($period) { + '24h' => ['views_24h', 'downloads_24h'], + default => ['views_7d', 'downloads_7d'], + }; + + $cutoff = now()->subDays($windowDays)->toDateTimeString(); + $updated = 0; + + Artwork::query() + ->select('id') + ->where('is_public', true) + ->where('is_approved', true) + ->whereNull('deleted_at') + ->whereNotNull('published_at') + ->where('published_at', '>=', $cutoff) + ->orderBy('id') + ->chunkById($chunkSize, function ($artworks) use ($column, &$updated): void { + $ids = $artworks->pluck('id')->toArray(); + $inClause = implode(',', array_fill(0, count($ids), '?')); + + // One bulk UPDATE per chunk – uses pre-computed windowed counters + // for views and downloads (accurate rolling windows, reset nightly/weekly) + // rather than all-time totals. All other signals use correlated subqueries. + // Column name ($column) is controlled internally, not user-supplied. + DB::update( + "UPDATE artworks + SET + {$column} = GREATEST( + COALESCE((SELECT score_total FROM artwork_award_stats WHERE artwork_award_stats.artwork_id = artworks.id), 0) * ? + + COALESCE((SELECT favorites FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ? + + COALESCE((SELECT COUNT(*) FROM artwork_reactions WHERE artwork_reactions.artwork_id = artworks.id), 0) * ? + + COALESCE((SELECT {$dlCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ? + + COALESCE((SELECT {$viewCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ? + - (TIMESTAMPDIFF(HOUR, artworks.published_at, NOW()) * ?) + , 0), + last_trending_calculated_at = NOW() + WHERE id IN ({$inClause})", + array_merge( + [self::W_AWARD, self::W_FAVORITE, self::W_REACTION, self::W_DOWNLOAD, self::W_VIEW, self::DECAY_RATE], + $ids + ) + ); + + $updated += count($ids); + }); + + Log::info('TrendingService: recalculation complete', [ + 'period' => $period, + 'column' => $column, + 'updated' => $updated, + ]); + + return $updated; + } + + /** + * Dispatch Meilisearch re-index jobs for artworks in the trending window. + * Called after recalculate() to keep the search index current. + */ + public function syncToSearchIndex(string $period = '7d', int $chunkSize = 500): void + { + $windowDays = $period === '24h' ? 7 : 30; + $cutoff = now()->subDays($windowDays)->toDateTimeString(); + + Artwork::query() + ->select('id') + ->where('is_public', true) + ->where('is_approved', true) + ->whereNull('deleted_at') + ->where('published_at', '>=', $cutoff) + ->chunkById($chunkSize, function ($artworks): void { + foreach ($artworks as $artwork) { + \App\Jobs\IndexArtworkJob::dispatch($artwork->id); + } + }); + } +} diff --git a/app/Services/UserPreferenceService.php b/app/Services/UserPreferenceService.php new file mode 100644 index 00000000..fbbe9631 --- /dev/null +++ b/app/Services/UserPreferenceService.php @@ -0,0 +1,93 @@ + ['space', 'nature', ...], // up to 5 slugs + * 'top_categories' => ['wallpapers', ...], // up to 3 slugs + * 'followed_creators' => [1, 5, 23, ...], // user IDs + * ] + */ +final class UserPreferenceService +{ + private const CACHE_TTL = 300; // 5 minutes + + public function build(User $user): array + { + return Cache::remember( + "user.prefs.{$user->id}", + self::CACHE_TTL, + fn () => $this->compute($user) + ); + } + + private function compute(User $user): array + { + return [ + 'top_tags' => $this->topTags($user), + 'top_categories' => $this->topCategories($user), + 'followed_creators' => $this->followedCreatorIds($user), + ]; + } + + /** Top tag slugs derived from the user's favourited artworks */ + private function topTags(User $user, int $limit = 5): array + { + return DB::table('artwork_favourites as af') + ->join('artwork_tag as at', 'at.artwork_id', '=', 'af.artwork_id') + ->join('tags as t', 't.id', '=', 'at.tag_id') + ->where('af.user_id', $user->id) + ->where('t.is_active', true) + ->selectRaw('t.slug, COUNT(*) as cnt') + ->groupBy('t.id', 't.slug') + ->orderByDesc('cnt') + ->limit($limit) + ->pluck('slug') + ->values() + ->all(); + } + + /** Top category slugs derived from the user's favourited artworks */ + private function topCategories(User $user, int $limit = 3): array + { + return DB::table('artwork_favourites as af') + ->join('artwork_category as ac', 'ac.artwork_id', '=', 'af.artwork_id') + ->join('categories as c', 'c.id', '=', 'ac.category_id') + ->where('af.user_id', $user->id) + ->whereNull('c.deleted_at') + ->selectRaw('c.slug, COUNT(*) as cnt') + ->groupBy('c.id', 'c.slug') + ->orderByDesc('cnt') + ->limit($limit) + ->pluck('slug') + ->values() + ->all(); + } + + /** IDs of creators the user follows, latest follows first */ + private function followedCreatorIds(User $user, int $limit = 100): array + { + return DB::table('user_followers') + ->where('follower_id', $user->id) + ->orderByDesc('created_at') + ->limit($limit) + ->pluck('user_id') + ->values() + ->all(); + } +} diff --git a/composer.json b/composer.json index 73a471aa..6c4f1557 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "laravel/scout": "^10.24", "laravel/tinker": "^2.10.1", "league/commonmark": "^2.8", - "meilisearch/meilisearch-php": "^1.16" + "meilisearch/meilisearch-php": "^1.16", + "predis/predis": "^3.4" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 711ebf4c..51c32a4f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dcc955601c6f66f01bb520614508ed66", + "content-hash": "e49ab9bf98b9dc4002e839deb7b45cdf", "packages": [ { "name": "brick/math", @@ -3053,6 +3053,69 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "predis/predis", + "version": "v3.4.1", + "source": { + "type": "git", + "url": "https://github.com/predis/predis.git", + "reference": "0850f2f36ee179f0ff96c92c750e1366c6cd754c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/predis/predis/zipball/0850f2f36ee179f0ff96c92c750e1366c6cd754c", + "reference": "0850f2f36ee179f0ff96c92c750e1366c6cd754c", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ~9.4.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till Krüss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v3.4.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2026-02-23T19:51:21+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/config/scout.php b/config/scout.php index c8091e16..76a3be21 100644 --- a/config/scout.php +++ b/config/scout.php @@ -106,6 +106,11 @@ return [ 'downloads', 'likes', 'views', + 'trending_score_24h', + 'trending_score_7d', + 'favorites_count', + 'awards_received_count', + 'downloads_count', ], 'rankingRules' => [ 'words', diff --git a/database/migrations/2026_02_27_000001_add_trending_scores_to_artworks_table.php b/database/migrations/2026_02_27_000001_add_trending_scores_to_artworks_table.php new file mode 100644 index 00000000..4a843928 --- /dev/null +++ b/database/migrations/2026_02_27_000001_add_trending_scores_to_artworks_table.php @@ -0,0 +1,26 @@ +float('trending_score_24h', 10, 4)->default(0)->after('is_approved')->index(); + $table->float('trending_score_7d', 10, 4)->default(0)->after('trending_score_24h')->index(); + $table->timestamp('last_trending_calculated_at')->nullable()->after('trending_score_7d'); + }); + } + + public function down(): void + { + Schema::table('artworks', function (Blueprint $table): void { + $table->dropIndex(['trending_score_24h']); + $table->dropIndex(['trending_score_7d']); + $table->dropColumn(['trending_score_24h', 'trending_score_7d', 'last_trending_calculated_at']); + }); + } +}; diff --git a/database/migrations/2026_02_27_000002_add_windowed_stats_to_artwork_stats.php b/database/migrations/2026_02_27_000002_add_windowed_stats_to_artwork_stats.php new file mode 100644 index 00000000..d4e77bc8 --- /dev/null +++ b/database/migrations/2026_02_27_000002_add_windowed_stats_to_artwork_stats.php @@ -0,0 +1,35 @@ +unsignedBigInteger('views_24h')->default(0)->after('views'); + $table->unsignedBigInteger('views_7d')->default(0)->after('views_24h'); + $table->unsignedBigInteger('downloads_24h')->default(0)->after('downloads'); + $table->unsignedBigInteger('downloads_7d')->default(0)->after('downloads_24h'); + }); + } + + public function down(): void + { + Schema::table('artwork_stats', function (Blueprint $table) { + $table->dropColumn(['views_24h', 'views_7d', 'downloads_24h', 'downloads_7d']); + }); + } +}; diff --git a/database/migrations/2026_02_27_000002_create_activity_events_table.php b/database/migrations/2026_02_27_000002_create_activity_events_table.php new file mode 100644 index 00000000..e0b8cfa7 --- /dev/null +++ b/database/migrations/2026_02_27_000002_create_activity_events_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('actor_id')->index(); + $table->string('type', 20)->index(); // upload|comment|favorite|award|follow + $table->string('target_type', 20)->index(); // artwork|user + $table->unsignedBigInteger('target_id')->index(); + $table->json('meta')->nullable(); // extra context (category, tag, etc.) + $table->timestamp('created_at')->useCurrent()->index(); + + // Composite indexes for feed queries + $table->index(['type', 'created_at'], 'activity_events_type_created_idx'); + $table->index(['actor_id', 'created_at'], 'activity_events_actor_created_idx'); + $table->index(['target_type', 'target_id'], 'activity_events_target_idx'); + + $table->foreign('actor_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_events'); + } +}; diff --git a/database/migrations/2026_02_27_000003_create_artwork_view_events_table.php b/database/migrations/2026_02_27_000003_create_artwork_view_events_table.php new file mode 100644 index 00000000..8a35992c --- /dev/null +++ b/database/migrations/2026_02_27_000003_create_artwork_view_events_table.php @@ -0,0 +1,51 @@ +id(); + $table->unsignedBigInteger('artwork_id'); + $table->unsignedBigInteger('user_id')->nullable(); // null = guest + + $table->timestamp('viewed_at')->useCurrent(); + + // Windowed aggregate queries: COUNT(*) WHERE artwork_id=? AND viewed_at>=? + $table->index(['artwork_id', 'viewed_at']); + // Per-user history: recent artworks viewed by a user + $table->index(['user_id', 'viewed_at']); + // Pruning: DELETE WHERE viewed_at < cutoff + $table->index('viewed_at'); + + $table->foreign('artwork_id') + ->references('id')->on('artworks') + ->cascadeOnDelete(); + + $table->foreign('user_id') + ->references('id')->on('users') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_view_events'); + } +}; diff --git a/docs/discovery-personalization-engine.md b/docs/discovery-personalization-engine.md new file mode 100644 index 00000000..d55676df --- /dev/null +++ b/docs/discovery-personalization-engine.md @@ -0,0 +1,591 @@ +# Discovery & Personalization Engine + +Covers the trending system, following feed, personalized homepage, similar artworks, unified activity feed, and all input signal collection that powers the ranking formula. + +--- + +## Table of Contents + +1. [Architecture Overview](#1-architecture-overview) +2. [Input Signal Collection](#2-input-signal-collection) +3. [Windowed Stats (views & downloads)](#3-windowed-stats-views--downloads) +4. [Trending Engine](#4-trending-engine) +5. [Discover Routes](#5-discover-routes) +6. [Following Feed](#6-following-feed) +7. [Personalized Homepage](#7-personalized-homepage) +8. [Similar Artworks API](#8-similar-artworks-api) +9. [Unified Activity Feed](#9-unified-activity-feed) +10. [Meilisearch Configuration](#10-meilisearch-configuration) +11. [Caching Strategy](#11-caching-strategy) +12. [Scheduled Jobs](#12-scheduled-jobs) +13. [Testing](#13-testing) +14. [Operational Runbook](#14-operational-runbook) + +--- + +## 1. Architecture Overview + +``` +Browser + │ + ├─ POST /api/art/{id}/view → ArtworkViewController + ├─ POST /api/art/{id}/download → ArtworkDownloadController + └─ POST /api/artworks/{id}/favorite / reactions / awards / comments + │ + ▼ + ArtworkStatsService UserStatsService + artwork_stats (all-time + user_statistics + windowed counters) └─ artwork_views_received_count + artwork_downloads (log) downloads_received_count + │ + ▼ + skinbase:reset-windowed-stats (nightly/weekly) + └─ zeros views_24h / views_7d + └─ recomputes downloads_24h / downloads_7d from log + │ + ▼ + skinbase:recalculate-trending (every 30 min) + └─ bulk UPDATE artworks.trending_score_24h / _7d + └─ dispatches IndexArtworkJob → Meilisearch + │ + ▼ + Meilisearch index (artworks) + └─ sortable: trending_score_7d, trending_score_24h, views, ... + └─ filterable: author_id, tags, category, orientation, is_public, ... + │ + ▼ + HomepageService / DiscoverController / SimilarArtworksController + └─ Redis cache (5 min TTL) + │ + ▼ + Inertia + React frontend +``` + +--- + +## 2. Input Signal Collection + +### 2.1 View tracking — `POST /api/art/{id}/view` + +**Controller:** `App\Http\Controllers\Api\ArtworkViewController` +**Route name:** `api.art.view` +**Throttle:** 5 requests per 10 minutes per IP + +**Deduplication (layered):** + +| Layer | Mechanism | Scope | +|---|---|---| +| Client-side | `sessionStorage` key `sb_viewed_{id}` set before the request | Browser tab lifetime | +| Server-side | `$request->session()->put('art_viewed.{id}', true)` | Laravel session lifetime | +| Throttle | `throttle:5,10` route middleware | Per-IP per-artwork | + +The React component `ArtworkActions.jsx` fires a `useEffect` on mount that checks `sessionStorage` first, then hits the endpoint. The response includes `counted: true|false` so callers can confirm whether the increment actually happened. + +**What gets incremented:** + +``` +artwork_stats.views +1 (all-time) +artwork_stats.views_24h +1 (zeroed nightly) +artwork_stats.views_7d +1 (zeroed weekly) +user_statistics.artwork_views_received_count +1 (creator aggregate) +``` + +Via `ArtworkStatsService::incrementViews()` with `defer: true` (Redis when available, direct DB fallback). + +--- + +### 2.2 Download tracking — `POST /api/art/{id}/download` + +**Controller:** `App\Http\Controllers\Api\ArtworkDownloadController` +**Route name:** `api.art.download` +**Throttle:** 10 requests per minute per IP + +The endpoint: +1. Inserts a row in `artwork_downloads` (persistent event log with `created_at`) +2. Increments `artwork_stats.downloads`, `downloads_24h`, `downloads_7d` +3. Returns `{"ok": true, "url": ""}` for the native browser download + +The `` buttons in `ArtworkActions.jsx` call `trackDownload()` on click — a fire-and-forget `fetch()` POST. The actual browser download is triggered by the `href`/`download` attributes and is never blocked by the tracking request. + +**What gets incremented:** + +``` +artwork_downloads INSERT (event log, persisted forever) +artwork_stats.downloads +1 (all-time) +artwork_stats.downloads_24h +1 (recomputed from log nightly) +artwork_stats.downloads_7d +1 (recomputed from log weekly) +user_statistics.downloads_received_count +1 (creator aggregate) +``` + +Via `ArtworkStatsService::incrementDownloads()` with `defer: true`. + +--- + +### 2.3 Other signals (already existed) + +| Signal | Endpoint / Service | Written to | +|---|---|---| +| Favorite toggle | `POST /api/artworks/{id}/favorite` | `user_favorites`, `artwork_stats.favorites` | +| Reaction toggle | `POST /api/artworks/{id}/reactions` | `artwork_reactions` | +| Award | `ArtworkAwardController` | `artwork_award_stats.score_total` | +| Comment | `ArtworkCommentController` | `artwork_comments`, `activity_events` | +| Follow | `FollowService` | `user_followers`, `activity_events` | + +--- + +### 2.4 ArtworkStatsService — Redis deferral + +When Redis is available all increments are pushed to a list key `artwork_stats:deltas` as JSON payloads. A separate job/command (`processPendingFromRedis`) drains the queue and applies bulk `applyDelta()` calls. If Redis is unavailable the service falls back transparently to a direct DB increment. + +```php +// Deferred (default for view/download controllers) +$svc->incrementViews($artworkId, 1, defer: true); + +// Immediate (e.g. favorites toggle needs instant feedback) +$svc->incrementDownloads($artworkId, 1, defer: false); +``` + +--- + +## 3. Windowed Stats (views & downloads) + +### 3.1 Why windowed columns? + +The trending formula needs _recent_ activity, not all-time totals. `artwork_stats.views` is a monotonically increasing counter — using it for trending would permanently favour old popular artworks and new artworks could never compete. + +The solution is four cached window columns refreshed on a schedule: + +| Column | Meaning | Reset cadence | +|---|---|---| +| `views_24h` | Views since last midnight reset | Nightly at 03:30 | +| `views_7d` | Views since last Monday reset | Weekly (Mon) at 03:30 | +| `downloads_24h` | Downloads in last 24 h | Nightly at 03:30 (recomputed from log) | +| `downloads_7d` | Downloads in last 7 days | Weekly (Mon) at 03:30 (recomputed from log) | + +### 3.2 How views windowing works + +**No per-view event log exists** (storing millions of view rows would be expensive). Instead: + +- Every view event increments `views_24h` and `views_7d` alongside `views`. +- The reset command **zeroes** both columns. Artworks re-accumulate from the reset time onward. +- Accuracy is "views since last reset", which is close enough for trending (error ≤ 1 day). + +### 3.3 How downloads windowing works + +**`artwork_downloads` is a full event log** with `created_at`. The reset command: + +1. Queries `COUNT(*) FROM artwork_downloads WHERE artwork_id = ? AND created_at >= NOW() - {interval}` for each artwork in chunks of 1000. +2. Writes the exact count back to `downloads_24h` / `downloads_7d`. + +This overwrites any drift from deferred Redis increments, making download windows always accurate at reset time. + +### 3.4 Reset command + +```bash +php artisan skinbase:reset-windowed-stats --period=24h +php artisan skinbase:reset-windowed-stats --period=7d +``` + +Uses chunked PHP loop (no `GREATEST()` / `INTERVAL` MySQL syntax) → works in both production MySQL and SQLite test DB. + +--- + +## 4. Trending Engine + +### 4.1 Formula + +``` +score = (award_score × 5.0) + + (favorites × 3.0) + + (reactions × 2.0) + + (downloads_Xd × 1.0) ← windowed: 24h or 7d + + (views_Xd × 2.0) ← windowed: 24h or 7d + - (hours_since_published × 0.1) + +score = max(score, 0) ← clamped via GREATEST() +``` + +Weights are constants in `TrendingService` (`W_AWARD`, `W_FAVORITE`, etc.) — adjust without a schema change. + +### 4.2 Output columns + +| Artworks column | Meaning | +|---|---| +| `trending_score_24h` | Score using `views_24h` + `downloads_24h`; targets artworks ≤ 7 days old | +| `trending_score_7d` | Score using `views_7d` + `downloads_7d`; targets artworks ≤ 30 days old | +| `last_trending_calculated_at` | Timestamp of last calculation | + +### 4.3 Recalculation command + +```bash +php artisan skinbase:recalculate-trending --period=24h +php artisan skinbase:recalculate-trending --period=7d +php artisan skinbase:recalculate-trending --period=all +php artisan skinbase:recalculate-trending --period=7d --skip-index # skip Meilisearch jobs +php artisan skinbase:recalculate-trending --chunk=500 # smaller DB chunks +``` + +**Implementation:** `App\Services\TrendingService::recalculate()` + +1. Chunks artworks published within the look-back window (`chunkById(1000, ...)`). +2. Issues one bulk MySQL `UPDATE ... WHERE id IN (...)` per chunk — no per-artwork queries in the hot path. +3. After each chunk, dispatches `IndexArtworkJob` per artwork to push updated scores to Meilisearch (skippable with `--skip-index`). + +> **Note:** The raw SQL uses `GREATEST()` and `TIMESTAMPDIFF(HOUR, ...)` which are MySQL 8 only. The command is tested in production against MySQL; the 4 related Pest tests are skipped on SQLite with a clear skip message. + +### 4.4 Meilisearch sync after calculation + +`TrendingService::syncToSearchIndex()` dispatches `IndexArtworkJob` for every artwork in the trending window. The job calls `Artwork::searchable()` which triggers `toSearchableArray()`, which includes `trending_score_24h` and `trending_score_7d`. + +--- + +## 5. Discover Routes + +All routes under `/discover/*` are registered in `routes/web.php` and handled by `App\Http\Controllers\Web\DiscoverController`. All use **Meilisearch sorting** — no SQL `ORDER BY` in the hot path. + +| Route | Name | Sort key | Auth | +|---|---|---|---| +| `/discover/trending` | `discover.trending` | `trending_score_7d:desc` | No | +| `/discover/fresh` | `discover.fresh` | `created_at:desc` | No | +| `/discover/top-rated` | `discover.top-rated` | `likes:desc` | No | +| `/discover/most-downloaded` | `discover.most-downloaded` | `downloads:desc` | No | +| `/discover/following` | `discover.following` | `created_at:desc` (DB) | Yes | + +--- + +## 6. Following Feed + +**Route:** `GET /discover/following` (auth required) +**Controller:** `DiscoverController::following()` + +### Logic + +``` +1. Get user's following IDs from user_followers +2. If empty → show empty state (see below) +3. If present → Artwork::whereIn('user_id', $followingIds) + ->orderByDesc('published_at') + ->paginate(24) + + cached 1 min per user per page +``` + +### Empty state + +When the user follows nobody: + +- `fallback_trending` — up to 12 trending artworks (Meilisearch, with DB fallback) +- `fallback_creators` — 8 most-followed verified users (ordered by `user_statistics.followers_count`) +- `empty: true` flag passed to the view +- The `discoverTrending()` call is wrapped in `try/catch` so a Meilisearch outage never breaks the empty state page + +--- + +## 7. Personalized Homepage + +**Controller:** `HomeController::index()` +**Service:** `App\Services\HomepageService` + +### Guest sections + +```php +[ + 'hero' => first featured artwork, + 'trending' => 12 artworks sorted by trending_score_7d, + 'fresh' => 12 newest artworks, + 'tags' => 12 most-used tags, + 'creators' => creator spotlight, + 'news' => latest news posts, +] +``` + +### Authenticated sections (personalized) + +```php +[ + 'hero' => same as guest, + 'from_following' => artworks from followed creators (up to 12, cached 1 min), + 'trending' => same as guest, + 'by_tags' => artworks matching user's top 5 tags, + 'by_categories' => fresh uploads in user's top 3 favourite categories, + 'tags' => same as guest, + 'creators' => same as guest, + 'news' => same as guest, + 'preferences' => { top_tags, top_categories }, +] +``` + +### UserPreferenceService + +`App\Services\UserPreferenceService::build(User $user)` — cached 5 min per user. + +Computes preferences from the user's **favourited artworks**: + +| Output key | Source | +|---|---| +| `top_tags` (up to 5) | Tags on artworks in `artwork_favourites` | +| `top_categories` (up to 3) | Categories on artworks in `artwork_favourites` | +| `followed_creators` | IDs from `user_followers` | + +### getTrending() — Meilisearch-first + +```php +Artwork::search('') + ->options([ + 'filter' => 'is_public = true AND is_approved = true', + 'sort' => ['trending_score_7d:desc', 'trending_score_24h:desc', 'views:desc'], + ]) + ->paginate($limit, 'page', 1); +``` + +Falls back to `getTrendingFromDb()` — `orderByDesc('trending_score_7d')` with no correlated subqueries — when Meilisearch is unavailable. + +--- + +## 8. Similar Artworks API + +**Route:** `GET /api/art/{id}/similar` +**Controller:** `App\Http\Controllers\Api\SimilarArtworksController` +**Route name:** `api.art.similar` +**Throttle:** 60/min +**Cache:** 5 min per artwork ID +**Max results:** 12 + +### Similarity algorithm + +Meilisearch filters are built in priority order: + +``` +is_public = true +is_approved = true +id != {source_id} +author_id != {source_author_id} ← same creator excluded +orientation = "{landscape|portrait}" ← only for non-square (visual coherence) +(tags = "X" OR tags = "Y" OR ...) ← tag overlap (primary signal) + OR (if no tags) +(category = "X" OR ...) ← category fallback +``` + +Meilisearch's own ranking then sorts by relevance within those filters. Results are mapped to a slim JSON shape: `{id, title, slug, thumb, url, author_id}`. + +--- + +## 9. Unified Activity Feed + +**Route:** `GET /community/activity?type=global|following` +**Controller:** `App\Http\Controllers\Web\CommunityActivityController` + +### `activity_events` schema + +| Column | Type | Notes | +|---|---|---| +| `id` | bigint PK | | +| `actor_id` | bigint FK users | Who did the action | +| `type` | varchar | `upload` `comment` `favorite` `award` `follow` | +| `target_type` | varchar | `artwork` `user` | +| `target_id` | bigint | ID of the target object | +| `meta` | json nullable | Extra data (e.g. award tier) | +| `created_at` | timestamp | No `updated_at` — immutable events | + +### Where events are recorded + +| Event type | Recording point | +|---|---| +| `upload` | `UploadController::finish()` on publish | +| `follow` | `FollowService::follow()` | +| `award` | `ArtworkAwardController::store()` | +| `favorite` | `ArtworkInteractionController::favorite()` | +| `comment` | `ArtworkCommentController::store()` | + +All via `ActivityEvent::record($actorId, $type, $targetType, $targetId, $meta)`. + +### Feed filters + +- **Global** — all recent events, newest first, paginated 30/page +- **Following** — `WHERE actor_id IN (following_ids)` — only events from users you follow + +The controller enriches each event batch with its target objects in a single query per target type (no N+1). + +--- + +## 10. Meilisearch Configuration + +Configured in `config/scout.php` under `meilisearch.index-settings`. + +Push settings to a running instance: +```bash +php artisan scout:sync-index-settings +``` + +### Artworks index settings + +**Searchable attributes** (ranked in order): +1. `title` +2. `tags` +3. `author_name` +4. `description` + +**Filterable attributes:** +`tags`, `category`, `content_type`, `orientation`, `resolution`, `author_id`, `is_public`, `is_approved` + +**Sortable attributes:** +`created_at`, `downloads`, `likes`, `views`, `trending_score_24h`, `trending_score_7d`, `favorites_count`, `awards_received_count`, `downloads_count` + +### toSearchableArray() — fields indexed per artwork + +```php +[ + 'id', 'slug', 'title', 'description', + 'author_id', 'author_name', + 'category', 'content_type', 'tags', + 'resolution', 'orientation', + 'downloads', 'likes', 'views', + 'created_at', 'is_public', 'is_approved', + 'trending_score_24h', 'trending_score_7d', + 'favorites_count', 'awards_received_count', 'downloads_count', + 'awards' => { gold, silver, bronze, score }, +] +``` + +--- + +## 11. Caching Strategy + +| Data | Cache key | TTL | Driver | +|---|---|---|---| +| Homepage trending | `homepage.trending.{limit}` | 5 min | Redis/file | +| Homepage fresh | `homepage.fresh.{limit}` | 5 min | Redis/file | +| Homepage hero | `homepage.hero` | 5 min | Redis/file | +| Homepage tags | `homepage.tags.{limit}` | 5 min | Redis/file | +| User preferences | `user.prefs.{user_id}` | 5 min | Redis/file | +| Following feed | `discover.following.{user_id}.p{page}` | 1 min | Redis/file | +| Similar artworks | `api.similar.{artwork_id}` | 5 min | Redis/file | + +**Rules:** +- Personalized data (`from_following`, `by_tags`, `by_categories`) is **not** independently cached — it falls inside `allForUser()` which is called fresh per request. +- Long-running cache busting: the trending command and reset command do not explicitly clear cache — the TTL is short enough that stale data self-expires within one trending cycle. + +--- + +## 12. Scheduled Jobs + +All registered in `routes/console.php` via `Schedule::command()`. + +| Time | Command | Purpose | +|---|---|---| +| Every 30 min | `skinbase:recalculate-trending --period=24h` | Update `trending_score_24h` | +| Every 30 min | `skinbase:recalculate-trending --period=7d --skip-index` | Update `trending_score_7d` (background) | +| 03:00 daily | `uploads:cleanup` | Remove stale draft uploads | +| 03:10 daily | `analytics:aggregate-similar-artworks` | Offline similarity metrics | +| 03:20 daily | `analytics:aggregate-feed` | Feed evaluation metrics | +| 03:30 daily | `skinbase:reset-windowed-stats --period=24h` | Zero views_24h, recompute downloads_24h | +| Monday 03:30 | `skinbase:reset-windowed-stats --period=7d` | Zero views_7d, recompute downloads_7d | + +**Reset runs at 03:30** so it fires after the other maintenance tasks (03:00–03:20). The next trending recalculation (every 30 min, including ~03:30 or ~04:00) picks up the freshly-zeroed windowed stats and writes accurate trending scores. + +--- + +## 13. Testing + +All tests live under `tests/Feature/Discovery/`. + +| Test file | Coverage | +|---|---| +| `ActivityEventRecordingTest.php` | `ActivityEvent::record()`, all 5 types, actor relation, meta, route smoke tests for the activity feed | +| `FollowingFeedTest.php` | Auth redirect, empty state fallback, pagination, creator exclusion | +| `HomepagePersonalizationTest.php` | Guest vs auth homepage sections, preferences shape, 200 responses | +| `SimilarArtworksApiTest.php` | 404 cases, response shape, result count ≤ 12, creator exclusion | +| `SignalTrackingTest.php` | View endpoint (404s, first count, session dedup), download endpoint (404s, DB row, guest vs auth), route names | +| `TrendingServiceTest.php` | Zero artworks, skip outside window, skip private/unapproved — _recalculate() tests skipped on SQLite (MySQL-only SQL)_ | +| `WindowedStatsTest.php` | `incrementViews/Downloads` update all 3 columns, reset command zeros views, recomputes downloads from log, window boundary correctness | + +Run all discovery tests: +```bash +php artisan test tests/Feature/Discovery/ +``` + +Run specific suite: +```bash +php artisan test tests/Feature/Discovery/SignalTrackingTest.php +``` + +**SQLite vs MySQL note:** Four tests in `TrendingServiceTest` are marked `.skip()` with the message _"Requires MySQL: uses GREATEST() and TIMESTAMPDIFF()"_. Run them against a real MySQL instance in CI or staging to validate the bulk UPDATE formula. + +--- + +## 14. Operational Runbook + +### Trending scores are stuck / not updating + +```bash +# Check last calculated timestamp +SELECT id, title, last_trending_calculated_at FROM artworks ORDER BY last_trending_calculated_at DESC LIMIT 5; + +# Manually trigger recalculation +php artisan skinbase:recalculate-trending --period=all + +# Re-push scores to Meilisearch +php artisan skinbase:recalculate-trending --period=7d +``` + +### Windowed counters look wrong after a deploy + +```bash +# Force a reset and recompute +php artisan skinbase:reset-windowed-stats --period=24h +php artisan skinbase:reset-windowed-stats --period=7d + +# Then recalculate trending with fresh numbers +php artisan skinbase:recalculate-trending --period=all +``` + +### Meilisearch out of sync with DB + +```bash +# Re-push all artworks in the trending window +php artisan skinbase:recalculate-trending --period=all + +# Or full re-index +php artisan scout:import "App\Models\Artwork" +``` + +### Push updated index settings (after changing config/scout.php) + +```bash +php artisan scout:sync-index-settings +``` + +### Check what the trending formula is reading + +```sql +SELECT + a.id, + a.title, + a.published_at, + s.views, + s.views_24h, + s.views_7d, + s.downloads, + s.downloads_24h, + s.downloads_7d, + s.favorites, + a.trending_score_24h, + a.trending_score_7d, + a.last_trending_calculated_at +FROM artworks a +LEFT JOIN artwork_stats s ON s.artwork_id = a.id +WHERE a.is_public = 1 AND a.is_approved = 1 +ORDER BY a.trending_score_7d DESC +LIMIT 20; +``` + +### Inspect the artwork_downloads log + +```sql +-- Downloads in the last 24 hours per artwork +SELECT artwork_id, COUNT(*) as dl_24h +FROM artwork_downloads +WHERE created_at >= NOW() - INTERVAL 1 DAY +GROUP BY artwork_id +ORDER BY dl_24h DESC +LIMIT 20; +``` diff --git a/playwright-report/data/3805b34cfd601b3a221f7f1a9b99827131dffbb1.png b/playwright-report/data/3805b34cfd601b3a221f7f1a9b99827131dffbb1.png deleted file mode 100644 index b36a2ff5..00000000 Binary files a/playwright-report/data/3805b34cfd601b3a221f7f1a9b99827131dffbb1.png and /dev/null differ diff --git a/playwright-report/data/44eed0617fdda327586a382401abe7fe1b3e62b2.md b/playwright-report/data/44eed0617fdda327586a382401abe7fe1b3e62b2.md deleted file mode 100644 index b6dacef2..00000000 --- a/playwright-report/data/44eed0617fdda327586a382401abe7fe1b3e62b2.md +++ /dev/null @@ -1,173 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - banner [ref=e2]: - - generic [ref=e3]: - - link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]: - - /url: / - - img "Skinbase.org" [ref=e5] - - generic [ref=e6]: Skinbase.org - - navigation "Main navigation" [ref=e7]: - - button "Discover" [ref=e9] [cursor=pointer]: - - text: Discover - - img [ref=e10] - - button "Browse" [ref=e13] [cursor=pointer]: - - text: Browse - - img [ref=e14] - - button "Creators" [ref=e17] [cursor=pointer]: - - text: Creators - - img [ref=e18] - - button "Community" [ref=e21] [cursor=pointer]: - - text: Community - - img [ref=e22] - - generic [ref=e26]: - - button "Open search" [ref=e27] [cursor=pointer]: - - img [ref=e28] - - generic [ref=e30]: Search\u2026 - - generic [ref=e31]: CtrlK - - search: - - generic: - - img - - searchbox "Search" - - generic: - - generic: Esc - - button "Close search": - - img - - link "Upload" [ref=e32] [cursor=pointer]: - - /url: http://skinbase26.test/upload - - img [ref=e33] - - text: Upload - - generic [ref=e35]: - - link "Favourites" [ref=e36] [cursor=pointer]: - - /url: http://skinbase26.test/dashboard/favorites - - img [ref=e37] - - link "Messages" [ref=e39] [cursor=pointer]: - - /url: http://skinbase26.test/messages - - img [ref=e40] - - link "Notifications" [ref=e42] [cursor=pointer]: - - /url: http://skinbase26.test/dashboard/comments - - img [ref=e43] - - button "E2E Owner E2E Owner" [ref=e47] [cursor=pointer]: - - img "E2E Owner" [ref=e48] - - generic [ref=e49]: E2E Owner - - img [ref=e50] - - text:                      - - main [ref=e52]: - - generic [ref=e55]: - - complementary [ref=e56]: - - generic [ref=e57]: - - heading "Messages" [level=1] [ref=e58] - - button "New message" [ref=e59] [cursor=pointer]: - - img [ref=e60] - - searchbox "Search all messages…" [ref=e63] - - generic [ref=e64]: - - searchbox "Search conversations…" [ref=e66] - - list [ref=e67]: - - listitem [ref=e68]: - - button "E e2ep708148630 now Seed latest from owner" [ref=e69] [cursor=pointer]: - - generic [ref=e70]: E - - generic [ref=e71]: - - generic [ref=e72]: - - generic [ref=e74]: e2ep708148630 - - generic [ref=e75]: now - - generic [ref=e77]: Seed latest from owner - - main [ref=e78]: - - generic [ref=e79]: - - generic [ref=e80]: - - paragraph [ref=e82]: e2ep708148630 - - button "Pin" [ref=e83] [cursor=pointer] - - searchbox "Search in this conversation…" [ref=e85] - - generic [ref=e86]: - - generic [ref=e87]: - - separator [ref=e88] - - generic [ref=e89]: Today - - separator [ref=e90] - - generic [ref=e92]: - - generic [ref=e94]: E - - generic [ref=e95]: - - generic [ref=e96]: - - generic [ref=e97]: e2ep708148630 - - generic [ref=e98]: 09:11 PM - - paragraph [ref=e102]: Seed hello - - generic [ref=e104]: - - generic [ref=e106]: E - - generic [ref=e107]: - - generic [ref=e108]: - - generic [ref=e109]: e2eo708148630 - - generic [ref=e110]: 09:11 PM - - paragraph [ref=e114]: Seed latest from owner - - generic [ref=e115]: Seen 4s ago - - generic [ref=e116]: - - button "📎" [ref=e117] [cursor=pointer] - - textbox "Write a message… (Enter to send, Shift+Enter for new line)" [ref=e118] - - button "Send" [disabled] [ref=e119] - - contentinfo [ref=e120]: - - generic [ref=e121]: - - generic [ref=e122]: - - img "Skinbase" [ref=e123] - - generic [ref=e124]: Skinbase - - generic [ref=e125]: - - link "Bug Report" [ref=e126] [cursor=pointer]: - - /url: /bug-report - - link "RSS Feeds" [ref=e127] [cursor=pointer]: - - /url: /rss-feeds - - link "FAQ" [ref=e128] [cursor=pointer]: - - /url: /faq - - link "Rules and Guidelines" [ref=e129] [cursor=pointer]: - - /url: /rules-and-guidelines - - link "Staff" [ref=e130] [cursor=pointer]: - - /url: /staff - - link "Privacy Policy" [ref=e131] [cursor=pointer]: - - /url: /privacy-policy - - generic [ref=e132]: © 2026 Skinbase.org - - generic [ref=e133]: - - generic [ref=e135]: - - generic [ref=e137]: - - generic [ref=e138] [cursor=pointer]: - - generic: Request - - generic [ref=e139] [cursor=pointer]: - - generic: Timeline - - generic [ref=e140] [cursor=pointer]: - - generic: Queries - - generic [ref=e141]: "14" - - generic [ref=e142] [cursor=pointer]: - - generic: Models - - generic [ref=e143]: "5" - - generic [ref=e144] [cursor=pointer]: - - generic: Cache - - generic [ref=e145]: "2" - - generic [ref=e146]: - - generic [ref=e153] [cursor=pointer]: - - generic [ref=e154]: "4" - - generic [ref=e155]: GET /api/messages/4 - - generic [ref=e156] [cursor=pointer]: - - generic: 706ms - - generic [ref=e158] [cursor=pointer]: - - generic: 28MB - - generic [ref=e160] [cursor=pointer]: - - generic: 12.x - - generic [ref=e162]: - - generic [ref=e164]: - - generic: - - list - - generic [ref=e166]: - - list [ref=e167] - - textbox "Search" [ref=e170] - - generic [ref=e171]: - - list - - generic [ref=e173]: - - list - - list [ref=e178] - - generic [ref=e180]: - - generic: - - list - - generic [ref=e182]: - - list [ref=e183] - - textbox "Search" [ref=e186] - - generic [ref=e187]: - - list - - generic [ref=e189]: - - generic: - - list -``` \ No newline at end of file diff --git a/playwright-report/index.html b/playwright-report/index.html index 55c66700..4d105f67 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/resources/js/Pages/Messages/Index.jsx b/resources/js/Pages/Messages/Index.jsx index 1da64528..ea7c9834 100644 --- a/resources/js/Pages/Messages/Index.jsx +++ b/resources/js/Pages/Messages/Index.jsx @@ -39,6 +39,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { const [conversations, setConversations] = useState([]) const [loadingConvs, setLoadingConvs] = useState(true) const [activeId, setActiveId] = useState(initialId ?? null) + const [realtimeEnabled, setRealtimeEnabled] = useState(false) const [showNewModal, setShowNewModal] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState([]) @@ -60,11 +61,32 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { useEffect(() => { loadConversations() - // Phase 1 polling: refresh conversation list every 15 seconds - pollRef.current = setInterval(loadConversations, 15_000) - return () => clearInterval(pollRef.current) + apiFetch('/api/messages/settings') + .then(data => setRealtimeEnabled(!!data?.realtime_enabled)) + .catch(() => setRealtimeEnabled(false)) + + return () => { + if (pollRef.current) clearInterval(pollRef.current) + } }, [loadConversations]) + useEffect(() => { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + + if (realtimeEnabled) { + return + } + + pollRef.current = setInterval(loadConversations, 15_000) + + return () => { + if (pollRef.current) clearInterval(pollRef.current) + } + }, [loadConversations, realtimeEnabled]) + const handleSelectConversation = useCallback((id) => { setActiveId(id) history.replaceState(null, '', `/messages/${id}`) @@ -190,6 +212,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { key={activeId} conversationId={activeId} conversation={activeConversation} + realtimeEnabled={realtimeEnabled} currentUserId={userId} currentUsername={username} apiFetch={apiFetch} diff --git a/resources/js/Search/SearchBar.jsx b/resources/js/Search/SearchBar.jsx index a7f70f17..374f3f3f 100644 --- a/resources/js/Search/SearchBar.jsx +++ b/resources/js/Search/SearchBar.jsx @@ -181,7 +181,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag - Search\u2026 + Search {isMac ? '\u2318' : 'Ctrl'}K diff --git a/resources/js/components/artwork/ArtworkActions.jsx b/resources/js/components/artwork/ArtworkActions.jsx index 769116a9..c30327a6 100644 --- a/resources/js/components/artwork/ArtworkActions.jsx +++ b/resources/js/components/artwork/ArtworkActions.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false }) { const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked)) @@ -10,6 +10,29 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = ? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') : null + // Track the view once per browser session (sessionStorage prevents re-firing). + useEffect(() => { + if (!artwork?.id) return + const key = `sb_viewed_${artwork.id}` + if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return + if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, '1') + fetch(`/api/art/${artwork.id}/view`, { + method: 'POST', + headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' }, + credentials: 'same-origin', + }).catch(() => {}) + }, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps + + // Fire-and-forget download tracking — does not interrupt the native download. + const trackDownload = () => { + if (!artwork?.id) return + fetch(`/api/art/${artwork.id}/download`, { + method: 'POST', + headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' }, + credentials: 'same-origin', + }).catch(() => {}) + } + const postInteraction = async (url, body) => { const response = await fetch(url, { method: 'POST', @@ -82,6 +105,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
Download @@ -125,6 +149,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = Download diff --git a/resources/js/components/messaging/ConversationThread.jsx b/resources/js/components/messaging/ConversationThread.jsx index fb432b55..5e84e160 100644 --- a/resources/js/components/messaging/ConversationThread.jsx +++ b/resources/js/components/messaging/ConversationThread.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react' +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import MessageBubble from './MessageBubble' /** @@ -7,6 +7,7 @@ import MessageBubble from './MessageBubble' export default function ConversationThread({ conversationId, conversation, + realtimeEnabled = false, currentUserId, currentUsername, apiFetch, @@ -22,9 +23,11 @@ export default function ConversationThread({ const [loadingMore, setLoadingMore] = useState(false) const [error, setError] = useState(null) const [attachments, setAttachments] = useState([]) + const [uploadProgress, setUploadProgress] = useState(null) const [typingUsers, setTypingUsers] = useState([]) const [threadSearch, setThreadSearch] = useState('') const [threadSearchResults, setThreadSearchResults] = useState([]) + const [lightboxImage, setLightboxImage] = useState(null) const fileInputRef = useRef(null) const bottomRef = useRef(null) const threadRef = useRef(null) @@ -34,6 +37,22 @@ export default function ConversationThread({ const latestIdRef = useRef(null) const shouldAutoScrollRef = useRef(true) const draftKey = `nova_draft_${conversationId}` + const previewAttachments = useMemo(() => { + return attachments.map(file => ({ + file, + previewUrl: isImageLike(file) ? URL.createObjectURL(file) : null, + })) + }, [attachments]) + + useEffect(() => { + return () => { + for (const item of previewAttachments) { + if (item.previewUrl) { + URL.revokeObjectURL(item.previewUrl) + } + } + } + }, [previewAttachments]) // ── Initial load ───────────────────────────────────────────────────────── const loadMessages = useCallback(async () => { @@ -58,37 +77,42 @@ export default function ConversationThread({ setBody(storedDraft ?? '') loadMessages() - // Phase 1 polling: check new messages every 10 seconds - pollRef.current = setInterval(async () => { - try { - const data = await apiFetch(`/api/messages/${conversationId}`) - const latestChunk = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId)) - if (latestChunk.length && latestChunk[latestChunk.length - 1].id !== latestIdRef.current) { - shouldAutoScrollRef.current = true - setMessages(prev => mergeMessageLists(prev, latestChunk)) - latestIdRef.current = latestChunk[latestChunk.length - 1].id - onConversationUpdated() - } - } catch (_) {} - }, 10_000) - - return () => clearInterval(pollRef.current) - }, [conversationId, draftKey]) - - useEffect(() => { - typingPollRef.current = setInterval(async () => { - try { - const data = await apiFetch(`/api/messages/${conversationId}/typing`) - setTypingUsers(data.typing ?? []) - } catch (_) {} - }, 2_000) + if (!realtimeEnabled) { + pollRef.current = setInterval(async () => { + try { + const data = await apiFetch(`/api/messages/${conversationId}`) + const latestChunk = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId)) + if (latestChunk.length && latestChunk[latestChunk.length - 1].id !== latestIdRef.current) { + shouldAutoScrollRef.current = true + setMessages(prev => mergeMessageLists(prev, latestChunk)) + latestIdRef.current = latestChunk[latestChunk.length - 1].id + onConversationUpdated() + } + } catch (_) {} + }, 10_000) + } return () => { - clearInterval(typingPollRef.current) + if (pollRef.current) clearInterval(pollRef.current) + } + }, [conversationId, draftKey, realtimeEnabled, currentUserId, apiFetch, loadMessages, onConversationUpdated]) + + useEffect(() => { + if (!realtimeEnabled) { + typingPollRef.current = setInterval(async () => { + try { + const data = await apiFetch(`/api/messages/${conversationId}/typing`) + setTypingUsers(data.typing ?? []) + } catch (_) {} + }, 2_000) + } + + return () => { + if (typingPollRef.current) clearInterval(typingPollRef.current) clearTimeout(typingStopTimerRef.current) apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) } - }, [conversationId, apiFetch]) + }, [conversationId, apiFetch, realtimeEnabled]) useEffect(() => { const content = body.trim() @@ -190,10 +214,10 @@ export default function ConversationThread({ const formData = new FormData() formData.append('body', text) attachments.forEach(file => formData.append('attachments[]', file)) + setUploadProgress(0) - const msg = await apiFetch(`/api/messages/${conversationId}`, { - method: 'POST', - body: formData, + const msg = await sendMessageWithProgress(`/api/messages/${conversationId}`, formData, (progress) => { + setUploadProgress(progress) }) setMessages(prev => prev.map(m => m.id === optimistic.id ? msg : m)) latestIdRef.current = msg.id @@ -203,6 +227,7 @@ export default function ConversationThread({ setMessages(prev => prev.filter(m => m.id !== optimistic.id)) setError(e.message) } finally { + setUploadProgress(null) setSending(false) } }, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey]) @@ -292,6 +317,24 @@ export default function ConversationThread({ } }, [conversation, currentUserId, apiFetch, conversationId, onConversationUpdated]) + const toggleMute = useCallback(async () => { + try { + await apiFetch(`/api/messages/${conversationId}/mute`, { method: 'POST' }) + onConversationUpdated() + } catch (e) { + setError(e.message) + } + }, [apiFetch, conversationId, onConversationUpdated]) + + const toggleArchive = useCallback(async () => { + try { + await apiFetch(`/api/messages/${conversationId}/archive`, { method: 'POST' }) + onConversationUpdated() + } catch (e) { + setError(e.message) + } + }, [apiFetch, conversationId, onConversationUpdated]) + useEffect(() => { let cancelled = false const q = threadSearch.trim() @@ -330,6 +373,7 @@ export default function ConversationThread({ const threadLabel = conversation?.type === 'group' ? (conversation?.title ?? 'Group conversation') : (conversation?.all_participants?.find(p => p.user_id !== currentUserId)?.user?.username ?? 'Direct message') + const myParticipant = conversation?.my_participant ?? conversation?.all_participants?.find(p => p.user_id === currentUserId) const otherParticipant = conversation?.all_participants?.find(p => p.user_id !== currentUserId) const otherLastReadAt = otherParticipant?.last_read_at ?? null const lastMessageId = messages[messages.length - 1]?.id ?? null @@ -365,7 +409,21 @@ export default function ConversationThread({ onClick={togglePin} className="ml-auto text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800" > - {conversation?.my_participant?.is_pinned ? 'Unpin' : 'Pin'} + {myParticipant?.is_pinned ? 'Unpin' : 'Pin'} + + + @@ -429,6 +487,7 @@ export default function ConversationThread({ onUnreact={handleUnreact} onEdit={handleEdit} onReport={handleReportMessage} + onOpenImage={setLightboxImage} seenText={buildSeenText({ message: msg, isMine: msg.sender_id === currentUserId, @@ -490,14 +549,41 @@ export default function ConversationThread({ {attachments.length > 0 && (
- {attachments.map((file, idx) => ( + {previewAttachments.map(({ file, previewUrl }, idx) => (
+ {previewUrl && ( + {file.name} + )} {file.name}
))}
)} + + {sending && uploadProgress !== null && ( +
+
+
+
+

Uploading {uploadProgress}%

+
+ )} + + {lightboxImage && ( +
setLightboxImage(null)}> + {lightboxImage.original_name e.stopPropagation()} + /> +
+ )}
) } @@ -585,3 +671,42 @@ function isSameDay(a, b) { a.getMonth() === b.getMonth() && a.getDate() === b.getDate() } + +function isImageLike(file) { + return file?.type?.startsWith('image/') +} + +function getCsrf() { + return document.querySelector('meta[name="csrf-token"]')?.content ?? '' +} + +function sendMessageWithProgress(url, formData, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', url) + xhr.setRequestHeader('X-CSRF-TOKEN', getCsrf()) + xhr.setRequestHeader('Accept', 'application/json') + + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable) return + const progress = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100))) + onProgress(progress) + } + + xhr.onload = () => { + try { + const json = JSON.parse(xhr.responseText || '{}') + if (xhr.status >= 200 && xhr.status < 300) { + resolve(json) + return + } + reject(new Error(json.message || `HTTP ${xhr.status}`)) + } catch (_) { + reject(new Error(`HTTP ${xhr.status}`)) + } + } + + xhr.onerror = () => reject(new Error('Network error')) + xhr.send(formData) + }) +} diff --git a/resources/js/components/messaging/MessageBubble.jsx b/resources/js/components/messaging/MessageBubble.jsx index 49a8985a..3388a927 100644 --- a/resources/js/components/messaging/MessageBubble.jsx +++ b/resources/js/components/messaging/MessageBubble.jsx @@ -10,7 +10,7 @@ const QUICK_REACTIONS = ['👍', '❤️', '🔥', '😂', '👏', '😮'] * - Inline edit for own messages * - Soft-delete display */ -export default function MessageBubble({ message, isMine, showAvatar, onReact, onUnreact, onEdit, onReport = null, seenText = null }) { +export default function MessageBubble({ message, isMine, showAvatar, onReact, onUnreact, onEdit, onReport = null, onOpenImage = null, seenText = null }) { const [showPicker, setShowPicker] = useState(false) const [editing, setEditing] = useState(false) const [editBody, setEditBody] = useState(message.body ?? '') @@ -119,14 +119,18 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on {message.attachments.map(att => (
{att.type === 'image' ? ( - + ) : ( +
+

{{ $page_title }}

+
+ + {{-- Tab bar --}} +
+ +
+ @forelse($enriched as $event) +
+
+ + {{ $event['actor']['name'] ?? 'Someone' }} + + + @switch($event['type']) + @case('upload') + uploaded + @break + @case('comment') + commented on + @break + @case('favorite') + favourited + @break + @case('award') + awarded + @break + @case('follow') + started following + @break + @default + interacted with + @endswitch + + @if($event['target']) + @if($event['target_type'] === 'artwork') + {{ $event['target']['title'] }} + @if(!empty($event['target']['thumb'])) + + @endif + @elseif($event['target_type'] === 'user') + {{ $event['target']['name'] ?? $event['target']['username'] ?? '' }} + @endif + @endif + + {{ \Carbon\Carbon::parse($event['created_at'])->diffForHumans() }} +
+
+ @empty +
+ @if($active_tab === 'following') + Follow some creators to see their activity here. + @else + No activity yet. Be the first! + @endif +
+ @endforelse +
+ + {{-- Pagination --}} +
+ {{ $events->links() }} +
+
+@endsection diff --git a/resources/views/web/community/activity.blade.php b/resources/views/web/community/activity.blade.php new file mode 100644 index 00000000..be1d0730 --- /dev/null +++ b/resources/views/web/community/activity.blade.php @@ -0,0 +1,87 @@ +@extends('layouts.nova') + +@section('title', $page_title . ' — Skinbase') + +@section('content') +
+
+

{{ $page_title }}

+
+ + {{-- Tab bar --}} + + +
+ @forelse($enriched as $event) +
+
+ + {{ $event['actor']['name'] ?? 'Someone' }} + + + @switch($event['type']) + @case('upload') + uploaded + @break + @case('comment') + commented on + @break + @case('favorite') + favourited + @break + @case('award') + awarded + @break + @case('follow') + started following + @break + @default + interacted with + @endswitch + + @if($event['target']) + @if($event['target_type'] === 'artwork') + {{ $event['target']['title'] }} + @if(!empty($event['target']['thumb'])) + + @endif + @elseif($event['target_type'] === 'user') + {{ $event['target']['name'] ?? $event['target']['username'] ?? '' }} + @endif + @endif + + {{ \Carbon\Carbon::parse($event['created_at'])->diffForHumans() }} +
+
+ @empty +
+ @if($active_tab === 'following') + Follow some creators to see their activity here. + @else + No activity yet. Be the first! + @endif +
+ @endforelse +
+ + {{-- Pagination --}} +
+ {{ $events->links() }} +
+
+@endsection diff --git a/routes/api.php b/routes/api.php index 2b88d83a..0f913537 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,25 @@ use Illuminate\Support\Facades\Route; +// ── Per-artwork signal tracking (public) ──────────────────────────────────── +// GET /api/art/{id}/similar → up to 12 similar artworks (Meilisearch) +// POST /api/art/{id}/view → record a view (session-deduped, 5 per 10 min) +// POST /api/art/{id}/download → record a download, returns file URL (10/min) +Route::middleware(['web', 'throttle:60,1']) + ->get('art/{id}/similar', \App\Http\Controllers\Api\SimilarArtworksController::class) + ->whereNumber('id') + ->name('api.art.similar'); + +Route::middleware(['web', 'throttle:5,10']) + ->post('art/{id}/view', \App\Http\Controllers\Api\ArtworkViewController::class) + ->whereNumber('id') + ->name('api.art.view'); + +Route::middleware(['web', 'throttle:10,1']) + ->post('art/{id}/download', \App\Http\Controllers\Api\ArtworkDownloadController::class) + ->whereNumber('id') + ->name('api.art.download'); + /** * API v1 routes for Artworks module * diff --git a/routes/console.php b/routes/console.php index 5d3e087e..228d5b1f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,6 +2,7 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; use App\Uploads\Services\CleanupService; Artisan::command('inspire', function () { @@ -14,3 +15,51 @@ Artisan::command('uploads:cleanup {--limit=100 : Maximum drafts to clean in one $this->info("Uploads cleanup deleted {$deleted} draft(s)."); })->purpose('Delete stale draft uploads and temporary files'); + +// ── Scheduled tasks ──────────────────────────────────────────────────────────── + +// Recalculate trending scores every 30 minutes (staggered: 24h first, then 7d) +Schedule::command('skinbase:recalculate-trending --period=24h') + ->everyThirtyMinutes() + ->name('trending-24h') + ->withoutOverlapping(); + +Schedule::command('skinbase:recalculate-trending --period=7d --skip-index') + ->everyThirtyMinutes() + ->name('trending-7d') + ->runInBackground() + ->withoutOverlapping(); + +// Reset windowed view/download counters so trending uses recent-activity data. +// Downloads are recomputed from the artwork_downloads log (accurate). +// Views are zeroed (no per-view event log) and re-accumulate from midnight. +Schedule::command('skinbase:reset-windowed-stats --period=24h') + ->dailyAt('03:30') + ->name('reset-windowed-stats-24h') + ->withoutOverlapping(); + +Schedule::command('skinbase:reset-windowed-stats --period=7d') + ->weeklyOn(1, '03:30') // Monday 03:30 + ->name('reset-windowed-stats-7d') + ->withoutOverlapping(); + +// Daily maintenance +Schedule::command('uploads:cleanup')->dailyAt('03:00'); +Schedule::command('analytics:aggregate-similar-artworks')->dailyAt('03:10'); +Schedule::command('analytics:aggregate-feed')->dailyAt('03:20'); + +// Drain Redis artwork-stat delta queue so MySQL counters stay fresh. +// Run every 5 minutes with overlap protection. +Schedule::command('skinbase:flush-redis-stats') + ->everyFiveMinutes() + ->name('flush-redis-stats') + ->withoutOverlapping(); + +// Prune artwork_view_events rows older than 90 days. +// Runs Sunday at 04:00, after all other weekly maintenance. +Schedule::command('skinbase:prune-view-events --days=90') + ->weekly() + ->sundays() + ->at('04:00') + ->name('prune-view-events') + ->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index 83bc359d..02473321 100644 --- a/routes/web.php +++ b/routes/web.php @@ -378,3 +378,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('messages')->n Route::get('/', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'index'])->name('index'); Route::get('/{id}', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'show'])->whereNumber('id')->name('show'); }); + +// ── Community Activity Feed ─────────────────────────────────────────────────── +Route::get('/community/activity', [\App\Http\Controllers\Web\CommunityActivityController::class, 'index']) + ->name('community.activity'); diff --git a/scripts/check_redis.php b/scripts/check_redis.php new file mode 100644 index 00000000..58beb7c7 --- /dev/null +++ b/scripts/check_redis.php @@ -0,0 +1,20 @@ +make('Illuminate\Contracts\Console\Kernel')->bootstrap(); + +try { + $redis = Illuminate\Support\Facades\Redis::connection(); + $result = $redis->ping(); + $payload = is_object($result) && method_exists($result, 'getPayload') ? $result->getPayload() : $result; + $ok = ($payload === 'PONG' || $result === true || $result === 1); + echo 'Redis: ' . ($ok ? 'OK (PONG)' : 'UNEXPECTED: ' . var_export($result, true)) . PHP_EOL; + echo 'Host: ' . config('database.redis.default.host') . ':' . config('database.redis.default.port') . PHP_EOL; + echo 'Client: ' . config('database.redis.client') . PHP_EOL; + + // Also check if the stats delta key has anything queued + $depth = $redis->llen('artwork_stats:deltas'); + echo 'Delta queue depth (artwork_stats:deltas): ' . $depth . PHP_EOL; +} catch (Exception $e) { + echo 'FAILED: ' . $e->getMessage() . PHP_EOL; +} diff --git a/test-results/.last-run.json b/test-results/.last-run.json index 214b37cc..cbcc1fba 100644 --- a/test-results/.last-run.json +++ b/test-results/.last-run.json @@ -1,6 +1,4 @@ { - "status": "failed", - "failedTests": [ - "107d361b6fc8beba4b6c-bd5f3b54043cc6ed6ffb" - ] + "status": "passed", + "failedTests": [] } \ No newline at end of file diff --git a/test-results/messaging-Messaging-UI-sho-f7c3e-t-message-from-current-user-chromium/error-context.md b/test-results/messaging-Messaging-UI-sho-f7c3e-t-message-from-current-user-chromium/error-context.md deleted file mode 100644 index b6dacef2..00000000 --- a/test-results/messaging-Messaging-UI-sho-f7c3e-t-message-from-current-user-chromium/error-context.md +++ /dev/null @@ -1,173 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - banner [ref=e2]: - - generic [ref=e3]: - - link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]: - - /url: / - - img "Skinbase.org" [ref=e5] - - generic [ref=e6]: Skinbase.org - - navigation "Main navigation" [ref=e7]: - - button "Discover" [ref=e9] [cursor=pointer]: - - text: Discover - - img [ref=e10] - - button "Browse" [ref=e13] [cursor=pointer]: - - text: Browse - - img [ref=e14] - - button "Creators" [ref=e17] [cursor=pointer]: - - text: Creators - - img [ref=e18] - - button "Community" [ref=e21] [cursor=pointer]: - - text: Community - - img [ref=e22] - - generic [ref=e26]: - - button "Open search" [ref=e27] [cursor=pointer]: - - img [ref=e28] - - generic [ref=e30]: Search\u2026 - - generic [ref=e31]: CtrlK - - search: - - generic: - - img - - searchbox "Search" - - generic: - - generic: Esc - - button "Close search": - - img - - link "Upload" [ref=e32] [cursor=pointer]: - - /url: http://skinbase26.test/upload - - img [ref=e33] - - text: Upload - - generic [ref=e35]: - - link "Favourites" [ref=e36] [cursor=pointer]: - - /url: http://skinbase26.test/dashboard/favorites - - img [ref=e37] - - link "Messages" [ref=e39] [cursor=pointer]: - - /url: http://skinbase26.test/messages - - img [ref=e40] - - link "Notifications" [ref=e42] [cursor=pointer]: - - /url: http://skinbase26.test/dashboard/comments - - img [ref=e43] - - button "E2E Owner E2E Owner" [ref=e47] [cursor=pointer]: - - img "E2E Owner" [ref=e48] - - generic [ref=e49]: E2E Owner - - img [ref=e50] - - text:                      - - main [ref=e52]: - - generic [ref=e55]: - - complementary [ref=e56]: - - generic [ref=e57]: - - heading "Messages" [level=1] [ref=e58] - - button "New message" [ref=e59] [cursor=pointer]: - - img [ref=e60] - - searchbox "Search all messages…" [ref=e63] - - generic [ref=e64]: - - searchbox "Search conversations…" [ref=e66] - - list [ref=e67]: - - listitem [ref=e68]: - - button "E e2ep708148630 now Seed latest from owner" [ref=e69] [cursor=pointer]: - - generic [ref=e70]: E - - generic [ref=e71]: - - generic [ref=e72]: - - generic [ref=e74]: e2ep708148630 - - generic [ref=e75]: now - - generic [ref=e77]: Seed latest from owner - - main [ref=e78]: - - generic [ref=e79]: - - generic [ref=e80]: - - paragraph [ref=e82]: e2ep708148630 - - button "Pin" [ref=e83] [cursor=pointer] - - searchbox "Search in this conversation…" [ref=e85] - - generic [ref=e86]: - - generic [ref=e87]: - - separator [ref=e88] - - generic [ref=e89]: Today - - separator [ref=e90] - - generic [ref=e92]: - - generic [ref=e94]: E - - generic [ref=e95]: - - generic [ref=e96]: - - generic [ref=e97]: e2ep708148630 - - generic [ref=e98]: 09:11 PM - - paragraph [ref=e102]: Seed hello - - generic [ref=e104]: - - generic [ref=e106]: E - - generic [ref=e107]: - - generic [ref=e108]: - - generic [ref=e109]: e2eo708148630 - - generic [ref=e110]: 09:11 PM - - paragraph [ref=e114]: Seed latest from owner - - generic [ref=e115]: Seen 4s ago - - generic [ref=e116]: - - button "📎" [ref=e117] [cursor=pointer] - - textbox "Write a message… (Enter to send, Shift+Enter for new line)" [ref=e118] - - button "Send" [disabled] [ref=e119] - - contentinfo [ref=e120]: - - generic [ref=e121]: - - generic [ref=e122]: - - img "Skinbase" [ref=e123] - - generic [ref=e124]: Skinbase - - generic [ref=e125]: - - link "Bug Report" [ref=e126] [cursor=pointer]: - - /url: /bug-report - - link "RSS Feeds" [ref=e127] [cursor=pointer]: - - /url: /rss-feeds - - link "FAQ" [ref=e128] [cursor=pointer]: - - /url: /faq - - link "Rules and Guidelines" [ref=e129] [cursor=pointer]: - - /url: /rules-and-guidelines - - link "Staff" [ref=e130] [cursor=pointer]: - - /url: /staff - - link "Privacy Policy" [ref=e131] [cursor=pointer]: - - /url: /privacy-policy - - generic [ref=e132]: © 2026 Skinbase.org - - generic [ref=e133]: - - generic [ref=e135]: - - generic [ref=e137]: - - generic [ref=e138] [cursor=pointer]: - - generic: Request - - generic [ref=e139] [cursor=pointer]: - - generic: Timeline - - generic [ref=e140] [cursor=pointer]: - - generic: Queries - - generic [ref=e141]: "14" - - generic [ref=e142] [cursor=pointer]: - - generic: Models - - generic [ref=e143]: "5" - - generic [ref=e144] [cursor=pointer]: - - generic: Cache - - generic [ref=e145]: "2" - - generic [ref=e146]: - - generic [ref=e153] [cursor=pointer]: - - generic [ref=e154]: "4" - - generic [ref=e155]: GET /api/messages/4 - - generic [ref=e156] [cursor=pointer]: - - generic: 706ms - - generic [ref=e158] [cursor=pointer]: - - generic: 28MB - - generic [ref=e160] [cursor=pointer]: - - generic: 12.x - - generic [ref=e162]: - - generic [ref=e164]: - - generic: - - list - - generic [ref=e166]: - - list [ref=e167] - - textbox "Search" [ref=e170] - - generic [ref=e171]: - - list - - generic [ref=e173]: - - list - - list [ref=e178] - - generic [ref=e180]: - - generic: - - list - - generic [ref=e182]: - - list [ref=e183] - - textbox "Search" [ref=e186] - - generic [ref=e187]: - - list - - generic [ref=e189]: - - generic: - - list -``` \ No newline at end of file diff --git a/test-results/messaging-Messaging-UI-sho-f7c3e-t-message-from-current-user-chromium/test-failed-1.png b/test-results/messaging-Messaging-UI-sho-f7c3e-t-message-from-current-user-chromium/test-failed-1.png deleted file mode 100644 index b36a2ff5..00000000 Binary files a/test-results/messaging-Messaging-UI-sho-f7c3e-t-message-from-current-user-chromium/test-failed-1.png and /dev/null differ diff --git a/tests/Feature/Discovery/ActivityEventRecordingTest.php b/tests/Feature/Discovery/ActivityEventRecordingTest.php new file mode 100644 index 00000000..7cfd46d8 --- /dev/null +++ b/tests/Feature/Discovery/ActivityEventRecordingTest.php @@ -0,0 +1,136 @@ +create(); + $artwork = Artwork::factory()->create(); + + ActivityEvent::record( + actorId: $user->id, + type: ActivityEvent::TYPE_FAVORITE, + targetType: ActivityEvent::TARGET_ARTWORK, + targetId: $artwork->id, + meta: ['source' => 'test'], + ); + + $this->assertDatabaseHas('activity_events', [ + 'actor_id' => $user->id, + 'type' => 'favorite', + 'target_type' => 'artwork', + 'target_id' => $artwork->id, + ]); +}); + +it('stores all five event types without error', function () { + $user = User::factory()->create(); + $artwork = Artwork::factory()->create(); + + $events = [ + [ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id], + [ActivityEvent::TYPE_COMMENT, ActivityEvent::TARGET_ARTWORK, $artwork->id], + [ActivityEvent::TYPE_FAVORITE, ActivityEvent::TARGET_ARTWORK, $artwork->id], + [ActivityEvent::TYPE_AWARD, ActivityEvent::TARGET_ARTWORK, $artwork->id], + [ActivityEvent::TYPE_FOLLOW, ActivityEvent::TARGET_USER, $user->id], + ]; + + foreach ($events as [$type, $targetType, $targetId]) { + ActivityEvent::record($user->id, $type, $targetType, $targetId); + } + + expect(ActivityEvent::where('actor_id', $user->id)->count())->toBe(5); +}); + +it('created_at is populated on the returned instance', function () { + $user = User::factory()->create(); + $artwork = Artwork::factory()->create(); + + $event = ActivityEvent::record( + $user->id, + ActivityEvent::TYPE_COMMENT, + ActivityEvent::TARGET_ARTWORK, + $artwork->id, + ); + + expect($event->created_at)->not->toBeNull(); +}); + +it('actor relation resolves after record()', function () { + $user = User::factory()->create(); + $artwork = Artwork::factory()->create(); + + $event = ActivityEvent::record($user->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id); + + expect($event->actor->id)->toBe($user->id); +}); + +it('meta is null when empty array is passed', function () { + $user = User::factory()->create(); + $artwork = Artwork::factory()->create(); + + $event = ActivityEvent::record($user->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id); + + expect($event->meta)->toBeNull(); +}); + +it('meta is stored when non-empty array is passed', function () { + $user = User::factory()->create(); + $artwork = Artwork::factory()->create(); + + $event = ActivityEvent::record( + $user->id, + ActivityEvent::TYPE_AWARD, + ActivityEvent::TARGET_ARTWORK, + $artwork->id, + ['medal' => 'gold'], + ); + + expect($event->meta)->toBe(['medal' => 'gold']); +}); + +// ── Community activity feed route ───────────────────────────────────────────── + +it('global activity feed returns 200 for guests', function () { + $this->get('/community/activity')->assertStatus(200); +}); + +it('following tab returns 200 for users with no follows', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/community/activity?type=following') + ->assertStatus(200); +}); + +it('following tab shows only events from followed users', function () { + $user = User::factory()->create(); + $creator = User::factory()->create(); + $other = User::factory()->create(); + $artwork = Artwork::factory()->create(); + + // user_followers has no updated_at column + DB::table('user_followers')->insert([ + 'user_id' => $creator->id, + 'follower_id' => $user->id, + 'created_at' => now(), + ]); + + // Event from followed creator + ActivityEvent::record($creator->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id); + // Event from non-followed user (should not appear) + ActivityEvent::record($other->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id); + + $response = $this->actingAs($user)->get('/community/activity?type=following'); + $response->assertStatus(200); + + $events = $response->original->gatherData()['events']; + expect($events->total())->toBe(1); + expect($events->first()->actor_id)->toBe($creator->id); +}); diff --git a/tests/Feature/Discovery/FollowingFeedTest.php b/tests/Feature/Discovery/FollowingFeedTest.php new file mode 100644 index 00000000..10ee82f2 --- /dev/null +++ b/tests/Feature/Discovery/FollowingFeedTest.php @@ -0,0 +1,87 @@ + 'null']); +}); + +it('redirects unauthenticated users to login', function () { + $this->get(route('discover.following')) + ->assertRedirect(route('login')); +}); + +it('shows empty state with fallback data when user follows nobody', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get(route('discover.following')); + + $response->assertStatus(200); + $response->assertViewHas('empty', true); + $response->assertViewHas('fallback_trending'); + $response->assertViewHas('fallback_creators'); + $response->assertViewHas('section', 'following'); +}); + +it('paginates artworks from followed creators', function () { + $user = User::factory()->create(); + $creator = User::factory()->create(); + + // user_followers has no updated_at column + DB::table('user_followers')->insert([ + 'user_id' => $creator->id, + 'follower_id' => $user->id, + 'created_at' => now(), + ]); + + Artwork::factory()->count(3)->create([ + 'user_id' => $creator->id, + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subHour(), + ]); + + $response = $this->actingAs($user)->get(route('discover.following')); + + $response->assertStatus(200); + $response->assertViewHas('section', 'following'); + $response->assertViewMissing('empty'); +}); + +it('does not include artworks from non-followed creators in the feed', function () { + $user = User::factory()->create(); + $creator = User::factory()->create(); + $stranger = User::factory()->create(); + + DB::table('user_followers')->insert([ + 'user_id' => $creator->id, + 'follower_id' => $user->id, + 'created_at' => now(), + ]); + + // Only the stranger has an artwork — creator has none + Artwork::factory()->create([ + 'user_id' => $stranger->id, + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subHour(), + ]); + + $response = $this->actingAs($user)->get(route('discover.following')); + $response->assertStatus(200); + + /** @var \Illuminate\Pagination\LengthAwarePaginator $artworks */ + $artworks = $response->original->gatherData()['artworks']; + expect($artworks->total())->toBe(0); +}); + +it('other discover routes return 200 without Meilisearch', function () { + // Trending and fresh routes fall through to DB fallback with null driver + $this->get(route('discover.trending'))->assertStatus(200); + $this->get(route('discover.fresh'))->assertStatus(200); +}); diff --git a/tests/Feature/Discovery/HomepagePersonalizationTest.php b/tests/Feature/Discovery/HomepagePersonalizationTest.php new file mode 100644 index 00000000..120f3d73 --- /dev/null +++ b/tests/Feature/Discovery/HomepagePersonalizationTest.php @@ -0,0 +1,80 @@ + 'null']); + + // ArtworkService is not final so it can be mocked + $artworksMock = Mockery::mock(ArtworkService::class); + $artworksMock->shouldReceive('getFeaturedArtworks') + ->andReturn(new LengthAwarePaginator(collect(), 0, 1)) + ->byDefault(); + app()->instance(ArtworkService::class, $artworksMock); +}); + +// ── Route integration ───────────────────────────────────────────────────────── + +it('home page renders 200 for guests', function () { + $this->get('/')->assertStatus(200); +}); + +it('home page renders 200 for authenticated users', function () { + $this->actingAs(User::factory()->create()) + ->get('/') + ->assertStatus(200); +}); + +// ── HomepageService section shape ───────────────────────────────────────────── + +it('guest homepage has expected sections but no from_following', function () { + $sections = app(HomepageService::class)->all(); + + expect($sections)->toHaveKeys(['hero', 'trending', 'fresh', 'tags', 'creators', 'news']); + expect($sections)->not->toHaveKey('from_following'); + expect($sections)->not->toHaveKey('by_tags'); + expect($sections)->not->toHaveKey('by_categories'); +}); + +it('authenticated homepage contains all personalised sections', function () { + $user = User::factory()->create(); + $sections = app(HomepageService::class)->allForUser($user); + + expect($sections)->toHaveKeys([ + 'hero', + 'from_following', + 'trending', + 'by_tags', + 'by_categories', + 'tags', + 'creators', + 'news', + 'preferences', + ]); +}); + +it('preferences section exposes top_tags and top_categories arrays', function () { + $user = User::factory()->create(); + $sections = app(HomepageService::class)->allForUser($user); + + expect($sections['preferences'])->toHaveKeys(['top_tags', 'top_categories']); + expect($sections['preferences']['top_tags'])->toBeArray(); + expect($sections['preferences']['top_categories'])->toBeArray(); +}); + +it('guest and auth homepages have different key sets', function () { + $user = User::factory()->create(); + + $guest = array_keys(app(HomepageService::class)->all()); + $auth = array_keys(app(HomepageService::class)->allForUser($user)); + + expect($guest)->not->toEqual($auth); + expect(in_array('from_following', $auth))->toBeTrue(); + expect(in_array('from_following', $guest))->toBeFalse(); +}); diff --git a/tests/Feature/Discovery/SignalTrackingTest.php b/tests/Feature/Discovery/SignalTrackingTest.php new file mode 100644 index 00000000..629af2ec --- /dev/null +++ b/tests/Feature/Discovery/SignalTrackingTest.php @@ -0,0 +1,165 @@ + 'null']); +}); + +// ── ArtworkViewController (POST /api/art/{id}/view) ────────────────────────── + +it('returns 404 for a non-existent artwork on view', function () { + $this->postJson('/api/art/99999/view')->assertStatus(404); +}); + +it('returns 404 for a private artwork on view', function () { + $artwork = Artwork::factory()->create(['is_public' => false]); + $this->postJson("/api/art/{$artwork->id}/view")->assertStatus(404); +}); + +it('returns 404 for an unapproved artwork on view', function () { + $artwork = Artwork::factory()->create(['is_approved' => false]); + $this->postJson("/api/art/{$artwork->id}/view")->assertStatus(404); +}); + +it('records a view and returns ok=true on first call', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + // Ensure a stats row exists with 0 views + DB::table('artwork_stats')->insertOrIgnore([ + 'artwork_id' => $artwork->id, + 'views' => 0, + 'downloads' => 0, + 'favorites' => 0, + 'rating_avg' => 0, + 'rating_count' => 0, + ]); + + $mock = $this->mock(ArtworkStatsService::class); + $mock->shouldReceive('logViewEvent') + ->once() + ->with($artwork->id, null); // null = guest (unauthenticated request) + $mock->shouldReceive('incrementViews') + ->once() + ->with($artwork->id, 1, true); + + $response = $this->postJson("/api/art/{$artwork->id}/view"); + + $response->assertStatus(200) + ->assertJsonPath('ok', true) + ->assertJsonPath('counted', true); +}); + +it('skips DB increment and returns counted=false if artwork was already viewed this session', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + // Mark as already viewed in the session + session()->put("art_viewed.{$artwork->id}", true); + + $mock = $this->mock(ArtworkStatsService::class); + $mock->shouldReceive('incrementViews')->never(); + + $response = $this->postJson("/api/art/{$artwork->id}/view"); + + $response->assertStatus(200) + ->assertJsonPath('ok', true) + ->assertJsonPath('counted', false); +}); + +// ── ArtworkDownloadController (POST /api/art/{id}/download) ────────────────── + +it('returns 404 for a non-existent artwork on download', function () { + $this->postJson('/api/art/99999/download')->assertStatus(404); +}); + +it('returns 404 for a private artwork on download', function () { + $artwork = Artwork::factory()->create(['is_public' => false]); + $this->postJson("/api/art/{$artwork->id}/download")->assertStatus(404); +}); + +it('records a download and returns ok=true with a url', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + $mock = $this->mock(ArtworkStatsService::class); + $mock->shouldReceive('incrementDownloads') + ->once() + ->with($artwork->id, 1, true); + + $response = $this->postJson("/api/art/{$artwork->id}/download"); + + $response->assertStatus(200) + ->assertJsonPath('ok', true) + ->assertJsonStructure(['ok', 'url']); +}); + +it('inserts a row in artwork_downloads on valid download', function () { + $user = User::factory()->create(); + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + // Stub the stats service so we don't need Redis + $mock = $this->mock(ArtworkStatsService::class); + $mock->shouldReceive('incrementDownloads')->once(); + + $this->actingAs($user)->postJson("/api/art/{$artwork->id}/download"); + + $this->assertDatabaseHas('artwork_downloads', [ + 'artwork_id' => $artwork->id, + 'user_id' => $user->id, + ]); +}); + +it('records download as guest (no user_id) when unauthenticated', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + $mock = $this->mock(ArtworkStatsService::class); + $mock->shouldReceive('incrementDownloads')->once(); + + $this->postJson("/api/art/{$artwork->id}/download"); + + $this->assertDatabaseHas('artwork_downloads', [ + 'artwork_id' => $artwork->id, + 'user_id' => null, + ]); +}); + +// ── Route names ─────────────────────────────────────────────────────────────── + +it('view endpoint route is named api.art.view', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(), + ]); + expect(route('api.art.view', ['id' => $artwork->id]))->toContain("/api/art/{$artwork->id}/view"); +}); + +it('download endpoint route is named api.art.download', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(), + ]); + expect(route('api.art.download', ['id' => $artwork->id]))->toContain("/api/art/{$artwork->id}/download"); +}); diff --git a/tests/Feature/Discovery/SimilarArtworksApiTest.php b/tests/Feature/Discovery/SimilarArtworksApiTest.php new file mode 100644 index 00000000..af75e848 --- /dev/null +++ b/tests/Feature/Discovery/SimilarArtworksApiTest.php @@ -0,0 +1,117 @@ + 'null']); +}); + +// ── 404 cases ───────────────────────────────────────────────────────────────── + +it('returns 404 for a non-existent artwork id', function () { + $this->getJson('/api/art/99999/similar') + ->assertStatus(404) + ->assertJsonPath('error', 'Artwork not found'); +}); + +it('returns 404 for a private artwork', function () { + $artwork = Artwork::factory()->create(['is_public' => false]); + + $this->getJson("/api/art/{$artwork->id}/similar") + ->assertStatus(404); +}); + +it('returns 404 for an unapproved artwork', function () { + $artwork = Artwork::factory()->create(['is_approved' => false]); + + $this->getJson("/api/art/{$artwork->id}/similar") + ->assertStatus(404); +}); + +it('returns 404 for an unpublished artwork', function () { + $artwork = Artwork::factory()->unpublished()->create(); + + $this->getJson("/api/art/{$artwork->id}/similar") + ->assertStatus(404); +}); + +// ── Success cases ───────────────────────────────────────────────────────────── + +it('returns a data array for a valid public artwork', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + $response = $this->getJson("/api/art/{$artwork->id}/similar"); + + $response->assertStatus(200); + $response->assertJsonStructure(['data']); + expect($response->json('data'))->toBeArray(); +}); + +it('the source artwork id is never present in results', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + $ids = collect($this->getJson("/api/art/{$artwork->id}/similar")->json('data')) + ->pluck('id') + ->all(); + + expect($ids)->not->toContain($artwork->id); +}); + +it('result count does not exceed 12', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + $count = count($this->getJson("/api/art/{$artwork->id}/similar")->json('data')); + + // null Scout driver returns 0 results; max is 12 + expect($count <= 12)->toBeTrue(); +}); + +it('results do not include artworks by the same creator', function () { + $creatorA = User::factory()->create(); + $creatorB = User::factory()->create(); + + $source = Artwork::factory()->create([ + 'user_id' => $creatorA->id, + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + // A matching artwork from a different creator + Artwork::factory()->create([ + 'user_id' => $creatorB->id, + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + $response = $this->getJson("/api/art/{$source->id}/similar"); + $response->assertStatus(200); + + $items = $response->json('data'); + + // With null Scout driver the search returns 0 items; if items are present + // none should belong to the source artwork's creator. + foreach ($items as $item) { + expect($item)->toHaveKeys(['id', 'title', 'slug', 'thumb', 'url', 'author_id']); + expect($item['author_id'])->not->toBe($creatorA->id); + } + + expect(true)->toBeTrue(); // always at least one assertion +}); diff --git a/tests/Feature/Discovery/TrendingServiceTest.php b/tests/Feature/Discovery/TrendingServiceTest.php new file mode 100644 index 00000000..909c9855 --- /dev/null +++ b/tests/Feature/Discovery/TrendingServiceTest.php @@ -0,0 +1,98 @@ +recalculate('24h'))->toBe(0); + expect(app(TrendingService::class)->recalculate('7d'))->toBe(0); +}); + +it('updates trending_score_24h for artworks published within 7 days', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subHours(6), + ]); + + $updated = app(TrendingService::class)->recalculate('24h'); + + expect($updated)->toBe(1); + + $artwork->refresh(); + expect($artwork->trending_score_24h)->toBeFloat(); + expect($artwork->last_trending_calculated_at)->not->toBeNull(); +})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite'); + +it('updates trending_score_7d for artworks published within 30 days', function () { + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDays(10), + ]); + + $updated = app(TrendingService::class)->recalculate('7d'); + + expect($updated)->toBe(1); + + $artwork->refresh(); + expect($artwork->trending_score_7d)->toBeFloat(); +})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite'); + +it('skips artworks published outside the look-back window', function () { + Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDays(45), // outside 30-day window + ]); + + expect(app(TrendingService::class)->recalculate('7d'))->toBe(0); +}); + +it('skips private artworks', function () { + Artwork::factory()->create([ + 'is_public' => false, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + expect(app(TrendingService::class)->recalculate('24h'))->toBe(0); +}); + +it('skips unapproved artworks', function () { + Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => false, + 'published_at' => now()->subDay(), + ]); + + expect(app(TrendingService::class)->recalculate('24h'))->toBe(0); +}); + +it('score is always non-negative (GREATEST clamp)', function () { + // Artwork with no stats — time decay may be large, but score is clamped to ≥ 0 + $artwork = Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDays(6), + ]); + + app(TrendingService::class)->recalculate('24h'); + + $artwork->refresh(); + expect($artwork->trending_score_24h)->toBeGreaterThanOrEqualTo(0.0); +})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite'); + +it('processes multiple artworks in a single run', function () { + Artwork::factory()->count(5)->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + expect(app(TrendingService::class)->recalculate('7d'))->toBe(5); +})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite'); diff --git a/tests/Feature/Discovery/WindowedStatsTest.php b/tests/Feature/Discovery/WindowedStatsTest.php new file mode 100644 index 00000000..3d8578c8 --- /dev/null +++ b/tests/Feature/Discovery/WindowedStatsTest.php @@ -0,0 +1,147 @@ + 'null']); +}); + +// ── Helper: ensure a stats row exists ──────────────────────────────────────── + +function seedStats(int $artworkId, array $overrides = []): void +{ + DB::table('artwork_stats')->insertOrIgnore(array_merge([ + 'artwork_id' => $artworkId, + 'views' => 0, + 'views_24h' => 0, + 'views_7d' => 0, + 'downloads' => 0, + 'downloads_24h' => 0, + 'downloads_7d' => 0, + 'favorites' => 0, + 'rating_avg' => 0, + 'rating_count' => 0, + ], $overrides)); +} + +// ── ArtworkStatsService ─────────────────────────────────────────────────────── + +it('incrementViews updates views, views_24h, and views_7d', function () { + $artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]); + seedStats($artwork->id); + + app(ArtworkStatsService::class)->incrementViews($artwork->id, 3, defer: false); + + $row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first(); + expect((int) $row->views)->toBe(3); + expect((int) $row->views_24h)->toBe(3); + expect((int) $row->views_7d)->toBe(3); +}); + +it('incrementDownloads updates downloads, downloads_24h, and downloads_7d', function () { + $artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]); + seedStats($artwork->id); + + app(ArtworkStatsService::class)->incrementDownloads($artwork->id, 2, defer: false); + + $row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first(); + expect((int) $row->downloads)->toBe(2); + expect((int) $row->downloads_24h)->toBe(2); + expect((int) $row->downloads_7d)->toBe(2); +}); + +it('multiple view increments accumulate across all three columns', function () { + $artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]); + seedStats($artwork->id); + + $svc = app(ArtworkStatsService::class); + $svc->incrementViews($artwork->id, 1, defer: false); + $svc->incrementViews($artwork->id, 1, defer: false); + $svc->incrementViews($artwork->id, 1, defer: false); + + $row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first(); + expect((int) $row->views)->toBe(3); + expect((int) $row->views_24h)->toBe(3); + expect((int) $row->views_7d)->toBe(3); +}); + +// ── ResetWindowedStatsCommand ───────────────────────────────────────────────── + +it('reset-windowed-stats --period=24h zeros views_24h', function () { + $artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]); + seedStats($artwork->id, ['views_24h' => 50, 'views_7d' => 200]); + + $this->artisan('skinbase:reset-windowed-stats', ['--period' => '24h']) + ->assertExitCode(0); + + $row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first(); + expect((int) $row->views_24h)->toBe(0); + // 7d column is NOT touched by a 24h reset + expect((int) $row->views_7d)->toBe(200); +}); + +it('reset-windowed-stats --period=7d zeros views_7d', function () { + $artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]); + seedStats($artwork->id, ['views_24h' => 50, 'views_7d' => 200]); + + $this->artisan('skinbase:reset-windowed-stats', ['--period' => '7d']) + ->assertExitCode(0); + + $row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first(); + expect((int) $row->views_7d)->toBe(0); + // 24h column is NOT touched by a 7d reset + expect((int) $row->views_24h)->toBe(50); +}); + +it('reset-windowed-stats recomputes downloads_24h from artwork_downloads log', function () { + $artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]); + seedStats($artwork->id, ['downloads_24h' => 99]); // stale value + + // Insert 3 downloads within the last 24 hours + $ip = inet_pton('127.0.0.1'); + DB::table('artwork_downloads')->insert([ + ['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(1)], + ['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(6)], + ['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(12)], + ]); + + // Insert 2 old downloads outside the 24h window + DB::table('artwork_downloads')->insert([ + ['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(2)], + ['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(5)], + ]); + + $this->artisan('skinbase:reset-windowed-stats', ['--period' => '24h']) + ->assertExitCode(0); + + $row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first(); + // Should equal exactly the 3 recent downloads, not the stale 99 + expect((int) $row->downloads_24h)->toBe(3); +}); + +it('reset-windowed-stats recomputes downloads_7d including all downloads in 7-day window', function () { + $artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDays(10)]); + seedStats($artwork->id, ['downloads_7d' => 0]); + + $ip = inet_pton('127.0.0.1'); + DB::table('artwork_downloads')->insert([ + ['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(1)], + ['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(5)], + ['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(8)], // outside 7d + ]); + + $this->artisan('skinbase:reset-windowed-stats', ['--period' => '7d']) + ->assertExitCode(0); + + $row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first(); + expect((int) $row->downloads_7d)->toBe(2); +}); + +it('reset-windowed-stats returns failure for invalid period', function () { + $this->artisan('skinbase:reset-windowed-stats', ['--period' => 'bad']) + ->assertExitCode(1); +}); diff --git a/tests/e2e/messaging.spec.ts b/tests/e2e/messaging.spec.ts index 26989b8a..7cf965d0 100644 --- a/tests/e2e/messaging.spec.ts +++ b/tests/e2e/messaging.spec.ts @@ -5,6 +5,7 @@ type Fixture = { email: string password: string conversation_id: number + latest_message_id: number } function seedMessagingFixture(): Fixture { @@ -46,7 +47,7 @@ function seedMessagingFixture(): Fixture { "$last = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $owner->id, 'body' => 'Seed latest from owner']);", "$conversation->update(['last_message_at' => $last->created_at]);", "ConversationParticipant::where('conversation_id', $conversation->id)->where('user_id', $peer->id)->update(['last_read_at' => Carbon::parse($last->created_at)->addSeconds(15)]);", - "echo json_encode(['email' => $owner->email, 'password' => 'password', 'conversation_id' => $conversation->id]);", + "echo json_encode(['email' => $owner->email, 'password' => 'password', 'conversation_id' => $conversation->id, 'latest_message_id' => $last->id]);", ].join(' ') const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], { @@ -106,7 +107,7 @@ test.describe('Messaging UI', () => { await login(page, fixture) await page.goto(`/messages/${fixture.conversation_id}`) - await expect(page.locator('text=Seed latest from owner')).toBeVisible() + await expect(page.locator(`#message-${fixture.latest_message_id}`)).toContainText('Seed latest from owner') await expect(page.locator('text=/^Seen\\s.+\\sago$/')).toBeVisible() }) })