messages implemented
This commit is contained in:
290
app/Services/UserStatsService.php
Normal file
290
app/Services/UserStatsService.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?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("MAX(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("MAX(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user