0). * - ensureRow() upserts the row before any counter touch. * - recomputeUser() rebuilds all columns from authoritative tables. */ final class UserStatsService { // ─── Row management ────────────────────────────────────────────────────── /** * Guarantee a user_statistics row exists for the given user. * Safe to call before every increment. */ public function ensureRow(int $userId): void { DB::table('user_statistics')->insertOrIgnore([ 'user_id' => $userId, 'created_at' => now(), 'updated_at' => now(), ]); } // ─── Increment helpers ──────────────────────────────────────────────────── public function incrementUploads(int $userId, int $by = 1): void { $this->ensureRow($userId); $this->inc($userId, 'uploads_count', $by); $this->touchActive($userId); $this->reindex($userId); } public function decrementUploads(int $userId, int $by = 1): void { $this->dec($userId, 'uploads_count', $by); $this->reindex($userId); } public function incrementDownloadsReceived(int $creatorUserId, int $by = 1): void { $this->ensureRow($creatorUserId); $this->inc($creatorUserId, 'downloads_received_count', $by); $this->reindex($creatorUserId); } public function incrementArtworkViewsReceived(int $creatorUserId, int $by = 1): void { $this->ensureRow($creatorUserId); $this->inc($creatorUserId, 'artwork_views_received_count', $by); // Views are high-frequency – do NOT reindex on every view. } public function incrementAwardsReceived(int $creatorUserId, int $by = 1): void { $this->ensureRow($creatorUserId); $this->inc($creatorUserId, 'awards_received_count', $by); $this->reindex($creatorUserId); } public function decrementAwardsReceived(int $creatorUserId, int $by = 1): void { $this->dec($creatorUserId, 'awards_received_count', $by); $this->reindex($creatorUserId); } public function incrementFavoritesReceived(int $creatorUserId, int $by = 1): void { $this->ensureRow($creatorUserId); $this->inc($creatorUserId, 'favorites_received_count', $by); $this->reindex($creatorUserId); } public function decrementFavoritesReceived(int $creatorUserId, int $by = 1): void { $this->dec($creatorUserId, 'favorites_received_count', $by); $this->reindex($creatorUserId); } public function incrementCommentsReceived(int $creatorUserId, int $by = 1): void { $this->ensureRow($creatorUserId); $this->inc($creatorUserId, 'comments_received_count', $by); $this->reindex($creatorUserId); } public function decrementCommentsReceived(int $creatorUserId, int $by = 1): void { $this->dec($creatorUserId, 'comments_received_count', $by); $this->reindex($creatorUserId); } public function incrementReactionsReceived(int $creatorUserId, int $by = 1): void { $this->ensureRow($creatorUserId); $this->inc($creatorUserId, 'reactions_received_count', $by); $this->reindex($creatorUserId); } public function decrementReactionsReceived(int $creatorUserId, int $by = 1): void { $this->dec($creatorUserId, 'reactions_received_count', $by); $this->reindex($creatorUserId); } public function incrementProfileViews(int $userId, int $by = 1): void { $this->ensureRow($userId); $this->inc($userId, 'profile_views_count', $by); } // ─── Timestamp helpers ──────────────────────────────────────────────────── public function setLastUploadAt(int $userId, ?Carbon $timestamp = null): void { $this->ensureRow($userId); DB::table('user_statistics') ->where('user_id', $userId) ->update([ 'last_upload_at' => ($timestamp ?? now())->toDateTimeString(), 'updated_at' => now(), ]); } public function setLastActiveAt(int $userId, ?Carbon $timestamp = null): void { $this->ensureRow($userId); DB::table('user_statistics') ->where('user_id', $userId) ->update([ 'last_active_at' => ($timestamp ?? now())->toDateTimeString(), 'updated_at' => now(), ]); } // ─── Recompute ──────────────────────────────────────────────────────────── /** * Recompute all counters for a single user from authoritative tables. * Returns the computed values (array) without writing when $dryRun=true. * * @return array */ public function recomputeUser(int $userId, bool $dryRun = false): array { $computed = [ 'uploads_count' => (int) DB::table('artworks') ->where('user_id', $userId) ->whereNull('deleted_at') ->count(), 'downloads_received_count' => (int) DB::table('artwork_downloads as d') ->join('artworks as a', 'a.id', '=', 'd.artwork_id') ->where('a.user_id', $userId) ->whereNull('a.deleted_at') ->count(), 'artwork_views_received_count' => (int) DB::table('artwork_stats as s') ->join('artworks as a', 'a.id', '=', 's.artwork_id') ->where('a.user_id', $userId) ->whereNull('a.deleted_at') ->sum('s.views'), 'awards_received_count' => (int) DB::table('artwork_awards as aw') ->join('artworks as a', 'a.id', '=', 'aw.artwork_id') ->where('a.user_id', $userId) ->whereNull('a.deleted_at') ->count(), 'favorites_received_count' => (int) DB::table('artwork_favourites as f') ->join('artworks as a', 'a.id', '=', 'f.artwork_id') ->where('a.user_id', $userId) ->whereNull('a.deleted_at') ->count(), 'comments_received_count' => (int) DB::table('artwork_comments as c') ->join('artworks as a', 'a.id', '=', 'c.artwork_id') ->where('a.user_id', $userId) ->whereNull('a.deleted_at') ->whereNull('c.deleted_at') ->count(), 'reactions_received_count' => (int) DB::table('artwork_reactions as r') ->join('artworks as a', 'a.id', '=', 'r.artwork_id') ->where('a.user_id', $userId) ->whereNull('a.deleted_at') ->count(), 'followers_count' => (int) DB::table('user_followers') ->where('user_id', $userId) ->count(), 'following_count' => (int) DB::table('user_followers') ->where('follower_id', $userId) ->count(), 'last_upload_at' => DB::table('artworks') ->where('user_id', $userId) ->whereNull('deleted_at') ->max('created_at'), ]; if (! $dryRun) { $this->ensureRow($userId); DB::table('user_statistics') ->where('user_id', $userId) ->update(array_merge($computed, ['updated_at' => now()])); $this->reindex($userId); } return $computed; } /** * Recompute stats for all users in chunks. * * @param int $chunk Users per chunk. */ public function recomputeAll(int $chunk = 1000): void { DB::table('users') ->whereNull('deleted_at') ->orderBy('id') ->chunk($chunk, function ($users) { foreach ($users as $user) { $this->recomputeUser($user->id); } }); } // ─── Private helpers ────────────────────────────────────────────────────── private function inc(int $userId, string $column, int $by = 1): void { DB::table('user_statistics') ->where('user_id', $userId) ->update([ $column => $this->nonNegativeCounterExpression($column, $by), 'updated_at' => now(), ]); } private function dec(int $userId, string $column, int $by = 1): void { DB::table('user_statistics') ->where('user_id', $userId) ->where($column, '>', 0) ->update([ $column => $this->nonNegativeCounterExpression($column, -$by), 'updated_at' => now(), ]); } private function touchActive(int $userId): void { DB::table('user_statistics') ->where('user_id', $userId) ->update([ 'last_active_at' => now(), 'updated_at' => now(), ]); } private function nonNegativeCounterExpression(string $column, int $delta) { if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $column)) { throw new InvalidArgumentException('Invalid statistics column name.'); } $driver = DB::connection()->getDriverName(); $deltaSql = $delta >= 0 ? "+ {$delta}" : "- ".abs($delta); if ($driver === 'sqlite') { return DB::raw("max(0, COALESCE({$column}, 0) {$deltaSql})"); } return DB::raw("GREATEST(0, COALESCE({$column}, 0) {$deltaSql})"); } /** * Queue a Meilisearch reindex for the user. * Uses IndexUserJob to avoid blocking the request. */ private function reindex(int $userId): void { IndexUserJob::dispatch($userId); } }