storing analytics data

This commit is contained in:
2026-02-27 09:46:51 +01:00
parent 15b7b77d20
commit f0cca76eb3
57 changed files with 3478 additions and 466 deletions

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\ArtworkStatsService;
use Illuminate\Console\Command;
/**
* Drain the Redis artwork-stat delta queue into MySQL.
*
* The ArtworkStatsService::incrementViews/Downloads methods push compressed
* delta payloads to a Redis list (`artwork_stats:deltas`) when Redis is
* available. This command drains that queue by applying each delta to the
* artwork_stats table via applyDelta().
*
* Designed to run every 5 minutes so counters stay reasonably fresh while
* keeping MySQL write pressure low. If Redis is unavailable the command exits
* immediately without error the service already fell back to direct DB
* writes in that case.
*
* Usage:
* php artisan skinbase:flush-redis-stats
* php artisan skinbase:flush-redis-stats --max=500
*/
class FlushRedisStatsCommand extends Command
{
protected $signature = 'skinbase:flush-redis-stats {--max=1000 : Maximum deltas to process per run}';
protected $description = 'Drain Redis artwork stat delta queue into MySQL';
public function handle(ArtworkStatsService $service): int
{
$max = (int) $this->option('max');
$processed = $service->processPendingFromRedis($max);
if ($this->getOutput()->isVerbose()) {
$this->info("Processed {$processed} artwork-stat delta(s) from Redis.");
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Delete artwork_view_events rows older than N days.
*
* The view event log grows ~proportionally to site traffic. Rows beyond the
* retention window are no longer useful for trending (which looks back ≤7
* days) or for computing "recently viewed" lists in the UI.
*
* Default retention is 90 days long enough for analytics queries and user
* history pages, short enough to keep the table from growing unbounded.
*
* Usage:
* php artisan skinbase:prune-view-events
* php artisan skinbase:prune-view-events --days=30
*/
class PruneViewEventsCommand extends Command
{
protected $signature = 'skinbase:prune-view-events {--days=90 : Delete events older than this many days}';
protected $description = 'Delete artwork_view_events rows older than N days';
public function handle(): int
{
$days = (int) $this->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;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\TrendingService;
use Illuminate\Console\Command;
/**
* php artisan skinbase:recalculate-trending [--period=24h|7d] [--chunk=1000] [--skip-index]
*/
class RecalculateTrendingCommand extends Command
{
protected $signature = 'skinbase:recalculate-trending
{--period=7d : Period to recalculate (24h or 7d). Use "all" to run both.}
{--chunk=1000 : DB chunk size}
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
protected $description = 'Recalculate trending scores for artworks and sync to Meilisearch';
public function __construct(private readonly TrendingService $trending)
{
parent::__construct();
}
public function handle(): int
{
$period = (string) $this->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;
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* php artisan skinbase:reset-windowed-stats --period=24h|7d
*
* Resets / recomputes the sliding-window stats columns in artwork_stats:
*
* views_24h / views_7d
* Zeroed on each reset because we have no per-view event log.
* Artworks re-accumulate from the next view event onward.
* (Low-traffic reset window: 03:30 means minimal trending disruption.)
*
* downloads_24h / downloads_7d
* Recomputed accurately from the artwork_downloads event log.
* A single bulk UPDATE with a correlated COUNT() is safe here because
* it runs once nightly/weekly, not in the hot path.
*
* Scheduled in routes/console.php:
* --period=24h daily at 03:30
* --period=7d weekly (Monday) at 03:30
*/
class ResetWindowedStatsCommand extends Command
{
protected $signature = 'skinbase:reset-windowed-stats
{--period=24h : Window to reset: 24h or 7d}';
protected $description = 'Reset windowed view/download counters in artwork_stats';
public function handle(): int
{
$period = (string) $this->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;
}
}

View File

@@ -12,6 +12,7 @@ use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\EvaluateFeedWeightsCommand; use App\Console\Commands\EvaluateFeedWeightsCommand;
use App\Console\Commands\AiTagArtworksCommand; use App\Console\Commands\AiTagArtworksCommand;
use App\Console\Commands\CompareFeedAbCommand; use App\Console\Commands\CompareFeedAbCommand;
use App\Console\Commands\RecalculateTrendingCommand;
use App\Uploads\Commands\CleanupUploadsCommand; use App\Uploads\Commands\CleanupUploadsCommand;
class Kernel extends ConsoleKernel class Kernel extends ConsoleKernel
@@ -36,6 +37,7 @@ class Kernel extends ConsoleKernel
CompareFeedAbCommand::class, CompareFeedAbCommand::class,
AiTagArtworksCommand::class, AiTagArtworksCommand::class,
\App\Console\Commands\MigrateFollows::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('uploads:cleanup')->dailyAt('03:00');
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10'); $schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20'); $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();
} }
/** /**

View File

@@ -34,6 +34,17 @@ final class ArtworkAwardController extends Controller
$award = $this->service->award($artwork, $user, $data['medal']); $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( return response()->json(
$this->buildPayload($artwork->id, $user->id), $this->buildPayload($artwork->id, $user->id),
201 201

View File

@@ -93,6 +93,16 @@ class ArtworkCommentController extends Controller
$comment->load(['user', 'user.profile']); $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); return response()->json(['data' => $this->formatComment($comment, $request->user()->id)], 201);
} }

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* POST /api/art/{id}/download
*
* Records a download event and returns the full-resolution download URL.
*
* Responsibilities:
* 1. Validates the artwork is public and published.
* 2. Inserts a row in artwork_downloads (artwork_id, user_id, ip, user_agent).
* 3. Increments artwork_stats.downloads + forwards to creator stats.
* 4. Returns {"ok": true, "url": "<download_url>"} 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 '';
}
}

View File

@@ -36,6 +36,16 @@ final class ArtworkInteractionController extends Controller
if ($state) { if ($state) {
$svc->incrementFavoritesReceived($creatorId); $svc->incrementFavoritesReceived($creatorId);
$svc->setLastActiveAt((int) $request->user()->id); $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 { } else {
$svc->decrementFavoritesReceived($creatorId); $svc->decrementFavoritesReceived($creatorId);
} }

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* POST /api/art/{id}/view
*
* Fire-and-forget view tracker.
*
* Deduplication strategy (layered):
* 1. Session key (`art_viewed.{id}`) prevents double-counts within the
* same browser session (survives page reloads).
* 2. Route throttle (5 per 10 minutes per IP+artwork) catches bots that
* don't send session cookies.
*
* The frontend should additionally guard with sessionStorage so it only
* calls this endpoint once per page load.
*/
final class ArtworkViewController extends Controller
{
public function __construct(private readonly ArtworkStatsService $stats) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::public()
->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]);
}
}

View File

@@ -18,6 +18,7 @@ class MessagingSettingsController extends Controller
{ {
return response()->json([ return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone', '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([ return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from, 'allow_messages_from' => $request->user()->allow_messages_from,
'realtime_enabled' => (bool) config('messaging.realtime', false),
]); ]);
} }
} }

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
/**
* GET /api/art/{id}/similar
*
* Returns up to 12 similar artworks based on:
* 1. Tag overlap (primary signal)
* 2. Same category
* 3. Similar orientation
*
* Uses Meilisearch via ArtworkSearchService for fast retrieval.
* Current artwork and its creator are excluded from results.
*/
final class SimilarArtworksController extends Controller
{
private const LIMIT = 12;
private const CACHE_TTL = 300; // 5 minutes
public function __construct(private readonly ArtworkSearchService $search) {}
public function __invoke(int $id): JsonResponse
{
$artwork = Artwork::public()
->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',
};
}
}

