optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -0,0 +1,558 @@
<?php
declare(strict_types=1);
namespace App\Services\Activity;
use App\Models\Achievement;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\ForumPost;
use App\Models\ForumThread;
use App\Models\User;
use App\Models\UserActivity;
use App\Support\AvatarUrl;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
final class UserActivityService
{
public const DEFAULT_PER_PAGE = 20;
private const FEED_SCHEMA_VERSION = 2;
private const FILTER_ALL = 'all';
private const FILTER_UPLOADS = 'uploads';
private const FILTER_COMMENTS = 'comments';
private const FILTER_LIKES = 'likes';
private const FILTER_FORUM = 'forum';
private const FILTER_FOLLOWING = 'following';
public function logUpload(int $userId, int $artworkId, array $meta = []): ?UserActivity
{
return $this->log($userId, UserActivity::TYPE_UPLOAD, UserActivity::ENTITY_ARTWORK, $artworkId, $meta);
}
public function logComment(int $userId, int $commentId, bool $isReply = false, array $meta = []): ?UserActivity
{
return $this->log(
$userId,
$isReply ? UserActivity::TYPE_REPLY : UserActivity::TYPE_COMMENT,
UserActivity::ENTITY_ARTWORK_COMMENT,
$commentId,
$meta,
);
}
public function logLike(int $userId, int $artworkId, array $meta = []): ?UserActivity
{
return $this->log($userId, UserActivity::TYPE_LIKE, UserActivity::ENTITY_ARTWORK, $artworkId, $meta);
}
public function logFavourite(int $userId, int $artworkId, array $meta = []): ?UserActivity
{
return $this->log($userId, UserActivity::TYPE_FAVOURITE, UserActivity::ENTITY_ARTWORK, $artworkId, $meta);
}
public function logFollow(int $userId, int $targetUserId, array $meta = []): ?UserActivity
{
return $this->log($userId, UserActivity::TYPE_FOLLOW, UserActivity::ENTITY_USER, $targetUserId, $meta);
}
public function logAchievement(int $userId, int $achievementId, array $meta = []): ?UserActivity
{
return $this->log($userId, UserActivity::TYPE_ACHIEVEMENT, UserActivity::ENTITY_ACHIEVEMENT, $achievementId, $meta);
}
public function logForumPost(int $userId, int $threadId, array $meta = []): ?UserActivity
{
return $this->log($userId, UserActivity::TYPE_FORUM_POST, UserActivity::ENTITY_FORUM_THREAD, $threadId, $meta);
}
public function logForumReply(int $userId, int $postId, array $meta = []): ?UserActivity
{
return $this->log($userId, UserActivity::TYPE_FORUM_REPLY, UserActivity::ENTITY_FORUM_POST, $postId, $meta);
}
public function feedForUser(User $user, string $filter = self::FILTER_ALL, int $page = 1, int $perPage = self::DEFAULT_PER_PAGE): array
{
$normalizedFilter = $this->normalizeFilter($filter);
$resolvedPage = max(1, $page);
$resolvedPerPage = max(1, min(50, $perPage));
$version = $this->cacheVersion((int) $user->id);
return Cache::remember(
sprintf('user_activity_feed:%d:%d:%s:%d:%d', (int) $user->id, $version, $normalizedFilter, $resolvedPage, $resolvedPerPage),
now()->addSeconds(30),
function () use ($user, $normalizedFilter, $resolvedPage, $resolvedPerPage): array {
return $this->buildFeed($user, $normalizedFilter, $resolvedPage, $resolvedPerPage);
}
);
}
public function normalizeFilter(string $filter): string
{
return match (strtolower(trim($filter))) {
self::FILTER_UPLOADS => self::FILTER_UPLOADS,
self::FILTER_COMMENTS => self::FILTER_COMMENTS,
self::FILTER_LIKES => self::FILTER_LIKES,
self::FILTER_FORUM => self::FILTER_FORUM,
self::FILTER_FOLLOWING => self::FILTER_FOLLOWING,
default => self::FILTER_ALL,
};
}
public function invalidateUserFeed(int $userId): void
{
if ($userId <= 0) {
return;
}
$this->bumpCacheVersion($userId);
}
private function log(int $userId, string $type, string $entityType, int $entityId, array $meta = []): ?UserActivity
{
if ($userId <= 0 || $entityId <= 0) {
return null;
}
$activity = UserActivity::query()->create([
'user_id' => $userId,
'type' => $type,
'entity_type' => $entityType,
'entity_id' => $entityId,
'meta' => $meta ?: null,
'created_at' => now(),
]);
$this->bumpCacheVersion($userId);
return $activity;
}
private function buildFeed(User $user, string $filter, int $page, int $perPage): array
{
$query = UserActivity::query()
->where('user_id', (int) $user->id)
->whereNull('hidden_at')
->whereIn('type', $this->typesForFilter($filter))
->latest('created_at')
->latest('id');
$total = (clone $query)->count();
/** @var Collection<int, UserActivity> $rows */
$rows = $query
->forPage($page, $perPage)
->get(['id', 'user_id', 'type', 'entity_type', 'entity_id', 'meta', 'created_at']);
$actor = $user->loadMissing('profile')->loadCount('artworks');
$related = $this->loadRelated($rows);
$data = $rows
->map(fn (UserActivity $activity): ?array => $this->formatActivity($activity, $actor, $related))
->filter()
->values()
->all();
return [
'data' => $data,
'meta' => [
'current_page' => $page,
'last_page' => (int) max(1, ceil($total / $perPage)),
'per_page' => $perPage,
'total' => $total,
'has_more' => ($page * $perPage) < $total,
],
'filter' => $filter,
];
}
private function loadRelated(Collection $rows): array
{
$artworkIds = $rows
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_ARTWORK)
->pluck('entity_id')
->map(fn (mixed $id): int => (int) $id)
->unique()
->values()
->all();
$commentIds = $rows
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_ARTWORK_COMMENT)
->pluck('entity_id')
->map(fn (mixed $id): int => (int) $id)
->unique()
->values()
->all();
$userIds = $rows
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_USER)
->pluck('entity_id')
->map(fn (mixed $id): int => (int) $id)
->unique()
->values()
->all();
$achievementIds = $rows
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_ACHIEVEMENT)
->pluck('entity_id')
->map(fn (mixed $id): int => (int) $id)
->unique()
->values()
->all();
$forumThreadIds = $rows
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_FORUM_THREAD)
->pluck('entity_id')
->map(fn (mixed $id): int => (int) $id)
->unique()
->values()
->all();
$forumPostIds = $rows
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_FORUM_POST)
->pluck('entity_id')
->map(fn (mixed $id): int => (int) $id)
->unique()
->values()
->all();
return [
'artworks' => empty($artworkIds)
? collect()
: Artwork::query()
->with(['stats'])
->whereIn('id', $artworkIds)
->public()
->published()
->whereNull('deleted_at')
->get()
->keyBy('id'),
'comments' => empty($commentIds)
? collect()
: ArtworkComment::query()
->with(['artwork.stats'])
->whereIn('id', $commentIds)
->where('is_approved', true)
->whereNull('deleted_at')
->whereHas('artwork', fn ($query) => $query->public()->published()->whereNull('deleted_at'))
->get()
->keyBy('id'),
'users' => empty($userIds)
? collect()
: User::query()
->with('profile:user_id,avatar_hash')
->withCount('artworks')
->whereIn('id', $userIds)
->where('is_active', true)
->whereNull('deleted_at')
->get()
->keyBy('id'),
'achievements' => empty($achievementIds)
? collect()
: Achievement::query()
->whereIn('id', $achievementIds)
->get()
->keyBy('id'),
'forum_threads' => empty($forumThreadIds)
? collect()
: ForumThread::query()
->with('category:id,name,slug')
->whereIn('id', $forumThreadIds)
->where('visibility', 'public')
->whereNull('deleted_at')
->get()
->keyBy('id'),
'forum_posts' => empty($forumPostIds)
? collect()
: ForumPost::query()
->with(['thread.category:id,name,slug'])
->whereIn('id', $forumPostIds)
->whereNull('deleted_at')
->where('flagged', false)
->whereHas('thread', fn ($query) => $query->where('visibility', 'public')->whereNull('deleted_at'))
->get()
->keyBy('id'),
];
}
private function formatActivity(UserActivity $activity, User $actor, array $related): ?array
{
$base = [
'id' => (int) $activity->id,
'type' => (string) $activity->type,
'entity_type' => (string) $activity->entity_type,
'created_at' => $activity->created_at?->toIso8601String(),
'time_ago' => $activity->created_at?->diffForHumans(),
'actor' => $this->buildUserPayload($actor),
'meta' => is_array($activity->meta) ? $activity->meta : [],
];
return match ($activity->type) {
UserActivity::TYPE_UPLOAD,
UserActivity::TYPE_LIKE,
UserActivity::TYPE_FAVOURITE => $this->formatArtworkActivity($base, $activity, $related),
UserActivity::TYPE_COMMENT,
UserActivity::TYPE_REPLY => $this->formatCommentActivity($base, $activity, $related),
UserActivity::TYPE_FOLLOW => $this->formatFollowActivity($base, $activity, $related),
UserActivity::TYPE_ACHIEVEMENT => $this->formatAchievementActivity($base, $activity, $related),
UserActivity::TYPE_FORUM_POST,
UserActivity::TYPE_FORUM_REPLY => $this->formatForumActivity($base, $activity, $related),
default => null,
};
}
private function formatArtworkActivity(array $base, UserActivity $activity, array $related): ?array
{
/** @var Artwork|null $artwork */
$artwork = $related['artworks']->get((int) $activity->entity_id);
if (! $artwork) {
return null;
}
return [
...$base,
'artwork' => $this->buildArtworkPayload($artwork),
];
}
private function formatCommentActivity(array $base, UserActivity $activity, array $related): ?array
{
/** @var ArtworkComment|null $comment */
$comment = $related['comments']->get((int) $activity->entity_id);
if (! $comment || ! $comment->artwork) {
return null;
}
return [
...$base,
'artwork' => $this->buildArtworkPayload($comment->artwork),
'comment' => [
'id' => (int) $comment->id,
'parent_id' => $comment->parent_id ? (int) $comment->parent_id : null,
'body' => $this->plainTextExcerpt((string) ($comment->raw_content ?? $comment->content ?? '')),
'url' => route('art.show', ['id' => (int) $comment->artwork_id, 'slug' => Str::slug((string) $comment->artwork->slug ?: (string) $comment->artwork->title)]) . '#comment-' . $comment->id,
],
];
}
private function formatFollowActivity(array $base, UserActivity $activity, array $related): ?array
{
/** @var User|null $target */
$target = $related['users']->get((int) $activity->entity_id);
if (! $target) {
return null;
}
return [
...$base,
'target_user' => $this->buildUserPayload($target),
];
}
private function formatAchievementActivity(array $base, UserActivity $activity, array $related): ?array
{
/** @var Achievement|null $achievement */
$achievement = $related['achievements']->get((int) $activity->entity_id);
if (! $achievement) {
return null;
}
return [
...$base,
'achievement' => [
'id' => (int) $achievement->id,
'name' => (string) $achievement->name,
'slug' => (string) $achievement->slug,
'description' => (string) ($achievement->description ?? ''),
'icon' => (string) ($achievement->icon ?? 'fa-solid fa-trophy'),
'xp_reward' => (int) ($achievement->xp_reward ?? 0),
],
];
}
private function formatForumActivity(array $base, UserActivity $activity, array $related): ?array
{
if ($activity->type === UserActivity::TYPE_FORUM_POST) {
/** @var ForumThread|null $thread */
$thread = $related['forum_threads']->get((int) $activity->entity_id);
if (! $thread) {
return null;
}
return [
...$base,
'forum' => [
'thread' => $this->buildForumThreadPayload($thread),
'post' => null,
],
];
}
/** @var ForumPost|null $post */
$post = $related['forum_posts']->get((int) $activity->entity_id);
if (! $post || ! $post->thread) {
return null;
}
return [
...$base,
'forum' => [
'thread' => $this->buildForumThreadPayload($post->thread),
'post' => [
'id' => (int) $post->id,
'excerpt' => $this->plainTextExcerpt((string) $post->content),
'url' => $this->forumThreadUrl($post->thread) . '#post-' . $post->id,
],
],
];
}
private function buildArtworkPayload(Artwork $artwork): array
{
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
if ($slug === '') {
$slug = (string) $artwork->id;
}
return [
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) ($artwork->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
'thumb' => $artwork->thumbUrl('md') ?? $artwork->thumbnail_url,
'stats' => [
'views' => (int) ($artwork->stats?->views ?? 0),
'likes' => (int) ($artwork->stats?->favorites ?? 0),
'comments' => (int) ($artwork->stats?->comments_count ?? 0),
],
];
}
private function buildUserPayload(User $user): array
{
$username = (string) ($user->username ?? '');
return [
'id' => (int) $user->id,
'name' => html_entity_decode((string) ($user->name ?? $username ?: 'User'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'username' => $username,
'profile_url' => $username !== '' ? route('profile.show', ['username' => strtolower($username)]) : null,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64),
'badge' => $this->resolveBadge($user),
];
}
private function buildForumThreadPayload(ForumThread $thread): array
{
return [
'id' => (int) $thread->id,
'title' => $this->plainText((string) $thread->title),
'url' => $this->forumThreadUrl($thread),
'category_name' => $this->plainText((string) ($thread->category?->name ?? 'Forum')),
'category_slug' => (string) ($thread->category?->slug ?? ''),
];
}
private function plainTextExcerpt(string $content, int $limit = 220): string
{
$text = $this->plainText($content);
return Str::limit($text, $limit, '...');
}
private function plainText(string $value): string
{
return trim((string) (preg_replace('/\s+/', ' ', strip_tags($this->decodeHtml($value))) ?? ''));
}
private function decodeHtml(string $value): string
{
$decoded = $value;
for ($pass = 0; $pass < 5; $pass++) {
$next = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($next === $decoded) {
break;
}
$decoded = $next;
}
return str_replace(['´', '&acute;'], ["'", "'"], $decoded);
}
private function forumThreadUrl(ForumThread $thread): string
{
$topic = (string) ($thread->slug ?: $thread->id);
if (Route::has('forum.topic.show')) {
return (string) route('forum.topic.show', ['topic' => $topic]);
}
if (Route::has('forum.thread.show')) {
return (string) route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug ?: $thread->id]);
}
return '/forum/topic/' . $topic;
}
private function resolveBadge(User $user): ?array
{
if ($user->hasRole('admin')) {
return ['label' => 'Admin', 'tone' => 'rose'];
}
if ($user->hasRole('moderator')) {
return ['label' => 'Moderator', 'tone' => 'amber'];
}
if ((int) ($user->artworks_count ?? 0) > 0) {
return ['label' => 'Creator', 'tone' => 'sky'];
}
return null;
}
private function typesForFilter(string $filter): array
{
return match ($filter) {
self::FILTER_UPLOADS => [UserActivity::TYPE_UPLOAD],
self::FILTER_COMMENTS => [UserActivity::TYPE_COMMENT, UserActivity::TYPE_REPLY],
self::FILTER_LIKES => [UserActivity::TYPE_LIKE, UserActivity::TYPE_FAVOURITE],
self::FILTER_FORUM => [UserActivity::TYPE_FORUM_POST, UserActivity::TYPE_FORUM_REPLY],
self::FILTER_FOLLOWING => [UserActivity::TYPE_FOLLOW],
default => [
UserActivity::TYPE_UPLOAD,
UserActivity::TYPE_COMMENT,
UserActivity::TYPE_REPLY,
UserActivity::TYPE_LIKE,
UserActivity::TYPE_FAVOURITE,
UserActivity::TYPE_FOLLOW,
UserActivity::TYPE_ACHIEVEMENT,
UserActivity::TYPE_FORUM_POST,
UserActivity::TYPE_FORUM_REPLY,
],
};
}
private function cacheVersion(int $userId): int
{
return (int) Cache::get($this->versionKey($userId), self::FEED_SCHEMA_VERSION);
}
private function bumpCacheVersion(int $userId): void
{
$key = $this->versionKey($userId);
Cache::add($key, self::FEED_SCHEMA_VERSION, now()->addDays(7));
Cache::increment($key);
}
private function versionKey(int $userId): string
{
return 'user_activity_feed_version:v' . self::FEED_SCHEMA_VERSION . ':' . $userId;
}
}