storing analytics data

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

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\TrendingService;
use Illuminate\Console\Command;
/**
* php artisan skinbase:recalculate-trending [--period=24h|7d] [--chunk=1000] [--skip-index]
*/
class RecalculateTrendingCommand extends Command
{
protected $signature = 'skinbase:recalculate-trending
{--period=7d : Period to recalculate (24h or 7d). Use "all" to run both.}
{--chunk=1000 : DB chunk size}
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
protected $description = 'Recalculate trending scores for artworks and sync to Meilisearch';
public function __construct(private readonly TrendingService $trending)
{
parent::__construct();
}
public function handle(): int
{
$period = (string) $this->option('period');
$chunkSize = (int) $this->option('chunk');
$skipIndex = (bool) $this->option('skip-index');
$periods = $period === 'all' ? ['24h', '7d'] : [$period];
foreach ($periods as $p) {
if (! in_array($p, ['24h', '7d'], true)) {
$this->error("Invalid period '{$p}'. Use 24h, 7d, or all.");
return self::FAILURE;
}
$this->info("Recalculating trending ({$p}) …");
$start = microtime(true);
$updated = $this->trending->recalculate($p, $chunkSize);
$elapsed = round(microtime(true) - $start, 2);
$this->info("{$updated} artworks updated in {$elapsed}s");
if (! $skipIndex) {
$this->info(" Dispatching Meilisearch index jobs …");
$this->trending->syncToSearchIndex($p);
$this->info(" ✓ Index jobs dispatched");
}
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* php artisan skinbase:reset-windowed-stats --period=24h|7d
*
* Resets / recomputes the sliding-window stats columns in artwork_stats:
*
* views_24h / views_7d
* Zeroed on each reset because we have no per-view event log.
* Artworks re-accumulate from the next view event onward.
* (Low-traffic reset window: 03:30 means minimal trending disruption.)
*
* downloads_24h / downloads_7d
* Recomputed accurately from the artwork_downloads event log.
* A single bulk UPDATE with a correlated COUNT() is safe here because
* it runs once nightly/weekly, not in the hot path.
*
* Scheduled in routes/console.php:
* --period=24h daily at 03:30
* --period=7d weekly (Monday) at 03:30
*/
class ResetWindowedStatsCommand extends Command
{
protected $signature = 'skinbase:reset-windowed-stats
{--period=24h : Window to reset: 24h or 7d}';
protected $description = 'Reset windowed view/download counters in artwork_stats';
public function handle(): int
{
$period = (string) $this->option('period');
if (! in_array($period, ['24h', '7d'], true)) {
$this->error("Invalid period '{$period}'. Use 24h or 7d.");
return self::FAILURE;
}
[$viewsCol, $downloadsCol, $cutoff] = match ($period) {
'24h' => ['views_24h', 'downloads_24h', now()->subDay()],
default => ['views_7d', 'downloads_7d', now()->subDays(7)],
};
$start = microtime(true);
// ── 1. Zero the views window column ──────────────────────────────────
// We have no per-view event log, so we reset the accumulator.
$viewsReset = DB::table('artwork_stats')->update([$viewsCol => 0]);
// ── 2. Recompute downloads window from the event log ─────────────────
// artwork_downloads has created_at, so each row's window is accurate.
// Chunked PHP loop avoids MySQL-only functions (GREATEST, INTERVAL)
// so this command works in both MySQL (production) and SQLite (tests).
$downloadsRecomputed = 0;
DB::table('artwork_stats')
->orderBy('artwork_id')
->chunk(1000, function ($rows) use ($downloadsCol, $cutoff, &$downloadsRecomputed): void {
foreach ($rows as $row) {
$count = DB::table('artwork_downloads')
->where('artwork_id', $row->artwork_id)
->where('created_at', '>=', $cutoff)
->count();
DB::table('artwork_stats')
->where('artwork_id', $row->artwork_id)
->update([$downloadsCol => max(0, $count)]);
$downloadsRecomputed++;
}
});
$elapsed = round(microtime(true) - $start, 2);
$this->info("Period: {$period}");
$this->info(" {$viewsCol}: zeroed {$viewsReset} rows");
$this->info(" {$downloadsCol}: recomputed {$downloadsRecomputed} rows ({$elapsed}s)");
Log::info('ResetWindowedStats complete', [
'period' => $period,
'views_col' => $viewsCol,
'views_rows_reset' => $viewsReset,
'downloads_col' => $downloadsCol,
'downloads_recomputed' => $downloadsRecomputed,
'elapsed_s' => $elapsed,
]);
return self::SUCCESS;
}
}

View File

@@ -12,6 +12,7 @@ use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\EvaluateFeedWeightsCommand;
use App\Console\Commands\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();
}
/**

View File

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

View File

@@ -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);
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* POST /api/art/{id}/download
*
* Records a download event and returns the full-resolution download URL.
*
* Responsibilities:
* 1. Validates the artwork is public and published.
* 2. Inserts a row in artwork_downloads (artwork_id, user_id, ip, user_agent).
* 3. Increments artwork_stats.downloads + forwards to creator stats.
* 4. Returns {"ok": true, "url": "<download_url>"} so the frontend can
* trigger the actual browser download.
*
* The frontend fires this POST on click, then uses the returned URL to
* trigger the file download (or falls back to the pre-resolved URL it
* already has).
*/
final class ArtworkDownloadController extends Controller
{
public function __construct(private readonly ArtworkStatsService $stats) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->with(['user:id'])
->where('id', $id)
->first();
if (! $artwork) {
return response()->json(['error' => 'Not found'], 404);
}
// Record the download event — non-blocking, errors are swallowed.
$this->recordDownload($request, $artwork);
// Increment counters — deferred via Redis when available.
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: true);
// Resolve the highest-resolution download URL available.
$url = $this->resolveDownloadUrl($artwork);
return response()->json(['ok' => true, 'url' => $url]);
}
/**
* Insert a row in artwork_downloads.
* Uses a raw insert for the binary(16) IP column.
* Silently ignores failures (analytics should never break user flow).
*/
private function recordDownload(Request $request, Artwork $artwork): void
{
try {
$ip = $request->ip() ?? '0.0.0.0';
$bin = @inet_pton($ip);
DB::table('artwork_downloads')->insert([
'artwork_id' => $artwork->id,
'user_id' => $request->user()?->id,
'ip' => $bin !== false ? $bin : null,
'user_agent' => mb_substr((string) $request->userAgent(), 0, 512),
'created_at' => now(),
]);
} catch (\Throwable) {
// Analytics failure must never interrupt the download.
}
}
/**
* Resolve the best available download URL: XL LG MD.
* Returns an empty string if no thumbnail can be resolved.
*/
private function resolveDownloadUrl(Artwork $artwork): string
{
foreach (['xl', 'lg', 'md'] as $size) {
$thumb = ThumbnailPresenter::present($artwork, $size);
if (! empty($thumb['url'])) {
return (string) $thumb['url'];
}
}
return '';
}
}