View File

@@ -518,6 +518,16 @@ final class UploadController extends Controller
$artwork->published_at = now(); $artwork->published_at = now();
$artwork->save(); $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([ return response()->json([
'success' => true, 'success' => true,
'artwork_id' => (int) $artwork->id, 'artwork_id' => (int) $artwork->id,

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Community activity feed.
*
* GET /community/activity?type=global|following
*/
final class CommunityActivityController extends Controller
{
private const PER_PAGE = 30;
public function index(Request $request)
{
$user = $request->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(),
];
});
}
}

View File

@@ -8,6 +8,7 @@ use App\Services\ArtworkSearchService;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -190,19 +191,48 @@ final class DiscoverController extends Controller
->pluck('user_id'); ->pluck('user_id');
if ($followingIds->isEmpty()) { 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', [ return view('web.discover.index', [
'artworks' => $artworks, 'artworks' => collect(),
'page_title' => 'Following Feed', 'page_title' => 'Following Feed',
'section' => 'following', 'section' => 'following',
'description' => 'Follow some creators to see their work here.', 'description' => 'Follow some creators to see their work here.',
'icon' => 'fa-user-group', 'icon' => 'fa-user-group',
'empty' => true, 'empty' => true,
'fallback_trending' => $fallbackArtworks,
'fallback_creators' => $suggestedCreators,
]); ]);
} }
$artworks = Artwork::query() $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() ->public()
->published() ->published()
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order']) ->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
@@ -210,6 +240,7 @@ final class DiscoverController extends Controller
->orderByDesc('published_at') ->orderByDesc('published_at')
->paginate($perPage) ->paginate($perPage)
->withQueryString(); ->withQueryString();
});
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));

View File

