From f0cca76eb373df09fcf0192b033511e9fdee0ed3 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Fri, 27 Feb 2026 09:46:51 +0100 Subject: [PATCH] storing analytics data --- .../Commands/FlushRedisStatsCommand.php | 44 ++ .../Commands/PruneViewEventsCommand.php | 42 ++ .../Commands/RecalculateTrendingCommand.php | 57 ++ .../Commands/ResetWindowedStatsCommand.php | 97 +++ app/Console/Kernel.php | 5 + .../Api/ArtworkAwardController.php | 11 + .../Api/ArtworkCommentController.php | 10 + .../Api/ArtworkDownloadController.php | 96 +++ .../Api/ArtworkInteractionController.php | 10 + .../Controllers/Api/ArtworkViewController.php | 62 ++ .../Messaging/MessagingSettingsController.php | 2 + .../Api/SimilarArtworksController.php | 121 ++++ app/Http/Controllers/Api/UploadController.php | 10 + .../Web/CommunityActivityController.php | 124 ++++ .../Controllers/Web/DiscoverController.php | 61 +- app/Http/Controllers/Web/HomeController.php | 10 +- app/Models/ActivityEvent.php | 93 +++ app/Models/Artwork.php | 6 + app/Services/ArtworkSearchService.php | 64 +- app/Services/ArtworkStatsService.php | 56 +- app/Services/FollowService.php | 12 + app/Services/HomepageService.php | 199 ++++-- app/Services/TrendingService.php | 132 ++++ app/Services/UserPreferenceService.php | 93 +++ composer.json | 3 +- composer.lock | 65 +- config/scout.php | 5 + ..._add_trending_scores_to_artworks_table.php | 26 + ...02_add_windowed_stats_to_artwork_stats.php | 35 ++ ...27_000002_create_activity_events_table.php | 39 ++ ...00003_create_artwork_view_events_table.php | 51 ++ docs/discovery-personalization-engine.md | 591 ++++++++++++++++++ ...05b34cfd601b3a221f7f1a9b99827131dffbb1.png | Bin 40411 -> 0 bytes ...4eed0617fdda327586a382401abe7fe1b3e62b2.md | 173 ----- playwright-report/index.html | 2 +- resources/js/Pages/Messages/Index.jsx | 29 +- resources/js/Search/SearchBar.jsx | 2 +- .../js/components/artwork/ArtworkActions.jsx | 27 +- .../messaging/ConversationThread.jsx | 189 +++++- .../js/components/messaging/MessageBubble.jsx | 10 +- resources/views/community/activity.blade.php | 87 +++ .../views/web/community/activity.blade.php | 87 +++ routes/api.php | 19 + routes/console.php | 49 ++ routes/web.php | 4 + scripts/check_redis.php | 20 + test-results/.last-run.json | 6 +- .../error-context.md | 173 ----- .../test-failed-1.png | Bin 40411 -> 0 bytes .../Discovery/ActivityEventRecordingTest.php | 136 ++++ tests/Feature/Discovery/FollowingFeedTest.php | 87 +++ .../Discovery/HomepagePersonalizationTest.php | 80 +++ .../Feature/Discovery/SignalTrackingTest.php | 165 +++++ .../Discovery/SimilarArtworksApiTest.php | 117 ++++ .../Feature/Discovery/TrendingServiceTest.php | 98 +++ tests/Feature/Discovery/WindowedStatsTest.php | 147 +++++ tests/e2e/messaging.spec.ts | 5 +- 57 files changed, 3478 insertions(+), 466 deletions(-) create mode 100644 app/Console/Commands/FlushRedisStatsCommand.php create mode 100644 app/Console/Commands/PruneViewEventsCommand.php create mode 100644 app/Console/Commands/RecalculateTrendingCommand.php create mode 100644 app/Console/Commands/ResetWindowedStatsCommand.php create mode 100644 app/Http/Controllers/Api/ArtworkDownloadController.php create mode 100644 app/Http/Controllers/Api/ArtworkViewController.php create mode 100644 app/Http/Controllers/Api/SimilarArtworksController.php create mode 100644 app/Http/Controllers/Web/CommunityActivityController.php create mode 100644 app/Models/ActivityEvent.php create mode 100644 app/Services/TrendingService.php create mode 100644 app/Services/UserPreferenceService.php create mode 100644 database/migrations/2026_02_27_000001_add_trending_scores_to_artworks_table.php create mode 100644 database/migrations/2026_02_27_000002_add_windowed_stats_to_artwork_stats.php create mode 100644 database/migrations/2026_02_27_000002_create_activity_events_table.php create mode 100644 database/migrations/2026_02_27_000003_create_artwork_view_events_table.php create mode 100644 docs/discovery-personalization-engine.md delete mode 100644 playwright-report/data/3805b34cfd601b3a221f7f1a9b99827131dffbb1.png delete mode 100644 playwright-report/data/44eed0617fdda327586a382401abe7fe1b3e62b2.md create mode 100644 resources/views/community/activity.blade.php create mode 100644 resources/views/web/community/activity.blade.php create mode 100644 scripts/check_redis.php delete mode 100644 test-results/messaging-Messaging-UI-sho-f7c3e-t-message-from-current-user-chromium/error-context.md delete mode 100644 test-results/messaging-Messaging-UI-sho-f7c3e-t-message-from-current-user-chromium/test-failed-1.png create mode 100644 tests/Feature/Discovery/ActivityEventRecordingTest.php create mode 100644 tests/Feature/Discovery/FollowingFeedTest.php create mode 100644 tests/Feature/Discovery/HomepagePersonalizationTest.php create mode 100644 tests/Feature/Discovery/SignalTrackingTest.php create mode 100644 tests/Feature/Discovery/SimilarArtworksApiTest.php create mode 100644 tests/Feature/Discovery/TrendingServiceTest.php create mode 100644 tests/Feature/Discovery/WindowedStatsTest.php 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 b36a2ff5e3f6e7f4f99c3f05825b54309f86e7a3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40411 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 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 b36a2ff5e3f6e7f4f99c3f05825b54309f86e7a3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40411 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 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() }) })