Files
SkinbaseNova/app/Services/Activity/UserActivityService.php
2026-03-28 19:15:39 +01:00

559 lines
20 KiB
PHP
Raw Permalink 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\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;
}
}