@@ -14,7 +14,10 @@ final class HomeController extends Controller
public function index(Request $request): \Illuminate\View\View 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']; $hero = $sections['hero'];
@@ -29,6 +32,7 @@ final class HomeController extends Controller
return view('web.home', [ return view('web.home', [
'meta' => $meta, 'meta' => $meta,
'props' => $sections, 'props' => $sections,
'is_logged_in' => (bool) $user,
]); ]);
} }
} }

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Unified activity feed event.
*
* Types: upload | comment | favorite | award | follow
* target_type: artwork | user
*
* @property int $id
* @property int $actor_id
* @property string $type
* @property string $target_type
* @property int $target_id
* @property array|null $meta
* @property \Illuminate\Support\Carbon $created_at
*/
class ActivityEvent extends Model
{
protected $table = 'activity_events';
public $timestamps = false;
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
protected $fillable = [
'actor_id',
'type',
'target_type',
'target_id',
'meta',
];
protected $casts = [
'actor_id' => '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;
}
}

View File

@@ -250,6 +250,12 @@ class Artwork extends Model
'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '', 'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '',
'is_public' => (bool) $this->is_public, 'is_public' => (bool) $this->is_public,
'is_approved' => (bool) $this->is_approved, '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' => [ 'awards' => [
'gold' => $awardStat?->gold_count ?? 0, 'gold' => $awardStat?->gold_count ?? 0,
'silver' => $awardStat?->silver_count ?? 0, 'silver' => $awardStat?->silver_count ?? 0,

View File

@@ -175,8 +175,8 @@ final class ArtworkSearchService
// ── Discover section helpers ─────────────────────────────────────────────── // ── Discover section helpers ───────────────────────────────────────────────
/** /**
* Trending: most viewed artworks, weighted toward recent uploads. * Trending: sorted by pre-computed trending_score_24h (recalculated every 30 min).
* Uses views:desc + recency via created_at:desc as tiebreaker. * Falls back to views:desc if the column is not yet populated.
*/ */
public function discoverTrending(int $perPage = 24): LengthAwarePaginator public function discoverTrending(int $perPage = 24): LengthAwarePaginator
{ {
@@ -185,7 +185,7 @@ final class ArtworkSearchService
return Artwork::search('') return Artwork::search('')
->options([ ->options([
'filter' => self::BASE_FILTER, '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); ->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 private function parseSort(string $sort): array

View File

@@ -23,26 +23,56 @@ class ArtworkStatsService
/** /**
* Increment views for an artwork. * Increment views for an artwork.
* Set $defer=true to push to Redis for async processing when available. * 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 public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
{ {
if ($defer && $this->redisAvailable()) { 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; return;
} }
$this->applyDelta($artworkId, ['views' => $by]); $this->applyDelta($artworkId, ['views' => $by, 'views_24h' => $by, 'views_7d' => $by]);
} }
/** /**
* Increment downloads for an artwork. * 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 public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
{ {
if ($defer && $this->redisAvailable()) { 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; 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(),
]);
}
} }
/** /**
@@ -77,7 +107,11 @@ class ArtworkStatsService
DB::table('artwork_stats')->insertOrIgnore([ DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artworkId, 'artwork_id' => $artworkId,
'views' => 0, 'views' => 0,
'views_24h' => 0,
'views_7d' => 0,
'downloads' => 0, 'downloads' => 0,
'downloads_24h' => 0,
'downloads_7d' => 0,
'favorites' => 0, 'favorites' => 0,
'rating_avg' => 0, 'rating_avg' => 0,
'rating_count' => 0, 'rating_count' => 0,
@@ -85,7 +119,7 @@ class ArtworkStatsService
foreach ($deltas as $column => $value) { foreach ($deltas as $column => $value) {
// Only allow known columns to avoid SQL injection. // 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; continue;
} }

View File

@@ -50,6 +50,18 @@ final class FollowService
$this->incrementCounter($targetId, 'followers_count'); $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; return $inserted;
} }

View File

@@ -6,6 +6,8 @@ namespace App\Services;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Tag; use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\UserPreferenceService;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -23,7 +25,11 @@ final class HomepageService
{ {
private const CACHE_TTL = 300; // 5 minutes 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 // 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 // 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 Meilisearch sorted by the pre-computed score (updated every 30 min).
* Uses correlated subqueries to avoid GROUP BY issues with MySQL strict mode. * 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 public function getTrending(int $limit = 12): array
{ {
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array { return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array {
$ids = DB::table('artworks') try {
->select('id') $results = Artwork::search('')
->selectRaw( ->options([
'(SELECT COALESCE(SUM(weight * CASE medal' 'filter' => 'is_public = true AND is_approved = true',
. ' WHEN \'gold\' THEN 3' 'sort' => ['trending_score_7d:desc', 'trending_score_24h:desc', 'views:desc'],
. ' WHEN \'silver\' THEN 2' ])
. ' ELSE 1 END), 0)' ->paginate($limit, 'page', 1);
. ' 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');
if ($ids->isEmpty()) { $results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
return [];
if ($results->isEmpty()) {
return $this->getTrendingFromDb($limit);
} }
$indexed = Artwork::with(['user:id,name,username', 'user.profile:user_id,avatar_hash']) return $results->getCollection()
->whereIn('id', $ids) ->map(fn ($a) => $this->serializeArtwork($a))
->get()
->keyBy('id');
return $ids
->filter(fn ($id) => $indexed->has($id))
->map(fn ($id) => $this->serializeArtwork($indexed[$id]))
->values() ->values()
->all(); ->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getTrending Meilisearch unavailable, DB fallback', [
'error' => $e->getMessage(),
]);
return $this->getTrendingFromDb($limit);
}
}); });
} }
/**
* 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. * 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 // Helpers
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* TrendingService
*
* Calculates and persists deterministic trending scores for artworks.
*
* Formula (Phase 1):
* score = (award_score * 5)
* + (favorites_count * 3)
* + (reactions_count * 2)
* + (downloads_count * 1)
* + (views * 2)
* - (hours_since_published * 0.1)
*
* The score is stored in artworks.trending_score_24h (artworks 7 days old)
* and artworks.trending_score_7d (artworks 30 days old).
*
* Both columns are updated every run; use `--period` to limit computation.
*/
final class TrendingService
{
/** Weight constants — tune via config('discovery.trending.*') if needed */
private const W_AWARD = 5.0;
private const W_FAVORITE = 3.0;
private const W_REACTION = 2.0;
private const W_DOWNLOAD = 1.0;
private const W_VIEW = 2.0;
private const DECAY_RATE = 0.1; // score loss per hour since publish
/**
* Recalculate trending scores for artworks published within the look-back window.
*
* @param string $period '24h' targets trending_score_24h (7-day window)
* '7d' targets trending_score_7d (30-day window)
* @param int $chunkSize Number of IDs per DB UPDATE batch
* @return int Number of artworks updated
*/
public function recalculate(string $period = '7d', int $chunkSize = 1000): int
{
[$column, $windowDays] = match ($period) {
'24h' => ['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);
}
});
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* UserPreferenceService
*
* Builds a lightweight preference profile for a user based on:
* - Tags on artworks they have favourited
* - Categories of artwork they have favourited / downloaded
* - Creators they follow
*
* Output shape:
* [
* 'top_tags' => ['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();
}
}

View File

@@ -16,7 +16,8 @@
"laravel/scout": "^10.24", "laravel/scout": "^10.24",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"league/commonmark": "^2.8", "league/commonmark": "^2.8",
"meilisearch/meilisearch-php": "^1.16" "meilisearch/meilisearch-php": "^1.16",
"predis/predis": "^3.4"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

65
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "dcc955601c6f66f01bb520614508ed66", "content-hash": "e49ab9bf98b9dc4002e839deb7b45cdf",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -3053,6 +3053,69 @@
], ],
"time": "2025-12-27T19:41:33+00:00" "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", "name": "psr/clock",
"version": "1.0.0", "version": "1.0.0",

View File

@@ -106,6 +106,11 @@ return [
'downloads', 'downloads',
'likes', 'likes',
'views', 'views',
'trending_score_24h',
'trending_score_7d',
'favorites_count',
'awards_received_count',
'downloads_count',
], ],
'rankingRules' => [ 'rankingRules' => [
'words', 'words',

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('artworks', function (Blueprint $table): void {
$table->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']);
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Adds sliding-window view and download counters to artwork_stats.
*
* These columns accumulate between scheduled resets:
* views_24h incremented on every view event; zeroed nightly at 03:30
* views_7d incremented on every view event; zeroed weekly on Monday 03:30
* downloads_24h recomputed nightly from artwork_downloads log (accurate)
* downloads_7d recomputed weekly from artwork_downloads log (accurate)
*
* TrendingService uses these instead of the all-time views/downloads totals.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('artwork_stats', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Unified activity feed events table.
*
* Event types: upload | comment | favorite | award | follow
* target_type: artwork | user
*/
public function up(): void
{
Schema::create('activity_events', function (Blueprint $table): void {
$table->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');
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Persistent view event log.
*
* Stores one row per view authenticated users get a user_id, guests are
* recorded with user_id = null. Enables:
* - "Recently viewed" per user
* - Exact windowed counts (replayable)
* - Unique-viewer counts per artwork
*
* Rows older than 90 days are pruned by the weekly
* skinbase:prune-view-events command.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('artwork_view_events', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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": "<highest-res thumbnail URL>"}` for the native browser download
The `<a download>` 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:0003: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;
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -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
```

File diff suppressed because one or more lines are too long

View File

@@ -39,6 +39,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
const [conversations, setConversations] = useState([]) const [conversations, setConversations] = useState([])
const [loadingConvs, setLoadingConvs] = useState(true) const [loadingConvs, setLoadingConvs] = useState(true)
const [activeId, setActiveId] = useState(initialId ?? null) const [activeId, setActiveId] = useState(initialId ?? null)
const [realtimeEnabled, setRealtimeEnabled] = useState(false)
const [showNewModal, setShowNewModal] = useState(false) const [showNewModal, setShowNewModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([]) const [searchResults, setSearchResults] = useState([])
@@ -60,11 +61,32 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
useEffect(() => { useEffect(() => {
loadConversations() loadConversations()
// Phase 1 polling: refresh conversation list every 15 seconds apiFetch('/api/messages/settings')
pollRef.current = setInterval(loadConversations, 15_000) .then(data => setRealtimeEnabled(!!data?.realtime_enabled))
return () => clearInterval(pollRef.current) .catch(() => setRealtimeEnabled(false))
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [loadConversations]) }, [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) => { const handleSelectConversation = useCallback((id) => {
setActiveId(id) setActiveId(id)
history.replaceState(null, '', `/messages/${id}`) history.replaceState(null, '', `/messages/${id}`)
@@ -190,6 +212,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
key={activeId} key={activeId}
conversationId={activeId} conversationId={activeId}
conversation={activeConversation} conversation={activeConversation}
realtimeEnabled={realtimeEnabled}
currentUserId={userId} currentUserId={userId}
currentUsername={username} currentUsername={username}
apiFetch={apiFetch} apiFetch={apiFetch}

View File

@@ -181,7 +181,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/> <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg> </svg>
<span className="text-sm flex-1 text-left truncate">Search\u2026</span> <span className="text-sm flex-1 text-left truncate">Search</span>
<kbd className="shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-white/[0.06] border border-white/[0.10] text-white/30"> <kbd className="shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-white/[0.06] border border-white/[0.10] text-white/30">
{isMac ? '\u2318' : 'Ctrl'}K {isMac ? '\u2318' : 'Ctrl'}K
</kbd> </kbd>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false }) { export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false }) {
const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked)) 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') ? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null : 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 postInteraction = async (url, body) => {
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
@@ -82,6 +105,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
<a <a
href={downloadUrl} href={downloadUrl}
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep hover:brightness-110" className="inline-flex min-h-11 w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep hover:brightness-110"
onClick={trackDownload}
download download
> >
Download Download
@@ -125,6 +149,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
<a <a
href={downloadUrl} href={downloadUrl}
download download
onClick={trackDownload}
className="pointer-events-auto inline-flex min-h-12 w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep hover:brightness-110" className="pointer-events-auto inline-flex min-h-12 w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep hover:brightness-110"
> >
Download Download

View File

@@ -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' import MessageBubble from './MessageBubble'
/** /**
@@ -7,6 +7,7 @@ import MessageBubble from './MessageBubble'
export default function ConversationThread({ export default function ConversationThread({
conversationId, conversationId,
conversation, conversation,
realtimeEnabled = false,
currentUserId, currentUserId,
currentUsername, currentUsername,
apiFetch, apiFetch,
@@ -22,9 +23,11 @@ export default function ConversationThread({
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [attachments, setAttachments] = useState([]) const [attachments, setAttachments] = useState([])
const [uploadProgress, setUploadProgress] = useState(null)
const [typingUsers, setTypingUsers] = useState([]) const [typingUsers, setTypingUsers] = useState([])
const [threadSearch, setThreadSearch] = useState('') const [threadSearch, setThreadSearch] = useState('')
const [threadSearchResults, setThreadSearchResults] = useState([]) const [threadSearchResults, setThreadSearchResults] = useState([])
const [lightboxImage, setLightboxImage] = useState(null)
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const bottomRef = useRef(null) const bottomRef = useRef(null)
const threadRef = useRef(null) const threadRef = useRef(null)
@@ -34,6 +37,22 @@ export default function ConversationThread({
const latestIdRef = useRef(null) const latestIdRef = useRef(null)
const shouldAutoScrollRef = useRef(true) const shouldAutoScrollRef = useRef(true)
const draftKey = `nova_draft_${conversationId}` 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 ───────────────────────────────────────────────────────── // ── Initial load ─────────────────────────────────────────────────────────
const loadMessages = useCallback(async () => { const loadMessages = useCallback(async () => {
@@ -58,7 +77,7 @@ export default function ConversationThread({
setBody(storedDraft ?? '') setBody(storedDraft ?? '')
loadMessages() loadMessages()
// Phase 1 polling: check new messages every 10 seconds if (!realtimeEnabled) {
pollRef.current = setInterval(async () => { pollRef.current = setInterval(async () => {
try { try {
const data = await apiFetch(`/api/messages/${conversationId}`) const data = await apiFetch(`/api/messages/${conversationId}`)
@@ -71,24 +90,29 @@ export default function ConversationThread({
} }
} catch (_) {} } catch (_) {}
}, 10_000) }, 10_000)
}
return () => clearInterval(pollRef.current) return () => {
}, [conversationId, draftKey]) if (pollRef.current) clearInterval(pollRef.current)
}
}, [conversationId, draftKey, realtimeEnabled, currentUserId, apiFetch, loadMessages, onConversationUpdated])
useEffect(() => { useEffect(() => {
if (!realtimeEnabled) {
typingPollRef.current = setInterval(async () => { typingPollRef.current = setInterval(async () => {
try { try {
const data = await apiFetch(`/api/messages/${conversationId}/typing`) const data = await apiFetch(`/api/messages/${conversationId}/typing`)
setTypingUsers(data.typing ?? []) setTypingUsers(data.typing ?? [])
} catch (_) {} } catch (_) {}
}, 2_000) }, 2_000)
}
return () => { return () => {
clearInterval(typingPollRef.current) if (typingPollRef.current) clearInterval(typingPollRef.current)
clearTimeout(typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current)
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
} }
}, [conversationId, apiFetch]) }, [conversationId, apiFetch, realtimeEnabled])
useEffect(() => { useEffect(() => {
const content = body.trim() const content = body.trim()
@@ -190,10 +214,10 @@ export default function ConversationThread({
const formData = new FormData() const formData = new FormData()
formData.append('body', text) formData.append('body', text)
attachments.forEach(file => formData.append('attachments[]', file)) attachments.forEach(file => formData.append('attachments[]', file))
setUploadProgress(0)
const msg = await apiFetch(`/api/messages/${conversationId}`, { const msg = await sendMessageWithProgress(`/api/messages/${conversationId}`, formData, (progress) => {
method: 'POST', setUploadProgress(progress)
body: formData,
}) })
setMessages(prev => prev.map(m => m.id === optimistic.id ? msg : m)) setMessages(prev => prev.map(m => m.id === optimistic.id ? msg : m))
latestIdRef.current = msg.id latestIdRef.current = msg.id
@@ -203,6 +227,7 @@ export default function ConversationThread({
setMessages(prev => prev.filter(m => m.id !== optimistic.id)) setMessages(prev => prev.filter(m => m.id !== optimistic.id))
setError(e.message) setError(e.message)
} finally { } finally {
setUploadProgress(null)
setSending(false) setSending(false)
} }
}, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey]) }, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey])
@@ -292,6 +317,24 @@ export default function ConversationThread({
} }
}, [conversation, currentUserId, apiFetch, conversationId, onConversationUpdated]) }, [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(() => { useEffect(() => {
let cancelled = false let cancelled = false
const q = threadSearch.trim() const q = threadSearch.trim()
@@ -330,6 +373,7 @@ export default function ConversationThread({
const threadLabel = conversation?.type === 'group' const threadLabel = conversation?.type === 'group'
? (conversation?.title ?? 'Group conversation') ? (conversation?.title ?? 'Group conversation')
: (conversation?.all_participants?.find(p => p.user_id !== currentUserId)?.user?.username ?? 'Direct message') : (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 otherParticipant = conversation?.all_participants?.find(p => p.user_id !== currentUserId)
const otherLastReadAt = otherParticipant?.last_read_at ?? null const otherLastReadAt = otherParticipant?.last_read_at ?? null
const lastMessageId = messages[messages.length - 1]?.id ?? null const lastMessageId = messages[messages.length - 1]?.id ?? null
@@ -365,7 +409,21 @@ export default function ConversationThread({
onClick={togglePin} 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" 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'}
</button>
<button
type="button"
onClick={toggleMute}
className="text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
>
{myParticipant?.is_muted ? 'Unmute' : 'Mute'}
</button>
<button
type="button"
onClick={toggleArchive}
className="text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
>
{myParticipant?.is_archived ? 'Unarchive' : 'Archive'}
</button> </button>
</div> </div>
@@ -429,6 +487,7 @@ export default function ConversationThread({
onUnreact={handleUnreact} onUnreact={handleUnreact}
onEdit={handleEdit} onEdit={handleEdit}
onReport={handleReportMessage} onReport={handleReportMessage}
onOpenImage={setLightboxImage}
seenText={buildSeenText({ seenText={buildSeenText({
message: msg, message: msg,
isMine: msg.sender_id === currentUserId, isMine: msg.sender_id === currentUserId,
@@ -490,14 +549,41 @@ export default function ConversationThread({
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="px-4 pb-3 flex flex-wrap gap-2"> <div className="px-4 pb-3 flex flex-wrap gap-2">
{attachments.map((file, idx) => ( {previewAttachments.map(({ file, previewUrl }, idx) => (
<div key={`${file.name}-${idx}`} className="inline-flex items-center gap-2 rounded-lg bg-gray-100 dark:bg-gray-800 px-2 py-1 text-xs text-gray-700 dark:text-gray-300"> <div key={`${file.name}-${idx}`} className="inline-flex items-center gap-2 rounded-lg bg-gray-100 dark:bg-gray-800 px-2 py-1 text-xs text-gray-700 dark:text-gray-300">
{previewUrl && (
<img
src={previewUrl}
alt={file.name}
className="h-10 w-10 object-cover rounded"
/>
)}
<span className="truncate max-w-[220px]">{file.name}</span> <span className="truncate max-w-[220px]">{file.name}</span>
<button type="button" onClick={() => removeAttachment(idx)} className="text-red-500"></button> <button type="button" onClick={() => removeAttachment(idx)} className="text-red-500"></button>
</div> </div>
))} ))}
</div> </div>
)} )}
{sending && uploadProgress !== null && (
<div className="px-4 pb-3">
<div className="h-2 rounded bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div className="h-full bg-blue-500" style={{ width: `${uploadProgress}%` }} />
</div>
<p className="mt-1 text-[11px] text-gray-500">Uploading {uploadProgress}%</p>
</div>
)}
{lightboxImage && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-6" onClick={() => setLightboxImage(null)}>
<img
src={lightboxImage.url}
alt={lightboxImage.original_name || 'Attachment'}
className="max-h-full max-w-full rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div> </div>
) )
} }
@@ -585,3 +671,42 @@ function isSameDay(a, b) {
a.getMonth() === b.getMonth() && a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate() 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)
})
}

View File

@@ -10,7 +10,7 @@ const QUICK_REACTIONS = ['👍', '❤️', '🔥', '😂', '👏', '😮']
* - Inline edit for own messages * - Inline edit for own messages
* - Soft-delete display * - 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 [showPicker, setShowPicker] = useState(false)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [editBody, setEditBody] = useState(message.body ?? '') const [editBody, setEditBody] = useState(message.body ?? '')
@@ -119,14 +119,18 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on
{message.attachments.map(att => ( {message.attachments.map(att => (
<div key={att.id}> <div key={att.id}>
{att.type === 'image' ? ( {att.type === 'image' ? (
<a href={`/messages/attachments/${att.id}`} target="_blank" rel="noopener noreferrer"> <button
type="button"
onClick={() => onOpenImage?.({ id: att.id, original_name: att.original_name, url: `/messages/attachments/${att.id}` })}
className="block"
>
<img <img
src={`/messages/attachments/${att.id}`} src={`/messages/attachments/${att.id}`}
alt={att.original_name} alt={att.original_name}
className="max-h-44 rounded-lg border border-white/20" className="max-h-44 rounded-lg border border-white/20"
loading="lazy" loading="lazy"
/> />
</a> </button>
) : ( ) : (
<a <a
href={`/messages/attachments/${att.id}`} href={`/messages/attachments/${att.id}`}

View File

@@ -0,0 +1,87 @@
@extends('layouts.nova')
@section('title', $page_title . ' — Skinbase')
@section('content')
<div class="container-fluid legacy-page">
<div class="page-heading">
<h1 class="page-header"><i class="fa fa-stream"></i> {{ $page_title }}</h1>
</div>
{{-- Tab bar --}}
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link {{ $active_tab === 'global' ? 'active' : '' }}"
href="{{ route('community.activity', ['type' => 'global']) }}">
<i class="fa fa-globe"></i> Global
</a>
</li>
@auth
<li class="nav-item">
<a class="nav-link {{ $active_tab === 'following' ? 'active' : '' }}"
href="{{ route('community.activity', ['type' => 'following']) }}">
<i class="fa fa-user-group"></i> Following
</a>
</li>
@endauth
</ul>
<div class="activity-feed">
@forelse($enriched as $event)
<div class="activity-event media mb-3 p-2 rounded bg-dark-subtle">
<div class="media-body">
<span class="fw-semibold">
<a href="{{ $event['actor']['url'] ?? '#' }}">{{ $event['actor']['name'] ?? 'Someone' }}</a>
</span>
@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')
<a href="{{ $event['target']['url'] }}">{{ $event['target']['title'] }}</a>
@if(!empty($event['target']['thumb']))
<img src="{{ $event['target']['thumb'] }}" alt="" class="ms-2 rounded" style="height:36px;width:auto;vertical-align:middle;">
@endif
@elseif($event['target_type'] === 'user')
<a href="{{ $event['target']['url'] ?? '#' }}">{{ $event['target']['name'] ?? $event['target']['username'] ?? '' }}</a>
@endif
@endif
<small class="text-muted ms-2">{{ \Carbon\Carbon::parse($event['created_at'])->diffForHumans() }}</small>
</div>
</div>
@empty
<div class="alert alert-info">
@if($active_tab === 'following')
Follow some creators to see their activity here.
@else
No activity yet. Be the first!
@endif
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-3">
{{ $events->links() }}
</div>
</div>
@endsection

View File

@@ -0,0 +1,87 @@
@extends('layouts.nova')
@section('title', $page_title . ' — Skinbase')
@section('content')
<div class="container-fluid legacy-page">
<div class="page-heading">
<h1 class="page-header"><i class="fa fa-stream"></i> {{ $page_title }}</h1>
</div>
{{-- Tab bar --}}
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link {{ $active_tab === 'global' ? 'active' : '' }}"
href="{{ route('community.activity', ['type' => 'global']) }}">
<i class="fa fa-globe"></i> Global
</a>
</li>
@auth
<li class="nav-item">
<a class="nav-link {{ $active_tab === 'following' ? 'active' : '' }}"
href="{{ route('community.activity', ['type' => 'following']) }}">
<i class="fa fa-user-group"></i> Following
</a>
</li>
@endauth
</ul>
<div class="activity-feed">
@forelse($enriched as $event)
<div class="activity-event media mb-3 p-2 rounded bg-dark-subtle">
<div class="media-body">
<span class="fw-semibold">
<a href="{{ $event['actor']['url'] ?? '#' }}">{{ $event['actor']['name'] ?? 'Someone' }}</a>
</span>
@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')
<a href="{{ $event['target']['url'] }}">{{ $event['target']['title'] }}</a>
@if(!empty($event['target']['thumb']))
<img src="{{ $event['target']['thumb'] }}" alt="" class="ms-2 rounded" style="height:36px;width:auto;vertical-align:middle;">
@endif
@elseif($event['target_type'] === 'user')
<a href="{{ $event['target']['url'] ?? '#' }}">{{ $event['target']['name'] ?? $event['target']['username'] ?? '' }}</a>
@endif
@endif
<small class="text-muted ms-2">{{ \Carbon\Carbon::parse($event['created_at'])->diffForHumans() }}</small>
</div>
</div>
@empty
<div class="alert alert-info">
@if($active_tab === 'following')
Follow some creators to see their activity here.
@else
No activity yet. Be the first!
@endif
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-3">
{{ $events->links() }}
</div>
</div>
@endsection

View File

@@ -2,6 +2,25 @@
use Illuminate\Support\Facades\Route; 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 * API v1 routes for Artworks module
* *

View File

@@ -2,6 +2,7 @@
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
use App\Uploads\Services\CleanupService; use App\Uploads\Services\CleanupService;
Artisan::command('inspire', function () { 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)."); $this->info("Uploads cleanup deleted {$deleted} draft(s).");
})->purpose('Delete stale draft uploads and temporary files'); })->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();

View File

@@ -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('/', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'index'])->name('index');
Route::get('/{id}', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'show'])->whereNumber('id')->name('show'); 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');

20
scripts/check_redis.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
$app = require __DIR__ . '/../bootstrap/app.php';
$app->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;
}

View File

@@ -1,6 +1,4 @@
{ {
"status": "failed", "status": "passed",
"failedTests": [ "failedTests": []
"107d361b6fc8beba4b6c-bd5f3b54043cc6ed6ffb"
]
} }

View File

@@ -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
```

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\DB;
// ── ActivityEvent::record() factory helper ────────────────────────────────────
it('creates a db row via record()', function () {
$user = User::factory()->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);
});

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
// Use null Scout driver so no Meilisearch calls are made
config(['scout.driver' => '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);
});

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Services\ArtworkService;
use App\Services\HomepageService;
use Illuminate\Pagination\LengthAwarePaginator;
beforeEach(function () {
// Use null Scout driver — Meilisearch calls return empty results gracefully
config(['scout.driver' => '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();
});

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Services\ArtworkStatsService;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
// Disable Meilisearch and Redis during tests
config(['scout.driver' => '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");
});

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
beforeEach(function () {
// Use null Scout driver so no Meilisearch calls are made
config(['scout.driver' => '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
});

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Services\TrendingService;
// RefreshDatabase is applied automatically to all Feature tests via Pest.php
it('returns zero when no artworks exist', function () {
expect(app(TrendingService::class)->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');

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
config(['scout.driver' => '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);
});

View File

@@ -5,6 +5,7 @@ type Fixture = {
email: string email: string
password: string password: string
conversation_id: number conversation_id: number
latest_message_id: number
} }
function seedMessagingFixture(): Fixture { 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']);", "$last = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $owner->id, 'body' => 'Seed latest from owner']);",
"$conversation->update(['last_message_at' => $last->created_at]);", "$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)]);", "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(' ') ].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], { const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
@@ -106,7 +107,7 @@ test.describe('Messaging UI', () => {
await login(page, fixture) await login(page, fixture)
await page.goto(`/messages/${fixture.conversation_id}`) 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() await expect(page.locator('text=/^Seen\\s.+\\sago$/')).toBeVisible()
}) })
}) })