storing analytics data
This commit is contained in:
44
app/Console/Commands/FlushRedisStatsCommand.php
Normal file
44
app/Console/Commands/FlushRedisStatsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/PruneViewEventsCommand.php
Normal file
42
app/Console/Commands/PruneViewEventsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
57
app/Console/Commands/RecalculateTrendingCommand.php
Normal file
57
app/Console/Commands/RecalculateTrendingCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
97
app/Console/Commands/ResetWindowedStatsCommand.php
Normal file
97
app/Console/Commands/ResetWindowedStatsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||
use App\Console\Commands\AiTagArtworksCommand;
|
||||
use App\Console\Commands\CompareFeedAbCommand;
|
||||
use App\Console\Commands\RecalculateTrendingCommand;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
@@ -36,6 +37,7 @@ class Kernel extends ConsoleKernel
|
||||
CompareFeedAbCommand::class,
|
||||
AiTagArtworksCommand::class,
|
||||
\App\Console\Commands\MigrateFollows::class,
|
||||
RecalculateTrendingCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -46,6 +48,9 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->command('uploads:cleanup')->dailyAt('03:00');
|
||||
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
||||
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
||||
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
|
||||
$schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes();
|
||||
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,17 @@ final class ArtworkAwardController extends Controller
|
||||
|
||||
$award = $this->service->award($artwork, $user, $data['medal']);
|
||||
|
||||
// Record activity event
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: $user->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_AWARD,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artwork->id,
|
||||
meta: ['medal' => $data['medal']],
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json(
|
||||
$this->buildPayload($artwork->id, $user->id),
|
||||
201
|
||||
|
||||
@@ -93,6 +93,16 @@ class ArtworkCommentController extends Controller
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
|
||||
// Record activity event (fire-and-forget; never break the response)
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: $request->user()->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_COMMENT,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json(['data' => $this->formatComment($comment, $request->user()->id)], 201);
|
||||
}
|
||||
|
||||
|
||||
96
app/Http/Controllers/Api/ArtworkDownloadController.php
Normal file
96
app/Http/Controllers/Api/ArtworkDownloadController.php
Normal 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 '';
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,16 @@ final class ArtworkInteractionController extends Controller
|
||||
if ($state) {
|
||||
$svc->incrementFavoritesReceived($creatorId);
|
||||
$svc->setLastActiveAt((int) $request->user()->id);
|
||||
|
||||
// Record activity event (new favourite only)
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: (int) $request->user()->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_FAVORITE,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artworkId,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
} else {
|
||||
$svc->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
|
||||
62
app/Http/Controllers/Api/ArtworkViewController.php
Normal file
62
app/Http/Controllers/Api/ArtworkViewController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class MessagingSettingsController extends Controller
|
||||
{
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
|
||||
'realtime_enabled' => (bool) config('messaging.realtime', false),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ class MessagingSettingsController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from,
|
||||
'realtime_enabled' => (bool) config('messaging.realtime', false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
121
app/Http/Controllers/Api/SimilarArtworksController.php
Normal file
121
app/Http/Controllers/Api/SimilarArtworksController.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -518,6 +518,16 @@ final class UploadController extends Controller
|
||||
$artwork->published_at = now();
|
||||
$artwork->save();
|
||||
|
||||
// Record upload activity event
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: (int) $user->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
|
||||
124
app/Http/Controllers/Web/CommunityActivityController.php
Normal file
124
app/Http/Controllers/Web/CommunityActivityController.php
Normal 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(),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Services\ArtworkSearchService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
@@ -190,26 +191,56 @@ final class DiscoverController extends Controller
|
||||
->pluck('user_id');
|
||||
|
||||
if ($followingIds->isEmpty()) {
|
||||
$artworks = Artwork::query()->paginate(0);
|
||||
// Trending fallback: show popular artworks so the page isn't blank
|
||||
try {
|
||||
$fallbackResults = $this->searchService->discoverTrending(12);
|
||||
$fallbackArtworks = $fallbackResults->getCollection()
|
||||
->transform(fn ($a) => $this->presentArtwork($a));
|
||||
} catch (\Throwable) {
|
||||
$fallbackArtworks = collect();
|
||||
}
|
||||
|
||||
// Suggested creators: most-followed users the viewer doesn't follow yet
|
||||
$suggestedCreators = DB::table('users')
|
||||
->join('user_statistics', 'users.id', '=', 'user_statistics.user_id')
|
||||
->where('users.id', '!=', $user->id)
|
||||
->whereNotNull('users.email_verified_at')
|
||||
->where('users.is_active', true)
|
||||
->orderByDesc('user_statistics.followers_count')
|
||||
->limit(8)
|
||||
->select(
|
||||
'users.id',
|
||||
'users.name',
|
||||
'users.username',
|
||||
'user_statistics.followers_count',
|
||||
)
|
||||
->get();
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Following Feed',
|
||||
'section' => 'following',
|
||||
'description' => 'Follow some creators to see their work here.',
|
||||
'icon' => 'fa-user-group',
|
||||
'empty' => true,
|
||||
'artworks' => collect(),
|
||||
'page_title' => 'Following Feed',
|
||||
'section' => 'following',
|
||||
'description' => 'Follow some creators to see their work here.',
|
||||
'icon' => 'fa-user-group',
|
||||
'empty' => true,
|
||||
'fallback_trending' => $fallbackArtworks,
|
||||
'fallback_creators' => $suggestedCreators,
|
||||
]);
|
||||
}
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
$page = (int) request()->get('page', 1);
|
||||
$cacheKey = "discover.following.{$user->id}.p{$page}";
|
||||
|
||||
$artworks = Cache::remember($cacheKey, 60, function () use ($user, $followingIds, $perPage): \Illuminate\Pagination\LengthAwarePaginator {
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
});
|
||||
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ final class HomeController extends Controller
|
||||
|
||||
public function index(Request $request): \Illuminate\View\View
|
||||
{
|
||||
$sections = $this->homepage->all();
|
||||
$user = $request->user();
|
||||
$sections = $user
|
||||
? $this->homepage->allForUser($user)
|
||||
: $this->homepage->all();
|
||||
|
||||
$hero = $sections['hero'];
|
||||
|
||||
@@ -27,8 +30,9 @@ final class HomeController extends Controller
|
||||
];
|
||||
|
||||
return view('web.home', [
|
||||
'meta' => $meta,
|
||||
'props' => $sections,
|
||||
'meta' => $meta,
|
||||
'props' => $sections,
|
||||
'is_logged_in' => (bool) $user,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
93
app/Models/ActivityEvent.php
Normal file
93
app/Models/ActivityEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,12 @@ class Artwork extends Model
|
||||
'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '',
|
||||
'is_public' => (bool) $this->is_public,
|
||||
'is_approved' => (bool) $this->is_approved,
|
||||
// ── Trending / discovery fields ────────────────────────────────────
|
||||
'trending_score_24h' => (float) ($this->trending_score_24h ?? 0),
|
||||
'trending_score_7d' => (float) ($this->trending_score_7d ?? 0),
|
||||
'favorites_count' => (int) ($stat?->favorites ?? 0),
|
||||
'awards_received_count' => (int) ($awardStat?->score_total ?? 0),
|
||||
'downloads_count' => (int) ($stat?->downloads ?? 0),
|
||||
'awards' => [
|
||||
'gold' => $awardStat?->gold_count ?? 0,
|
||||
'silver' => $awardStat?->silver_count ?? 0,
|
||||
|
||||
@@ -175,8 +175,8 @@ final class ArtworkSearchService
|
||||
// ── Discover section helpers ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Trending: most viewed artworks, weighted toward recent uploads.
|
||||
* Uses views:desc + recency via created_at:desc as tiebreaker.
|
||||
* Trending: sorted by pre-computed trending_score_24h (recalculated every 30 min).
|
||||
* Falls back to views:desc if the column is not yet populated.
|
||||
*/
|
||||
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
@@ -185,7 +185,7 @@ final class ArtworkSearchService
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['views:desc', 'created_at:desc'],
|
||||
'sort' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'views:desc', 'created_at:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
@@ -239,6 +239,64 @@ final class ArtworkSearchService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Artworks matching any of the given tag slugs, sorted by trending score.
|
||||
* Used for personalized "Because you like {tags}" homepage section.
|
||||
*
|
||||
* @param string[] $tagSlugs
|
||||
*/
|
||||
public function discoverByTags(array $tagSlugs, int $limit = 12): LengthAwarePaginator
|
||||
{
|
||||
if (empty($tagSlugs)) {
|
||||
return $this->popular($limit);
|
||||
}
|
||||
|
||||
$tagFilter = implode(' OR ', array_map(
|
||||
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
||||
array_slice($tagSlugs, 0, 5)
|
||||
));
|
||||
|
||||
$cacheKey = 'discover.by-tags.' . md5(implode(',', $tagSlugs));
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
|
||||
'sort' => ['trending_score_7d:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate($limit);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh artworks in given categories, sorted by created_at desc.
|
||||
* Used for personalized "Fresh in your favourite categories" section.
|
||||
*
|
||||
* @param string[] $categorySlugs
|
||||
*/
|
||||
public function discoverByCategories(array $categorySlugs, int $limit = 12): LengthAwarePaginator
|
||||
{
|
||||
if (empty($categorySlugs)) {
|
||||
return $this->recent($limit);
|
||||
}
|
||||
|
||||
$catFilter = implode(' OR ', array_map(
|
||||
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
||||
array_slice($categorySlugs, 0, 3)
|
||||
));
|
||||
|
||||
$cacheKey = 'discover.by-cats.' . md5(implode(',', $categorySlugs));
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
|
||||
'sort' => ['created_at:desc'],
|
||||
])
|
||||
->paginate($limit);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function parseSort(string $sort): array
|
||||
|
||||
@@ -23,26 +23,56 @@ class ArtworkStatsService
|
||||
/**
|
||||
* Increment views for an artwork.
|
||||
* Set $defer=true to push to Redis for async processing when available.
|
||||
* Both all-time (views) and windowed (views_24h, views_7d) are updated.
|
||||
*/
|
||||
public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
$this->pushDelta($artworkId, 'views_24h', $by);
|
||||
$this->pushDelta($artworkId, 'views_7d', $by);
|
||||
return;
|
||||
}
|
||||
$this->applyDelta($artworkId, ['views' => $by]);
|
||||
$this->applyDelta($artworkId, ['views' => $by, 'views_24h' => $by, 'views_7d' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment downloads for an artwork.
|
||||
* Both all-time (downloads) and windowed (downloads_24h, downloads_7d) are updated.
|
||||
*/
|
||||
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
$this->pushDelta($artworkId, 'downloads_24h', $by);
|
||||
$this->pushDelta($artworkId, 'downloads_7d', $by);
|
||||
return;
|
||||
}
|
||||
$this->applyDelta($artworkId, ['downloads' => $by]);
|
||||
$this->applyDelta($artworkId, ['downloads' => $by, 'downloads_24h' => $by, 'downloads_7d' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one row to artwork_view_events (the persistent event log).
|
||||
*
|
||||
* Called from ArtworkViewController after session dedup passes.
|
||||
* Guests (unauthenticated) are recorded with user_id = null.
|
||||
* Rows are pruned after 90 days by skinbase:prune-view-events.
|
||||
*/
|
||||
public function logViewEvent(int $artworkId, ?int $userId): void
|
||||
{
|
||||
try {
|
||||
DB::table('artwork_view_events')->insert([
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $userId,
|
||||
'viewed_at' => now(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to write artwork_view_events row', [
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,17 +105,21 @@ class ArtworkStatsService
|
||||
DB::transaction(function () use ($artworkId, $deltas) {
|
||||
// Ensure a stats row exists — insert default zeros if missing.
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artworkId,
|
||||
'views' => 0,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'artwork_id' => $artworkId,
|
||||
'views' => 0,
|
||||
'views_24h' => 0,
|
||||
'views_7d' => 0,
|
||||
'downloads' => 0,
|
||||
'downloads_24h' => 0,
|
||||
'downloads_7d' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
|
||||
foreach ($deltas as $column => $value) {
|
||||
// Only allow known columns to avoid SQL injection.
|
||||
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
|
||||
if (! in_array($column, ['views', 'views_24h', 'views_7d', 'downloads', 'downloads_24h', 'downloads_7d', 'favorites', 'rating_count'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,18 @@ final class FollowService
|
||||
$this->incrementCounter($targetId, 'followers_count');
|
||||
});
|
||||
|
||||
// Record activity event outside the transaction to avoid deadlocks
|
||||
if ($inserted) {
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: $actorId,
|
||||
type: \App\Models\ActivityEvent::TYPE_FOLLOW,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_USER,
|
||||
targetId: $targetId,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\UserPreferenceService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -23,7 +25,11 @@ final class HomepageService
|
||||
{
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public function __construct(private readonly ArtworkService $artworks) {}
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworks,
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly UserPreferenceService $prefs,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Public aggregator
|
||||
@@ -44,6 +50,36 @@ final class HomepageService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Personalized homepage data for an authenticated user.
|
||||
*
|
||||
* Sections:
|
||||
* 1. from_following – artworks from creators you follow
|
||||
* 2. trending – same trending feed as guests
|
||||
* 3. by_tags – artworks matching user's top tags
|
||||
* 4. by_categories – fresh uploads in user's favourite categories
|
||||
* 5. tags / creators / news – shared with guest homepage
|
||||
*/
|
||||
public function allForUser(\App\Models\User $user): array
|
||||
{
|
||||
$prefs = $this->prefs->build($user);
|
||||
|
||||
return [
|
||||
'hero' => $this->getHeroArtwork(),
|
||||
'from_following' => $this->getFollowingFeed($user, $prefs),
|
||||
'trending' => $this->getTrending(),
|
||||
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
|
||||
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
|
||||
'tags' => $this->getPopularTags(),
|
||||
'creators' => $this->getCreatorSpotlight(),
|
||||
'news' => $this->getNews(),
|
||||
'preferences' => [
|
||||
'top_tags' => $prefs['top_tags'] ?? [],
|
||||
'top_categories' => $prefs['top_categories'] ?? [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Sections
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -72,54 +108,61 @@ final class HomepageService
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending: up to 12 artworks ordered by award score, views, downloads, recent activity.
|
||||
* Trending: up to 12 artworks sorted by pre-computed trending_score_7d.
|
||||
*
|
||||
* Award score = SUM(weight × medal_value) where gold=3, silver=2, bronze=1.
|
||||
* Uses correlated subqueries to avoid GROUP BY issues with MySQL strict mode.
|
||||
* Uses Meilisearch sorted by the pre-computed score (updated every 30 min).
|
||||
* Falls back to DB ORDER BY trending_score_7d if Meilisearch is unavailable.
|
||||
* Spec: no heavy joins in the hot path.
|
||||
*/
|
||||
public function getTrending(int $limit = 12): array
|
||||
{
|
||||
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||||
$ids = DB::table('artworks')
|
||||
->select('id')
|
||||
->selectRaw(
|
||||
'(SELECT COALESCE(SUM(weight * CASE medal'
|
||||
. ' WHEN \'gold\' THEN 3'
|
||||
. ' WHEN \'silver\' THEN 2'
|
||||
. ' ELSE 1 END), 0)'
|
||||
. ' FROM artwork_awards WHERE artwork_awards.artwork_id = artworks.id) AS award_score'
|
||||
)
|
||||
->selectRaw('COALESCE((SELECT views FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_views')
|
||||
->selectRaw('COALESCE((SELECT downloads FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_downloads')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
->orderByDesc('award_score')
|
||||
->orderByDesc('stat_views')
|
||||
->orderByDesc('stat_downloads')
|
||||
->orderByDesc('published_at')
|
||||
->limit($limit)
|
||||
->pluck('id');
|
||||
try {
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'sort' => ['trending_score_7d:desc', 'trending_score_24h:desc', 'views:desc'],
|
||||
])
|
||||
->paginate($limit, 'page', 1);
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
return [];
|
||||
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
|
||||
|
||||
if ($results->isEmpty()) {
|
||||
return $this->getTrendingFromDb($limit);
|
||||
}
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getTrending Meilisearch unavailable, DB fallback', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $this->getTrendingFromDb($limit);
|
||||
}
|
||||
|
||||
$indexed = Artwork::with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->whereIn('id', $ids)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $ids
|
||||
->filter(fn ($id) => $indexed->has($id))
|
||||
->map(fn ($id) => $this->serializeArtwork($indexed[$id]))
|
||||
->values()
|
||||
->all();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DB-only fallback for trending (Meilisearch unavailable).
|
||||
* Uses pre-computed trending_score_7d column — no correlated subqueries.
|
||||
*/
|
||||
private function getTrendingFromDb(int $limit): array
|
||||
{
|
||||
return Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->orderByDesc('trending_score_7d')
|
||||
->orderByDesc('trending_score_24h')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh uploads: latest 12 approved public artworks.
|
||||
*/
|
||||
@@ -268,6 +311,84 @@ final class HomepageService
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Personalized sections (auth only)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Latest artworks from creators the user follows (max 12).
|
||||
*/
|
||||
public function getFollowingFeed(\App\Models\User $user, array $prefs): array
|
||||
{
|
||||
$followingIds = $prefs['followed_creators'] ?? [];
|
||||
|
||||
if (empty($followingIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Cache::remember(
|
||||
"homepage.following.{$user->id}",
|
||||
60, // short TTL – personal data
|
||||
function () use ($followingIds): array {
|
||||
$artworks = Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->orderByDesc('published_at')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artworks matching the user's top tags (max 12).
|
||||
* Powered by Meilisearch.
|
||||
*/
|
||||
public function getByTags(array $tagSlugs): array
|
||||
{
|
||||
if (empty($tagSlugs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$results = $this->search->discoverByTags($tagSlugs, 12);
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh artworks in the user's favourite categories (max 12).
|
||||
* Powered by Meilisearch.
|
||||
*/
|
||||
public function getByCategories(array $categorySlugs): array
|
||||
{
|
||||
if (empty($categorySlugs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$results = $this->search->discoverByCategories($categorySlugs, 12);
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
132
app/Services/TrendingService.php
Normal file
132
app/Services/TrendingService.php
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
93
app/Services/UserPreferenceService.php
Normal file
93
app/Services/UserPreferenceService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
"laravel/scout": "^10.24",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"league/commonmark": "^2.8",
|
||||
"meilisearch/meilisearch-php": "^1.16"
|
||||
"meilisearch/meilisearch-php": "^1.16",
|
||||
"predis/predis": "^3.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
65
composer.lock
generated
65
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "dcc955601c6f66f01bb520614508ed66",
|
||||
"content-hash": "e49ab9bf98b9dc4002e839deb7b45cdf",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -3053,6 +3053,69 @@
|
||||
],
|
||||
"time": "2025-12-27T19:41:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "predis/predis",
|
||||
"version": "v3.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/predis/predis.git",
|
||||
"reference": "0850f2f36ee179f0ff96c92c750e1366c6cd754c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/predis/predis/zipball/0850f2f36ee179f0ff96c92c750e1366c6cd754c",
|
||||
"reference": "0850f2f36ee179f0ff96c92c750e1366c6cd754c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0",
|
||||
"psr/http-message": "^1.0|^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.3",
|
||||
"phpstan/phpstan": "^1.9",
|
||||
"phpunit/phpcov": "^6.0 || ^8.0",
|
||||
"phpunit/phpunit": "^8.0 || ~9.4.4"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Predis\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Till Krüss",
|
||||
"homepage": "https://till.im",
|
||||
"role": "Maintainer"
|
||||
}
|
||||
],
|
||||
"description": "A flexible and feature-complete Redis/Valkey client for PHP.",
|
||||
"homepage": "http://github.com/predis/predis",
|
||||
"keywords": [
|
||||
"nosql",
|
||||
"predis",
|
||||
"redis"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/predis/predis/issues",
|
||||
"source": "https://github.com/predis/predis/tree/v3.4.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/tillkruss",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-23T19:51:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -106,6 +106,11 @@ return [
|
||||
'downloads',
|
||||
'likes',
|
||||
'views',
|
||||
'trending_score_24h',
|
||||
'trending_score_7d',
|
||||
'favorites_count',
|
||||
'awards_received_count',
|
||||
'downloads_count',
|
||||
],
|
||||
'rankingRules' => [
|
||||
'words',
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
591
docs/discovery-personalization-engine.md
Normal file
591
docs/discovery-personalization-engine.md
Normal 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:00–03:20). The next trending recalculation (every 30 min, including ~03:30 or ~04:00) picks up the freshly-zeroed windowed stats and writes accurate trending scores.
|
||||
|
||||
---
|
||||
|
||||
## 13. Testing
|
||||
|
||||
All tests live under `tests/Feature/Discovery/`.
|
||||
|
||||
| Test file | Coverage |
|
||||
|---|---|
|
||||
| `ActivityEventRecordingTest.php` | `ActivityEvent::record()`, all 5 types, actor relation, meta, route smoke tests for the activity feed |
|
||||
| `FollowingFeedTest.php` | Auth redirect, empty state fallback, pagination, creator exclusion |
|
||||
| `HomepagePersonalizationTest.php` | Guest vs auth homepage sections, preferences shape, 200 responses |
|
||||
| `SimilarArtworksApiTest.php` | 404 cases, response shape, result count ≤ 12, creator exclusion |
|
||||
| `SignalTrackingTest.php` | View endpoint (404s, first count, session dedup), download endpoint (404s, DB row, guest vs auth), route names |
|
||||
| `TrendingServiceTest.php` | Zero artworks, skip outside window, skip private/unapproved — _recalculate() tests skipped on SQLite (MySQL-only SQL)_ |
|
||||
| `WindowedStatsTest.php` | `incrementViews/Downloads` update all 3 columns, reset command zeros views, recomputes downloads from log, window boundary correctness |
|
||||
|
||||
Run all discovery tests:
|
||||
```bash
|
||||
php artisan test tests/Feature/Discovery/
|
||||
```
|
||||
|
||||
Run specific suite:
|
||||
```bash
|
||||
php artisan test tests/Feature/Discovery/SignalTrackingTest.php
|
||||
```
|
||||
|
||||
**SQLite vs MySQL note:** Four tests in `TrendingServiceTest` are marked `.skip()` with the message _"Requires MySQL: uses GREATEST() and TIMESTAMPDIFF()"_. Run them against a real MySQL instance in CI or staging to validate the bulk UPDATE formula.
|
||||
|
||||
---
|
||||
|
||||
## 14. Operational Runbook
|
||||
|
||||
### Trending scores are stuck / not updating
|
||||
|
||||
```bash
|
||||
# Check last calculated timestamp
|
||||
SELECT id, title, last_trending_calculated_at FROM artworks ORDER BY last_trending_calculated_at DESC LIMIT 5;
|
||||
|
||||
# Manually trigger recalculation
|
||||
php artisan skinbase:recalculate-trending --period=all
|
||||
|
||||
# Re-push scores to Meilisearch
|
||||
php artisan skinbase:recalculate-trending --period=7d
|
||||
```
|
||||
|
||||
### Windowed counters look wrong after a deploy
|
||||
|
||||
```bash
|
||||
# Force a reset and recompute
|
||||
php artisan skinbase:reset-windowed-stats --period=24h
|
||||
php artisan skinbase:reset-windowed-stats --period=7d
|
||||
|
||||
# Then recalculate trending with fresh numbers
|
||||
php artisan skinbase:recalculate-trending --period=all
|
||||
```
|
||||
|
||||
### Meilisearch out of sync with DB
|
||||
|
||||
```bash
|
||||
# Re-push all artworks in the trending window
|
||||
php artisan skinbase:recalculate-trending --period=all
|
||||
|
||||
# Or full re-index
|
||||
php artisan scout:import "App\Models\Artwork"
|
||||
```
|
||||
|
||||
### Push updated index settings (after changing config/scout.php)
|
||||
|
||||
```bash
|
||||
php artisan scout:sync-index-settings
|
||||
```
|
||||
|
||||
### Check what the trending formula is reading
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
a.id,
|
||||
a.title,
|
||||
a.published_at,
|
||||
s.views,
|
||||
s.views_24h,
|
||||
s.views_7d,
|
||||
s.downloads,
|
||||
s.downloads_24h,
|
||||
s.downloads_7d,
|
||||
s.favorites,
|
||||
a.trending_score_24h,
|
||||
a.trending_score_7d,
|
||||
a.last_trending_calculated_at
|
||||
FROM artworks a
|
||||
LEFT JOIN artwork_stats s ON s.artwork_id = a.id
|
||||
WHERE a.is_public = 1 AND a.is_approved = 1
|
||||
ORDER BY a.trending_score_7d DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
### Inspect the artwork_downloads log
|
||||
|
||||
```sql
|
||||
-- Downloads in the last 24 hours per artwork
|
||||
SELECT artwork_id, COUNT(*) as dl_24h
|
||||
FROM artwork_downloads
|
||||
WHERE created_at >= NOW() - INTERVAL 1 DAY
|
||||
GROUP BY artwork_id
|
||||
ORDER BY dl_24h DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
@@ -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
@@ -39,6 +39,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
const [conversations, setConversations] = useState([])
|
||||
const [loadingConvs, setLoadingConvs] = useState(true)
|
||||
const [activeId, setActiveId] = useState(initialId ?? null)
|
||||
const [realtimeEnabled, setRealtimeEnabled] = useState(false)
|
||||
const [showNewModal, setShowNewModal] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
@@ -60,11 +61,32 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
useEffect(() => {
|
||||
loadConversations()
|
||||
|
||||
// Phase 1 polling: refresh conversation list every 15 seconds
|
||||
pollRef.current = setInterval(loadConversations, 15_000)
|
||||
return () => clearInterval(pollRef.current)
|
||||
apiFetch('/api/messages/settings')
|
||||
.then(data => setRealtimeEnabled(!!data?.realtime_enabled))
|
||||
.catch(() => setRealtimeEnabled(false))
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [loadConversations])
|
||||
|
||||
useEffect(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
|
||||
if (realtimeEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
pollRef.current = setInterval(loadConversations, 15_000)
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [loadConversations, realtimeEnabled])
|
||||
|
||||
const handleSelectConversation = useCallback((id) => {
|
||||
setActiveId(id)
|
||||
history.replaceState(null, '', `/messages/${id}`)
|
||||
@@ -190,6 +212,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
key={activeId}
|
||||
conversationId={activeId}
|
||||
conversation={activeConversation}
|
||||
realtimeEnabled={realtimeEnabled}
|
||||
currentUserId={userId}
|
||||
currentUsername={username}
|
||||
apiFetch={apiFetch}
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<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">
|
||||
{isMac ? '\u2318' : 'Ctrl'}K
|
||||
</kbd>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false }) {
|
||||
const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked))
|
||||
@@ -10,6 +10,29 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
// Track the view once per browser session (sessionStorage prevents re-firing).
|
||||
useEffect(() => {
|
||||
if (!artwork?.id) return
|
||||
const key = `sb_viewed_${artwork.id}`
|
||||
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return
|
||||
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, '1')
|
||||
fetch(`/api/art/${artwork.id}/view`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {})
|
||||
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fire-and-forget download tracking — does not interrupt the native download.
|
||||
const trackDownload = () => {
|
||||
if (!artwork?.id) return
|
||||
fetch(`/api/art/${artwork.id}/download`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const postInteraction = async (url, body) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -82,6 +105,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
|
||||
<a
|
||||
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"
|
||||
onClick={trackDownload}
|
||||
download
|
||||
>
|
||||
Download
|
||||
@@ -125,6 +149,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
Download
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import MessageBubble from './MessageBubble'
|
||||
|
||||
/**
|
||||
@@ -7,6 +7,7 @@ import MessageBubble from './MessageBubble'
|
||||
export default function ConversationThread({
|
||||
conversationId,
|
||||
conversation,
|
||||
realtimeEnabled = false,
|
||||
currentUserId,
|
||||
currentUsername,
|
||||
apiFetch,
|
||||
@@ -22,9 +23,11 @@ export default function ConversationThread({
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [uploadProgress, setUploadProgress] = useState(null)
|
||||
const [typingUsers, setTypingUsers] = useState([])
|
||||
const [threadSearch, setThreadSearch] = useState('')
|
||||
const [threadSearchResults, setThreadSearchResults] = useState([])
|
||||
const [lightboxImage, setLightboxImage] = useState(null)
|
||||
const fileInputRef = useRef(null)
|
||||
const bottomRef = useRef(null)
|
||||
const threadRef = useRef(null)
|
||||
@@ -34,6 +37,22 @@ export default function ConversationThread({
|
||||
const latestIdRef = useRef(null)
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
const draftKey = `nova_draft_${conversationId}`
|
||||
const previewAttachments = useMemo(() => {
|
||||
return attachments.map(file => ({
|
||||
file,
|
||||
previewUrl: isImageLike(file) ? URL.createObjectURL(file) : null,
|
||||
}))
|
||||
}, [attachments])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const item of previewAttachments) {
|
||||
if (item.previewUrl) {
|
||||
URL.revokeObjectURL(item.previewUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [previewAttachments])
|
||||
|
||||
// ── Initial load ─────────────────────────────────────────────────────────
|
||||
const loadMessages = useCallback(async () => {
|
||||
@@ -58,37 +77,42 @@ export default function ConversationThread({
|
||||
setBody(storedDraft ?? '')
|
||||
loadMessages()
|
||||
|
||||
// Phase 1 polling: check new messages every 10 seconds
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/${conversationId}`)
|
||||
const latestChunk = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
|
||||
if (latestChunk.length && latestChunk[latestChunk.length - 1].id !== latestIdRef.current) {
|
||||
shouldAutoScrollRef.current = true
|
||||
setMessages(prev => mergeMessageLists(prev, latestChunk))
|
||||
latestIdRef.current = latestChunk[latestChunk.length - 1].id
|
||||
onConversationUpdated()
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 10_000)
|
||||
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [conversationId, draftKey])
|
||||
|
||||
useEffect(() => {
|
||||
typingPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/${conversationId}/typing`)
|
||||
setTypingUsers(data.typing ?? [])
|
||||
} catch (_) {}
|
||||
}, 2_000)
|
||||
if (!realtimeEnabled) {
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/${conversationId}`)
|
||||
const latestChunk = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
|
||||
if (latestChunk.length && latestChunk[latestChunk.length - 1].id !== latestIdRef.current) {
|
||||
shouldAutoScrollRef.current = true
|
||||
setMessages(prev => mergeMessageLists(prev, latestChunk))
|
||||
latestIdRef.current = latestChunk[latestChunk.length - 1].id
|
||||
onConversationUpdated()
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 10_000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(typingPollRef.current)
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [conversationId, draftKey, realtimeEnabled, currentUserId, apiFetch, loadMessages, onConversationUpdated])
|
||||
|
||||
useEffect(() => {
|
||||
if (!realtimeEnabled) {
|
||||
typingPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/${conversationId}/typing`)
|
||||
setTypingUsers(data.typing ?? [])
|
||||
} catch (_) {}
|
||||
}, 2_000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typingPollRef.current) clearInterval(typingPollRef.current)
|
||||
clearTimeout(typingStopTimerRef.current)
|
||||
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
||||
}
|
||||
}, [conversationId, apiFetch])
|
||||
}, [conversationId, apiFetch, realtimeEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const content = body.trim()
|
||||
@@ -190,10 +214,10 @@ export default function ConversationThread({
|
||||
const formData = new FormData()
|
||||
formData.append('body', text)
|
||||
attachments.forEach(file => formData.append('attachments[]', file))
|
||||
setUploadProgress(0)
|
||||
|
||||
const msg = await apiFetch(`/api/messages/${conversationId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
const msg = await sendMessageWithProgress(`/api/messages/${conversationId}`, formData, (progress) => {
|
||||
setUploadProgress(progress)
|
||||
})
|
||||
setMessages(prev => prev.map(m => m.id === optimistic.id ? msg : m))
|
||||
latestIdRef.current = msg.id
|
||||
@@ -203,6 +227,7 @@ export default function ConversationThread({
|
||||
setMessages(prev => prev.filter(m => m.id !== optimistic.id))
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
setSending(false)
|
||||
}
|
||||
}, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey])
|
||||
@@ -292,6 +317,24 @@ export default function ConversationThread({
|
||||
}
|
||||
}, [conversation, currentUserId, apiFetch, conversationId, onConversationUpdated])
|
||||
|
||||
const toggleMute = useCallback(async () => {
|
||||
try {
|
||||
await apiFetch(`/api/messages/${conversationId}/mute`, { method: 'POST' })
|
||||
onConversationUpdated()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}, [apiFetch, conversationId, onConversationUpdated])
|
||||
|
||||
const toggleArchive = useCallback(async () => {
|
||||
try {
|
||||
await apiFetch(`/api/messages/${conversationId}/archive`, { method: 'POST' })
|
||||
onConversationUpdated()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}, [apiFetch, conversationId, onConversationUpdated])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const q = threadSearch.trim()
|
||||
@@ -330,6 +373,7 @@ export default function ConversationThread({
|
||||
const threadLabel = conversation?.type === 'group'
|
||||
? (conversation?.title ?? 'Group conversation')
|
||||
: (conversation?.all_participants?.find(p => p.user_id !== currentUserId)?.user?.username ?? 'Direct message')
|
||||
const myParticipant = conversation?.my_participant ?? conversation?.all_participants?.find(p => p.user_id === currentUserId)
|
||||
const otherParticipant = conversation?.all_participants?.find(p => p.user_id !== currentUserId)
|
||||
const otherLastReadAt = otherParticipant?.last_read_at ?? null
|
||||
const lastMessageId = messages[messages.length - 1]?.id ?? null
|
||||
@@ -365,7 +409,21 @@ export default function ConversationThread({
|
||||
onClick={togglePin}
|
||||
className="ml-auto text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
{conversation?.my_participant?.is_pinned ? 'Unpin' : 'Pin'}
|
||||
{myParticipant?.is_pinned ? 'Unpin' : 'Pin'}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -429,6 +487,7 @@ export default function ConversationThread({
|
||||
onUnreact={handleUnreact}
|
||||
onEdit={handleEdit}
|
||||
onReport={handleReportMessage}
|
||||
onOpenImage={setLightboxImage}
|
||||
seenText={buildSeenText({
|
||||
message: msg,
|
||||
isMine: msg.sender_id === currentUserId,
|
||||
@@ -490,14 +549,41 @@ export default function ConversationThread({
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<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">
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
className="h-10 w-10 object-cover rounded"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate max-w-[220px]">{file.name}</span>
|
||||
<button type="button" onClick={() => removeAttachment(idx)} className="text-red-500">✕</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -585,3 +671,42 @@ function isSameDay(a, b) {
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
function isImageLike(file) {
|
||||
return file?.type?.startsWith('image/')
|
||||
}
|
||||
|
||||
function getCsrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
|
||||
}
|
||||
|
||||
function sendMessageWithProgress(url, formData, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', url)
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', getCsrf())
|
||||
xhr.setRequestHeader('Accept', 'application/json')
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable) return
|
||||
const progress = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)))
|
||||
onProgress(progress)
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText || '{}')
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(json)
|
||||
return
|
||||
}
|
||||
reject(new Error(json.message || `HTTP ${xhr.status}`))
|
||||
} catch (_) {
|
||||
reject(new Error(`HTTP ${xhr.status}`))
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () => reject(new Error('Network error'))
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const QUICK_REACTIONS = ['👍', '❤️', '🔥', '😂', '👏', '😮']
|
||||
* - Inline edit for own messages
|
||||
* - Soft-delete display
|
||||
*/
|
||||
export default function MessageBubble({ message, isMine, showAvatar, onReact, onUnreact, onEdit, onReport = null, seenText = null }) {
|
||||
export default function MessageBubble({ message, isMine, showAvatar, onReact, onUnreact, onEdit, onReport = null, onOpenImage = null, seenText = null }) {
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editBody, setEditBody] = useState(message.body ?? '')
|
||||
@@ -119,14 +119,18 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on
|
||||
{message.attachments.map(att => (
|
||||
<div key={att.id}>
|
||||
{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
|
||||
src={`/messages/attachments/${att.id}`}
|
||||
alt={att.original_name}
|
||||
className="max-h-44 rounded-lg border border-white/20"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={`/messages/attachments/${att.id}`}
|
||||
|
||||
87
resources/views/community/activity.blade.php
Normal file
87
resources/views/community/activity.blade.php
Normal 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
|
||||
87
resources/views/web/community/activity.blade.php
Normal file
87
resources/views/web/community/activity.blade.php
Normal 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
|
||||
@@ -2,6 +2,25 @@
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// ── Per-artwork signal tracking (public) ────────────────────────────────────
|
||||
// GET /api/art/{id}/similar → up to 12 similar artworks (Meilisearch)
|
||||
// POST /api/art/{id}/view → record a view (session-deduped, 5 per 10 min)
|
||||
// POST /api/art/{id}/download → record a download, returns file URL (10/min)
|
||||
Route::middleware(['web', 'throttle:60,1'])
|
||||
->get('art/{id}/similar', \App\Http\Controllers\Api\SimilarArtworksController::class)
|
||||
->whereNumber('id')
|
||||
->name('api.art.similar');
|
||||
|
||||
Route::middleware(['web', 'throttle:5,10'])
|
||||
->post('art/{id}/view', \App\Http\Controllers\Api\ArtworkViewController::class)
|
||||
->whereNumber('id')
|
||||
->name('api.art.view');
|
||||
|
||||
Route::middleware(['web', 'throttle:10,1'])
|
||||
->post('art/{id}/download', \App\Http\Controllers\Api\ArtworkDownloadController::class)
|
||||
->whereNumber('id')
|
||||
->name('api.art.download');
|
||||
|
||||
/**
|
||||
* API v1 routes for Artworks module
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
use App\Uploads\Services\CleanupService;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
@@ -14,3 +15,51 @@ Artisan::command('uploads:cleanup {--limit=100 : Maximum drafts to clean in one
|
||||
|
||||
$this->info("Uploads cleanup deleted {$deleted} draft(s).");
|
||||
})->purpose('Delete stale draft uploads and temporary files');
|
||||
|
||||
// ── Scheduled tasks ────────────────────────────────────────────────────────────
|
||||
|
||||
// Recalculate trending scores every 30 minutes (staggered: 24h first, then 7d)
|
||||
Schedule::command('skinbase:recalculate-trending --period=24h')
|
||||
->everyThirtyMinutes()
|
||||
->name('trending-24h')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::command('skinbase:recalculate-trending --period=7d --skip-index')
|
||||
->everyThirtyMinutes()
|
||||
->name('trending-7d')
|
||||
->runInBackground()
|
||||
->withoutOverlapping();
|
||||
|
||||
// Reset windowed view/download counters so trending uses recent-activity data.
|
||||
// Downloads are recomputed from the artwork_downloads log (accurate).
|
||||
// Views are zeroed (no per-view event log) and re-accumulate from midnight.
|
||||
Schedule::command('skinbase:reset-windowed-stats --period=24h')
|
||||
->dailyAt('03:30')
|
||||
->name('reset-windowed-stats-24h')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::command('skinbase:reset-windowed-stats --period=7d')
|
||||
->weeklyOn(1, '03:30') // Monday 03:30
|
||||
->name('reset-windowed-stats-7d')
|
||||
->withoutOverlapping();
|
||||
|
||||
// Daily maintenance
|
||||
Schedule::command('uploads:cleanup')->dailyAt('03:00');
|
||||
Schedule::command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
||||
Schedule::command('analytics:aggregate-feed')->dailyAt('03:20');
|
||||
|
||||
// Drain Redis artwork-stat delta queue so MySQL counters stay fresh.
|
||||
// Run every 5 minutes with overlap protection.
|
||||
Schedule::command('skinbase:flush-redis-stats')
|
||||
->everyFiveMinutes()
|
||||
->name('flush-redis-stats')
|
||||
->withoutOverlapping();
|
||||
|
||||
// Prune artwork_view_events rows older than 90 days.
|
||||
// Runs Sunday at 04:00, after all other weekly maintenance.
|
||||
Schedule::command('skinbase:prune-view-events --days=90')
|
||||
->weekly()
|
||||
->sundays()
|
||||
->at('04:00')
|
||||
->name('prune-view-events')
|
||||
->withoutOverlapping();
|
||||
|
||||
@@ -378,3 +378,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('messages')->n
|
||||
Route::get('/', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'index'])->name('index');
|
||||
Route::get('/{id}', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'show'])->whereNumber('id')->name('show');
|
||||
});
|
||||
|
||||
// ── Community Activity Feed ───────────────────────────────────────────────────
|
||||
Route::get('/community/activity', [\App\Http\Controllers\Web\CommunityActivityController::class, 'index'])
|
||||
->name('community.activity');
|
||||
|
||||
20
scripts/check_redis.php
Normal file
20
scripts/check_redis.php
Normal 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;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"107d361b6fc8beba4b6c-bd5f3b54043cc6ed6ffb"
|
||||
]
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
136
tests/Feature/Discovery/ActivityEventRecordingTest.php
Normal file
136
tests/Feature/Discovery/ActivityEventRecordingTest.php
Normal 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);
|
||||
});
|
||||
87
tests/Feature/Discovery/FollowingFeedTest.php
Normal file
87
tests/Feature/Discovery/FollowingFeedTest.php
Normal 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);
|
||||
});
|
||||
80
tests/Feature/Discovery/HomepagePersonalizationTest.php
Normal file
80
tests/Feature/Discovery/HomepagePersonalizationTest.php
Normal 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();
|
||||
});
|
||||
165
tests/Feature/Discovery/SignalTrackingTest.php
Normal file
165
tests/Feature/Discovery/SignalTrackingTest.php
Normal 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");
|
||||
});
|
||||
117
tests/Feature/Discovery/SimilarArtworksApiTest.php
Normal file
117
tests/Feature/Discovery/SimilarArtworksApiTest.php
Normal 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
|
||||
});
|
||||
98
tests/Feature/Discovery/TrendingServiceTest.php
Normal file
98
tests/Feature/Discovery/TrendingServiceTest.php
Normal 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');
|
||||
147
tests/Feature/Discovery/WindowedStatsTest.php
Normal file
147
tests/Feature/Discovery/WindowedStatsTest.php
Normal 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);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ type Fixture = {
|
||||
email: string
|
||||
password: string
|
||||
conversation_id: number
|
||||
latest_message_id: number
|
||||
}
|
||||
|
||||
function seedMessagingFixture(): Fixture {
|
||||
@@ -46,7 +47,7 @@ function seedMessagingFixture(): Fixture {
|
||||
"$last = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $owner->id, 'body' => 'Seed latest from owner']);",
|
||||
"$conversation->update(['last_message_at' => $last->created_at]);",
|
||||
"ConversationParticipant::where('conversation_id', $conversation->id)->where('user_id', $peer->id)->update(['last_read_at' => Carbon::parse($last->created_at)->addSeconds(15)]);",
|
||||
"echo json_encode(['email' => $owner->email, 'password' => 'password', 'conversation_id' => $conversation->id]);",
|
||||
"echo json_encode(['email' => $owner->email, 'password' => 'password', 'conversation_id' => $conversation->id, 'latest_message_id' => $last->id]);",
|
||||
].join(' ')
|
||||
|
||||
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
@@ -106,7 +107,7 @@ test.describe('Messaging UI', () => {
|
||||
await login(page, fixture)
|
||||
await page.goto(`/messages/${fixture.conversation_id}`)
|
||||
|
||||
await expect(page.locator('text=Seed latest from owner')).toBeVisible()
|
||||
await expect(page.locator(`#message-${fixture.latest_message_id}`)).toContainText('Seed latest from owner')
|
||||
await expect(page.locator('text=/^Seen\\s.+\\sago$/')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user