View File

@@ -36,6 +36,16 @@ final class ArtworkInteractionController extends Controller
if ($state) {
$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);
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* POST /api/art/{id}/view
*
* Fire-and-forget view tracker.
*
* Deduplication strategy (layered):
* 1. Session key (`art_viewed.{id}`) prevents double-counts within the
* same browser session (survives page reloads).
* 2. Route throttle (5 per 10 minutes per IP+artwork) catches bots that
* don't send session cookies.
*
* The frontend should additionally guard with sessionStorage so it only
* calls this endpoint once per page load.
*/
final class ArtworkViewController extends Controller
{
public function __construct(private readonly ArtworkStatsService $stats) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->where('id', $id)
->first();
if (! $artwork) {
return response()->json(['error' => 'Not found'], 404);
}
$sessionKey = 'art_viewed.' . $id;
// Already counted this session — return early without touching the DB.
if ($request->hasSession() && $request->session()->has($sessionKey)) {
return response()->json(['ok' => true, 'counted' => false]);
}
// Write persistent event log (auth user_id or null for guests).
$this->stats->logViewEvent((int) $artwork->id, $request->user()?->id);
// Defer to Redis when available, fall back to direct DB increment.
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
// Mark this session so the artwork is not counted again.
if ($request->hasSession()) {
$request->session()->put($sessionKey, true);
}
return response()->json(['ok' => true, 'counted' => true]);
}
}

View File

