optimizations
This commit is contained in:
558
app/Services/Activity/UserActivityService.php
Normal file
558
app/Services/Activity/UserActivityService.php
Normal 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(['´', '´'], ["'", "'"], $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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user