Files
SkinbaseNova/app/Services/UserStatsService.php
Gregor Klevze eee7df1f8c feat: artwork page carousels, recommendations, avatars & fixes
- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
2026-02-28 14:05:39 +01:00

291 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\IndexUserJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* UserStatsService single source of truth for user_statistics counters.
*
* All counter updates MUST go through this service.
* No direct increments in controllers or jobs.
*
* Design:
* - Atomic SQL increments (no read-modify-write races).
* - Negative counters are prevented at the SQL level (WHERE col > 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<string, int|string|null>
*/
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 => DB::raw("GREATEST(0, COALESCE({$column}, 0) + {$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 => DB::raw("GREATEST(0, COALESCE({$column}, 0) - {$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(),
]);
}
/**
* Queue a Meilisearch reindex for the user.
* Uses IndexUserJob to avoid blocking the request.
*/
private function reindex(int $userId): void
{
IndexUserJob::dispatch($userId);
}
}