@@ -18,6 +18,7 @@ class MessagingSettingsController extends Controller
{
return response()->json([
'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),
]);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
/**
* GET /api/art/{id}/similar
*
* Returns up to 12 similar artworks based on:
* 1. Tag overlap (primary signal)
* 2. Same category
* 3. Similar orientation
*
* Uses Meilisearch via ArtworkSearchService for fast retrieval.
* Current artwork and its creator are excluded from results.
*/
final class SimilarArtworksController extends Controller
{
private const LIMIT = 12;
private const CACHE_TTL = 300; // 5 minutes
public function __construct(private readonly ArtworkSearchService $search) {}
public function __invoke(int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->with(['tags:id,slug', 'categories:id,slug'])
->find($id);
if (! $artwork) {
return response()->json(['error' => 'Artwork not found'], 404);
}
$cacheKey = "api.similar.{$artwork->id}";
$items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) {
return $this->findSimilar($artwork);
});
return response()->json(['data' => $items]);
}
private function findSimilar(Artwork $artwork): array
{
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
$orientation = $this->orientation($artwork);
// Build Meilisearch filter: exclude self and same creator
$filterParts = [
'is_public = true',
'is_approved = true',
'id != ' . $artwork->id,
'author_id != ' . $artwork->user_id,
];
// Filter by same orientation (landscape/portrait) — improves visual coherence
if ($orientation !== 'square') {
$filterParts[] = 'orientation = "' . $orientation . '"';
}
// Priority 1: tag overlap (OR match across tags)
if ($tagSlugs !== []) {
$tagFilter = implode(' OR ', array_map(
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
$tagSlugs
));
$filterParts[] = '(' . $tagFilter . ')';
} elseif ($categorySlugs !== []) {
// Fallback to category if no tags
$catFilter = implode(' OR ', array_map(
fn (string $c): string => 'category = "' . addslashes($c) . '"',
$categorySlugs
));
$filterParts[] = '(' . $catFilter . ')';
}
$results = Artwork::search('')
->options([
'filter' => implode(' AND ', $filterParts),
'sort' => ['trending_score_7d:desc', 'likes:desc'],
])
->paginate(self::LIMIT);
return $results->getCollection()
->map(fn (Artwork $a): array => [
'id' => $a->id,
'title' => $a->title,
'slug' => $a->slug,
'thumb' => $a->thumbUrl('md'),
'url' => '/art/' . $a->id . '/' . $a->slug,
'author_id' => $a->user_id,
'orientation' => $this->orientation($a),
'width' => $a->width,
'height' => $a->height,
])
->values()
->all();
}
private function orientation(Artwork $artwork): string
{
if (! $artwork->width || ! $artwork->height) {
return 'square';
}
return match (true) {
$artwork->width > $artwork->height => 'landscape',
$artwork->height > $artwork->width => 'portrait',
default => 'square',
};
}
}

View File

@@ -518,6 +518,16 @@ final class UploadController extends Controller
$artwork->published_at = now();
$artwork->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,

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Community activity feed.
*
* GET /community/activity?type=global|following
*/
final class CommunityActivityController extends Controller
{
private const PER_PAGE = 30;
public function index(Request $request)
{
$user = $request->user();
$type = $request->query('type', 'global'); // global | following
$perPage = self::PER_PAGE;
$query = ActivityEvent::query()
->orderByDesc('created_at')
->with(['actor:id,name,username']);
if ($type === 'following' && $user) {
// Show only events from followed users
$followingIds = DB::table('user_followers')
->where('follower_id', $user->id)
->pluck('user_id')
->all();
if (empty($followingIds)) {
$query->whereRaw('0 = 1'); // empty result set
} else {
$query->whereIn('actor_id', $followingIds);
}
}
$events = $query->paginate($perPage)->withQueryString();
$enriched = $this->enrich($events->getCollection());
return view('web.community.activity', [
'events' => $events,
'enriched' => $enriched,
'active_tab' => $type,
'page_title' => 'Community Activity',
]);
}
/**
* Attach target object data to each event for display.
*/
private function enrich(\Illuminate\Support\Collection $events): \Illuminate\Support\Collection
{
// Collect artwork IDs and user IDs to eager-load
$artworkIds = $events
->where('target_type', ActivityEvent::TARGET_ARTWORK)
->pluck('target_id')
->unique()
->values()
->all();
$userIds = $events
->where('target_type', ActivityEvent::TARGET_USER)
->pluck('target_id')
->unique()
->values()
->all();
$artworks = Artwork::whereIn('id', $artworkIds)
->with('user:id,name,username')
->get(['id', 'title', 'slug', 'user_id', 'hash', 'thumb_ext'])
->keyBy('id');
$users = User::whereIn('id', $userIds)
->with('profile:user_id,avatar_hash')
->get(['id', 'name', 'username'])
->keyBy('id');
return $events->map(function (ActivityEvent $event) use ($artworks, $users): array {
$target = null;
if ($event->target_type === ActivityEvent::TARGET_ARTWORK) {
$artwork = $artworks->get($event->target_id);
$target = $artwork ? [
'id' => $artwork->id,
'title' => $artwork->title,
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
'thumb' => $artwork->thumbUrl('sm'),
] : null;
} elseif ($event->target_type === ActivityEvent::TARGET_USER) {
$u = $users->get($event->target_id);
$target = $u ? [
'id' => $u->id,
'name' => $u->name,
'username' => $u->username,
'url' => '/@' . $u->username,
] : null;
}
return [
'id' => $event->id,
'type' => $event->type,
'target_type' => $event->target_type,
'actor' => [
'id' => $event->actor?->id,
'name' => $event->actor?->name,
'username' => $event->actor?->username,
'url' => '/@' . $event->actor?->username,
],
'target' => $target,
'created_at' => $event->created_at?->toIso8601String(),
];
});
}
}

View File

@@ -8,6 +8,7 @@ use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\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));

View File

@@ -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,
]);
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Unified activity feed event.
*
* Types: upload | comment | favorite | award | follow
* target_type: artwork | user
*
* @property int $id
* @property int $actor_id
* @property string $type
* @property string $target_type
* @property int $target_id
* @property array|null $meta
* @property \Illuminate\Support\Carbon $created_at
*/
class ActivityEvent extends Model
{
protected $table = 'activity_events';
public $timestamps = false;
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
protected $fillable = [
'actor_id',
'type',
'target_type',
'target_id',
'meta',
];
protected $casts = [
'actor_id' => 'integer',
'target_id' => 'integer',
'meta' => 'array',
'created_at' => 'datetime',
];
// ── Event type constants ──────────────────────────────────────────────────
const TYPE_UPLOAD = 'upload';
const TYPE_COMMENT = 'comment';
const TYPE_FAVORITE = 'favorite';
const TYPE_AWARD = 'award';
const TYPE_FOLLOW = 'follow';
const TARGET_ARTWORK = 'artwork';
const TARGET_USER = 'user';
// ── Relations ─────────────────────────────────────────────────────────────
/** The user who performed the action */
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
// ── Factory helpers ───────────────────────────────────────────────────────
public static function record(
int $actorId,
string $type,
string $targetType,
int $targetId,
array $meta = []
): static {
$event = static::create([
'actor_id' => $actorId,
'type' => $type,
'target_type' => $targetType,
'target_id' => $targetId,
'meta' => $meta ?: null,
'created_at' => now(),
]);
// Ensure created_at is available on the returned instance
// ($timestamps = false means Eloquent doesn't auto-populate it)
if ($event->created_at === null) {
$event->created_at = now();
}
return $event;
}
}

View File

@@ -250,6 +250,12 @@ class Artwork extends Model
'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '',
'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,

View File

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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* TrendingService
*
* Calculates and persists deterministic trending scores for artworks.
*
* Formula (Phase 1):
* score = (award_score * 5)
* + (favorites_count * 3)
* + (reactions_count * 2)
* + (downloads_count * 1)
* + (views * 2)
* - (hours_since_published * 0.1)
*
* The score is stored in artworks.trending_score_24h (artworks 7 days old)
* and artworks.trending_score_7d (artworks 30 days old).
*
* Both columns are updated every run; use `--period` to limit computation.
*/
final class TrendingService
{
/** Weight constants — tune via config('discovery.trending.*') if needed */
private const W_AWARD = 5.0;
private const W_FAVORITE = 3.0;
private const W_REACTION = 2.0;
private const W_DOWNLOAD = 1.0;
private const W_VIEW = 2.0;
private const DECAY_RATE = 0.1; // score loss per hour since publish
/**
* Recalculate trending scores for artworks published within the look-back window.
*
* @param string $period '24h' targets trending_score_24h (7-day window)
* '7d' targets trending_score_7d (30-day window)
* @param int $chunkSize Number of IDs per DB UPDATE batch
* @return int Number of artworks updated
*/
public function recalculate(string $period = '7d', int $chunkSize = 1000): int
{
[$column, $windowDays] = match ($period) {
'24h' => ['trending_score_24h', 7],
default => ['trending_score_7d', 30],
};
// Use the windowed counters: views_24h/views_7d and downloads_24h/downloads_7d
// instead of all-time totals so trending reflects recent activity.
[$viewCol, $dlCol] = match ($period) {
'24h' => ['views_24h', 'downloads_24h'],
default => ['views_7d', 'downloads_7d'],
};
$cutoff = now()->subDays($windowDays)->toDateTimeString();
$updated = 0;
Artwork::query()
->select('id')
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->whereNotNull('published_at')
->where('published_at', '>=', $cutoff)
->orderBy('id')
->chunkById($chunkSize, function ($artworks) use ($column, &$updated): void {
$ids = $artworks->pluck('id')->toArray();
$inClause = implode(',', array_fill(0, count($ids), '?'));
// One bulk UPDATE per chunk uses pre-computed windowed counters
// for views and downloads (accurate rolling windows, reset nightly/weekly)
// rather than all-time totals. All other signals use correlated subqueries.
// Column name ($column) is controlled internally, not user-supplied.
DB::update(
"UPDATE artworks
SET
{$column} = GREATEST(
COALESCE((SELECT score_total FROM artwork_award_stats WHERE artwork_award_stats.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT favorites FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT COUNT(*) FROM artwork_reactions WHERE artwork_reactions.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT {$dlCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT {$viewCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
- (TIMESTAMPDIFF(HOUR, artworks.published_at, NOW()) * ?)
, 0),
last_trending_calculated_at = NOW()
WHERE id IN ({$inClause})",
array_merge(
[self::W_AWARD, self::W_FAVORITE, self::W_REACTION, self::W_DOWNLOAD, self::W_VIEW, self::DECAY_RATE],
$ids
)
);
$updated += count($ids);
});
Log::info('TrendingService: recalculation complete', [
'period' => $period,
'column' => $column,
'updated' => $updated,
]);
return $updated;
}
/**
* Dispatch Meilisearch re-index jobs for artworks in the trending window.
* Called after recalculate() to keep the search index current.
*/
public function syncToSearchIndex(string $period = '7d', int $chunkSize = 500): void
{
$windowDays = $period === '24h' ? 7 : 30;
$cutoff = now()->subDays($windowDays)->toDateTimeString();
Artwork::query()
->select('id')
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->where('published_at', '>=', $cutoff)
->chunkById($chunkSize, function ($artworks): void {
foreach ($artworks as $artwork) {
\App\Jobs\IndexArtworkJob::dispatch($artwork->id);
}
});
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* UserPreferenceService
*
* Builds a lightweight preference profile for a user based on:
* - Tags on artworks they have favourited
* - Categories of artwork they have favourited / downloaded
* - Creators they follow
*
* Output shape:
* [
* 'top_tags' => ['space', 'nature', ...], // up to 5 slugs
* 'top_categories' => ['wallpapers', ...], // up to 3 slugs
* 'followed_creators' => [1, 5, 23, ...], // user IDs
* ]
*/
final class UserPreferenceService
{
private const CACHE_TTL = 300; // 5 minutes
public function build(User $user): array
{
return Cache::remember(
"user.prefs.{$user->id}",
self::CACHE_TTL,
fn () => $this->compute($user)
);
}
private function compute(User $user): array
{
return [
'top_tags' => $this->topTags($user),
'top_categories' => $this->topCategories($user),
'followed_creators' => $this->followedCreatorIds($user),
];
}
/** Top tag slugs derived from the user's favourited artworks */
private function topTags(User $user, int $limit = 5): array
{
return DB::table('artwork_favourites as af')
->join('artwork_tag as at', 'at.artwork_id', '=', 'af.artwork_id')
->join('tags as t', 't.id', '=', 'at.tag_id')
->where('af.user_id', $user->id)
->where('t.is_active', true)
->selectRaw('t.slug, COUNT(*) as cnt')
->groupBy('t.id', 't.slug')
->orderByDesc('cnt')
->limit($limit)
->pluck('slug')
->values()
->all();
}
/** Top category slugs derived from the user's favourited artworks */
private function topCategories(User $user, int $limit = 3): array
{
return DB::table('artwork_favourites as af')
->join('artwork_category as ac', 'ac.artwork_id', '=', 'af.artwork_id')
->join('categories as c', 'c.id', '=', 'ac.category_id')
->where('af.user_id', $user->id)
->whereNull('c.deleted_at')
->selectRaw('c.slug, COUNT(*) as cnt')
->groupBy('c.id', 'c.slug')
->orderByDesc('cnt')
->limit($limit)
->pluck('slug')
->values()
->all();
}
/** IDs of creators the user follows, latest follows first */
private function followedCreatorIds(User $user, int $limit = 100): array
{
return DB::table('user_followers')
->where('follower_id', $user->id)
->orderByDesc('created_at')
->limit($limit)
->pluck('user_id')
->values()
->all();
}
}