Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Achievement;
use App\Models\Artwork;
use App\Models\Story;
use App\Models\User;
use App\Models\UserAchievement;
use App\Notifications\AchievementUnlockedNotification;
use App\Services\Activity\UserActivityService;
use App\Services\Posts\PostAchievementService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class AchievementService
{
public function __construct(
private readonly XPService $xp,
private readonly PostAchievementService $achievementPosts,
) {}
public function checkAchievements(User|int $user): array
{
$currentUser = $this->resolveUser($user);
$unlocked = [];
foreach ($this->unlockableDefinitions($currentUser) as $achievement) {
if ($this->unlockAchievement($currentUser, $achievement)) {
$unlocked[] = $achievement->slug;
}
}
$this->forgetSummaryCache((int) $currentUser->id);
return $unlocked;
}
public function previewUnlocks(User|int $user): array
{
$currentUser = $this->resolveUser($user);
return $this->unlockableDefinitions($currentUser)
->pluck('slug')
->values()
->all();
}
public function unlockAchievement(User|int $user, Achievement|int $achievement): bool
{
$currentUser = $user instanceof User ? $user : User::query()->findOrFail($user);
$currentAchievement = $achievement instanceof Achievement
? $achievement
: Achievement::query()->findOrFail($achievement);
$inserted = false;
DB::transaction(function () use ($currentUser, $currentAchievement, &$inserted): void {
$result = UserAchievement::query()->insertOrIgnore([
'user_id' => (int) $currentUser->id,
'achievement_id' => (int) $currentAchievement->id,
'unlocked_at' => now(),
]);
if ($result === 0) {
return;
}
$inserted = true;
});
if (! $inserted) {
return false;
}
if ((int) $currentAchievement->xp_reward > 0) {
$this->xp->addXP(
(int) $currentUser->id,
(int) $currentAchievement->xp_reward,
'achievement_unlocked:' . $currentAchievement->slug,
(int) $currentAchievement->id,
false,
);
}
$currentUser->notify(new AchievementUnlockedNotification($currentAchievement));
$this->achievementPosts->achievementUnlocked($currentUser, $currentAchievement);
$this->forgetSummaryCache((int) $currentUser->id);
try {
app(UserActivityService::class)->logAchievement((int) $currentUser->id, (int) $currentAchievement->id, [
'name' => (string) $currentAchievement->name,
]);
} catch (\Throwable) {}
return true;
}
public function hasAchievement(User|int $user, string $achievementSlug): bool
{
$userId = $user instanceof User ? (int) $user->id : $user;
return UserAchievement::query()
->where('user_id', $userId)
->whereHas('achievement', fn ($query) => $query->where('slug', $achievementSlug))
->exists();
}
public function summary(User|int $user): array
{
$userId = $user instanceof User ? (int) $user->id : $user;
return Cache::remember($this->summaryCacheKey($userId), now()->addMinutes(10), function () use ($userId): array {
$currentUser = User::query()->with('statistics')->findOrFail($userId);
$progress = $this->progressSnapshot($currentUser);
$unlockedMap = UserAchievement::query()
->where('user_id', $userId)
->get()
->keyBy('achievement_id');
$items = $this->definitions()->map(function (Achievement $achievement) use ($progress, $unlockedMap): array {
$progressValue = $this->progressValue($progress, $achievement);
/** @var UserAchievement|null $unlocked */
$unlocked = $unlockedMap->get($achievement->id);
return [
'id' => (int) $achievement->id,
'name' => $achievement->name,
'slug' => $achievement->slug,
'description' => $achievement->description,
'icon' => $achievement->icon,
'xp_reward' => (int) $achievement->xp_reward,
'type' => $achievement->type,
'condition_type' => $achievement->condition_type,
'condition_value' => (int) $achievement->condition_value,
'progress' => min((int) $achievement->condition_value, $progressValue),
'progress_percent' => $achievement->condition_value > 0
? (int) round((min((int) $achievement->condition_value, $progressValue) / (int) $achievement->condition_value) * 100)
: 100,
'unlocked' => $unlocked !== null,
'unlocked_at' => $unlocked?->unlocked_at?->toIso8601String(),
];
});
return [
'unlocked' => $items->where('unlocked', true)->sortByDesc('unlocked_at')->values()->all(),
'locked' => $items->where('unlocked', false)->values()->all(),
'recent' => $items->where('unlocked', true)->sortByDesc('unlocked_at')->take(4)->values()->all(),
'counts' => [
'total' => $items->count(),
'unlocked' => $items->where('unlocked', true)->count(),
'locked' => $items->where('unlocked', false)->count(),
],
];
});
}
public function definitions()
{
return Cache::remember('achievements:definitions', now()->addHour(), function () {
return Achievement::query()->orderBy('type')->orderBy('condition_value')->get();
});
}
public function forgetDefinitionsCache(): void
{
Cache::forget('achievements:definitions');
}
private function progressValue(array $progress, Achievement $achievement): int
{
return (int) ($progress[$achievement->condition_type] ?? 0);
}
private function resolveUser(User|int $user): User
{
return $user instanceof User
? $user->loadMissing('statistics')
: User::query()->with('statistics')->findOrFail($user);
}
private function unlockableDefinitions(User $user): Collection
{
$progress = $this->progressSnapshot($user);
$unlockedSlugs = $this->unlockedSlugs((int) $user->id);
return $this->definitions()->filter(function (Achievement $achievement) use ($progress, $unlockedSlugs): bool {
if ($this->progressValue($progress, $achievement) < (int) $achievement->condition_value) {
return false;
}
return ! isset($unlockedSlugs[$achievement->slug]);
})->values();
}
private function progressSnapshot(User $user): array
{
return [
'upload_count' => Artwork::query()
->published()
->where('user_id', $user->id)
->count(),
'likes_received' => (int) DB::table('artwork_likes as likes')
->join('artworks as artworks', 'artworks.id', '=', 'likes.artwork_id')
->where('artworks.user_id', $user->id)
->count(),
'followers_count' => (int) ($user->statistics?->followers_count ?? $user->followers()->count()),
'stories_published' => Story::query()->published()->where('creator_id', $user->id)->count(),
'level_reached' => (int) ($user->level ?? 1),
];
}
private function unlockedSlugs(int $userId): array
{
return UserAchievement::query()
->where('user_id', $userId)
->join('achievements', 'achievements.id', '=', 'user_achievements.achievement_id')
->pluck('achievements.slug')
->flip()
->all();
}
private function forgetSummaryCache(int $userId): void
{
Cache::forget($this->summaryCacheKey($userId));
}
private function summaryCacheKey(int $userId): string
{
return 'achievements:summary:' . $userId;
}
}

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;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\ActivityEvent;
use App\Models\User;
final class ActivityService
{
public function __construct(private readonly CommunityActivityService $communityActivity) {}
public function record(int $actorId, string $type, string $targetType, int $targetId, array $meta = []): void
{
ActivityEvent::record(
actorId: $actorId,
type: $type,
targetType: $targetType,
targetId: $targetId,
meta: $meta,
);
}
public function communityFeed(?User $viewer, string $filter = 'all', int $page = 1, int $perPage = CommunityActivityService::DEFAULT_PER_PAGE, ?int $actorUserId = null): array
{
return $this->communityActivity->getFeed($viewer, $filter, $page, $perPage, $actorUserId);
}
public function requiresAuthentication(string $filter): bool
{
return $this->communityActivity->requiresAuthentication($filter);
}
}

View File

@@ -0,0 +1,872 @@
<?php
declare(strict_types=1);
namespace App\Services\Analytics;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class DiscoveryFeedbackReportService
{
public function buildReport(string $from, string $to, int $limit = 20): array
{
if (! Schema::hasTable('user_discovery_events')) {
return [
'overview' => $this->emptyOverview(),
'daily_feedback' => [],
'trend_summary' => $this->emptyTrendSummary(),
'by_surface' => [],
'by_algo_surface' => [],
'top_artworks' => [],
];
}
$dailyFeedback = $this->dailyFeedback($from, $to);
$trendSummary = $this->trendSummary($dailyFeedback);
$surfaceTrendMap = $this->surfaceTrendMap($from, $to);
$bySurface = $this->attachSurfaceTrendMap($this->bySurface($from, $to), $surfaceTrendMap);
$algoSurfaceTrendMap = $this->algoSurfaceTrendMap($from, $to);
$byAlgoSurface = $this->attachAlgoSurfaceTrendMap($this->byAlgoSurface($from, $to), $algoSurfaceTrendMap);
return [
'overview' => $this->overview($from, $to),
'daily_feedback' => $dailyFeedback,
'trend_summary' => $trendSummary,
'by_surface' => $bySurface,
'by_algo_surface' => $byAlgoSurface,
'top_artworks' => $this->topArtworks($from, $to, $limit),
'latest_aggregated_date' => $this->latestAggregatedDate(),
];
}
private function overview(string $from, string $to): array
{
$row = DB::table('user_discovery_events')
->selectRaw('COUNT(*) AS total_events')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw('COUNT(DISTINCT artwork_id) AS unique_artworks')
->selectRaw("SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS views")
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites")
->selectRaw("SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS downloads")
->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags")
->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags")
->whereBetween('event_date', [$from, $to])
->first();
$views = (int) ($row->views ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$favorites = (int) ($row->favorites ?? 0);
$downloads = (int) ($row->downloads ?? 0);
$hiddenArtworks = (int) ($row->hidden_artworks ?? 0);
$dislikedTags = (int) ($row->disliked_tags ?? 0);
$undoHiddenArtworks = (int) ($row->undo_hidden_artworks ?? 0);
$undoDislikedTags = (int) ($row->undo_disliked_tags ?? 0);
$feedbackActions = $favorites + $downloads;
$negativeFeedbackActions = $hiddenArtworks + $dislikedTags;
$undoActions = $undoHiddenArtworks + $undoDislikedTags;
return [
'total_events' => (int) ($row->total_events ?? 0),
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_artworks' => (int) ($row->unique_artworks ?? 0),
'views' => $views,
'clicks' => $clicks,
'favorites' => $favorites,
'downloads' => $downloads,
'feedback_actions' => $feedbackActions,
'hidden_artworks' => $hiddenArtworks,
'disliked_tags' => $dislikedTags,
'negative_feedback_actions' => $negativeFeedbackActions,
'undo_hidden_artworks' => $undoHiddenArtworks,
'undo_disliked_tags' => $undoDislikedTags,
'undo_actions' => $undoActions,
'ctr' => round($views > 0 ? $clicks / $views : 0.0, 6),
'favorite_rate_per_click' => round($clicks > 0 ? $favorites / $clicks : 0.0, 6),
'download_rate_per_click' => round($clicks > 0 ? $downloads / $clicks : 0.0, 6),
'feedback_rate_per_click' => round($clicks > 0 ? $feedbackActions / $clicks : 0.0, 6),
'negative_feedback_rate_per_click' => round($clicks > 0 ? $negativeFeedbackActions / $clicks : 0.0, 6),
'undo_rate_per_negative_feedback' => round($negativeFeedbackActions > 0 ? $undoActions / $negativeFeedbackActions : 0.0, 6),
];
}
private function bySurface(string $from, string $to): array
{
if (Schema::hasTable('discovery_feedback_daily_metrics')) {
return DB::table('discovery_feedback_daily_metrics')
->selectRaw('surface')
->selectRaw('SUM(views) AS views')
->selectRaw('SUM(clicks) AS clicks')
->selectRaw('SUM(favorites) AS favorites')
->selectRaw('SUM(downloads) AS downloads')
->selectRaw('SUM(feedback_actions) AS feedback_actions')
->selectRaw('SUM(hidden_artworks) AS hidden_artworks')
->selectRaw('SUM(disliked_tags) AS disliked_tags')
->selectRaw('SUM(negative_feedback_actions) AS negative_feedback_actions')
->selectRaw('SUM(undo_hidden_artworks) AS undo_hidden_artworks')
->selectRaw('SUM(undo_disliked_tags) AS undo_disliked_tags')
->selectRaw('SUM(undo_actions) AS undo_actions')
->selectRaw('SUM(unique_users) AS unique_users')
->selectRaw('SUM(unique_artworks) AS unique_artworks')
->whereBetween('metric_date', [$from, $to])
->groupBy('surface')
->orderByDesc('clicks')
->orderByDesc('favorites')
->get()
->map(fn ($row): array => $this->formatEventSummaryRow($row, ['surface' => (string) ($row->surface ?? 'unknown')]))
->all();
}
$surfaceExpression = $this->surfaceExpression();
return DB::table('user_discovery_events')
->selectRaw($surfaceExpression . ' AS surface')
->selectRaw('COUNT(*) AS total_events')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw('COUNT(DISTINCT artwork_id) AS unique_artworks')
->selectRaw("SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS views")
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites")
->selectRaw("SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS downloads")
->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags")
->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags")
->whereBetween('event_date', [$from, $to])
->groupBy(DB::raw($surfaceExpression))
->orderByDesc('clicks')
->orderByDesc('favorites')
->get()
->map(fn ($row): array => $this->formatEventSummaryRow($row, ['surface' => (string) ($row->surface ?? 'unknown')]))
->all();
}
private function byAlgoSurface(string $from, string $to): array
{
if (Schema::hasTable('discovery_feedback_daily_metrics')) {
return DB::table('discovery_feedback_daily_metrics')
->selectRaw('algo_version')
->selectRaw('surface')
->selectRaw('SUM(views) AS views')
->selectRaw('SUM(clicks) AS clicks')
->selectRaw('SUM(favorites) AS favorites')
->selectRaw('SUM(downloads) AS downloads')
->selectRaw('SUM(feedback_actions) AS feedback_actions')
->selectRaw('SUM(hidden_artworks) AS hidden_artworks')
->selectRaw('SUM(disliked_tags) AS disliked_tags')
->selectRaw('SUM(negative_feedback_actions) AS negative_feedback_actions')
->selectRaw('SUM(undo_hidden_artworks) AS undo_hidden_artworks')
->selectRaw('SUM(undo_disliked_tags) AS undo_disliked_tags')
->selectRaw('SUM(undo_actions) AS undo_actions')
->selectRaw('SUM(unique_users) AS unique_users')
->selectRaw('SUM(unique_artworks) AS unique_artworks')
->whereBetween('metric_date', [$from, $to])
->groupBy('algo_version', 'surface')
->orderBy('algo_version')
->orderByDesc('clicks')
->get()
->map(fn ($row): array => $this->formatEventSummaryRow($row, [
'algo_version' => (string) ($row->algo_version ?? ''),
'surface' => (string) ($row->surface ?? 'unknown'),
]))
->all();
}
$surfaceExpression = $this->surfaceExpression();
return DB::table('user_discovery_events')
->selectRaw('algo_version')
->selectRaw($surfaceExpression . ' AS surface')
->selectRaw('COUNT(*) AS total_events')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw('COUNT(DISTINCT artwork_id) AS unique_artworks')
->selectRaw("SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS views")
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites")
->selectRaw("SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS downloads")
->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags")
->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags")
->whereBetween('event_date', [$from, $to])
->groupBy('algo_version', DB::raw($surfaceExpression))
->orderBy('algo_version')
->orderByDesc('clicks')
->get()
->map(fn ($row): array => $this->formatEventSummaryRow($row, [
'algo_version' => (string) ($row->algo_version ?? ''),
'surface' => (string) ($row->surface ?? 'unknown'),
]))
->all();
}
private function topArtworks(string $from, string $to, int $limit): array
{
$surfaceExpression = $this->surfaceExpression();
return DB::table('user_discovery_events as e')
->leftJoin('artworks as a', 'a.id', '=', 'e.artwork_id')
->selectRaw('e.artwork_id')
->selectRaw('a.title as artwork_title')
->selectRaw('e.algo_version')
->selectRaw($surfaceExpression . ' AS surface')
->selectRaw("SUM(CASE WHEN e.event_type = 'view' THEN 1 ELSE 0 END) AS views")
->selectRaw("SUM(CASE WHEN e.event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->selectRaw("SUM(CASE WHEN e.event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites")
->selectRaw("SUM(CASE WHEN e.event_type = 'download' THEN 1 ELSE 0 END) AS downloads")
->selectRaw("SUM(CASE WHEN e.event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks")
->selectRaw("SUM(CASE WHEN e.event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags")
->selectRaw("SUM(CASE WHEN e.event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks")
->selectRaw("SUM(CASE WHEN e.event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags")
->whereBetween('e.event_date', [$from, $to])
->groupBy('e.artwork_id', 'a.title', 'e.algo_version', DB::raw($surfaceExpression))
->get()
->map(fn ($row): array => $this->formatEventSummaryRow($row, [
'artwork_id' => (int) ($row->artwork_id ?? 0),
'artwork_title' => (string) ($row->artwork_title ?? ''),
'algo_version' => (string) ($row->algo_version ?? ''),
'surface' => (string) ($row->surface ?? 'unknown'),
]))
->sort(static function (array $left, array $right): int {
$favoriteCompare = $right['favorites'] <=> $left['favorites'];
if ($favoriteCompare !== 0) {
return $favoriteCompare;
}
$downloadCompare = $right['downloads'] <=> $left['downloads'];
if ($downloadCompare !== 0) {
return $downloadCompare;
}
return $right['clicks'] <=> $left['clicks'];
})
->take($limit)
->values()
->all();
}
private function dailyFeedback(string $from, string $to): array
{
if (Schema::hasTable('discovery_feedback_daily_metrics')) {
return DB::table('discovery_feedback_daily_metrics')
->selectRaw('metric_date')
->selectRaw('SUM(views) AS views')
->selectRaw('SUM(clicks) AS clicks')
->selectRaw('SUM(favorites) AS favorites')
->selectRaw('SUM(downloads) AS downloads')
->selectRaw('SUM(feedback_actions) AS feedback_actions')
->selectRaw('SUM(hidden_artworks) AS hidden_artworks')
->selectRaw('SUM(disliked_tags) AS disliked_tags')
->selectRaw('SUM(negative_feedback_actions) AS negative_feedback_actions')
->selectRaw('SUM(undo_hidden_artworks) AS undo_hidden_artworks')
->selectRaw('SUM(undo_disliked_tags) AS undo_disliked_tags')
->selectRaw('SUM(undo_actions) AS undo_actions')
->whereBetween('metric_date', [$from, $to])
->groupBy('metric_date')
->orderBy('metric_date')
->get()
->map(fn ($row): array => [
'date' => (string) $row->metric_date,
'views' => (int) ($row->views ?? 0),
'clicks' => (int) ($row->clicks ?? 0),
'favorites' => (int) ($row->favorites ?? 0),
'downloads' => (int) ($row->downloads ?? 0),
'feedback_actions' => (int) ($row->feedback_actions ?? 0),
'hidden_artworks' => (int) ($row->hidden_artworks ?? 0),
'disliked_tags' => (int) ($row->disliked_tags ?? 0),
'negative_feedback_actions' => (int) ($row->negative_feedback_actions ?? 0),
'undo_hidden_artworks' => (int) ($row->undo_hidden_artworks ?? 0),
'undo_disliked_tags' => (int) ($row->undo_disliked_tags ?? 0),
'undo_actions' => (int) ($row->undo_actions ?? 0),
])
->all();
}
return DB::table('user_discovery_events')
->selectRaw('event_date')
->selectRaw("SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS views")
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites")
->selectRaw("SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS downloads")
->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags")
->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags")
->whereBetween('event_date', [$from, $to])
->groupBy('event_date')
->orderBy('event_date')
->get()
->map(fn ($row): array => [
'date' => (string) $row->event_date,
'views' => (int) ($row->views ?? 0),
'clicks' => (int) ($row->clicks ?? 0),
'favorites' => (int) ($row->favorites ?? 0),
'downloads' => (int) ($row->downloads ?? 0),
'feedback_actions' => (int) (($row->favorites ?? 0) + ($row->downloads ?? 0)),
'hidden_artworks' => (int) ($row->hidden_artworks ?? 0),
'disliked_tags' => (int) ($row->disliked_tags ?? 0),
'negative_feedback_actions' => (int) (($row->hidden_artworks ?? 0) + ($row->disliked_tags ?? 0)),
'undo_hidden_artworks' => (int) ($row->undo_hidden_artworks ?? 0),
'undo_disliked_tags' => (int) ($row->undo_disliked_tags ?? 0),
'undo_actions' => (int) (($row->undo_hidden_artworks ?? 0) + ($row->undo_disliked_tags ?? 0)),
])
->all();
}
/**
* @param array<int, array<string, mixed>> $rows
* @param array<string, array<string, mixed>> $surfaceTrendMap
* @return array<int, array<string, mixed>>
*/
private function attachSurfaceTrendMap(array $rows, array $surfaceTrendMap): array
{
$rows = array_map(function (array $row) use ($surfaceTrendMap): array {
$surface = (string) ($row['surface'] ?? 'unknown');
return array_merge($row, [
'trend' => $surfaceTrendMap[$surface] ?? $this->emptySurfaceTrend(),
]);
}, $rows);
return $this->sortRowsByTrendRisk($rows);
}
/**
* @param array<int, array<string, mixed>> $rows
* @param array<string, array<string, mixed>> $algoSurfaceTrendMap
* @return array<int, array<string, mixed>>
*/
private function attachAlgoSurfaceTrendMap(array $rows, array $algoSurfaceTrendMap): array
{
$rows = array_map(function (array $row) use ($algoSurfaceTrendMap): array {
$algoVersion = (string) ($row['algo_version'] ?? '');
$surface = (string) ($row['surface'] ?? 'unknown');
$key = $this->algoSurfaceTrendKey($algoVersion, $surface);
return array_merge($row, [
'trend' => $algoSurfaceTrendMap[$key] ?? $this->emptySurfaceTrend(),
]);
}, $rows);
return $this->sortRowsByTrendRisk($rows);
}
/**
* @return array<string, array<string, mixed>>
*/
private function surfaceTrendMap(string $from, string $to): array
{
$rows = $this->dailySurfaceMetrics($from, $to);
if ($rows === []) {
return [];
}
$dates = array_values(array_unique(array_map(
static fn (array $row): string => (string) $row['date'],
$rows,
)));
sort($dates);
$latestDate = $dates[array_key_last($dates)] ?? null;
$previousDate = count($dates) > 1 ? $dates[count($dates) - 2] : null;
$grouped = [];
foreach ($rows as $row) {
$date = (string) ($row['date'] ?? '');
$surface = (string) ($row['surface'] ?? 'unknown');
$grouped[$surface][$date] = $row;
}
$trendMap = [];
foreach ($grouped as $surface => $surfaceRows) {
$latest = $latestDate !== null ? ($surfaceRows[$latestDate] ?? null) : null;
$previous = $previousDate !== null ? ($surfaceRows[$previousDate] ?? null) : null;
$trendMap[$surface] = [
'latest_day' => $latest,
'previous_day' => $previous,
'overall_status' => $this->overallTrendStatus($latest, $previous),
'deltas' => [
'feedback_actions' => $this->formatTrendDelta($latest, $previous, 'feedback_actions', 'up'),
'negative_feedback_actions' => $this->formatTrendDelta($latest, $previous, 'negative_feedback_actions', 'down'),
'undo_actions' => $this->formatTrendDelta($latest, $previous, 'undo_actions', 'up'),
],
];
}
return $trendMap;
}
/**
* @return array<string, array<string, mixed>>
*/
private function algoSurfaceTrendMap(string $from, string $to): array
{
$rows = $this->dailyAlgoSurfaceMetrics($from, $to);
if ($rows === []) {
return [];
}
$dates = array_values(array_unique(array_map(
static fn (array $row): string => (string) $row['date'],
$rows,
)));
sort($dates);
$latestDate = $dates[array_key_last($dates)] ?? null;
$previousDate = count($dates) > 1 ? $dates[count($dates) - 2] : null;
$grouped = [];
foreach ($rows as $row) {
$date = (string) ($row['date'] ?? '');
$algoVersion = (string) ($row['algo_version'] ?? '');
$surface = (string) ($row['surface'] ?? 'unknown');
$grouped[$this->algoSurfaceTrendKey($algoVersion, $surface)][$date] = $row;
}
$trendMap = [];
foreach ($grouped as $key => $algoSurfaceRows) {
$latest = $latestDate !== null ? ($algoSurfaceRows[$latestDate] ?? null) : null;
$previous = $previousDate !== null ? ($algoSurfaceRows[$previousDate] ?? null) : null;
$trendMap[$key] = [
'latest_day' => $latest,
'previous_day' => $previous,
'overall_status' => $this->overallTrendStatus($latest, $previous),
'deltas' => [
'feedback_actions' => $this->formatTrendDelta($latest, $previous, 'feedback_actions', 'up'),
'negative_feedback_actions' => $this->formatTrendDelta($latest, $previous, 'negative_feedback_actions', 'down'),
'undo_actions' => $this->formatTrendDelta($latest, $previous, 'undo_actions', 'up'),
],
];
}
return $trendMap;
}
/**
* @return array<int, array<string, mixed>>
*/
private function dailySurfaceMetrics(string $from, string $to): array
{
if (Schema::hasTable('discovery_feedback_daily_metrics')) {
return DB::table('discovery_feedback_daily_metrics')
->selectRaw('metric_date')
->selectRaw('surface')
->selectRaw('SUM(feedback_actions) AS feedback_actions')
->selectRaw('SUM(negative_feedback_actions) AS negative_feedback_actions')
->selectRaw('SUM(undo_actions) AS undo_actions')
->whereBetween('metric_date', [$from, $to])
->groupBy('metric_date', 'surface')
->orderBy('metric_date')
->get()
->map(fn ($row): array => [
'date' => (string) $row->metric_date,
'surface' => (string) ($row->surface ?? 'unknown'),
'feedback_actions' => (int) ($row->feedback_actions ?? 0),
'negative_feedback_actions' => (int) ($row->negative_feedback_actions ?? 0),
'undo_actions' => (int) ($row->undo_actions ?? 0),
])
->all();
}
$surfaceExpression = $this->surfaceExpression();
return DB::table('user_discovery_events')
->selectRaw('event_date')
->selectRaw($surfaceExpression . ' AS surface')
->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS feedback_actions")
->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS negative_feedback_actions")
->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_actions")
->whereBetween('event_date', [$from, $to])
->groupBy('event_date', DB::raw($surfaceExpression))
->orderBy('event_date')
->get()
->map(fn ($row): array => [
'date' => (string) $row->event_date,
'surface' => (string) ($row->surface ?? 'unknown'),
'feedback_actions' => (int) ($row->feedback_actions ?? 0),
'negative_feedback_actions' => (int) ($row->negative_feedback_actions ?? 0),
'undo_actions' => (int) ($row->undo_actions ?? 0),
])
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function dailyAlgoSurfaceMetrics(string $from, string $to): array
{
if (Schema::hasTable('discovery_feedback_daily_metrics')) {
return DB::table('discovery_feedback_daily_metrics')
->selectRaw('metric_date')
->selectRaw('algo_version')
->selectRaw('surface')
->selectRaw('SUM(feedback_actions) AS feedback_actions')
->selectRaw('SUM(negative_feedback_actions) AS negative_feedback_actions')
->selectRaw('SUM(undo_actions) AS undo_actions')
->whereBetween('metric_date', [$from, $to])
->groupBy('metric_date', 'algo_version', 'surface')
->orderBy('metric_date')
->get()
->map(fn ($row): array => [
'date' => (string) $row->metric_date,
'algo_version' => (string) ($row->algo_version ?? ''),
'surface' => (string) ($row->surface ?? 'unknown'),
'feedback_actions' => (int) ($row->feedback_actions ?? 0),
'negative_feedback_actions' => (int) ($row->negative_feedback_actions ?? 0),
'undo_actions' => (int) ($row->undo_actions ?? 0),
])
->all();
}
$surfaceExpression = $this->surfaceExpression();
return DB::table('user_discovery_events')
->selectRaw('event_date')
->selectRaw('algo_version')
->selectRaw($surfaceExpression . ' AS surface')
->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS feedback_actions")
->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS negative_feedback_actions")
->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) + SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_actions")
->whereBetween('event_date', [$from, $to])
->groupBy('event_date', 'algo_version', DB::raw($surfaceExpression))
->orderBy('event_date')
->get()
->map(fn ($row): array => [
'date' => (string) $row->event_date,
'algo_version' => (string) ($row->algo_version ?? ''),
'surface' => (string) ($row->surface ?? 'unknown'),
'feedback_actions' => (int) ($row->feedback_actions ?? 0),
'negative_feedback_actions' => (int) ($row->negative_feedback_actions ?? 0),
'undo_actions' => (int) ($row->undo_actions ?? 0),
])
->all();
}
private function algoSurfaceTrendKey(string $algoVersion, string $surface): string
{
return $algoVersion . '|' . $surface;
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private function sortRowsByTrendRisk(array $rows): array
{
usort($rows, function (array $left, array $right): int {
$leftLevel = (string) ($left['trend']['overall_status']['level'] ?? 'neutral');
$rightLevel = (string) ($right['trend']['overall_status']['level'] ?? 'neutral');
$levelCompare = $this->trendLevelRank($leftLevel) <=> $this->trendLevelRank($rightLevel);
if ($levelCompare !== 0) {
return $levelCompare;
}
$leftScore = (int) ($left['trend']['overall_status']['score'] ?? 0);
$rightScore = (int) ($right['trend']['overall_status']['score'] ?? 0);
$scoreCompare = $leftScore <=> $rightScore;
if ($scoreCompare !== 0) {
return $scoreCompare;
}
$clickCompare = ((int) ($right['clicks'] ?? 0)) <=> ((int) ($left['clicks'] ?? 0));
if ($clickCompare !== 0) {
return $clickCompare;
}
return ((int) ($right['feedback_actions'] ?? 0)) <=> ((int) ($left['feedback_actions'] ?? 0));
});
return $rows;
}
private function trendLevelRank(string $level): int
{
return match ($level) {
'risk' => 0,
'watch' => 1,
'healthy' => 2,
default => 3,
};
}
/**
* @param object $row
* @param array<string, mixed> $base
* @return array<string, mixed>
*/
private function formatEventSummaryRow(object $row, array $base): array
{
$views = (int) ($row->views ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$favorites = (int) ($row->favorites ?? 0);
$downloads = (int) ($row->downloads ?? 0);
$hiddenArtworks = (int) ($row->hidden_artworks ?? 0);
$dislikedTags = (int) ($row->disliked_tags ?? 0);
$undoHiddenArtworks = (int) ($row->undo_hidden_artworks ?? 0);
$undoDislikedTags = (int) ($row->undo_disliked_tags ?? 0);
$feedbackActions = $favorites + $downloads;
$negativeFeedbackActions = (int) ($row->negative_feedback_actions ?? ($hiddenArtworks + $dislikedTags));
$undoActions = (int) ($row->undo_actions ?? ($undoHiddenArtworks + $undoDislikedTags));
return array_merge($base, [
'total_events' => (int) ($row->total_events ?? ($views + $clicks + $favorites + $downloads + $hiddenArtworks + $dislikedTags + $undoHiddenArtworks + $undoDislikedTags)),
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_artworks' => (int) ($row->unique_artworks ?? 0),
'views' => $views,
'clicks' => $clicks,
'favorites' => $favorites,
'downloads' => $downloads,
'feedback_actions' => $feedbackActions,
'hidden_artworks' => $hiddenArtworks,
'disliked_tags' => $dislikedTags,
'negative_feedback_actions' => $negativeFeedbackActions,
'undo_hidden_artworks' => $undoHiddenArtworks,
'undo_disliked_tags' => $undoDislikedTags,
'undo_actions' => $undoActions,
'ctr' => round($views > 0 ? $clicks / $views : 0.0, 6),
'favorite_rate_per_click' => round($clicks > 0 ? $favorites / $clicks : 0.0, 6),
'download_rate_per_click' => round($clicks > 0 ? $downloads / $clicks : 0.0, 6),
'feedback_rate_per_click' => round($clicks > 0 ? $feedbackActions / $clicks : 0.0, 6),
'negative_feedback_rate_per_click' => round($clicks > 0 ? $negativeFeedbackActions / $clicks : 0.0, 6),
'undo_rate_per_negative_feedback' => round($negativeFeedbackActions > 0 ? $undoActions / $negativeFeedbackActions : 0.0, 6),
]);
}
private function surfaceExpression(): string
{
if (DB::connection()->getDriverName() === 'sqlite') {
return "COALESCE(NULLIF(JSON_EXTRACT(meta, '$.gallery_type'), ''), NULLIF(JSON_EXTRACT(meta, '$.surface'), ''), 'unknown')";
}
return "COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.gallery_type')), ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.surface')), ''), 'unknown')";
}
private function emptyOverview(): array
{
return [
'total_events' => 0,
'unique_users' => 0,
'unique_artworks' => 0,
'views' => 0,
'clicks' => 0,
'favorites' => 0,
'downloads' => 0,
'feedback_actions' => 0,
'hidden_artworks' => 0,
'disliked_tags' => 0,
'negative_feedback_actions' => 0,
'undo_hidden_artworks' => 0,
'undo_disliked_tags' => 0,
'undo_actions' => 0,
'ctr' => 0.0,
'favorite_rate_per_click' => 0.0,
'download_rate_per_click' => 0.0,
'feedback_rate_per_click' => 0.0,
'negative_feedback_rate_per_click' => 0.0,
'undo_rate_per_negative_feedback' => 0.0,
];
}
private function latestAggregatedDate(): ?string
{
if (! Schema::hasTable('discovery_feedback_daily_metrics')) {
return null;
}
$date = DB::table('discovery_feedback_daily_metrics')->max('metric_date');
return $date ? (string) $date : null;
}
/**
* @param array<int, array<string, mixed>> $dailyFeedback
* @return array<string, mixed>
*/
private function trendSummary(array $dailyFeedback): array
{
if ($dailyFeedback === []) {
return $this->emptyTrendSummary();
}
$latest = $dailyFeedback[array_key_last($dailyFeedback)] ?? null;
$previous = count($dailyFeedback) > 1 ? $dailyFeedback[count($dailyFeedback) - 2] : null;
$recentSeven = array_slice($dailyFeedback, -7);
return [
'latest_day' => $latest,
'previous_day' => $previous,
'rolling_7d_average' => [
'views' => $this->averageFromRows($recentSeven, 'views'),
'clicks' => $this->averageFromRows($recentSeven, 'clicks'),
'feedback_actions' => $this->averageFromRows($recentSeven, 'feedback_actions'),
'negative_feedback_actions' => $this->averageFromRows($recentSeven, 'negative_feedback_actions'),
'undo_actions' => $this->averageFromRows($recentSeven, 'undo_actions'),
],
'deltas' => [
'feedback_actions' => $this->formatTrendDelta($latest, $previous, 'feedback_actions', 'up'),
'negative_feedback_actions' => $this->formatTrendDelta($latest, $previous, 'negative_feedback_actions', 'down'),
'undo_actions' => $this->formatTrendDelta($latest, $previous, 'undo_actions', 'up'),
],
'overall_status' => $this->overallTrendStatus($latest, $previous),
];
}
/**
* @param array<string, mixed>|null $latest
* @param array<string, mixed>|null $previous
* @return array<string, mixed>
*/
private function overallTrendStatus(?array $latest, ?array $previous): array
{
if ($previous === null) {
return [
'level' => 'neutral',
'label' => 'No prior day',
'reason' => 'A second day of data is required to judge trend health.',
'score' => 0,
];
}
$feedbackDelta = $this->formatTrendDelta($latest, $previous, 'feedback_actions', 'up');
$negativeDelta = $this->formatTrendDelta($latest, $previous, 'negative_feedback_actions', 'down');
$undoDelta = $this->formatTrendDelta($latest, $previous, 'undo_actions', 'up');
$score = 0;
$score += $feedbackDelta['status'] === 'improved' ? 2 : ($feedbackDelta['status'] === 'worse' ? -2 : 0);
$score += $negativeDelta['status'] === 'improved' ? 2 : ($negativeDelta['status'] === 'worse' ? -2 : 0);
$score += $undoDelta['status'] === 'improved' ? 1 : ($undoDelta['status'] === 'worse' ? -1 : 0);
if ($score >= 3) {
return [
'level' => 'healthy',
'label' => 'Healthy',
'reason' => 'Positive signals are improving faster than negative feedback.',
'score' => $score,
];
}
if ($score <= -2) {
return [
'level' => 'risk',
'label' => 'Risk',
'reason' => 'Negative feedback is worsening or positive engagement is slipping.',
'score' => $score,
];
}
return [
'level' => 'watch',
'label' => 'Watch',
'reason' => 'Signals are mixed and worth monitoring.',
'score' => $score,
];
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function averageFromRows(array $rows, string $key): float
{
if ($rows === []) {
return 0.0;
}
$sum = array_sum(array_map(static fn (array $row): int => (int) ($row[$key] ?? 0), $rows));
return round($sum / count($rows), 2);
}
/**
* @param array<string, mixed>|null $latest
* @param array<string, mixed>|null $previous
* @return array<string, mixed>
*/
private function formatTrendDelta(?array $latest, ?array $previous, string $key, string $goodDirection): array
{
$latestValue = (int) ($latest[$key] ?? 0);
if ($previous === null) {
return [
'value' => 0,
'direction' => 'flat',
'status' => 'neutral',
'label' => 'No prior day',
];
}
$delta = $latestValue - (int) ($previous[$key] ?? 0);
if ($delta === 0) {
return [
'value' => 0,
'direction' => 'flat',
'status' => 'neutral',
'label' => 'Flat',
];
}
$improved = $goodDirection === 'down' ? $delta < 0 : $delta > 0;
return [
'value' => $delta,
'direction' => $delta > 0 ? 'up' : 'down',
'status' => $improved ? 'improved' : 'worse',
'label' => sprintf('%s %s%s vs prev day', $improved ? 'Improved' : 'Worse', $delta > 0 ? '+' : '', number_format($delta)),
];
}
/**
* @return array<string, mixed>
*/
private function emptyTrendSummary(): array
{
return [
'latest_day' => null,
'previous_day' => null,
'rolling_7d_average' => [
'views' => 0.0,
'clicks' => 0.0,
'feedback_actions' => 0.0,
'negative_feedback_actions' => 0.0,
'undo_actions' => 0.0,
],
'deltas' => [
'feedback_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'],
'negative_feedback_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'],
'undo_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'],
],
'overall_status' => [
'level' => 'neutral',
'label' => 'No prior day',
'reason' => 'A second day of data is required to judge trend health.',
'score' => 0,
],
];
}
/**
* @return array<string, mixed>
*/
private function emptySurfaceTrend(): array
{
return [
'latest_day' => null,
'previous_day' => null,
'overall_status' => [
'level' => 'neutral',
'label' => 'No prior day',
'reason' => 'A second day of data is required to judge trend health.',
'score' => 0,
],
'deltas' => [
'feedback_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'],
'negative_feedback_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'],
'undo_actions' => ['value' => 0, 'direction' => 'flat', 'status' => 'neutral', 'label' => 'No prior day'],
],
];
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Services\Analytics;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class TagInteractionReportService
{
public function buildReport(string $from, string $to, int $limit = 20): array
{
return [
'overview' => $this->overview($from, $to),
'daily_clicks' => $this->dailyClicks($from, $to),
'by_surface' => $this->bySurface($from, $to),
'top_tags' => $this->topTags($from, $to, $limit),
'top_queries' => $this->topQueries($from, $to, $limit),
'top_transitions' => $this->topTransitions($from, $to, $limit),
'latest_aggregated_date' => $this->latestAggregatedDate(),
];
}
private function overview(string $from, string $to): array
{
$row = DB::table('tag_interaction_events')
->selectRaw('COUNT(*) AS total_clicks')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw("COUNT(DISTINCT CASE WHEN tag_slug IS NOT NULL AND tag_slug <> '' THEN tag_slug END) AS distinct_tags")
->selectRaw('MAX(occurred_at) AS latest_event_at')
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->first();
return [
'total_clicks' => (int) ($row->total_clicks ?? 0),
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'distinct_tags' => (int) ($row->distinct_tags ?? 0),
'latest_event_at' => $row->latest_event_at,
];
}
private function dailyClicks(string $from, string $to): array
{
if (Schema::hasTable('tag_interaction_daily_metrics')) {
return DB::table('tag_interaction_daily_metrics')
->selectRaw('metric_date')
->selectRaw('SUM(clicks) AS clicks')
->whereBetween('metric_date', [$from, $to])
->groupBy('metric_date')
->orderBy('metric_date')
->get()
->map(static fn ($row): array => [
'date' => (string) $row->metric_date,
'clicks' => (int) ($row->clicks ?? 0),
])
->all();
}
return DB::table('tag_interaction_events')
->selectRaw('event_date')
->selectRaw('COUNT(*) AS clicks')
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->groupBy('event_date')
->orderBy('event_date')
->get()
->map(static fn ($row): array => [
'date' => (string) $row->event_date,
'clicks' => (int) ($row->clicks ?? 0),
])
->all();
}
private function bySurface(string $from, string $to): array
{
return DB::table('tag_interaction_events')
->selectRaw('surface')
->selectRaw('COUNT(*) AS clicks')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw('AVG(position) AS avg_position')
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->groupBy('surface')
->orderByDesc('clicks')
->get()
->map(static fn ($row): array => [
'surface' => (string) $row->surface,
'clicks' => (int) ($row->clicks ?? 0),
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
])
->all();
}
private function topTags(string $from, string $to, int $limit): array
{
return DB::table('tag_interaction_events')
->selectRaw('tag_slug')
->selectRaw('COUNT(*) AS clicks')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw("SUM(CASE WHEN surface IN ('related_chip', 'related_cluster', 'top_companion') THEN 1 ELSE 0 END) AS recommendation_clicks")
->selectRaw("SUM(CASE WHEN surface IN ('search_suggestion', 'rescue_suggestion') THEN 1 ELSE 0 END) AS search_clicks")
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->whereNotNull('tag_slug')
->where('tag_slug', '<>', '')
->groupBy('tag_slug')
->orderByDesc('clicks')
->limit($limit)
->get()
->map(static fn ($row): array => [
'tag_slug' => (string) $row->tag_slug,
'clicks' => (int) ($row->clicks ?? 0),
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'recommendation_clicks' => (int) ($row->recommendation_clicks ?? 0),
'search_clicks' => (int) ($row->search_clicks ?? 0),
])
->all();
}
private function topQueries(string $from, string $to, int $limit): array
{
return DB::table('tag_interaction_events')
->selectRaw("LOWER(TRIM(query)) AS query")
->selectRaw('COUNT(*) AS clicks')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw("COUNT(DISTINCT CASE WHEN tag_slug IS NOT NULL AND tag_slug <> '' THEN tag_slug END) AS resolved_tags")
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->whereNotNull('query')
->whereRaw("TRIM(query) <> ''")
->groupBy(DB::raw("LOWER(TRIM(query))"))
->orderByDesc('clicks')
->limit($limit)
->get()
->map(static fn ($row): array => [
'query' => (string) $row->query,
'clicks' => (int) ($row->clicks ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'resolved_tags' => (int) ($row->resolved_tags ?? 0),
])
->all();
}
private function topTransitions(string $from, string $to, int $limit): array
{
return DB::table('tag_interaction_events')
->selectRaw('source_tag_slug')
->selectRaw('tag_slug')
->selectRaw('COUNT(*) AS clicks')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw('AVG(position) AS avg_position')
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->whereNotNull('source_tag_slug')
->whereNotNull('tag_slug')
->where('source_tag_slug', '<>', '')
->where('tag_slug', '<>', '')
->groupBy('source_tag_slug', 'tag_slug')
->orderByDesc('clicks')
->limit($limit)
->get()
->map(static fn ($row): array => [
'source_tag_slug' => (string) $row->source_tag_slug,
'tag_slug' => (string) $row->tag_slug,
'clicks' => (int) ($row->clicks ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
])
->all();
}
private function latestAggregatedDate(): ?string
{
if (!Schema::hasTable('tag_interaction_daily_metrics')) {
return null;
}
$date = DB::table('tag_interaction_daily_metrics')->max('metric_date');
return $date ? (string) $date : null;
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ArtworkAttributionService
{
public function __construct(
private readonly GroupMembershipService $groupMembers,
private readonly GroupService $groups,
) {
}
public function apply(Artwork $artwork, User $actor, array $attributes, bool $requireDirectPublish = true): Artwork
{
$previousGroupId = (int) ($artwork->group_id ?? 0);
$group = $this->resolveGroup($actor, $attributes, $requireDirectPublish);
$allowedContributorIds = $group ? $this->groupMembers->activeContributorIds($group) : [(int) $actor->id];
$primaryAuthorId = $this->resolvePrimaryAuthorId($actor, $attributes, $allowedContributorIds);
$contributorCredits = $this->normalizeContributorCredits($attributes, $allowedContributorIds, $primaryAuthorId);
DB::transaction(function () use ($artwork, $actor, $group, $primaryAuthorId, $contributorCredits): void {
$artwork->group()->associate($group);
$artwork->uploadedBy()->associate($actor);
$artwork->primaryAuthor()->associate(User::query()->findOrFail($primaryAuthorId));
$artwork->published_as_type = $group ? Artwork::PUBLISHED_AS_GROUP : Artwork::PUBLISHED_AS_USER;
$artwork->published_as_id = $group?->id ?: (int) $artwork->user_id;
$artwork->save();
$artwork->contributors()->delete();
foreach ($contributorCredits as $index => $contributorCredit) {
$artwork->contributors()->create([
'user_id' => $contributorCredit['user_id'],
'credit_role' => $contributorCredit['credit_role'],
'is_primary' => $contributorCredit['is_primary'],
'sort_order' => $index,
]);
}
});
$artwork->loadMissing(['group.members', 'primaryAuthor.profile', 'contributors.user.profile', 'uploadedBy.profile']);
$newGroupId = (int) ($artwork->group_id ?? 0);
if ($previousGroupId > 0 && $previousGroupId !== $newGroupId) {
$previousGroup = Group::query()->find($previousGroupId);
if ($previousGroup) {
$this->groups->syncArtworkCount($previousGroup);
}
}
if ($newGroupId > 0) {
$this->groups->syncArtworkCount($artwork->group);
}
return $artwork;
}
private function resolveGroup(User $actor, array $attributes, bool $requireDirectPublish = true): ?Group
{
$groupIdentifier = $attributes['group'] ?? $attributes['group_id'] ?? null;
if ($groupIdentifier === null || $groupIdentifier === '') {
return null;
}
$group = is_numeric($groupIdentifier)
? Group::query()->with('members')->findOrFail((int) $groupIdentifier)
: Group::query()->with('members')->where('slug', (string) $groupIdentifier)->firstOrFail();
$canUseGroup = $requireDirectPublish
? $group->canPublishArtworks($actor)
: $group->canCreateArtworkDrafts($actor);
if (! $canUseGroup && ! $actor->isAdmin()) {
throw ValidationException::withMessages([
'group' => $requireDirectPublish
? 'You are not allowed to publish as this group.'
: 'You are not allowed to submit artwork for this group.',
]);
}
return $group;
}
private function resolvePrimaryAuthorId(User $actor, array $attributes, array $allowedContributorIds): int
{
$primaryAuthorId = isset($attributes['primary_author_user_id']) && is_numeric($attributes['primary_author_user_id'])
? (int) $attributes['primary_author_user_id']
: (int) $actor->id;
if (! in_array($primaryAuthorId, $allowedContributorIds, true)) {
throw ValidationException::withMessages([
'primary_author_user_id' => 'The selected primary author is not available for this publishing context.',
]);
}
return $primaryAuthorId;
}
private function normalizeContributorCredits(array $attributes, array $allowedContributorIds, int $primaryAuthorId): array
{
$structuredCredits = collect($attributes['contributor_credits'] ?? [])
->filter(fn ($credit): bool => is_array($credit) && is_numeric($credit['user_id'] ?? null))
->map(function (array $credit): array {
$creditRole = trim((string) ($credit['credit_role'] ?? ''));
return [
'user_id' => (int) $credit['user_id'],
'credit_role' => $creditRole !== '' ? $creditRole : null,
'is_primary' => (bool) ($credit['is_primary'] ?? false),
];
})
->filter(fn (array $credit): bool => in_array($credit['user_id'], $allowedContributorIds, true))
->reject(fn (array $credit): bool => $credit['user_id'] === $primaryAuthorId)
->unique('user_id')
->values();
if ($structuredCredits->isEmpty()) {
$structuredCredits = collect($attributes['contributor_user_ids'] ?? [])
->filter(fn ($id): bool => is_numeric($id))
->map(fn ($id): array => [
'user_id' => (int) $id,
'credit_role' => null,
'is_primary' => false,
])
->filter(fn (array $credit): bool => in_array($credit['user_id'], $allowedContributorIds, true))
->reject(fn (array $credit): bool => $credit['user_id'] === $primaryAuthorId)
->unique('user_id')
->values();
}
if ($structuredCredits->where('is_primary', true)->count() > 1) {
throw ValidationException::withMessages([
'contributor_credits' => 'Only one contributor can be marked as the lead supporting credit.',
]);
}
return $structuredCredits->all();
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat;
use App\Models\User;
class ArtworkAwardService
{
public function __construct(private readonly ArtworkMedalService $medals)
{
}
/**
* Award an artwork with the given medal.
* Throws ValidationException if the user already awarded this artwork.
*/
public function award(Artwork $artwork, User $user, string $medal): ArtworkAward
{
return $this->medals->award($artwork, $user, $medal);
}
/**
* Change an existing award medal for a user/artwork pair.
*/
public function changeAward(Artwork $artwork, User $user, string $medal): ArtworkAward
{
return $this->medals->changeMedal($artwork, $user, $medal);
}
/**
* Remove an award for a user/artwork pair.
* Uses model-level delete so the ArtworkAwardObserver fires.
*/
public function removeAward(Artwork $artwork, User $user): void
{
$this->medals->removeMedal($artwork, $user);
}
/**
* Recalculate and persist stats for the given artwork.
*/
public function recalcStats(int $artworkId): ArtworkAwardStat
{
return $this->medals->recalculateStats($artworkId);
}
/**
* Queue a non-blocking reindex for the artwork after award stats change.
*/
public function syncToSearch(Artwork $artwork): void
{
$this->medals->syncArtworkToSearch((int) $artwork->id);
}
}

View File

@@ -0,0 +1,530 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkRelation;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class ArtworkEvolutionService
{
/**
* @return list<string>
*/
public static function relationTypes(): array
{
return [
ArtworkRelation::TYPE_REMAKE_OF,
ArtworkRelation::TYPE_REMASTER_OF,
ArtworkRelation::TYPE_REVISION_OF,
ArtworkRelation::TYPE_INSPIRED_BY,
ArtworkRelation::TYPE_VARIATION_OF,
];
}
public function __construct(
private readonly ArtworkMaturityService $maturity,
private readonly GroupService $groups,
) {
}
/**
* @return array<int, array<string, string>>
*/
public function relationTypeOptions(): array
{
return array_map(fn (string $type): array => [
'value' => $type,
'label' => $this->relationTypeLabel($type),
'short_label' => $this->relationTypeShortLabel($type),
], self::relationTypes());
}
/**
* @param array{target_artwork_id?: int|null, relation_type?: string|null, note?: string|null} $payload
*/
public function syncPrimaryRelation(Artwork $sourceArtwork, User $actor, array $payload): ?ArtworkRelation
{
$this->ensureManageable($actor, $sourceArtwork, 'You can only update evolution links for artworks you manage.');
$targetArtworkId = (int) ($payload['target_artwork_id'] ?? 0);
$relationType = $this->normalizeRelationType((string) ($payload['relation_type'] ?? ArtworkRelation::TYPE_REMAKE_OF));
$note = $this->normalizeNote($payload['note'] ?? null);
if ($targetArtworkId <= 0) {
ArtworkRelation::query()->where('source_artwork_id', (int) $sourceArtwork->id)->delete();
return null;
}
if ($targetArtworkId === (int) $sourceArtwork->id) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose an older artwork, not the artwork you are editing right now.',
]);
}
$targetArtwork = Artwork::query()
->with(['group.members'])
->find($targetArtworkId);
if (! $targetArtwork) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose a valid artwork to link as the original version.',
]);
}
$this->ensureManageable($actor, $targetArtwork, 'You can only link artworks that you are allowed to manage.');
if (! $this->isPubliclyVisible($targetArtwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose a published public artwork for the original version.',
]);
}
if (! $this->isOlderVersionCandidate($sourceArtwork, $targetArtwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose an older artwork as the original version for this Then & Now story.',
]);
}
return DB::transaction(function () use ($sourceArtwork, $targetArtwork, $actor, $relationType, $note): ArtworkRelation {
ArtworkRelation::query()
->where('source_artwork_id', (int) $sourceArtwork->id)
->delete();
return ArtworkRelation::query()->create([
'source_artwork_id' => (int) $sourceArtwork->id,
'target_artwork_id' => (int) $targetArtwork->id,
'relation_type' => $relationType,
'note' => $note,
'sort_order' => 0,
'created_by_user_id' => (int) $actor->id,
])->load([
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
]);
});
}
/**
* @return array<string, mixed>|null
*/
public function editorRelation(Artwork $artwork, User $actor): ?array
{
$relation = ArtworkRelation::query()
->with(['targetArtwork.user.profile', 'targetArtwork.group', 'targetArtwork.categories.contentType'])
->where('source_artwork_id', (int) $artwork->id)
->orderBy('sort_order')
->orderBy('id')
->first();
if (! $relation || ! $relation->targetArtwork) {
return null;
}
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'short_label' => $this->relationTypeShortLabel((string) $relation->relation_type),
'note' => $relation->note,
'target_artwork' => $this->mapStudioOption($relation->targetArtwork, $actor),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function manageableSearchOptions(Artwork $sourceArtwork, User $actor, string $search = '', int $limit = 18): array
{
$this->ensureManageable($actor, $sourceArtwork, 'You can only search evolution links for artworks you manage.');
$manageableGroupIds = collect($this->groups->studioOptionsForUser($actor))
->filter(fn (array $group): bool => (bool) data_get($group, 'permissions.can_publish_artworks', false))
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter()
->values();
$term = trim($search);
$query = Artwork::query()
->with(['user.profile', 'group', 'categories.contentType'])
->whereKeyNot((int) $sourceArtwork->id)
->whereNull('deleted_at')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->where(function ($builder) use ($actor, $manageableGroupIds): void {
$builder->where('user_id', (int) $actor->id);
if ($manageableGroupIds->isNotEmpty()) {
$builder->orWhereIn('group_id', $manageableGroupIds->all());
}
});
if ($term !== '') {
$like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $term) . '%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhereHas('group', fn ($groupQuery) => $groupQuery->where('name', 'like', $like))
->orWhereHas('user', fn ($userQuery) => $userQuery
->where('name', 'like', $like)
->orWhere('username', 'like', $like));
});
}
$referenceTimestamp = $this->comparisonTimestamp($sourceArtwork);
return $query
->orderByRaw('CASE WHEN user_id = ? THEN 0 ELSE 1 END', [(int) $actor->id])
->orderByRaw('CASE WHEN published_at IS NULL THEN 1 ELSE 0 END')
->orderByDesc('published_at')
->limit(max(1, min($limit, 36)))
->get()
->filter(fn (Artwork $candidate): bool => $referenceTimestamp === null
|| $this->comparisonTimestamp($candidate)?->lte($referenceTimestamp)
|| $candidate->published_at === null)
->map(fn (Artwork $candidate): array => $this->mapStudioOption($candidate, $actor))
->values()
->all();
}
/**
* @return array<string, mixed>|null
*/
public function publicPayload(Artwork $artwork, ?User $viewer = null): ?array
{
$primaryRelation = ArtworkRelation::query()
->with([
'sourceArtwork.user.profile',
'sourceArtwork.group',
'sourceArtwork.categories.contentType',
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
])
->where('source_artwork_id', (int) $artwork->id)
->orderBy('sort_order')
->orderBy('id')
->first();
$incomingRelations = ArtworkRelation::query()
->with([
'sourceArtwork.user.profile',
'sourceArtwork.group',
'sourceArtwork.categories.contentType',
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
])
->where('target_artwork_id', (int) $artwork->id)
->orderByDesc('updated_at')
->orderByDesc('id')
->limit(4)
->get();
$primary = $primaryRelation ? $this->mapPrimaryPanel($primaryRelation, $viewer) : null;
$updates = $incomingRelations
->map(fn (ArtworkRelation $relation): ?array => $this->mapIncomingUpdate($relation, $viewer))
->filter()
->values()
->all();
if ($primary === null && $updates === []) {
return null;
}
return [
'eyebrow' => 'Artwork Evolution',
'primary' => $primary,
'updates' => $updates,
];
}
private function ensureManageable(User $actor, Artwork $artwork, string $message): void
{
if (! Gate::forUser($actor)->allows('update', $artwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => $message,
]);
}
}
private function isPubliclyVisible(Artwork $artwork): bool
{
return ! $artwork->trashed()
&& (bool) $artwork->is_public
&& (bool) $artwork->is_approved
&& $artwork->published_at !== null
&& $artwork->published_at->lte(now());
}
private function isOlderVersionCandidate(Artwork $sourceArtwork, Artwork $targetArtwork): bool
{
$sourceTimestamp = $this->comparisonTimestamp($sourceArtwork);
$targetTimestamp = $this->comparisonTimestamp($targetArtwork);
if ($sourceTimestamp === null || $targetTimestamp === null) {
return true;
}
return $targetTimestamp->lt($sourceTimestamp);
}
private function comparisonTimestamp(Artwork $artwork): ?Carbon
{
$value = $artwork->published_at ?: $artwork->created_at;
return $value instanceof Carbon ? $value : ($value ? Carbon::parse($value) : null);
}
private function normalizeRelationType(string $type): string
{
$normalized = Str::lower(trim($type));
return in_array($normalized, self::relationTypes(), true)
? $normalized
: ArtworkRelation::TYPE_REMAKE_OF;
}
private function normalizeNote(mixed $note): ?string
{
$resolved = trim((string) $note);
return $resolved !== '' ? $resolved : null;
}
/**
* @return array<string, mixed>
*/
private function mapStudioOption(Artwork $artwork, User $actor): array
{
$category = $artwork->categories->sortBy('sort_order')->first();
$publishedAt = $artwork->published_at;
$year = $publishedAt?->year ?: $artwork->created_at?->year;
return [
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'year' => $year,
'published_at' => optional($publishedAt)->toIsoString(),
'thumbnail' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? null,
'url' => route('art.show', [
'id' => (int) $artwork->id,
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
]),
'studio_edit_url' => route('studio.artworks.edit', ['id' => (int) $artwork->id]),
'content_type' => $category?->contentType?->name,
'category' => $category?->name,
'is_manageable' => Gate::forUser($actor)->allows('update', $artwork),
];
}
/**
* @return array<string, mixed>|null
*/
private function mapPrimaryPanel(ArtworkRelation $relation, ?User $viewer): ?array
{
$beforeArtwork = $relation->targetArtwork;
$afterArtwork = $relation->sourceArtwork;
if (! $beforeArtwork || ! $afterArtwork) {
return null;
}
if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
return null;
}
$before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
$after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));
if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
return null;
}
$beforeYear = $before['year'] ?? null;
$afterYear = $after['year'] ?? null;
$yearsApart = $this->yearsApart($beforeYear, $afterYear);
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'heading' => 'Then & Now',
'summary' => $this->primarySummary($beforeYear, $yearsApart),
'years_apart' => $yearsApart,
'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
'note' => $relation->note,
'before' => $before,
'after' => $after,
'compare' => [
'available' => $this->compareAvailable($before, $after),
'title' => 'Then & Now comparison',
],
];
}
/**
* @return array<string, mixed>|null
*/
private function mapIncomingUpdate(ArtworkRelation $relation, ?User $viewer): ?array
{
$beforeArtwork = $relation->targetArtwork;
$afterArtwork = $relation->sourceArtwork;
if (! $beforeArtwork || ! $afterArtwork) {
return null;
}
if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
return null;
}
$before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
$after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));
if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
return null;
}
$yearsApart = $this->yearsApart($before['year'] ?? null, $after['year'] ?? null);
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'heading' => 'Updated Version',
'summary' => $this->incomingSummary($after['year'] ?? null, $yearsApart),
'years_apart' => $yearsApart,
'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
'note' => $relation->note,
'before' => $before,
'after' => $after,
'compare' => [
'available' => $this->compareAvailable($before, $after),
'title' => 'Compare versions',
],
];
}
/**
* @return array<string, mixed>
*/
private function mapPublicCard(Artwork $artwork, ?User $viewer, string $roleLabel): array
{
$category = $artwork->categories->sortBy('sort_order')->first();
$md = ThumbnailPresenter::present($artwork, 'md');
$lg = ThumbnailPresenter::present($artwork, 'lg');
$xl = ThumbnailPresenter::present($artwork, 'xl');
$publishedAt = $artwork->published_at;
return $this->maturity->decoratePayload([
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', [
'id' => (int) $artwork->id,
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
]),
'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'published_at' => optional($publishedAt)->toIsoString(),
'year' => $publishedAt?->year ?: $artwork->created_at?->year,
'role_label' => $roleLabel,
'thumbnail' => $md['url'] ?? null,
'image_md' => $md['url'] ?? null,
'image_lg' => $lg['url'] ?? null,
'image_xl' => $xl['url'] ?? null,
'width' => (int) ($artwork->width ?? 0),
'height' => (int) ($artwork->height ?? 0),
'content_type' => $category?->contentType?->name,
'category' => $category?->name,
], $artwork, $viewer);
}
/**
* @param array<string, mixed> $card
*/
private function shouldOmitForViewer(array $card): bool
{
return (bool) data_get($card, 'maturity.should_hide', false);
}
/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
*/
private function compareAvailable(array $before, array $after): bool
{
return ! empty($before['image_lg']) && ! empty($after['image_lg']);
}
private function yearsApart(mixed $beforeYear, mixed $afterYear): ?int
{
if (! is_numeric($beforeYear) || ! is_numeric($afterYear)) {
return null;
}
return max(0, (int) $afterYear - (int) $beforeYear);
}
private function primarySummary(mixed $beforeYear, ?int $yearsApart): string
{
if (is_numeric($beforeYear) && $yearsApart !== null && $yearsApart > 0) {
return sprintf('This artwork revisits an earlier version from %d, %d years later.', (int) $beforeYear, $yearsApart);
}
if (is_numeric($beforeYear)) {
return sprintf('This artwork revisits an earlier version from %d.', (int) $beforeYear);
}
return 'This artwork revisits an earlier version from the creator archive.';
}
private function incomingSummary(mixed $afterYear, ?int $yearsApart): string
{
if (is_numeric($afterYear) && $yearsApart !== null && $yearsApart > 0) {
return sprintf('This artwork was later revisited in %d, %d years later.', (int) $afterYear, $yearsApart);
}
if (is_numeric($afterYear)) {
return sprintf('This artwork was later revisited in %d.', (int) $afterYear);
}
return 'This artwork later received an updated version from the same creator.';
}
private function relationTypeLabel(string $type): string
{
return match ($type) {
ArtworkRelation::TYPE_REMASTER_OF => 'Remaster of',
ArtworkRelation::TYPE_REVISION_OF => 'Revision of',
ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired by',
ArtworkRelation::TYPE_VARIATION_OF => 'Variation of',
default => 'Remake of',
};
}
private function relationTypeShortLabel(string $type): string
{
return match ($type) {
ArtworkRelation::TYPE_REMASTER_OF => 'Remaster',
ArtworkRelation::TYPE_REVISION_OF => 'Update',
ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired take',
ArtworkRelation::TYPE_VARIATION_OF => 'Variation',
default => 'Remake',
};
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat;
use App\Models\ArtworkMedal;
use App\Models\ArtworkMedalStat;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
final class ArtworkMedalService
{
public function __construct(private readonly HomepageService $homepage)
{
}
public function upsert(Artwork $artwork, User $user, string $medal): ArtworkMedal
{
$existing = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
return $existing
? $this->changeMedal($artwork, $user, $medal)
: $this->award($artwork, $user, $medal);
}
public function award(Artwork $artwork, User $user, string $medal): ArtworkMedal
{
$this->validateMedal($medal);
$exists = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->exists();
if ($exists) {
throw ValidationException::withMessages([
'medal' => 'You have already awarded this artwork. Use change to update.',
]);
}
return ArtworkMedal::query()->create([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'medal_type' => $medal,
'weight' => ArtworkAward::weightFor($medal),
]);
}
public function changeMedal(Artwork $artwork, User $user, string $medal): ArtworkMedal
{
$this->validateMedal($medal);
$award = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
if (! $award) {
throw ValidationException::withMessages([
'medal' => 'No existing medal found for this artwork.',
]);
}
$award->update([
'medal_type' => $medal,
'weight' => ArtworkAward::weightFor($medal),
]);
return $award->fresh();
}
public function removeMedal(Artwork $artwork, User $user): void
{
$award = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
if ($award) {
$award->delete();
}
}
public function recalculateStats(int $artworkId): ArtworkMedalStat
{
$rows = ArtworkMedal::query()
->where('artwork_id', $artworkId)
->get(['medal_type', 'weight', 'updated_at']);
$cutoff7d = now()->subDays(7);
$cutoff30d = now()->subDays(30);
$goldCount = 0;
$silverCount = 0;
$bronzeCount = 0;
$scoreTotal = 0;
$score7d = 0;
$score30d = 0;
$lastMedaledAt = null;
foreach ($rows as $row) {
$medal = (string) $row->medal;
$weight = (int) ($row->weight ?? ArtworkAward::weightFor($medal));
$updatedAt = $row->updated_at instanceof Carbon ? $row->updated_at : Carbon::parse($row->updated_at);
if ($medal === 'gold') {
$goldCount++;
} elseif ($medal === 'silver') {
$silverCount++;
} elseif ($medal === 'bronze') {
$bronzeCount++;
}
$scoreTotal += $weight;
if ($updatedAt->greaterThanOrEqualTo($cutoff7d)) {
$score7d += $weight;
}
if ($updatedAt->greaterThanOrEqualTo($cutoff30d)) {
$score30d += $weight;
}
if ($lastMedaledAt === null || $updatedAt->greaterThan($lastMedaledAt)) {
$lastMedaledAt = $updatedAt;
}
}
$stat = ArtworkAwardStat::query()->updateOrCreate(
['artwork_id' => $artworkId],
[
'gold_count' => $goldCount,
'silver_count' => $silverCount,
'bronze_count' => $bronzeCount,
'score_total' => $scoreTotal,
'score_7d' => $score7d,
'score_30d' => $score30d,
'last_medaled_at' => $lastMedaledAt,
]
);
return ArtworkMedalStat::query()->findOrFail($stat->artwork_id);
}
public function refreshArtworkMedalState(int $artworkId): ArtworkMedalStat
{
$stat = $this->recalculateStats($artworkId);
$this->syncArtworkToSearch($artworkId);
$this->homepage->clearFeaturedAndMedalCaches();
return $stat;
}
public function syncArtworkToSearch(int $artworkId): void
{
IndexArtworkJob::dispatch($artworkId);
}
private function validateMedal(string $medal): void
{
if (! in_array($medal, ArtworkAward::MEDALS, true)) {
throw ValidationException::withMessages([
'medal' => 'Invalid medal. Must be gold, silver, or bronze.',
]);
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\DeleteArtworkFromIndexJob;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use Illuminate\Support\Facades\Log;
/**
* Manages Meilisearch index operations for artworks.
*
* All write operations are dispatched to queues never block requests.
*/
final class ArtworkSearchIndexer
{
/**
* Queue an artwork for indexing (insert or update).
*/
public function index(Artwork $artwork): void
{
IndexArtworkJob::dispatch($artwork->id);
}
/**
* Queue an artwork for re-indexing after an update.
*/
public function update(Artwork $artwork): void
{
IndexArtworkJob::dispatch($artwork->id);
}
/**
* Queue removal of an artwork from the index.
*/
public function delete(int $id): void
{
DeleteArtworkFromIndexJob::dispatch($id);
}
/**
* Rebuild the entire artworks index in background chunks.
* Run via: php artisan artworks:search-rebuild
*/
public function rebuildAll(int $chunkSize = 500): void
{
Artwork::with(['user', 'tags', 'categories', 'stats', 'awardStat'])
->public()
->published()
->orderBy('id')
->chunk($chunkSize, function ($artworks): void {
foreach ($artworks as $artwork) {
IndexArtworkJob::dispatch($artwork->id);
}
});
Log::info('ArtworkSearchIndexer::rebuildAll — jobs dispatched');
}
}

View File

@@ -0,0 +1,523 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* High-level search API powered by Meilisearch via Laravel Scout.
*
* No Meili calls in controllers always go through this service.
*/
final class ArtworkSearchService
{
private const BASE_FILTER = 'is_public = true AND is_approved = true';
private const CACHE_TTL = 300; // 5 minutes
private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads'];
private const SEARCH_CANDIDATE_POOL_MULTIPLIER = 4;
private const SEARCH_CANDIDATE_POOL_MAX = 240;
public function __construct(
private readonly AdaptiveTimeWindow $timeWindow,
private readonly ArtworkMaturityService $maturity,
) {}
/**
* Full-text search with optional filters.
*
* Supported $filters keys:
* tags array<string> tag slugs (AND match)
* category string
* orientation string landscape | portrait | square
* resolution string e.g. "1920x1080"
* author_id int
* sort string created_at|downloads|likes|views (suffix :asc or :desc)
*/
public function search(string $q, array $filters = [], int $perPage = 24): LengthAwarePaginator
{
$filterParts = [self::BASE_FILTER];
$sort = [];
if (! empty($filters['tags'])) {
foreach ((array) $filters['tags'] as $tag) {
$filterParts[] = 'tags = "' . addslashes((string) $tag) . '"';
}
}
if (! empty($filters['category'])) {
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
}
if (! empty($filters['orientation'])) {
$filterParts[] = 'orientation = "' . addslashes((string) $filters['orientation']) . '"';
}
if (! empty($filters['resolution'])) {
$filterParts[] = 'resolution = "' . addslashes((string) $filters['resolution']) . '"';
}
if (! empty($filters['author_id'])) {
$filterParts[] = 'author_id = ' . (int) $filters['author_id'];
}
if (! empty($filters['sort'])) {
[$field, $dir] = $this->parseSort((string) $filters['sort']);
if ($field) {
$sort[] = $field . ':' . $dir;
}
}
$options = ['filter' => implode(' AND ', $filterParts)];
if ($sort !== []) {
$options['sort'] = $sort;
}
$options = $this->viewerAwareOptions($options);
return Artwork::search($q ?: '')
->options($options)
->paginate($perPage);
}
public function searchWithThumbnailPreference(array $options, int $perPage, bool $excludeMissing = false, ?int $page = null): LengthAwarePaginator
{
$page = max(1, $page ?? (int) request()->get('page', 1));
$candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page);
$results = Artwork::search('')
->options($this->viewerAwareOptions($options))
->paginate($candidateCount, 'page', 1);
$ordered = $this->rerankSearchCollectionByThumbnailHealth($results->getCollection(), $excludeMissing);
$offset = max(0, ($page - 1) * $perPage);
$slice = $ordered->slice($offset, $perPage)->values();
return new PaginationLengthAwarePaginator(
$slice->all(),
(int) $results->total(),
$perPage,
$page,
[
'path' => request()->url(),
'query' => request()->query(),
'pageName' => 'page',
]
);
}
/**
* Load artworks for a tag page, sorted by views + likes descending.
*/
public function byTag(string $slug, int $perPage = 24, string $sort = 'popular'): LengthAwarePaginator
{
$tag = Tag::where('slug', $slug)->first();
if (! $tag) {
return $this->emptyPaginator($perPage);
}
$sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular';
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
$query = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id))
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->with(['user.profile', 'categories.contentType']);
match ($sort) {
'likes' => $query
->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC')
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
->orderByDesc('artworks.published_at'),
'latest' => $query
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id'),
'downloads' => $query
->orderByRaw('COALESCE(artwork_stats.downloads, 0) DESC')
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
->orderByDesc('artworks.published_at'),
default => $query
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC')
->orderByDesc('artworks.published_at'),
};
return $query
->paginate($perPage)
->withQueryString();
});
}
/**
* Load artworks for a category, sorted by created_at desc.
*/
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
$cacheKey = "search.cat.catalog-visible.v2.{$cat}.{$this->viewerCacheSegment()}.page." . $page;
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage, $page) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
'sort' => ['created_at:desc'],
], $perPage, false, $page);
});
}
// ── Category / Content-Type page sorts ────────────────────────────────────
/**
* Meilisearch sort fields per alias.
* Used by categoryPageSort() and contentTypePageSort().
*/
private const CATEGORY_SORT_FIELDS = [
'trending' => ['trending_score_24h:desc', 'published_at_ts:desc'],
'fresh' => ['published_at_ts:desc'],
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'],
'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'],
'oldest' => ['published_at_ts:asc'],
];
/** Cache TTL (seconds) per sort alias for category pages. */
private const CATEGORY_SORT_TTL = [
'trending' => 300, // 5 min
'fresh' => 120, // 2 min
'top-rated' => 600, // 10 min
'favorited' => 300,
'downloaded' => 300,
'oldest' => 600,
];
/**
* Artworks for a single category page, sorted via Meilisearch.
* Default sort: trending (trending_score_24h:desc).
*
* Cache key pattern: category.{slug}.{sort}.{page}
* TTL varies by sort (see spec: 5/2/10 min).
*/
public function categoryPageSort(string $categorySlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator
{
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1);
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "category.catalog-visible.v2.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
], $perPage, false, (int) request()->get('page', 1));
});
}
/**
* Artworks for a content-type root page, sorted via Meilisearch.
* Default sort: trending.
*
* Cache key pattern: content_type.{slug}.{sort}.{page}
*/
public function contentTypePageSort(string $contentTypeSlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator
{
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1);
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "content_type.catalog-visible.v2.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
], $perPage, false, (int) request()->get('page', 1));
});
}
// -------------------------------------------------------------------------
/**
* Related artworks: same tags, different artwork, ranked by views + likes.
* Limit 12.
*/
public function related(Artwork $artwork, int $limit = 12): LengthAwarePaginator
{
$tags = $artwork->tags()->pluck('tags.slug')->values()->all();
if ($tags === []) {
return $this->popular($limit);
}
$cacheKey = "search.related.{$artwork->id}.{$this->viewerCacheSegment()}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork, $tags, $limit) {
$tagFilters = implode(' OR ', array_map(
fn ($t) => 'tags = "' . addslashes($t) . '"',
$tags
));
return Artwork::search('')
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')',
'sort' => ['views:desc', 'likes:desc'],
]))
->paginate($limit);
});
}
/**
* Most popular artworks by views.
*/
public function popular(int $perPage = 24): LengthAwarePaginator
{
return Cache::remember('search.popular.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER,
'sort' => ['views:desc', 'likes:desc'],
]))
->paginate($perPage);
});
}
/**
* Most recent artworks by publish timestamp.
*/
public function recent(int $perPage = 24): LengthAwarePaginator
{
return Cache::remember('search.recent.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER,
'sort' => ['published_at_ts:desc'],
]))
->paginate($perPage);
});
}
// ── Discover section helpers ───────────────────────────────────────────────
/**
* Trending: sorted by Ranking Engine V2 `ranking_score` (recalculated every 30 min).
*
* Spec §6: Uses ranking_score, limits to last 30 days,
* highlights high-velocity artworks via engagement_velocity tiebreaker.
*/
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
$cutoff = now()->subDays($windowDays)->toDateString();
// Include window in cache key so adaptive expansions surface immediately
$cacheKey = "discover.trending.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($perPage, $cutoff) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
], $perPage);
});
}
/**
* Rising: sorted by heat_score (recalculated every 15 min).
*
* Surfaces artworks with rapid recent engagement growth.
* Restricts to last 30 days, sorted by heat_score DESC.
*/
public function discoverRising(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
$cutoff = now()->subDays($windowDays)->toDateString();
$cacheKey = "discover.rising.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, 120, function () use ($perPage, $cutoff) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
], $perPage);
});
}
/**
* Fresh: newest uploads first.
*/
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.fresh.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
'sort' => ['published_at_ts:desc'],
], $perPage);
});
}
/**
* Top rated: highest number of favourites/likes.
*/
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.top-rated.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
'sort' => ['likes:desc', 'views:desc'],
], $perPage);
});
}
/**
* Most downloaded: highest download count.
*/
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.most-downloaded.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
'sort' => ['downloads:desc', 'views:desc'],
], $perPage);
});
}
/**
* Artworks matching any of the given tag slugs, sorted by trending score.
* Used for personalized "Because you like {tags}" homepage section.
*
* @param string[] $tagSlugs
*/
public function discoverByTags(array $tagSlugs, int $limit = 12): LengthAwarePaginator
{
if (empty($tagSlugs)) {
return $this->popular($limit);
}
$tagFilter = implode(' OR ', array_map(
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
array_slice($tagSlugs, 0, 5)
));
$cacheKey = 'discover.by-tags.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $tagSlugs));
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
'sort' => ['trending_score_7d:desc', 'likes:desc'],
], $limit, true, 1);
});
}
private function viewerAwareOptions(array $options): array
{
$options['filter'] = $this->maturity->appendSearchFilter((string) ($options['filter'] ?? self::BASE_FILTER), request()->user());
return $options;
}
private function viewerCacheSegment(): string
{
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
}
/**
* Fresh artworks in given categories, sorted by publish timestamp desc.
* Used for personalized "Fresh in your favourite categories" section.
*
* @param string[] $categorySlugs
*/
public function discoverByCategories(array $categorySlugs, int $limit = 12): LengthAwarePaginator
{
if (empty($categorySlugs)) {
return $this->recent($limit);
}
$catFilter = implode(' OR ', array_map(
fn (string $c): string => 'category = "' . addslashes($c) . '"',
array_slice($categorySlugs, 0, 3)
));
$cacheKey = 'discover.by-cats.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $categorySlugs));
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
'sort' => ['published_at_ts:desc'],
], $limit, true, 1);
});
}
// -------------------------------------------------------------------------
private function parseSort(string $sort): array
{
$allowed = ['created_at', 'downloads', 'likes', 'views'];
$parts = explode(':', $sort, 2);
$field = $parts[0] ?? '';
$dir = strtolower($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc';
return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc'];
}
private function rerankSearchCollectionByThumbnailHealth(Collection $items, bool $excludeMissing): Collection
{
if ($items->isEmpty()) {
return $items;
}
$ids = $items
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values();
if ($ids->isEmpty()) {
return $items->values();
}
$missingIds = Artwork::query()
->whereIn('id', $ids)
->where('has_missing_thumbnails', true)
->pluck('id')
->map(fn ($id) => (int) $id)
->flip();
if ($missingIds->isEmpty()) {
return $items->values();
}
$healthy = $items->reject(fn ($item) => $missingIds->has((int) ($item->id ?? 0)));
if ($excludeMissing) {
return $healthy->values();
}
return $healthy
->concat($items->filter(fn ($item) => $missingIds->has((int) ($item->id ?? 0))))
->values();
}
private function determineSearchCandidatePoolSize(int $perPage, int $page): int
{
return min(
self::SEARCH_CANDIDATE_POOL_MAX,
max($perPage, $perPage * max(self::SEARCH_CANDIDATE_POOL_MULTIPLIER, $page + 2))
);
}
private function emptyPaginator(int $perPage): LengthAwarePaginator
{
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);
}
}

View File

@@ -0,0 +1,431 @@
<?php
namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\User;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
/**
* ArtworkService
*
* Business logic for retrieving artworks. Controllers should remain thin and
* delegate to this service. This service never returns JSON or accesses
* the request() helper directly.
*/
class ArtworkService
{
protected int $cacheTtl = 3600; // seconds
public function __construct(
private readonly ContentTypeSlugResolver $contentTypeResolver,
private readonly ArtworkMaturityService $maturity,
)
{
}
/**
* Relations used by the featured artwork surfaces.
*
* @return array<int|string, mixed>
*/
private function featuredRelations(): array
{
return [
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order');
},
];
}
/**
* Lightweight relations needed to render browse/list cards.
*
* @return array<int|string, mixed>
*/
private function browseRelations(): array
{
return [
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
];
}
/**
* Shared browse query used by /browse, content-type pages, and category pages.
*/
private function browseQuery(string $sort = 'latest'): Builder
{
$query = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->with($this->browseRelations());
$normalizedSort = strtolower(trim($sort));
if ($normalizedSort === 'oldest') {
return $query->orderBy('published_at', 'asc');
}
return $query->orderByDesc('published_at');
}
/**
* Fetch a single public artwork by slug.
* Applies visibility rules (public + approved + not-deleted).
*
* @param string $slug
* @return Artwork
* @throws ModelNotFoundException
*/
public function getPublicArtworkBySlug(string $slug): Artwork
{
$key = 'artwork:' . $slug;
$artwork = Cache::remember($key, $this->cacheTtl, function () use ($slug) {
$a = Artwork::where('slug', $slug)
->public()
->published()
->first();
if (! $a) {
return null;
}
// Load lightweight relations for presentation; do NOT eager-load stats here.
$a->load(['translations', 'categories']);
return $a;
});
if (! $artwork) {
$e = new ModelNotFoundException();
$e->setModel(Artwork::class, [$slug]);
throw $e;
}
return $artwork;
}
/**
* Clear artwork cache by model instance.
*/
public function clearArtworkCache(Artwork $artwork): void
{
$this->clearArtworkCacheBySlug($artwork->slug);
}
/**
* Clear artwork cache by slug.
*/
public function clearArtworkCacheBySlug(string $slug): void
{
Cache::forget('artwork:' . $slug);
}
/**
* Get artworks for a given category, applying visibility rules and cursor pagination.
* Returns a CursorPaginator so controllers/resources can render paginated feeds.
*
* @param Category $category
* @param int $perPage
* @return CursorPaginator
*/
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
{
$query = Artwork::public()->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->with($this->browseRelations())
->whereHas('categories', function ($q) use ($category) {
$q->where('categories.id', $category->id);
})
->orderByDesc('published_at');
// Important: do NOT eager-load artwork_stats in listings
return $query->cursorPaginate($perPage);
}
/**
* Return the latest public artworks up to $limit.
*
* @param int $limit
* @return \Illuminate\Support\Collection|EloquentCollection
*/
public function getLatestArtworks(int $limit = 10): Collection
{
return Artwork::public()->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->orderByDesc('published_at')
->limit($limit)
->get();
}
/**
* Browse all public, approved, published artworks with pagination.
* Uses new authoritative tables only (no legacy joins) and eager-loads
* lightweight relations needed for presentation.
*/
public function browsePublicArtworks(int $perPage = 24, string $sort = 'latest'): CursorPaginator
{
$query = $this->browseQuery($sort);
// Use cursor pagination for high-load browse feeds (SEO handled via canonical URLs).
return $query->cursorPaginate($perPage);
}
/**
* Browse artworks scoped to a content type slug using keyset pagination.
* Applies public + approved + published filters.
*/
public function getArtworksByContentType(string $slug, int $perPage, string $sort = 'latest'): CursorPaginator
{
$contentType = $this->resolveContentTypeOrFail($slug);
$query = $this->browseQuery($sort)
->whereHas('categories', function ($q) use ($contentType) {
$q->where('categories.content_type_id', $contentType->id);
});
return $query->cursorPaginate($perPage);
}
/**
* Browse artworks for a category path (content type slug + nested category slugs).
* Uses slug-only resolution and keyset pagination.
*
* @param array<int, string> $slugs
*/
public function getArtworksByCategoryPath(array $slugs, int $perPage, string $sort = 'latest'): CursorPaginator
{
if (empty($slugs)) {
$e = new ModelNotFoundException();
$e->setModel(Category::class);
throw $e;
}
$parts = array_values(array_map('strtolower', $slugs));
$contentTypeSlug = array_shift($parts);
$contentType = $this->resolveContentTypeOrFail((string) $contentTypeSlug);
if (empty($parts)) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, []);
throw $e;
}
// Resolve the category path from roots downward within the content type.
$current = Category::where('content_type_id', $contentType->id)
->whereNull('parent_id')
->where('slug', array_shift($parts))
->first();
if (! $current) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, $slugs);
throw $e;
}
foreach ($parts as $slug) {
$current = $current->children()->where('slug', $slug)->first();
if (! $current) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, $slugs);
throw $e;
}
}
$categoryIds = $this->categoryAndDescendantIds($current);
$query = $this->browseQuery($sort)
->whereHas('categories', function ($q) use ($categoryIds) {
$q->whereIn('categories.id', $categoryIds);
});
return $query->cursorPaginate($perPage);
}
/**
* Collect category id plus all descendant category ids.
*
* @return array<int, int>
*/
private function categoryAndDescendantIds(Category $category): array
{
$allIds = [(int) $category->id];
$frontier = [(int) $category->id];
while (! empty($frontier)) {
$children = Category::whereIn('parent_id', $frontier)
->pluck('id')
->map(static fn ($id): int => (int) $id)
->all();
if (empty($children)) {
break;
}
$newIds = array_values(array_diff($children, $allIds));
if (empty($newIds)) {
break;
}
$allIds = array_values(array_unique(array_merge($allIds, $newIds)));
$frontier = $newIds;
}
return $allIds;
}
private function resolveContentTypeOrFail(string $slug): ContentType
{
$resolution = $this->contentTypeResolver->resolve($slug);
if (! $resolution->found() || $resolution->contentType === null) {
$e = new ModelNotFoundException();
$e->setModel(ContentType::class, [$slug]);
throw $e;
}
return $resolution->contentType;
}
/**
* Get featured artworks ordered by featured_at DESC, optionally filtered by type.
* Uses artwork_features table and applies public/approved/published filters.
*/
private function featuredBaseQuery(?int $type): Builder
{
return Artwork::query()
->select('artworks.*')
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
->where('af.is_active', true)
->whereNull('af.deleted_at')
->where(function ($query): void {
$query->whereNull('af.expires_at')
->orWhere('af.expires_at', '>', now());
})
->when($type !== null, function ($q) use ($type) {
$q->where('af.type', $type);
});
}
private function applyFeaturedEligibilityFilters(Builder $query): void
{
$query->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails();
}
private function applyFeaturedOrdering(Builder $query): Builder
{
if (Schema::hasColumn('artwork_features', 'force_hero')) {
$query->orderByDesc('af.force_hero');
}
return $query
->orderByDesc('af.priority')
->orderByRaw('COALESCE(aas.score_30d, 0) DESC')
->orderByDesc('af.featured_at')
->orderByDesc('artworks.published_at');
}
private function featuredSelectionQuery(?int $type): Builder
{
$query = $this->featuredBaseQuery($type);
$this->applyFeaturedEligibilityFilters($query);
return $this->applyFeaturedOrdering($query);
}
private function featuredHeroSelectionQuery(?int $type): Builder
{
$query = $this->featuredBaseQuery($type);
if (Schema::hasColumn('artwork_features', 'force_hero')) {
$query->where(function (Builder $selection): void {
$selection->where('af.force_hero', true)
->orWhere(function (Builder $eligible): void {
$this->applyFeaturedEligibilityFilters($eligible);
});
});
} else {
$this->applyFeaturedEligibilityFilters($query);
}
return $this->applyFeaturedOrdering($query);
}
public function getFeaturedArtworks(?int $type, int $perPage = 39): LengthAwarePaginator
{
return $this->featuredSelectionQuery($type)
->with($this->featuredRelations())
->paginate($perPage)
->withQueryString();
}
public function getFeaturedArtworkWinner(?int $type = null): ?Artwork
{
$artwork = $this->featuredHeroSelectionQuery($type)
->with($this->featuredRelations())
->first();
return $artwork instanceof Artwork ? $artwork : null;
}
/**
* Get artworks belonging to a specific user.
* If the requester is the owner, return all non-deleted artworks for that user.
* Public visitors only see public + approved + published artworks.
*
* @param int $userId
* @param bool $isOwner
* @param int $perPage
* @return CursorPaginator
*/
public function getArtworksByUser(int $userId, bool $isOwner, int $perPage = 24, ?User $viewer = null): CursorPaginator
{
$query = Artwork::where('user_id', $userId)
->with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->orderByDesc('published_at');
if (! $isOwner) {
// Apply public visibility constraints for non-owners
$query->public()->published();
$this->maturity->applyViewerFilter($query, $viewer);
} else {
// Owner: include all non-deleted items (do not force published/approved)
$query->whereNull('deleted_at');
}
return $query->cursorPaginate($perPage);
}
}

View File

@@ -0,0 +1,251 @@
<?php
namespace App\Services;
use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Throwable;
/**
* ArtworkStatsService
*
* Responsibilities:
* - Increment views and downloads using DB transactions
* - Optionally defer increments into Redis for async processing
* - Provide a processor to drain queued deltas (job-friendly)
*/
class ArtworkStatsService
{
protected string $redisKey = 'artwork_stats:deltas';
/**
* Increment views for an artwork.
* Set $defer=true to push to Redis for async processing when available.
* Both all-time (views) and windowed (views_24h, views_7d) are updated.
*/
public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'views', $by);
$this->pushDelta($artworkId, 'views_24h', $by);
$this->pushDelta($artworkId, 'views_7d', $by);
return;
}
$this->applyDelta($artworkId, ['views' => $by, 'views_24h' => $by, 'views_7d' => $by]);
}
/**
* Increment downloads for an artwork.
* Both all-time (downloads) and windowed (downloads_24h, downloads_7d) are updated.
*/
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'downloads', $by);
$this->pushDelta($artworkId, 'downloads_24h', $by);
$this->pushDelta($artworkId, 'downloads_7d', $by);
return;
}
$this->applyDelta($artworkId, ['downloads' => $by, 'downloads_24h' => $by, 'downloads_7d' => $by]);
}
/**
* Write one row to artwork_view_events (the persistent event log).
*
* Called from ArtworkViewController after session dedup passes.
* Guests (unauthenticated) are recorded with user_id = null.
* Rows are pruned after 90 days by skinbase:prune-view-events.
*/
public function logViewEvent(int $artworkId, ?int $userId): void
{
try {
DB::table('artwork_view_events')->insert([
'artwork_id' => $artworkId,
'user_id' => $userId,
'viewed_at' => now(),
]);
} catch (Throwable $e) {
Log::warning('Failed to write artwork_view_events row', [
'artwork_id' => $artworkId,
'user_id' => $userId,
'error' => $e->getMessage(),
]);
}
}
/**
* Increment views using an Artwork model.
*/
public function incrementViewsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementViews((int) $artwork->id, $by, $defer);
}
/**
* Increment downloads using an Artwork model.
*/
public function incrementDownloadsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementDownloads((int) $artwork->id, $by, $defer);
}
/**
* Apply a set of deltas to the artwork_stats row inside a transaction.
* After updating artwork-level stats, forwards view/download counts to
* UserStatsService so creator-level counters stay current.
*
* @param int $artworkId
* @param array<string,int> $deltas
*/
public function applyDelta(int $artworkId, array $deltas): void
{
try {
DB::transaction(function () use ($artworkId, $deltas) {
// Ensure a stats row exists — insert default zeros if missing.
DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artworkId,
'views' => 0,
'views_24h' => 0,
'views_7d' => 0,
'downloads' => 0,
'downloads_24h' => 0,
'downloads_7d' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
]);
foreach ($deltas as $column => $value) {
// Only allow known columns to avoid SQL injection.
if (! in_array($column, ['views', 'views_24h', 'views_7d', 'downloads', 'downloads_24h', 'downloads_7d', 'favorites', 'rating_count'], true)) {
continue;
}
DB::table('artwork_stats')
->where('artwork_id', $artworkId)
->increment($column, (int) $value);
}
});
// Forward creator-level counters outside the transaction.
$this->forwardCreatorStats($artworkId, $deltas);
} catch (Throwable $e) {
Log::error('Failed to apply artwork stats delta', [
'artwork_id' => $artworkId,
'deltas' => $deltas,
'error' => $e->getMessage(),
]);
}
}
/**
* After applying artwork-level deltas, forward relevant totals to the
* creator's user_statistics row via UserStatsService.
* Views skip Meilisearch reindex (high frequency covered by recompute).
*
* @param int $artworkId
* @param array<string,int> $deltas
*/
protected function forwardCreatorStats(int $artworkId, array $deltas): void
{
$viewDelta = (int) ($deltas['views'] ?? 0);
$downloadDelta = (int) ($deltas['downloads'] ?? 0);
if ($viewDelta <= 0 && $downloadDelta <= 0) {
return;
}
try {
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if (! $creatorId) {
return;
}
/** @var UserStatsService $svc */
$svc = app(UserStatsService::class);
if ($viewDelta > 0) {
// High-frequency: increment counter but skip Meilisearch reindex.
$svc->incrementArtworkViewsReceived($creatorId, $viewDelta);
}
if ($downloadDelta > 0) {
$svc->incrementDownloadsReceived($creatorId, $downloadDelta);
}
} catch (Throwable $e) {
Log::warning('Failed to forward creator stats from artwork delta', [
'artwork_id' => $artworkId,
'error' => $e->getMessage(),
]);
}
}
/**
* Push a delta to Redis queue for async processing.
*/
protected function pushDelta(int $artworkId, string $field, int $value): void
{
$payload = json_encode([
'artwork_id' => $artworkId,
'field' => $field,
'value' => $value,
'ts' => time(),
]);
try {
Redis::rpush($this->redisKey, $payload);
} catch (Throwable $e) {
// If Redis is unavailable, fall back to immediate apply to avoid data loss.
Log::warning('Redis unavailable for artwork stats; applying immediately', [
'error' => $e->getMessage(),
]);
$this->applyDelta($artworkId, [$field => $value]);
}
}
/**
* Drain and apply queued deltas from Redis. Returns number processed.
* Designed to be invoked by a queued job or artisan command.
*/
public function processPendingFromRedis(int $max = 1000): int
{
if (! $this->redisAvailable()) {
return 0;
}
$processed = 0;
try {
while ($processed < $max) {
$item = Redis::lpop($this->redisKey);
if (! $item) {
break;
}
$decoded = json_decode($item, true);
if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) {
continue;
}
$this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]);
$processed++;
}
} catch (Throwable $e) {
Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]);
}
return $processed;
}
protected function redisAvailable(): bool
{
try {
$pong = Redis::connection()->ping();
return (bool) $pong;
} catch (Throwable $e) {
return false;
}
}
}

View File

@@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkVersion;
use App\Models\ArtworkVersionEvent;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
/**
* ArtworkVersioningService
*
* Manages non-destructive file replacement for artworks.
*
* Guarantees:
* - All replacements create a new version row; originals are never deleted.
* - Engagement (views, favourites, downloads) is never reset.
* - Ranking scores receive a small protective decay on each replacement.
* - Abusive rapid replacement is blocked by rate limits.
* - Major visual changes trigger requires_reapproval flag.
*/
final class ArtworkVersioningService
{
// ── Rate-limit thresholds ─────────────────────────────────────────────
private const MAX_PER_HOUR = 3;
private const MAX_PER_DAY = 20;
// ── Reapproval: flag when dimension changes beyond this fraction ──────
private const DIMENSION_CHANGE_THRESHOLD = 0.5; // 50 % change triggers re-approval
// ── Ranking decay applied per replacement ─────────────────────────────
private const RANKING_DECAY_FACTOR = 0.93; // 7 % decay
// ──────────────────────────────────────────────────────────────────────
/**
* Create a new version for an artwork after a file replacement.
*
* This is the primary entry-point called by the controller.
*
* @param Artwork $artwork The artwork being updated.
* @param string $filePath Relative path stored for this version.
* @param string $fileHash SHA-256 hex hash of the new file.
* @param int $width New file width in pixels.
* @param int $height New file height in pixels.
* @param int $fileSize New file size in bytes.
* @param int $userId ID of the acting user (for audit log).
* @param string|null $changeNote Optional user-supplied change note.
*
* @throws TooManyRequestsHttpException When rate limit is exceeded.
* @throws \RuntimeException When the hash is identical to the current file.
*/
public function createNewVersion(
Artwork $artwork,
string $filePath,
string $fileHash,
int $width,
int $height,
int $fileSize,
int $userId,
?string $changeNote = null,
): ArtworkVersion {
// 1. Rate limit check
$this->rateLimitCheck($userId, $artwork->id);
// 2. Reject identical file
if ($artwork->hash === $fileHash) {
throw new \RuntimeException('The uploaded file is identical to the current version. No new version created.');
}
return DB::transaction(function () use (
$artwork, $filePath, $fileHash, $width, $height, $fileSize, $userId, $changeNote
): ArtworkVersion {
// 3. Determine next version number
$nextNumber = ($artwork->version_count ?? 1) + 1;
// 4. Mark all previous versions as not current
$artwork->versions()->update(['is_current' => false]);
// 5. Insert new version row
$version = ArtworkVersion::create([
'artwork_id' => $artwork->id,
'version_number' => $nextNumber,
'file_path' => $filePath,
'file_hash' => $fileHash,
'width' => $width,
'height' => $height,
'file_size' => $fileSize,
'change_note' => $changeNote,
'is_current' => true,
]);
// 6. Check whether moderation re-review is required
$needsReapproval = $this->shouldRequireReapproval($artwork, $width, $height);
// 7. Update artwork metadata (no engagement data touched)
$artwork->update([
'current_version_id' => $version->id,
'version_count' => $nextNumber,
'version_updated_at' => now(),
'requires_reapproval' => $needsReapproval,
]);
// 8. Ranking protection — apply small decay
$this->applyRankingProtection($artwork);
// 9. Audit log
ArtworkVersionEvent::create([
'artwork_id' => $artwork->id,
'user_id' => $userId,
'action' => 'create_version',
'version_id' => $version->id,
]);
// 10. Increment hourly/daily counters for rate limiting
$this->incrementRateLimitCounters($userId, $artwork->id);
return $version;
});
}
/**
* Restore a previous version by cloning it as a new (current) version.
*
* The restored file is treated as a brand-new version so the history
* remains strictly append-only and the version counter always increases.
*
* @throws TooManyRequestsHttpException When rate limit is exceeded.
*/
public function restoreVersion(
ArtworkVersion $version,
Artwork $artwork,
int $userId,
): ArtworkVersion {
return $this->createNewVersion(
$artwork,
$version->file_path,
$version->file_hash,
(int) $version->width,
(int) $version->height,
(int) $version->file_size,
$userId,
"Restored from version {$version->version_number}",
);
}
/**
* Decide whether the new file warrants a moderation re-check.
*
* Triggers when either dimension changes by more than the threshold.
*/
public function shouldRequireReapproval(Artwork $artwork, int $newWidth, int $newHeight): bool
{
// First version upload — no existing dimensions to compare
if (!$artwork->width || !$artwork->height) {
return false;
}
$widthChange = abs($newWidth - $artwork->width) / max($artwork->width, 1);
$heightChange = abs($newHeight - $artwork->height) / max($artwork->height, 1);
return $widthChange > self::DIMENSION_CHANGE_THRESHOLD
|| $heightChange > self::DIMENSION_CHANGE_THRESHOLD;
}
/**
* Apply a small protective decay (7 %) to ranking and heat scores.
*
* This prevents creators from gaming the ranking algorithm by rapidly
* cycling file versions to refresh discovery signals.
* Engagement totals (views, favourites, downloads) are NOT touched.
*/
public function applyRankingProtection(Artwork $artwork): void
{
try {
DB::table('artwork_stats')
->where('artwork_id', $artwork->id)
->update([
'ranking_score' => DB::raw('ranking_score * ' . self::RANKING_DECAY_FACTOR),
'heat_score' => DB::raw('heat_score * ' . self::RANKING_DECAY_FACTOR),
'engagement_velocity' => DB::raw('engagement_velocity * ' . self::RANKING_DECAY_FACTOR),
]);
} catch (\Throwable $e) {
// Non-fatal — log and continue so the version is still saved.
Log::warning('ArtworkVersioningService: ranking protection failed', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Throw TooManyRequestsHttpException when the user has exceeded either:
* 3 replacements per hour for this artwork
* 10 replacements per day for this user (across all artworks)
*/
public function rateLimitCheck(int $userId, int $artworkId): void
{
$hourKey = "artwork_version:hour:{$userId}:{$artworkId}";
$dayKey = "artwork_version:day:{$userId}";
$hourCount = (int) Cache::get($hourKey, 0);
$dayCount = (int) Cache::get($dayKey, 0);
if ($hourCount >= self::MAX_PER_HOUR) {
throw new TooManyRequestsHttpException(
3600,
'You have replaced this artwork too many times in the last hour. Please wait before trying again.'
);
}
if ($dayCount >= self::MAX_PER_DAY) {
throw new TooManyRequestsHttpException(
86400,
'You have reached the daily replacement limit. Please wait until tomorrow.'
);
}
}
// ── Private helpers ────────────────────────────────────────────────────
private function incrementRateLimitCounters(int $userId, int $artworkId): void
{
$hourKey = "artwork_version:hour:{$userId}:{$artworkId}";
$dayKey = "artwork_version:day:{$userId}";
// Hourly counter — expires in 1 hour
if (Cache::has($hourKey)) {
Cache::increment($hourKey);
} else {
Cache::put($hourKey, 1, 3600);
}
// Daily counter — expires at midnight (or 24 hours from first hit)
if (Cache::has($dayKey)) {
Cache::increment($dayKey);
} else {
Cache::put($dayKey, 1, 86400);
}
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Services\Artworks;
use App\DTOs\Artworks\ArtworkDraftResult;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\User;
use App\Services\GroupService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class ArtworkDraftService
{
public function __construct(
private readonly GroupService $groups,
) {
}
public function createDraft(User $user, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false, string|int|null $groupIdentifier = null): ArtworkDraftResult
{
return DB::transaction(function () use ($user, $title, $description, $categoryId, $isMature, $groupIdentifier) {
$slug = $this->makeSlug($title);
$group = $this->resolveGroup($user, $groupIdentifier);
$artwork = Artwork::create([
'user_id' => (int) $user->id,
'group_id' => $group?->id,
'uploaded_by_user_id' => (int) $user->id,
'primary_author_user_id' => (int) $user->id,
'published_as_type' => $group ? Artwork::PUBLISHED_AS_GROUP : Artwork::PUBLISHED_AS_USER,
'published_as_id' => $group?->id ?: (int) $user->id,
'title' => $title,
'slug' => $slug,
'description' => $description,
'file_name' => 'pending',
'file_path' => '',
'file_size' => 0,
'mime_type' => 'application/octet-stream',
'width' => 1,
'height' => 1,
'is_public' => false,
'visibility' => Artwork::VISIBILITY_PRIVATE,
'is_approved' => false,
'is_mature' => $isMature,
'published_at' => null,
'artwork_status' => 'draft',
]);
// Attach the selected category to the artwork pivot table
if ($categoryId !== null && \App\Models\Category::where('id', $categoryId)->exists()) {
$artwork->categories()->sync([$categoryId]);
}
if ($group) {
$this->groups->syncArtworkCount($group);
}
return new ArtworkDraftResult((int) $artwork->id, 'draft');
});
}
private function resolveGroup(User $user, string|int|null $groupIdentifier): ?Group
{
if ($groupIdentifier === null || $groupIdentifier === '') {
return null;
}
$group = is_numeric($groupIdentifier)
? Group::query()->with('members')->findOrFail((int) $groupIdentifier)
: Group::query()->with('members')->where('slug', (string) $groupIdentifier)->firstOrFail();
if (! $group->canCreateArtworkDrafts($user) && ! $user->isAdmin()) {
throw ValidationException::withMessages([
'group' => 'You are not allowed to create drafts for this group.',
]);
}
return $group;
}
private function makeSlug(string $title): string
{
$base = Str::slug($title);
return Str::limit($base !== '' ? $base : 'artwork', 160, '');
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Services\Auth;
class DisposableEmailService
{
public function isEnabled(): bool
{
return (bool) config('registration.disposable_domains_enabled', true);
}
public function isDisposableEmail(string $email): bool
{
if (! $this->isEnabled()) {
return false;
}
$domain = $this->extractDomain($email);
if ($domain === null) {
return false;
}
$blocked = (array) config('disposable_email_domains.domains', []);
foreach ($blocked as $entry) {
$pattern = strtolower(trim((string) $entry));
if ($pattern === '') {
continue;
}
if ($this->matchesPattern($domain, $pattern)) {
return true;
}
}
return false;
}
private function extractDomain(string $email): ?string
{
$normalized = strtolower(trim($email));
if ($normalized === '' || ! str_contains($normalized, '@')) {
return null;
}
$parts = explode('@', $normalized);
$domain = trim((string) end($parts));
return $domain !== '' ? $domain : null;
}
private function matchesPattern(string $domain, string $pattern): bool
{
if ($pattern === $domain) {
return true;
}
if (! str_contains($pattern, '*')) {
return false;
}
$quoted = preg_quote($pattern, '#');
$regex = '#^' . str_replace('\\*', '.*', $quoted) . '$#i';
return (bool) preg_match($regex, $domain);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Services\Auth;
use App\Models\SystemEmailQuota;
class RegistrationEmailQuotaService
{
public function isExceeded(): bool
{
$quota = $this->getCurrentPeriodQuota();
return $quota->sent_count >= $quota->limit_count;
}
public function incrementSentCount(): void
{
$quota = $this->getCurrentPeriodQuota();
$quota->sent_count = (int) $quota->sent_count + 1;
$quota->updated_at = now();
$quota->save();
}
private function getCurrentPeriodQuota(): SystemEmailQuota
{
$period = now()->format('Y-m');
return SystemEmailQuota::query()->firstOrCreate(
['period' => $period],
[
'sent_count' => 0,
'limit_count' => max(1, (int) config('registration.monthly_email_limit', 10000)),
'updated_at' => now(),
]
);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Services\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class RegistrationVerificationTokenService
{
public function createForUser(int $userId): string
{
DB::table('user_verification_tokens')->where('user_id', $userId)->delete();
$rawToken = Str::random(64);
$tokenHash = $this->hashToken($rawToken);
// Support environments where the migration hasn't renamed the column yet
$column = \Illuminate\Support\Facades\Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token';
DB::table('user_verification_tokens')->insert([
'user_id' => $userId,
$column => $tokenHash,
'expires_at' => now()->addHours($this->ttlHours()),
'created_at' => now(),
'updated_at' => now(),
]);
return $rawToken;
}
public function findValidRecord(string $rawToken): ?object
{
$tokenHash = $this->hashToken($rawToken);
$column = \Illuminate\Support\Facades\Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token';
$record = DB::table('user_verification_tokens')
->where($column, $tokenHash)
->first();
if (! $record) {
return null;
}
if (! hash_equals((string) ($record->{$column} ?? ''), $tokenHash)) {
return null;
}
if (now()->greaterThan($record->expires_at)) {
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
return null;
}
return $record;
}
private function ttlHours(): int
{
return max(1, (int) config('registration.verify_token_ttl_hours', 24));
}
private function hashToken(string $rawToken): string
{
return hash('sha256', $rawToken);
}
}

View File

@@ -0,0 +1,344 @@
<?php
namespace App\Services;
use App\Models\UserProfile;
use Carbon\Carbon;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\PngEncoder;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
class AvatarService
{
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const ALLOWED_POSITIONS = [
'top-left',
'top',
'top-right',
'left',
'center',
'right',
'bottom-left',
'bottom',
'bottom-right',
];
protected $sizes = [
'xs' => 32,
'sm' => 64,
'md' => 128,
'lg' => 256,
'xl' => 512,
];
protected $quality = 85;
private ?ImageManager $manager = null;
public function __construct()
{
$configuredSizes = array_values(array_filter((array) config('avatars.sizes', [32, 64, 128, 256, 512]), static fn ($size) => (int) $size > 0));
if ($configuredSizes !== []) {
$this->sizes = array_fill_keys(array_map('strval', $configuredSizes), null);
$this->sizes = array_combine(array_keys($this->sizes), $configuredSizes);
}
$this->quality = (int) config('avatars.quality', 85);
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable $e) {
logger()->warning('Avatar image manager configuration failed: ' . $e->getMessage());
$this->manager = null;
}
}
public function storeFromUploadedFile(int $userId, UploadedFile $file, string $position = 'center'): string
{
$this->assertImageManagerAvailable();
$this->assertStorageIsAllowed();
$binary = $this->assertSecureImageUpload($file);
return $this->storeFromBinary($userId, $binary, $position);
}
public function storeFromLegacyFile(int $userId, string $path): ?string
{
$this->assertImageManagerAvailable();
$this->assertStorageIsAllowed();
if (!file_exists($path) || !is_readable($path)) {
return null;
}
$binary = file_get_contents($path);
if ($binary === false || $binary === '') {
return null;
}
return $this->storeFromBinary($userId, $binary);
}
public function removeAvatar(int $userId): void
{
$diskName = (string) config('avatars.disk', 's3');
$disk = Storage::disk($diskName);
$existingHash = UserProfile::query()
->where('user_id', $userId)
->value('avatar_hash');
if (is_string($existingHash) && trim($existingHash) !== '') {
$disk->deleteDirectory($this->avatarDirectory(trim($existingHash)));
}
$disk->deleteDirectory("avatars/{$userId}");
UserProfile::query()->updateOrCreate(
['user_id' => $userId],
[
'avatar_hash' => null,
'avatar_mime' => null,
'avatar_updated_at' => Carbon::now(),
]
);
}
private function storeFromBinary(int $userId, string $binary, string $position = 'center'): string
{
$image = $this->readImageFromBinary($binary);
$image = $this->normalizeImage($image);
$cropPosition = $this->normalizePosition($position);
$normalizedSource = (string) $image->encode(new PngEncoder());
if ($normalizedSource === '') {
throw new RuntimeException('Avatar processing failed to prepare the source image.');
}
$diskName = (string) config('avatars.disk', 's3');
$disk = Storage::disk($diskName);
$existingHash = UserProfile::query()
->where('user_id', $userId)
->value('avatar_hash');
$hashSeed = '';
$encodedVariants = [];
foreach ($this->sizes as $size) {
$variant = $this->manager->read($normalizedSource)->cover($size, $size, $cropPosition);
$encoded = (string) $variant->encode(new WebpEncoder($this->quality));
$encodedVariants[(int) $size] = $encoded;
if ($size === 128) {
$hashSeed = $encoded;
}
}
if ($hashSeed === '') {
throw new RuntimeException('Avatar processing failed to generate a hash seed.');
}
$hash = hash('sha256', $hashSeed);
$basePath = $this->avatarDirectory($hash);
foreach ($encodedVariants as $size => $encoded) {
$disk->put("{$basePath}/{$size}.webp", $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
}
if (is_string($existingHash) && trim($existingHash) !== '' && trim($existingHash) !== $hash) {
$disk->deleteDirectory($this->avatarDirectory(trim($existingHash)));
}
$disk->deleteDirectory("avatars/{$userId}");
$this->updateProfileMetadata($userId, $hash);
return $hash;
}
private function avatarDirectory(string $hash): string
{
$p1 = substr($hash, 0, 2);
$p2 = substr($hash, 2, 2);
return sprintf('avatars/%s/%s/%s', $p1, $p2, $hash);
}
private function normalizePosition(string $position): string
{
$normalized = strtolower(trim($position));
if (in_array($normalized, self::ALLOWED_POSITIONS, true)) {
return $normalized;
}
return 'center';
}
private function normalizeImage($image)
{
try {
$core = $image->getCore();
$isImagickCore = is_object($core) && strtolower(get_class($core)) === 'imagick';
if ($isImagickCore) {
try {
$core->stripImage();
} catch (\Throwable $_) {
}
try {
$colorSpaceRgb = defined('\\Imagick::COLORSPACE_RGB') ? constant('\\Imagick::COLORSPACE_RGB') : null;
$colorSpaceSRgb = defined('\\Imagick::COLORSPACE_SRGB') ? constant('\\Imagick::COLORSPACE_SRGB') : null;
if (is_int($colorSpaceRgb)) {
$core->setImageColorspace($colorSpaceRgb);
} elseif (is_int($colorSpaceSRgb)) {
$core->setImageColorspace($colorSpaceSRgb);
}
} catch (\Throwable $_) {
}
try {
$alphaRemove = defined('\\Imagick::ALPHACHANNEL_REMOVE') ? constant('\\Imagick::ALPHACHANNEL_REMOVE') : null;
if (is_int($alphaRemove)) {
$core->setImageAlphaChannel($alphaRemove);
}
} catch (\Throwable $_) {
}
try {
$core->setBackgroundColor('white');
$layerFlatten = defined('\\Imagick::LAYERMETHOD_FLATTEN') ? constant('\\Imagick::LAYERMETHOD_FLATTEN') : null;
$flattened = is_int($layerFlatten) ? $core->mergeImageLayers($layerFlatten) : null;
if (is_object($flattened) && strtolower(get_class($flattened)) === 'imagick') {
$core->clear();
$core->destroy();
$image = $this->manager->read((string) $flattened->getImageBlob());
}
} catch (\Throwable $_) {
}
return $image;
}
$isGdCore = is_resource($core) || (is_object($core) && strtolower(get_class($core)) === 'gdimage');
if ($isGdCore) {
$width = imagesx($core);
$height = imagesy($core);
if ($width > 0 && $height > 0) {
$flattened = imagecreatetruecolor($width, $height);
if ($flattened !== false) {
$white = imagecolorallocate($flattened, 255, 255, 255);
imagefilledrectangle($flattened, 0, 0, $width, $height, $white);
imagecopy($flattened, $core, 0, 0, 0, 0, $width, $height);
ob_start();
imagepng($flattened);
$pngBinary = (string) ob_get_clean();
imagedestroy($flattened);
if ($pngBinary !== '') {
return $this->manager->read($pngBinary);
}
}
}
}
} catch (\Throwable $_) {
}
return $image;
}
private function readImageFromBinary(string $binary)
{
try {
return $this->manager->read($binary);
} catch (\Throwable $e) {
throw new RuntimeException('Failed to decode uploaded image.');
}
}
private function updateProfileMetadata(int $userId, string $hash): void
{
UserProfile::query()->updateOrCreate(
['user_id' => $userId],
[
'avatar_hash' => $hash,
'avatar_mime' => 'image/webp',
'avatar_updated_at' => Carbon::now(),
]
);
}
private function assertImageManagerAvailable(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Avatar image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
if (!app()->environment('production')) {
return;
}
$diskName = (string) config('avatars.disk', 's3');
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production avatar storage must use object storage, not local/public disks.');
}
}
private function assertSecureImageUpload(UploadedFile $file): string
{
if (! $file->isValid()) {
throw new RuntimeException('Avatar upload is not valid.');
}
$extension = strtolower((string) $file->getClientOriginalExtension());
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
throw new RuntimeException('Unsupported avatar file extension.');
}
$detectedMime = (string) $file->getMimeType();
if (!in_array($detectedMime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported avatar MIME type.');
}
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || !is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded avatar path.');
}
$binary = file_get_contents($uploadPath);
if ($binary === false || $binary === '') {
throw new RuntimeException('Unable to read uploaded avatar data.');
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$finfoMime = (string) $finfo->buffer($binary);
if (!in_array($finfoMime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Avatar content did not match allowed image MIME types.');
}
$dimensions = @getimagesizefromstring($binary);
if (!is_array($dimensions) || ($dimensions[0] ?? 0) < 1 || ($dimensions[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded avatar is not a valid image.');
}
return $binary;
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Services;
class BbcodeConverter
{
/**
* Convert simple BBCode to HTML. Safe-escapes content and supports basic tags.
*/
public function convert(?string $text): string
{
if ($text === null) return '';
// Normalize line endings
$text = str_replace(["\r\n", "\r"], "\n", $text);
// Protect code blocks first
$codeBlocks = [];
$text = preg_replace_callback('/\[code\](.*?)\[\/code\]/is', function ($m) use (&$codeBlocks) {
$idx = count($codeBlocks);
$codeBlocks[$idx] = '<pre><code>' . htmlspecialchars($m[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></pre>';
return "__CODEBLOCK_{$idx}__";
}, $text);
// Escape remaining text to avoid XSS
$text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// Basic tags
$simple = [
'/\[b\](.*?)\[\/b\]/is' => '<strong>$1</strong>',
'/\[i\](.*?)\[\/i\]/is' => '<em>$1</em>',
'/\[u\](.*?)\[\/u\]/is' => '<span style="text-decoration:underline;">$1</span>',
'/\[s\](.*?)\[\/s\]/is' => '<del>$1</del>',
];
foreach ($simple as $pat => $rep) {
$text = preg_replace($pat, $rep, $text);
}
// [url=link]text[/url] and [url]link[/url]
$text = preg_replace_callback('/\[url=(.*?)\](.*?)\[\/url\]/is', function ($m) {
$url = $this->sanitizeUrl(html_entity_decode($m[1]));
$label = $m[2];
return '<a href="' . $url . '" rel="noopener noreferrer" target="_blank">' . $label . '</a>';
}, $text);
$text = preg_replace_callback('/\[url\](.*?)\[\/url\]/is', function ($m) {
$url = $this->sanitizeUrl(html_entity_decode($m[1]));
return '<a href="' . $url . '" rel="noopener noreferrer" target="_blank">' . $url . '</a>';
}, $text);
// [img]url[/img]
$text = preg_replace_callback('/\[img\](.*?)\[\/img\]/is', function ($m) {
$src = $this->sanitizeUrl(html_entity_decode($m[1]));
return '<img src="' . $src . '" alt="" />';
}, $text);
// [quote]...[/quote]
$text = preg_replace('/\[quote\](.*?)\[\/quote\]/is', '<blockquote>$1</blockquote>', $text);
// [list] and [*]
// Convert [list]...[*]item[*]...[/list] to <ul><li>...</li></ul>
$text = preg_replace_callback('/\[list\](.*?)\[\/list\]/is', function ($m) {
$items = preg_split('/\[\*\]/', $m[1]);
$out = '';
foreach ($items as $it) {
$it = trim($it);
if ($it === '') continue;
$out .= '<li>' . $it . '</li>';
}
return '<ul>' . $out . '</ul>';
}, $text);
// sizes and colors: simple inline styles
$text = preg_replace('/\[size=(\d+)\](.*?)\[\/size\]/is', '<span style="font-size:$1px;">$2</span>', $text);
$text = preg_replace('/\[color=(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)\](.*?)\[\/color\]/is', '<span style="color:$1;">$2</span>', $text);
// Preserve line breaks
$text = nl2br($text);
// Restore code blocks
if (!empty($codeBlocks)) {
foreach ($codeBlocks as $i => $html) {
$text = str_replace('__CODEBLOCK_' . $i . '__', $html, $text);
}
}
return $text;
}
protected function sanitizeUrl($url)
{
$url = trim($url);
// allow relative paths
if (strpos($url, 'http://') === 0 || strpos($url, 'https://') === 0 || strpos($url, '/') === 0) {
return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// fallback: prefix with http:// if looks like domain
if (preg_match('/^[A-Za-z0-9\-\.]+(\:[0-9]+)?(\/.*)?$/', $url)) {
return 'http://' . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
return '#';
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Services\Cdn;
use App\Services\ThumbnailService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
final class ArtworkCdnPurgeService
{
/**
* @param array<int, string> $objectPaths
* @param array<string, mixed> $context
*/
public function purgeArtworkObjectPaths(array $objectPaths, array $context = []): bool
{
$urls = array_values(array_unique(array_filter(array_map(
fn (mixed $path): ?string => is_string($path) && trim($path) !== ''
? $this->cdnUrlForObjectPath($path)
: null,
$objectPaths,
))));
return $this->purgeUrls($urls, $context);
}
/**
* @param array<int, string> $variants
* @param array<string, mixed> $context
*/
public function purgeArtworkHashVariants(string $hash, string $extension = 'webp', array $variants = ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], array $context = []): bool
{
$urls = array_values(array_unique(array_filter(array_map(
fn (string $variant): ?string => ThumbnailService::fromHash($hash, $extension, $variant),
$variants,
))));
return $this->purgeUrls($urls, $context + ['hash' => $hash]);
}
/**
* @param array<int, string> $urls
* @param array<string, mixed> $context
*/
private function purgeUrls(array $urls, array $context = []): bool
{
if ($urls === []) {
return false;
}
if ($this->hasCloudflareCredentials()) {
return $this->purgeViaCloudflare($urls, $context);
}
$legacyPurgeUrl = trim((string) config('cdn.purge_url', ''));
if ($legacyPurgeUrl !== '') {
return $this->purgeViaLegacyWebhook($legacyPurgeUrl, $urls, $context);
}
Log::debug('CDN purge skipped - no Cloudflare or legacy purge configuration is available', $context + [
'url_count' => count($urls),
]);
return false;
}
private function purgeViaCloudflare(array $urls, array $context): bool
{
$purgeUrl = sprintf(
'https://api.cloudflare.com/client/v4/zones/%s/purge_cache',
trim((string) config('cdn.cloudflare.zone_id')),
);
try {
$response = Http::timeout(10)
->acceptJson()
->withToken(trim((string) config('cdn.cloudflare.api_token')))
->post($purgeUrl, ['files' => $urls]);
if ($response->successful()) {
return true;
}
Log::warning('Cloudflare artwork CDN purge failed', $context + [
'status' => $response->status(),
'body' => $response->body(),
'url_count' => count($urls),
]);
} catch (\Throwable $e) {
Log::warning('Cloudflare artwork CDN purge threw an exception', $context + [
'error' => $e->getMessage(),
'url_count' => count($urls),
]);
}
return false;
}
private function purgeViaLegacyWebhook(string $purgeUrl, array $urls, array $context): bool
{
$paths = array_values(array_unique(array_filter(array_map(function (string $url): ?string {
$path = parse_url($url, PHP_URL_PATH);
return is_string($path) && $path !== '' ? $path : null;
}, $urls))));
if ($paths === []) {
return false;
}
try {
$response = Http::timeout(10)->acceptJson()->post($purgeUrl, ['paths' => $paths]);
if ($response->successful()) {
return true;
}
Log::warning('Legacy artwork CDN purge failed', $context + [
'status' => $response->status(),
'body' => $response->body(),
'path_count' => count($paths),
]);
} catch (\Throwable $e) {
Log::warning('Legacy artwork CDN purge threw an exception', $context + [
'error' => $e->getMessage(),
'path_count' => count($paths),
]);
}
return false;
}
private function hasCloudflareCredentials(): bool
{
return trim((string) config('cdn.cloudflare.zone_id', '')) !== ''
&& trim((string) config('cdn.cloudflare.api_token', '')) !== '';
}
private function cdnUrlForObjectPath(string $objectPath): string
{
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($objectPath, '/');
}
}

View File

@@ -0,0 +1,788 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Collection;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Str;
class CollectionAiCurationService
{
public function __construct(
private readonly SmartCollectionService $smartCollections,
private readonly CollectionCampaignService $campaigns,
private readonly CollectionRecommendationService $recommendations,
) {
}
public function suggestTitle(Collection $collection, array $draft = []): array
{
$context = $this->buildContext($collection, $draft);
$theme = $context['primary_theme'] ?: $context['top_category'] ?: $context['event_label'] ?: 'Curated Highlights';
$primary = match ($context['type']) {
Collection::TYPE_EDITORIAL => 'Staff Picks: ' . $theme,
Collection::TYPE_COMMUNITY => ($context['allow_submissions'] ? 'Community Picks: ' : '') . $theme,
default => $theme,
};
$alternatives = array_values(array_unique(array_filter([
$primary,
$theme . ' Showcase',
$context['event_label'] ? $context['event_label'] . ': ' . $theme : null,
$context['type'] === Collection::TYPE_EDITORIAL ? $theme . ' Editorial' : $theme . ' Collection',
])));
return [
'title' => $alternatives[0] ?? $primary,
'alternatives' => array_slice($alternatives, 1, 3),
'rationale' => sprintf(
'Built from the strongest recurring theme%s across %d artworks.',
$context['primary_theme'] ? ' (' . $context['primary_theme'] . ')' : '',
$context['artworks_count']
),
'source' => 'heuristic-ai',
];
}
public function suggestSummary(Collection $collection, array $draft = []): array
{
$context = $this->buildContext($collection, $draft);
$typeLabel = match ($context['type']) {
Collection::TYPE_EDITORIAL => 'editorial',
Collection::TYPE_COMMUNITY => 'community',
default => 'curated',
};
$themePart = $context['theme_sentence'] !== ''
? ' focused on ' . $context['theme_sentence']
: '';
$creatorPart = $context['creator_count'] > 1
? sprintf(' featuring work from %d creators', $context['creator_count'])
: ' featuring a tightly selected set of pieces';
$summary = sprintf(
'A %s collection%s with %d artworks%s.',
$typeLabel,
$themePart,
$context['artworks_count'],
$creatorPart
);
$seo = sprintf(
'%s on Skinbase Nova: %d curated artworks%s.',
$this->draftString($collection, $draft, 'title') ?: $collection->title,
$context['artworks_count'],
$context['theme_sentence'] !== '' ? ' exploring ' . $context['theme_sentence'] : ''
);
return [
'summary' => Str::limit($summary, 220, ''),
'seo_description' => Str::limit($seo, 155, ''),
'rationale' => 'Summarised from the collection type, artwork count, creator mix, and recurring artwork themes.',
'source' => 'heuristic-ai',
];
}
public function suggestCover(Collection $collection, array $draft = []): array
{
$artworks = $this->candidateArtworks($collection, $draft, 24);
/** @var Artwork|null $winner */
$winner = $artworks
->sortByDesc(fn (Artwork $artwork) => $this->coverScore($artwork))
->first();
if (! $winner) {
return [
'artwork' => null,
'rationale' => 'Add or match a few artworks first so the assistant has something to rank.',
'source' => 'heuristic-ai',
];
}
$stats = $winner->stats;
return [
'artwork' => [
'id' => (int) $winner->id,
'title' => (string) $winner->title,
'thumb' => $winner->thumbUrl('md'),
'url' => route('art.show', [
'id' => $winner->id,
'slug' => Str::slug((string) ($winner->slug ?: $winner->title)) ?: (string) $winner->id,
]),
],
'rationale' => sprintf(
'Ranked highest for cover impact based on engagement, recency, and display-friendly proportions (%dx%d, %d views, %d likes).',
(int) ($winner->width ?? 0),
(int) ($winner->height ?? 0),
(int) ($stats?->views ?? $winner->view_count ?? 0),
(int) ($stats?->favorites ?? $winner->favourite_count ?? 0),
),
'source' => 'heuristic-ai',
];
}
public function suggestGrouping(Collection $collection, array $draft = []): array
{
$artworks = $this->candidateArtworks($collection, $draft, 36);
$themeBuckets = [];
foreach ($artworks as $artwork) {
$tag = $artwork->tags
->sortByDesc(fn ($item) => $item->pivot?->source === 'ai' ? 1 : 0)
->first();
$label = $tag?->name
?: ($artwork->categories->first()?->name)
?: ($artwork->stats?->views ? 'Popular highlights' : 'Curated picks');
if (! isset($themeBuckets[$label])) {
$themeBuckets[$label] = [
'label' => $label,
'artwork_ids' => [],
];
}
if (count($themeBuckets[$label]['artwork_ids']) < 5) {
$themeBuckets[$label]['artwork_ids'][] = (int) $artwork->id;
}
}
$groups = collect($themeBuckets)
->map(fn (array $bucket) => [
'label' => $bucket['label'],
'artwork_ids' => $bucket['artwork_ids'],
'count' => count($bucket['artwork_ids']),
])
->sortByDesc('count')
->take(4)
->values()
->all();
return [
'groups' => $groups,
'rationale' => $groups !== []
? 'Grouped by the strongest recurring artwork themes so the collection can be split into cleaner sections.'
: 'No strong theme groups were found yet.',
'source' => 'heuristic-ai',
];
}
public function suggestRelatedArtworks(Collection $collection, array $draft = []): array
{
$seedArtworks = $this->candidateArtworks($collection, $draft, 24);
$tagSlugs = $seedArtworks
->flatMap(fn (Artwork $artwork) => $artwork->tags->pluck('slug'))
->filter()
->unique()
->values();
$categoryIds = $seedArtworks
->flatMap(fn (Artwork $artwork) => $artwork->categories->pluck('id'))
->filter()
->unique()
->values();
$attachedIds = $collection->artworks()->pluck('artworks.id')->map(static fn ($id) => (int) $id)->all();
$candidates = Artwork::query()
->with(['tags', 'categories'])
->where('user_id', $collection->user_id)
->whereNotIn('id', $attachedIds)
->where(function ($query) use ($tagSlugs, $categoryIds): void {
if ($tagSlugs->isNotEmpty()) {
$query->orWhereHas('tags', fn ($tagQuery) => $tagQuery->whereIn('slug', $tagSlugs->all()));
}
if ($categoryIds->isNotEmpty()) {
$query->orWhereHas('categories', fn ($categoryQuery) => $categoryQuery->whereIn('categories.id', $categoryIds->all()));
}
})
->latest('published_at')
->limit(18)
->get()
->map(function (Artwork $artwork) use ($tagSlugs, $categoryIds): array {
$sharedTags = $artwork->tags->pluck('slug')->intersect($tagSlugs)->values();
$sharedCategories = $artwork->categories->pluck('id')->intersect($categoryIds)->values();
$score = ($sharedTags->count() * 3) + ($sharedCategories->count() * 2);
return [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'thumb' => $artwork->thumbUrl('sm'),
'score' => $score,
'shared_tags' => $sharedTags->take(3)->values()->all(),
'shared_categories' => $sharedCategories->count(),
];
})
->sortByDesc('score')
->take(6)
->values()
->all();
return [
'artworks' => $candidates,
'rationale' => $candidates !== []
? 'Suggested from your unassigned artworks that overlap most with the collections current themes and categories.'
: 'No closely related unassigned artworks were found in your gallery yet.',
'source' => 'heuristic-ai',
];
}
public function suggestTags(Collection $collection, array $draft = []): array
{
$context = $this->buildContext($collection, $draft);
$artworks = $this->candidateArtworks($collection, $draft, 24);
$tags = collect([
$context['primary_theme'],
$context['top_category'],
$context['event_label'] !== '' ? Str::slug($context['event_label']) : null,
$collection->type === Collection::TYPE_EDITORIAL ? 'staff-picks' : null,
$collection->type === Collection::TYPE_COMMUNITY ? 'community-curation' : null,
])
->filter()
->merge(
$artworks->flatMap(fn (Artwork $artwork) => $artwork->tags->pluck('slug'))
->filter()
->countBy()
->sortDesc()
->keys()
->take(4)
)
->map(fn ($value) => Str::of((string) $value)->replace('-', ' ')->trim()->lower()->value())
->filter()
->unique()
->take(6)
->values()
->all();
return [
'tags' => $tags,
'rationale' => 'Suggested from recurring artwork themes, categories, and collection type signals.',
'source' => 'heuristic-ai',
];
}
public function suggestSeoDescription(Collection $collection, array $draft = []): array
{
$summary = $this->suggestSummary($collection, $draft);
$title = $this->draftString($collection, $draft, 'title') ?: $collection->title;
$label = match ($collection->type) {
Collection::TYPE_EDITORIAL => 'Staff Pick',
Collection::TYPE_COMMUNITY => 'Community Collection',
default => 'Collection',
};
return [
'description' => Str::limit(sprintf('%s: %s. %s', $label, $title, $summary['seo_description']), 155, ''),
'rationale' => 'Optimised for social previews and search snippets using the title, type, and strongest collection theme.',
'source' => 'heuristic-ai',
];
}
public function detectWeakMetadata(Collection $collection, array $draft = []): array
{
$context = $this->buildContext($collection, $draft);
$issues = [];
$title = $this->draftString($collection, $draft, 'title') ?: (string) $collection->title;
$summary = $this->draftString($collection, $draft, 'summary');
$description = $this->draftString($collection, $draft, 'description');
$metadataScore = (float) ($collection->metadata_completeness_score ?? 0);
$coverArtworkId = $draft['cover_artwork_id'] ?? $collection->cover_artwork_id;
if ($title === '' || Str::length($title) < 12 || preg_match('/^(untitled|new collection|my collection)/i', $title) === 1) {
$issues[] = $this->metadataIssue(
'title',
'high',
'Title needs more specificity',
'Use a more descriptive title that signals the theme, series, or purpose of the collection.'
);
}
if ($summary === null || Str::length($summary) < 60) {
$issues[] = $this->metadataIssue(
'summary',
'high',
'Summary is missing or too short',
'Add a concise summary that explains what ties these artworks together and why the collection matters.'
);
}
if ($description === null || Str::length(strip_tags($description)) < 140) {
$issues[] = $this->metadataIssue(
'description',
'medium',
'Description lacks depth',
'Expand the description with context, mood, creator intent, or campaign framing so the collection reads as deliberate curation.'
);
}
if (! $coverArtworkId) {
$issues[] = $this->metadataIssue(
'cover',
'medium',
'No explicit cover artwork is set',
'Choose a cover artwork so the collection has a stronger visual anchor across profile, saved-library, and programming surfaces.'
);
}
if (($context['primary_theme'] ?? null) === null && ($context['top_category'] ?? null) === null) {
$issues[] = $this->metadataIssue(
'theme',
'medium',
'Theme signals are weak',
'Add or retag a few representative artworks so the collection has a clearer theme for discovery and recommendations.'
);
}
if ($metadataScore > 0 && $metadataScore < 65) {
$issues[] = $this->metadataIssue(
'metadata_score',
'medium',
'Metadata completeness is below the recommended threshold',
sprintf('The current metadata completeness score is %.1f. Tightening title, summary, description, and cover selection should improve it.', $metadataScore)
);
}
return [
'status' => $issues === [] ? 'healthy' : 'needs_work',
'issues' => $issues,
'rationale' => $issues === []
? 'The collection metadata is strong enough for creator-facing surfaces and AI assistance did not find obvious weak spots.'
: 'Detected from title specificity, metadata coverage, theme clarity, and cover readiness heuristics.',
'source' => 'heuristic-ai',
];
}
public function suggestStaleRefresh(Collection $collection, array $draft = []): array
{
$context = $this->buildContext($collection, $draft);
$referenceAt = $this->staleReferenceAt($collection);
$daysSinceRefresh = $referenceAt?->diffInDays(now()) ?? null;
$isStale = $daysSinceRefresh !== null && ($daysSinceRefresh >= 45 || (float) ($collection->freshness_score ?? 0) < 40);
$actions = [];
if ($isStale) {
$actions[] = [
'key' => 'refresh_summary',
'label' => 'Refresh the summary and description',
'reason' => 'A quick metadata pass helps older collections feel current again when they resurface in search or recommendations.',
];
if (! $collection->cover_artwork_id) {
$actions[] = [
'key' => 'set_cover',
'label' => 'Choose a stronger cover artwork',
'reason' => 'A defined cover is the fastest way to make a stale collection feel newly curated.',
];
}
if (($context['artworks_count'] ?? 0) < 6) {
$actions[] = [
'key' => 'add_recent_artworks',
'label' => 'Add newer artworks to the set',
'reason' => 'The collection is still relatively small, so a few fresh pieces would meaningfully improve recency and depth.',
];
} else {
$actions[] = [
'key' => 'resequence_highlights',
'label' => 'Resequence the leading artworks',
'reason' => 'Reordering the strongest pieces can refresh the collection without changing its core theme.',
];
}
if (($context['primary_theme'] ?? null) === null) {
$actions[] = [
'key' => 'tighten_theme',
'label' => 'Clarify the collection theme',
'reason' => 'The current artwork set does not emit a strong recurring theme, so a tighter selection would improve discovery quality.',
];
}
}
return [
'stale' => $isStale,
'days_since_refresh' => $daysSinceRefresh,
'last_active_at' => $referenceAt?->toIso8601String(),
'actions' => $actions,
'rationale' => $isStale
? 'Detected from freshness, recent activity, and current collection depth.'
: 'The collection has recent enough activity that a refresh is not urgent right now.',
'source' => 'heuristic-ai',
];
}
public function suggestCampaignFit(Collection $collection, array $draft = []): array
{
$context = $this->buildContext($collection, $draft);
$campaignSummary = $this->campaigns->campaignSummary($collection);
$seasonKey = $this->draftString($collection, $draft, 'season_key') ?: (string) ($collection->season_key ?? '');
$eventKey = $this->draftString($collection, $draft, 'event_key') ?: (string) ($collection->event_key ?? '');
$eventLabel = $this->draftString($collection, $draft, 'event_label') ?: (string) ($collection->event_label ?? '');
$candidates = Collection::query()
->public()
->where('id', '!=', $collection->id)
->whereNotNull('campaign_key')
->with(['user:id,username,name'])
->orderByDesc('ranking_score')
->orderByDesc('saves_count')
->orderByDesc('updated_at')
->limit(36)
->get()
->map(function (Collection $candidate) use ($collection, $context, $seasonKey, $eventKey): array {
$score = 0;
$reasons = [];
if ((string) $candidate->type === (string) $collection->type) {
$score += 4;
$reasons[] = 'Matches the same collection type.';
}
if ($seasonKey !== '' && $candidate->season_key === $seasonKey) {
$score += 5;
$reasons[] = 'Shares the same seasonal window.';
}
if ($eventKey !== '' && $candidate->event_key === $eventKey) {
$score += 6;
$reasons[] = 'Shares the same event context.';
}
if ((int) $candidate->user_id === (int) $collection->user_id) {
$score += 2;
$reasons[] = 'Comes from the same curator account.';
}
if ($context['top_category'] !== null && filled($candidate->theme_token) && Str::contains(mb_strtolower((string) $candidate->theme_token), mb_strtolower((string) $context['top_category']))) {
$score += 2;
$reasons[] = 'Theme token overlaps with the collection category.';
}
if ((float) ($candidate->ranking_score ?? 0) >= 70) {
$score += 1;
$reasons[] = 'Campaign is represented by a strong-performing collection.';
}
return [
'score' => $score,
'candidate' => $candidate,
'reasons' => $reasons,
];
})
->filter(fn (array $item): bool => $item['score'] > 0)
->sortByDesc(fn (array $item): string => sprintf('%08d-%s', $item['score'], optional($item['candidate']->updated_at)?->timestamp ?? 0))
->groupBy(fn (array $item): string => (string) $item['candidate']->campaign_key)
->map(function ($items, string $campaignKey): array {
$top = collect($items)->sortByDesc('score')->first();
$candidate = $top['candidate'];
return [
'campaign_key' => $campaignKey,
'campaign_label' => $candidate->campaign_label ?: Str::headline(str_replace(['_', '-'], ' ', $campaignKey)),
'score' => (int) $top['score'],
'reasons' => array_values(array_unique($top['reasons'])),
'sample_collection' => [
'id' => (int) $candidate->id,
'title' => (string) $candidate->title,
'slug' => (string) $candidate->slug,
],
];
})
->sortByDesc('score')
->take(4)
->values()
->all();
if ($candidates === [] && ($seasonKey !== '' || $eventKey !== '' || $eventLabel !== '')) {
$fallbackKey = $collection->campaign_key ?: ($eventKey !== '' ? Str::slug($eventKey) : ($seasonKey !== '' ? $seasonKey . '-editorial' : Str::slug($eventLabel)));
if ($fallbackKey !== '') {
$candidates[] = [
'campaign_key' => $fallbackKey,
'campaign_label' => $collection->campaign_label ?: ($eventLabel !== '' ? $eventLabel : Str::headline(str_replace(['_', '-'], ' ', $fallbackKey))),
'score' => 72,
'reasons' => array_values(array_filter([
$seasonKey !== '' ? 'Season metadata is already present and can anchor a campaign.' : null,
$eventLabel !== '' ? 'Event labeling is already specific enough for campaign framing.' : null,
'Surface suggestions indicate this collection is promotable once reviewed.',
])),
'sample_collection' => null,
];
}
}
return [
'current_context' => [
'campaign_key' => $collection->campaign_key,
'campaign_label' => $collection->campaign_label,
'event_key' => $eventKey !== '' ? $eventKey : null,
'event_label' => $eventLabel !== '' ? $eventLabel : null,
'season_key' => $seasonKey !== '' ? $seasonKey : null,
],
'eligibility' => $campaignSummary['eligibility'] ?? [],
'recommended_surfaces' => $campaignSummary['recommended_surfaces'] ?? [],
'fits' => $candidates,
'rationale' => $candidates !== []
? 'Suggested from existing campaign-aware collections with overlapping type, season, event, and performance context.'
: 'No strong campaign fits were detected yet; tighten seasonal or event metadata first.',
'source' => 'heuristic-ai',
];
}
public function suggestRelatedCollectionsToLink(Collection $collection, array $draft = []): array
{
$alreadyLinkedIds = $collection->manualRelatedCollections()->pluck('collections.id')->map(static fn ($id): int => (int) $id)->all();
$candidates = $this->recommendations->relatedPublicCollections($collection, 8)
->reject(fn (Collection $candidate): bool => in_array((int) $candidate->id, $alreadyLinkedIds, true))
->take(6)
->values();
$suggestions = $candidates->map(function (Collection $candidate) use ($collection): array {
$reasons = [];
if ((string) $candidate->type === (string) $collection->type) {
$reasons[] = 'Matches the same collection type.';
}
if ((int) $candidate->user_id === (int) $collection->user_id) {
$reasons[] = 'Owned by the same curator.';
}
if (filled($collection->campaign_key) && $candidate->campaign_key === $collection->campaign_key) {
$reasons[] = 'Shares the same campaign context.';
}
if (filled($collection->event_key) && $candidate->event_key === $collection->event_key) {
$reasons[] = 'Shares the same event context.';
}
if (filled($collection->season_key) && $candidate->season_key === $collection->season_key) {
$reasons[] = 'Shares the same seasonal framing.';
}
if ($reasons === []) {
$reasons[] = 'Ranks as a closely related public collection based on the platform recommendation model.';
}
return [
'id' => (int) $candidate->id,
'title' => (string) $candidate->title,
'slug' => (string) $candidate->slug,
'owner' => $candidate->displayOwnerName(),
'reasons' => $reasons,
'link_type' => (int) $candidate->user_id === (int) $collection->user_id ? 'same_curator' : 'discovery_adjacent',
];
})->all();
return [
'linked_collection_ids' => $alreadyLinkedIds,
'suggestions' => $suggestions,
'rationale' => $suggestions !== []
? 'Suggested from the related-public-collections model after excluding collections already linked manually.'
: 'No additional related public collections were strong enough to suggest right now.',
'source' => 'heuristic-ai',
];
}
public function explainSmartRules(Collection $collection, array $draft = []): array
{
$rules = is_array($draft['smart_rules_json'] ?? null)
? $draft['smart_rules_json']
: $collection->smart_rules_json;
if (! is_array($rules)) {
return [
'explanation' => 'This collection is currently curated manually, so there are no smart rules to explain.',
'source' => 'heuristic-ai',
];
}
$summary = $this->smartCollections->smartSummary($rules);
$preview = $this->smartCollections->preview($collection->user, $rules, true, 6);
return [
'explanation' => sprintf('%s The current rule set matches %d artworks in the preview.', $summary, $preview->total()),
'rationale' => 'Translated from the active smart rule JSON into a human-readable curator summary.',
'source' => 'heuristic-ai',
];
}
public function suggestSplitThemes(Collection $collection, array $draft = []): array
{
$grouping = $this->suggestGrouping($collection, $draft);
$groups = collect($grouping['groups'] ?? [])->take(2)->values();
if ($groups->count() < 2) {
return [
'splits' => [],
'rationale' => 'There are not enough distinct recurring themes yet to justify splitting this collection into two focused sets.',
'source' => 'heuristic-ai',
];
}
return [
'splits' => $groups->map(function (array $group, int $index) use ($collection): array {
$label = (string) ($group['label'] ?? ('Theme ' . ($index + 1)));
return [
'label' => $label,
'title' => trim(sprintf('%s: %s', $collection->title, $label)),
'artwork_ids' => array_values(array_map('intval', $group['artwork_ids'] ?? [])),
'count' => (int) ($group['count'] ?? 0),
];
})->all(),
'rationale' => 'Suggested from the two strongest artwork theme clusters so you can split the collection into clearer destination pages.',
'source' => 'heuristic-ai',
];
}
public function suggestMergeIdea(Collection $collection, array $draft = []): array
{
$related = $this->suggestRelatedArtworks($collection, $draft);
$context = $this->buildContext($collection, $draft);
$artworks = collect($related['artworks'] ?? [])->take(3)->values();
if ($artworks->isEmpty()) {
return [
'idea' => null,
'rationale' => 'No closely related artworks were found that would strengthen a merged follow-up collection yet.',
'source' => 'heuristic-ai',
];
}
$theme = $context['primary_theme'] ?: $context['top_category'] ?: 'Extended Showcase';
return [
'idea' => [
'title' => sprintf('%s Extended', Str::title((string) $theme)),
'summary' => sprintf('A follow-up collection idea that combines the current theme with %d closely related artworks from the same gallery.', $artworks->count()),
'related_artwork_ids' => $artworks->pluck('id')->map(static fn ($id) => (int) $id)->all(),
],
'rationale' => 'Suggested as a merge or spin-out concept using the current theme and the strongest related artworks not already attached.',
'source' => 'heuristic-ai',
];
}
private function buildContext(Collection $collection, array $draft = []): array
{
$artworks = $this->candidateArtworks($collection, $draft, 36);
$themes = $this->topThemes($artworks);
$categories = $artworks
->map(fn (Artwork $artwork) => $artwork->categories->first()?->name)
->filter()
->countBy()
->sortDesc();
return [
'type' => $this->draftString($collection, $draft, 'type') ?: $collection->type,
'allow_submissions' => array_key_exists('allow_submissions', $draft)
? (bool) $draft['allow_submissions']
: (bool) $collection->allow_submissions,
'artworks_count' => max(1, $artworks->count()),
'creator_count' => max(1, $artworks->pluck('user_id')->filter()->unique()->count()),
'primary_theme' => $themes->keys()->first(),
'theme_sentence' => $themes->keys()->take(2)->implode(' and '),
'top_category' => $categories->keys()->first(),
'event_label' => $this->draftString($collection, $draft, 'event_label') ?: (string) ($collection->event_label ?? ''),
];
}
/**
* @return SupportCollection<int, Artwork>
*/
private function candidateArtworks(Collection $collection, array $draft = [], int $limit = 24): SupportCollection
{
$mode = $this->draftString($collection, $draft, 'mode') ?: $collection->mode;
$smartRules = is_array($draft['smart_rules_json'] ?? null)
? $draft['smart_rules_json']
: $collection->smart_rules_json;
if ($mode === Collection::MODE_SMART && is_array($smartRules)) {
return $this->smartCollections
->preview($collection->user, $smartRules, true, max(6, $limit))
->getCollection()
->loadMissing(['tags', 'categories.contentType', 'stats']);
}
return $collection->artworks()
->with(['tags', 'categories.contentType', 'stats'])
->whereNull('artworks.deleted_at')
->select('artworks.*')
->limit(max(6, $limit))
->get();
}
/**
* @return SupportCollection<string, float>
*/
private function topThemes(SupportCollection $artworks): SupportCollection
{
return $artworks
->flatMap(function (Artwork $artwork): array {
return $artwork->tags->map(function ($tag): array {
return [
'label' => (string) $tag->name,
'weight' => $tag->pivot?->source === 'ai' ? 1.25 : 1.0,
];
})->all();
})
->groupBy('label')
->map(fn (SupportCollection $items) => (float) $items->sum('weight'))
->sortDesc()
->take(6);
}
private function coverScore(Artwork $artwork): float
{
$stats = $artwork->stats;
$views = (int) ($stats?->views ?? $artwork->view_count ?? 0);
$likes = (int) ($stats?->favorites ?? $artwork->favourite_count ?? 0);
$downloads = (int) ($stats?->downloads ?? 0);
$width = max(1, (int) ($artwork->width ?? 1));
$height = max(1, (int) ($artwork->height ?? 1));
$ratio = $width / $height;
$ratioBonus = $ratio >= 1.1 && $ratio <= 1.8 ? 40 : 0;
$freshness = $artwork->published_at ? max(0, 30 - min(30, $artwork->published_at->diffInDays(now()))) : 0;
return ($likes * 8) + ($downloads * 5) + ($views * 0.05) + $ratioBonus + $freshness;
}
private function draftString(Collection $collection, array $draft, string $key): ?string
{
if (! array_key_exists($key, $draft)) {
return $collection->{$key} !== null ? (string) $collection->{$key} : null;
}
$value = $draft[$key];
if ($value === null) {
return null;
}
return trim((string) $value);
}
/**
* @return array{key:string,severity:string,label:string,detail:string}
*/
private function metadataIssue(string $key, string $severity, string $label, string $detail): array
{
return [
'key' => $key,
'severity' => $severity,
'label' => $label,
'detail' => $detail,
];
}
private function staleReferenceAt(Collection $collection): ?CarbonInterface
{
return $collection->last_activity_at
?? $collection->updated_at
?? $collection->published_at;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
class CollectionAiOperationsService
{
public function __construct(
private readonly EditorialAutomationService $editorialAutomation,
) {
}
public function qualityReview(Collection $collection): array
{
return $this->editorialAutomation->qualityReview($collection);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionDailyStat;
use App\Services\ThumbnailPresenter;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class CollectionAnalyticsService
{
public function snapshot(Collection $collection, ?Carbon $date = null): void
{
$bucket = ($date ?? now())->toDateString();
DB::table('collection_daily_stats')->updateOrInsert(
[
'collection_id' => $collection->id,
'stat_date' => $bucket,
],
[
'views_count' => (int) $collection->views_count,
'likes_count' => (int) $collection->likes_count,
'follows_count' => (int) $collection->followers_count,
'saves_count' => (int) $collection->saves_count,
'comments_count' => (int) $collection->comments_count,
'shares_count' => (int) $collection->shares_count,
'submissions_count' => (int) $collection->submissions()->count(),
'created_at' => now(),
'updated_at' => now(),
]
);
}
public function overview(Collection $collection, int $days = 30): array
{
$rows = CollectionDailyStat::query()
->where('collection_id', $collection->id)
->where('stat_date', '>=', now()->subDays(max(7, $days - 1))->toDateString())
->orderBy('stat_date')
->get();
$first = $rows->first();
$last = $rows->last();
$delta = static fn (string $column): int => max(0, (int) ($last?->{$column} ?? 0) - (int) ($first?->{$column} ?? 0));
return [
'totals' => [
'views' => (int) $collection->views_count,
'likes' => (int) $collection->likes_count,
'follows' => (int) $collection->followers_count,
'saves' => (int) $collection->saves_count,
'comments' => (int) $collection->comments_count,
'shares' => (int) $collection->shares_count,
'submissions' => (int) $collection->submissions()->count(),
],
'range' => [
'days' => $days,
'views_delta' => $delta('views_count'),
'likes_delta' => $delta('likes_count'),
'follows_delta' => $delta('follows_count'),
'saves_delta' => $delta('saves_count'),
'comments_delta' => $delta('comments_count'),
],
'timeline' => $rows->map(fn (CollectionDailyStat $row) => [
'date' => $row->stat_date?->toDateString(),
'views' => (int) $row->views_count,
'likes' => (int) $row->likes_count,
'follows' => (int) $row->follows_count,
'saves' => (int) $row->saves_count,
'comments' => (int) $row->comments_count,
'shares' => (int) $row->shares_count,
'submissions' => (int) $row->submissions_count,
])->values()->all(),
'top_artworks' => $this->topArtworks($collection),
];
}
public function topArtworks(Collection $collection, int $limit = 8): array
{
return DB::table('collection_artwork as ca')
->join('artworks as a', 'a.id', '=', 'ca.artwork_id')
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'a.id')
->where('ca.collection_id', $collection->id)
->whereNull('a.deleted_at')
->orderByDesc(DB::raw('COALESCE(s.ranking_score, 0)'))
->orderByDesc(DB::raw('COALESCE(s.views, 0)'))
->limit(max(1, min($limit, 12)))
->get([
'a.id',
'a.title',
'a.slug',
'a.hash',
'a.thumb_ext',
DB::raw('COALESCE(s.views, 0) as views'),
DB::raw('COALESCE(s.favorites, 0) as favourites'),
DB::raw('COALESCE(s.shares_count, 0) as shares'),
DB::raw('COALESCE(s.ranking_score, 0) as ranking_score'),
])
->map(fn ($row) => [
'id' => (int) $row->id,
'title' => (string) $row->title,
'slug' => (string) $row->slug,
'thumb' => $row->hash && $row->thumb_ext ? ThumbnailPresenter::forHash((string) $row->hash, (string) $row->thumb_ext, 'sq') : null,
'views' => (int) $row->views,
'favourites' => (int) $row->favourites,
'shares' => (int) $row->shares,
'ranking_score' => round((float) $row->ranking_score, 2),
])
->values()
->all();
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\RefreshCollectionHealthJob;
use App\Jobs\RefreshCollectionQualityJob;
use App\Jobs\RefreshCollectionRecommendationJob;
use App\Jobs\ScanCollectionDuplicateCandidatesJob;
use App\Models\Collection;
use App\Models\User;
use Illuminate\Support\Collection as SupportCollection;
class CollectionBackgroundJobService
{
public function dispatchQualityRefresh(Collection $collection, ?User $actor = null): array
{
RefreshCollectionQualityJob::dispatch((int) $collection->id, $actor?->id)->afterCommit();
return [
'status' => 'queued',
'job' => 'quality_refresh',
'scope' => 'single',
'count' => 1,
'collection_ids' => [(int) $collection->id],
'message' => 'Quality refresh queued.',
];
}
public function dispatchHealthRefresh(?Collection $collection = null, ?User $actor = null): array
{
$targets = $collection ? collect([$collection]) : $this->healthTargets();
$targets->each(fn (Collection $item) => RefreshCollectionHealthJob::dispatch((int) $item->id, $actor?->id, 'programming-eligibility')->afterCommit());
return $this->queuedPayload('health_refresh', $targets, 'Health and eligibility refresh queued.');
}
public function dispatchRecommendationRefresh(?Collection $collection = null, ?User $actor = null, string $context = 'default'): array
{
$targets = $collection ? collect([$collection]) : $this->recommendationTargets();
$targets->each(fn (Collection $item) => RefreshCollectionRecommendationJob::dispatch((int) $item->id, $actor?->id, $context)->afterCommit());
return $this->queuedPayload('recommendation_refresh', $targets, 'Recommendation refresh queued.');
}
public function dispatchDuplicateScan(?Collection $collection = null, ?User $actor = null): array
{
$targets = $collection ? collect([$collection]) : $this->duplicateTargets();
$targets->each(fn (Collection $item) => ScanCollectionDuplicateCandidatesJob::dispatch((int) $item->id, $actor?->id)->afterCommit());
return $this->queuedPayload('duplicate_scan', $targets, 'Duplicate scan queued.');
}
public function dispatchScheduledMaintenance(bool $health = true, bool $recommendations = true, bool $duplicates = true): array
{
$summary = [];
if ($health) {
$summary['health'] = $this->dispatchHealthRefresh();
}
if ($recommendations) {
$summary['recommendations'] = $this->dispatchRecommendationRefresh();
}
if ($duplicates) {
$summary['duplicates'] = $this->dispatchDuplicateScan();
}
return $summary;
}
/** @return SupportCollection<int, Collection> */
private function healthTargets(): SupportCollection
{
$cutoff = now()->subHours(max(1, (int) config('collections.v5.queue.health_stale_after_hours', 24)));
return Collection::query()
->where(function ($query) use ($cutoff): void {
$query->whereNull('last_health_check_at')
->orWhere('last_health_check_at', '<=', $cutoff)
->orWhereColumn('updated_at', '>', 'last_health_check_at');
})
->orderBy('last_health_check_at')
->orderByDesc('updated_at')
->limit(max(1, (int) config('collections.v5.queue.health_batch_size', 40)))
->get(['id']);
}
/** @return SupportCollection<int, Collection> */
private function recommendationTargets(): SupportCollection
{
$cutoff = now()->subHours(max(1, (int) config('collections.v5.queue.recommendation_stale_after_hours', 12)));
return Collection::query()
->where('placement_eligibility', true)
->where(function ($query) use ($cutoff): void {
$query->whereNull('last_recommendation_refresh_at')
->orWhere('last_recommendation_refresh_at', '<=', $cutoff)
->orWhereColumn('updated_at', '>', 'last_recommendation_refresh_at');
})
->orderBy('last_recommendation_refresh_at')
->orderByDesc('ranking_score')
->limit(max(1, (int) config('collections.v5.queue.recommendation_batch_size', 40)))
->get(['id']);
}
/** @return SupportCollection<int, Collection> */
private function duplicateTargets(): SupportCollection
{
$cutoff = now()->subHours(max(1, (int) config('collections.v5.queue.duplicate_stale_after_hours', 24)));
return Collection::query()
->whereNull('canonical_collection_id')
->where(function ($query) use ($cutoff): void {
$query->where('updated_at', '>=', $cutoff)
->orWhereDoesntHave('mergeActionsAsSource', function ($mergeQuery) use ($cutoff): void {
$mergeQuery->where('action_type', 'suggested')
->where('updated_at', '>=', $cutoff);
});
})
->orderByDesc('updated_at')
->limit(max(1, (int) config('collections.v5.queue.duplicate_batch_size', 30)))
->get(['id']);
}
/**
* @param SupportCollection<int, Collection> $targets
*/
private function queuedPayload(string $job, SupportCollection $targets, string $message): array
{
$ids = $targets->pluck('id')->map(static fn ($id): int => (int) $id)->values()->all();
return [
'status' => 'queued',
'job' => $job,
'scope' => count($ids) === 1 ? 'single' : 'batch',
'count' => count($ids),
'collection_ids' => $ids,
'items' => [],
'message' => $ids === [] ? 'No collections needed this refresh.' : $message,
];
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\User;
use App\Services\CollectionBackgroundJobService;
use App\Services\CollectionCampaignService;
use App\Services\CollectionService;
use App\Services\CollectionWorkflowService;
use Illuminate\Validation\ValidationException;
class CollectionBulkActionService
{
public function __construct(
private readonly CollectionService $collections,
private readonly CollectionCampaignService $campaigns,
private readonly CollectionWorkflowService $workflow,
private readonly CollectionBackgroundJobService $backgroundJobs,
) {
}
public function apply(User $user, array $payload): array
{
$action = (string) $payload['action'];
$collectionIds = collect($payload['collection_ids'] ?? [])
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->unique()
->values();
if ($collectionIds->isEmpty()) {
throw ValidationException::withMessages([
'collection_ids' => 'Select at least one collection.',
]);
}
$collections = Collection::query()
->ownedBy((int) $user->id)
->whereIn('id', $collectionIds->all())
->get()
->keyBy(fn (Collection $collection): int => (int) $collection->id);
if ($collections->count() !== $collectionIds->count()) {
throw ValidationException::withMessages([
'collection_ids' => 'One or more selected collections are unavailable.',
]);
}
$items = [];
$updatedCollections = $collectionIds->map(function (int $collectionId) use ($collections, $user, $payload, $action, &$items): Collection {
$collection = $collections->get($collectionId);
if (! $collection instanceof Collection) {
throw ValidationException::withMessages([
'collection_ids' => 'One or more selected collections are unavailable.',
]);
}
$updated = match ($action) {
'archive' => $this->archive($collection->loadMissing('user'), $user),
'assign_campaign' => $this->assignCampaign($collection->loadMissing('user'), $payload, $user),
'update_lifecycle' => $this->updateLifecycle($collection->loadMissing('user'), $payload, $user),
'request_ai_review' => $this->requestAiReview($collection->loadMissing('user'), $user, $items),
'mark_editorial_review' => $this->markEditorialReview($collection->loadMissing('user'), $user),
default => throw ValidationException::withMessages([
'action' => 'Unsupported bulk action.',
]),
};
if ($action !== 'request_ai_review') {
$items[] = [
'collection_id' => (int) $updated->id,
'action' => $action,
];
}
return $updated;
});
return [
'action' => $action,
'count' => $updatedCollections->count(),
'items' => $items,
'collections' => $updatedCollections,
'message' => $this->messageFor($action, $updatedCollections->count()),
];
}
private function archive(Collection $collection, User $user): Collection
{
return $this->collections->updateCollection($collection, [
'lifecycle_state' => Collection::LIFECYCLE_ARCHIVED,
'archived_at' => now(),
], $user);
}
private function assignCampaign(Collection $collection, array $payload, User $user): Collection
{
return $this->campaigns->updateCampaign($collection, [
'campaign_key' => (string) $payload['campaign_key'],
'campaign_label' => $payload['campaign_label'] ?? null,
], $user);
}
private function updateLifecycle(Collection $collection, array $payload, User $user): Collection
{
$lifecycleState = (string) $payload['lifecycle_state'];
$attributes = [
'lifecycle_state' => $lifecycleState,
];
if ($lifecycleState === Collection::LIFECYCLE_ARCHIVED) {
$attributes['archived_at'] = now();
} else {
$attributes['archived_at'] = null;
}
if ($lifecycleState === Collection::LIFECYCLE_PUBLISHED) {
$attributes['visibility'] = Collection::VISIBILITY_PUBLIC;
$attributes['published_at'] = $collection->published_at ?? now();
$attributes['expired_at'] = null;
}
if ($lifecycleState === Collection::LIFECYCLE_DRAFT) {
$attributes['visibility'] = Collection::VISIBILITY_PRIVATE;
$attributes['expired_at'] = null;
$attributes['unpublished_at'] = null;
}
return $this->collections->updateCollection($collection, $attributes, $user);
}
private function requestAiReview(Collection $collection, User $user, array &$items): Collection
{
$result = $this->backgroundJobs->dispatchQualityRefresh($collection, $user);
$items[] = [
'collection_id' => (int) $collection->id,
'job' => $result['job'] ?? 'quality_refresh',
'status' => $result['status'] ?? 'queued',
];
return $collection->fresh()->loadMissing('user');
}
private function markEditorialReview(Collection $collection, User $user): Collection
{
if ($collection->workflow_state === Collection::WORKFLOW_IN_REVIEW) {
return $collection->fresh()->loadMissing('user');
}
return $this->workflow->update($collection, [
'workflow_state' => Collection::WORKFLOW_IN_REVIEW,
], $user);
}
private function messageFor(string $action, int $count): string
{
return match ($action) {
'archive' => $count === 1 ? 'Collection archived.' : sprintf('%d collections archived.', $count),
'assign_campaign' => $count === 1 ? 'Campaign assigned.' : sprintf('Campaign assigned to %d collections.', $count),
'update_lifecycle' => $count === 1 ? 'Collection lifecycle updated.' : sprintf('Lifecycle updated for %d collections.', $count),
'request_ai_review' => $count === 1 ? 'AI review requested.' : sprintf('AI review requested for %d collections.', $count),
'mark_editorial_review' => $count === 1 ? 'Collection marked for editorial review.' : sprintf('%d collections marked for editorial review.', $count),
default => 'Bulk action completed.',
};
}
}

View File

@@ -0,0 +1,403 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionSurfacePlacement;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Str;
class CollectionCampaignService
{
public function __construct(
private readonly CollectionService $collections,
private readonly CollectionDiscoveryService $discovery,
private readonly CollectionSurfaceService $surfaces,
) {
}
public function updateCampaign(Collection $collection, array $attributes, ?User $actor = null): Collection
{
return $this->collections->updateCollection(
$collection->loadMissing('user'),
$this->normalizeAttributes($collection, $attributes),
$actor,
);
}
public function campaignSummary(Collection $collection): array
{
return [
'campaign_key' => $collection->campaign_key,
'campaign_label' => $collection->campaign_label,
'event_key' => $collection->event_key,
'event_label' => $collection->event_label,
'season_key' => $collection->season_key,
'spotlight_style' => $collection->spotlight_style,
'schedule' => [
'published_at' => $collection->published_at?->toIso8601String(),
'unpublished_at' => $collection->unpublished_at?->toIso8601String(),
'expired_at' => $collection->expired_at?->toIso8601String(),
'is_scheduled' => $collection->published_at?->isFuture() ?? false,
'is_expiring_soon' => $collection->unpublished_at?->between(now(), now()->addDays(14)) ?? false,
],
'eligibility' => $this->eligibility($collection),
'surface_assignments' => $this->surfaceAssignments($collection),
'recommended_surfaces' => $this->suggestedSurfaceAssignments($collection),
'editorial_notes' => $collection->editorial_notes,
'staff_commercial_notes' => $collection->staff_commercial_notes,
];
}
public function eligibility(Collection $collection): array
{
$reasons = [];
if ($collection->visibility !== Collection::VISIBILITY_PUBLIC) {
$reasons[] = 'Collection must be public before it can drive public campaign surfaces.';
}
if ($collection->moderation_status !== Collection::MODERATION_ACTIVE) {
$reasons[] = 'Only moderation-approved collections are eligible for campaign promotion.';
}
if (in_array($collection->lifecycle_state, [
Collection::LIFECYCLE_DRAFT,
Collection::LIFECYCLE_SCHEDULED,
Collection::LIFECYCLE_EXPIRED,
Collection::LIFECYCLE_HIDDEN,
Collection::LIFECYCLE_RESTRICTED,
Collection::LIFECYCLE_UNDER_REVIEW,
], true)) {
$reasons[] = 'Collection lifecycle must be published, featured, or archived before campaign placement.';
}
if ($collection->published_at?->isFuture()) {
$reasons[] = 'Collection publish window has not opened yet.';
}
if ($collection->unpublished_at?->lte(now())) {
$reasons[] = 'Collection campaign window has already ended.';
}
return [
'is_campaign_ready' => count($reasons) === 0,
'is_publicly_featureable' => $collection->isFeatureablePublicly(),
'has_campaign_context' => filled($collection->campaign_key) || filled($collection->event_key) || filled($collection->season_key),
'reasons' => $reasons,
];
}
public function surfaceAssignments(Collection $collection): array
{
return $collection->placements()
->orderBy('surface_key')
->orderByDesc('priority')
->orderBy('starts_at')
->get()
->map(function ($placement): array {
return [
'id' => (int) $placement->id,
'surface_key' => (string) $placement->surface_key,
'placement_type' => (string) $placement->placement_type,
'priority' => (int) $placement->priority,
'campaign_key' => $placement->campaign_key,
'starts_at' => $placement->starts_at?->toIso8601String(),
'ends_at' => $placement->ends_at?->toIso8601String(),
'is_active' => (bool) $placement->is_active,
'notes' => $placement->notes,
];
})
->values()
->all();
}
public function suggestedSurfaceAssignments(Collection $collection): array
{
$suggestions = [];
if (filled($collection->campaign_key) || filled($collection->event_key) || filled($collection->season_key)) {
$suggestions[] = $this->surfaceSuggestion('homepage.featured_collections', 'campaign', 'Campaign-aware collection suitable for the homepage featured rail.', 90);
$suggestions[] = $this->surfaceSuggestion('discover.featured_collections', 'campaign', 'Campaign metadata makes this collection a strong discover spotlight candidate.', 85);
}
if ($collection->type === Collection::TYPE_EDITORIAL) {
$suggestions[] = $this->surfaceSuggestion('homepage.editorial_collections', 'editorial', 'Editorial ownership makes this collection a homepage editorial fit.', 88);
}
if ($collection->type === Collection::TYPE_COMMUNITY) {
$suggestions[] = $this->surfaceSuggestion('homepage.community_collections', 'community', 'Community curation makes this collection suitable for the community row.', 82);
}
if ((float) ($collection->ranking_score ?? 0) >= 60 || (bool) $collection->is_featured) {
$suggestions[] = $this->surfaceSuggestion('homepage.trending_collections', 'algorithmic', 'Strong ranking signals make this collection a trending candidate.', 76);
}
return collect($suggestions)
->unique('surface_key')
->values()
->all();
}
public function expiringCampaignsForOwner(User $user, int $days = 14, int $limit = 6): EloquentCollection
{
return Collection::query()
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->ownedBy((int) $user->id)
->whereNotNull('unpublished_at')
->whereBetween('unpublished_at', [now(), now()->addDays(max(1, $days))])
->orderBy('unpublished_at')
->limit(max(1, $limit))
->get();
}
public function publicLanding(string $campaignKey, int $limit = 18): array
{
$normalizedKey = trim($campaignKey);
$surfaceItems = $this->surfaces->resolveSurfaceItems(sprintf('campaign.%s.featured_collections', $normalizedKey), $limit);
$collections = $surfaceItems->isNotEmpty()
? $surfaceItems
: $this->discovery->publicCampaignCollections($normalizedKey, $limit);
$editorialCollections = $this->discovery->publicCampaignCollectionsByType($normalizedKey, Collection::TYPE_EDITORIAL, 6);
$communityCollections = $this->discovery->publicCampaignCollectionsByType($normalizedKey, Collection::TYPE_COMMUNITY, 6);
$trendingCollections = $this->discovery->publicTrendingCampaignCollections($normalizedKey, 6);
$recentCollections = $this->discovery->publicRecentCampaignCollections($normalizedKey, 6);
$leadCollection = $collections->first();
$placementSurfaces = CollectionSurfacePlacement::query()
->where('campaign_key', $normalizedKey)
->where('is_active', true)
->where(function ($query): void {
$query->whereNull('starts_at')->orWhere('starts_at', '<=', now());
})
->where(function ($query): void {
$query->whereNull('ends_at')->orWhere('ends_at', '>', now());
})
->orderBy('surface_key')
->pluck('surface_key')
->unique()
->values()
->all();
return [
'campaign' => [
'key' => $normalizedKey,
'label' => $leadCollection?->campaign_label ?: Str::headline(str_replace(['_', '-'], ' ', $normalizedKey)),
'description' => $leadCollection?->banner_text
?: sprintf('Public collections grouped under the %s campaign, including editorial, community, and discovery-ready showcases.', Str::headline(str_replace(['_', '-'], ' ', $normalizedKey))),
'badge_label' => $leadCollection?->badge_label,
'event_key' => $leadCollection?->event_key,
'event_label' => $leadCollection?->event_label,
'season_key' => $leadCollection?->season_key,
'active_surface_keys' => $placementSurfaces,
'collections_count' => $collections->count(),
],
'collections' => $collections,
'editorial_collections' => $editorialCollections,
'community_collections' => $communityCollections,
'trending_collections' => $trendingCollections,
'recent_collections' => $recentCollections,
];
}
public function batchEditorialPlan(array $collectionIds, array $attributes): array
{
$collections = Collection::query()
->with(['user:id,username,name'])
->whereIn('id', collect($collectionIds)->map(fn ($id) => (int) $id)->filter()->values()->all())
->orderBy('title')
->get();
$campaignAttributes = $this->batchCampaignAttributes($attributes);
$placementAttributes = $this->batchPlacementAttributes($attributes);
$surfaceKey = $placementAttributes['surface_key'] ?? null;
$items = $collections->map(function (Collection $collection) use ($campaignAttributes, $placementAttributes, $surfaceKey): array {
$campaignPreview = $this->normalizeAttributes($collection, $campaignAttributes);
$placementEligible = $surfaceKey ? $collection->isFeatureablePublicly() : null;
$placementReasons = [];
if ($surfaceKey && ! $collection->isFeatureablePublicly()) {
$placementReasons[] = 'Collection is not publicly featureable for staff surface placement.';
}
return [
'collection' => [
'id' => (int) $collection->id,
'title' => (string) $collection->title,
'slug' => (string) $collection->slug,
'visibility' => (string) $collection->visibility,
'lifecycle_state' => (string) $collection->lifecycle_state,
'moderation_status' => (string) $collection->moderation_status,
'owner' => $collection->user ? [
'id' => (int) $collection->user->id,
'username' => $collection->user->username,
'name' => $collection->user->name,
] : null,
],
'campaign_updates' => $campaignPreview,
'placement' => $surfaceKey ? [
'surface_key' => $surfaceKey,
'placement_type' => $placementAttributes['placement_type'] ?? 'campaign',
'priority' => (int) ($placementAttributes['priority'] ?? 0),
'starts_at' => $placementAttributes['starts_at'] ?? null,
'ends_at' => $placementAttributes['ends_at'] ?? null,
'is_active' => array_key_exists('is_active', $placementAttributes) ? (bool) $placementAttributes['is_active'] : true,
'campaign_key' => $placementAttributes['campaign_key'] ?? ($campaignPreview['campaign_key'] ?? $collection->campaign_key),
'notes' => $placementAttributes['notes'] ?? null,
'eligible' => $placementEligible,
'reasons' => $placementReasons,
] : null,
'existing_assignments' => $this->surfaceAssignments($collection),
'eligibility' => $this->eligibility($collection),
];
})->values();
return [
'collections_count' => $collections->count(),
'campaign_updates_count' => $items->filter(fn (array $item): bool => count($item['campaign_updates']) > 0)->count(),
'placement_candidates_count' => $items->filter(fn (array $item): bool => is_array($item['placement']))->count(),
'placement_eligible_count' => $items->filter(fn (array $item): bool => ($item['placement']['eligible'] ?? false) === true)->count(),
'items' => $items->all(),
];
}
public function applyBatchEditorialPlan(array $collectionIds, array $attributes, ?User $actor = null): array
{
$plan = $this->batchEditorialPlan($collectionIds, $attributes);
$campaignAttributes = $this->batchCampaignAttributes($attributes);
$placementAttributes = $this->batchPlacementAttributes($attributes);
$results = [];
foreach ($plan['items'] as $item) {
$collection = Collection::query()->find((int) Arr::get($item, 'collection.id'));
if (! $collection) {
continue;
}
$updatedCollection = count($campaignAttributes) > 0
? $this->updateCampaign($collection, $campaignAttributes, $actor)
: $collection->fresh();
$placementResult = null;
if (is_array($item['placement'])) {
if (($item['placement']['eligible'] ?? false) === true) {
$existingPlacement = CollectionSurfacePlacement::query()
->where('surface_key', $item['placement']['surface_key'])
->where('collection_id', $updatedCollection->id)
->first();
$placementPayload = array_merge($placementAttributes, [
'id' => $existingPlacement?->id,
'surface_key' => $item['placement']['surface_key'],
'collection_id' => $updatedCollection->id,
'campaign_key' => $item['placement']['campaign_key'],
'created_by_user_id' => $existingPlacement?->created_by_user_id ?: $actor?->id,
]);
$placement = $this->surfaces->upsertPlacement($placementPayload);
$placementResult = [
'status' => $existingPlacement ? 'updated' : 'created',
'placement_id' => (int) $placement->id,
'surface_key' => (string) $placement->surface_key,
];
} else {
$placementResult = [
'status' => 'skipped',
'reasons' => $item['placement']['reasons'] ?? [],
];
}
}
$results[] = [
'collection_id' => (int) $updatedCollection->id,
'campaign_updated' => count($campaignAttributes) > 0,
'placement' => $placementResult,
];
}
return [
'plan' => $plan,
'results' => $results,
];
}
private function normalizeAttributes(Collection $collection, array $attributes): array
{
if (array_key_exists('campaign_key', $attributes) && blank($attributes['campaign_key']) && ! array_key_exists('campaign_label', $attributes)) {
$attributes['campaign_label'] = null;
}
if (array_key_exists('event_key', $attributes) && blank($attributes['event_key']) && ! array_key_exists('event_label', $attributes)) {
$attributes['event_label'] = null;
}
if (
filled($attributes['campaign_key'] ?? $collection->campaign_key)
&& blank($attributes['campaign_label'] ?? $collection->campaign_label)
&& filled($attributes['event_label'] ?? $collection->event_label)
) {
$attributes['campaign_label'] = $attributes['event_label'] ?? $collection->event_label;
}
return $attributes;
}
private function batchCampaignAttributes(array $attributes): array
{
return collect([
'campaign_key',
'campaign_label',
'event_key',
'event_label',
'season_key',
'banner_text',
'badge_label',
'spotlight_style',
'editorial_notes',
])->reduce(function (array $carry, string $key) use ($attributes): array {
if (array_key_exists($key, $attributes)) {
$carry[$key] = $attributes[$key];
}
return $carry;
}, []);
}
private function batchPlacementAttributes(array $attributes): array
{
return collect([
'surface_key',
'placement_type',
'priority',
'starts_at',
'ends_at',
'is_active',
'campaign_key',
'notes',
])->reduce(function (array $carry, string $key) use ($attributes): array {
if (array_key_exists($key, $attributes)) {
$carry[$key] = $attributes[$key];
}
return $carry;
}, []);
}
private function surfaceSuggestion(string $surfaceKey, string $placementType, string $reason, int $priority): array
{
return [
'surface_key' => $surfaceKey,
'placement_type' => $placementType,
'reason' => $reason,
'priority' => $priority,
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionMergeAction;
use App\Models\User;
use Illuminate\Validation\ValidationException;
class CollectionCanonicalService
{
public function designate(Collection $source, Collection $target, ?User $actor = null): Collection
{
if ((int) $source->id === (int) $target->id) {
throw ValidationException::withMessages([
'target_collection_id' => 'A collection cannot canonicalize to itself.',
]);
}
$source->forceFill([
'canonical_collection_id' => $target->id,
'health_state' => Collection::HEALTH_MERGE_CANDIDATE,
'placement_eligibility' => false,
])->save();
CollectionMergeAction::query()->create([
'source_collection_id' => $source->id,
'target_collection_id' => $target->id,
'action_type' => 'approved',
'actor_user_id' => $actor?->id,
'summary' => 'Canonical target designated.',
]);
app(CollectionHistoryService::class)->record(
$source->fresh(),
$actor,
'canonicalized',
'Collection canonical target designated.',
null,
['canonical_collection_id' => (int) $target->id]
);
return $source->fresh();
}
}

View File

@@ -0,0 +1,383 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionMember;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CollectionCollaborationService
{
public function __construct(
private readonly NotificationService $notifications,
) {
}
public function ensureOwnerMembership(Collection $collection): void
{
CollectionMember::query()
->where('collection_id', $collection->id)
->where('role', Collection::MEMBER_ROLE_OWNER)
->where('user_id', '!=', $collection->user_id)
->update([
'role' => Collection::MEMBER_ROLE_EDITOR,
'updated_at' => now(),
]);
CollectionMember::query()->updateOrCreate(
[
'collection_id' => $collection->id,
'user_id' => $collection->user_id,
],
[
'invited_by_user_id' => $collection->user_id,
'role' => Collection::MEMBER_ROLE_OWNER,
'status' => Collection::MEMBER_STATUS_ACTIVE,
'invited_at' => now(),
'expires_at' => null,
'accepted_at' => now(),
'revoked_at' => null,
]
);
$this->syncCollaboratorsCount($collection);
}
public function ensureManagerMembership(Collection $collection, User $manager): void
{
if ((int) $collection->user_id === (int) $manager->id) {
return;
}
CollectionMember::query()->updateOrCreate(
[
'collection_id' => $collection->id,
'user_id' => $manager->id,
],
[
'invited_by_user_id' => $collection->user_id,
'role' => Collection::MEMBER_ROLE_EDITOR,
'status' => Collection::MEMBER_STATUS_ACTIVE,
'invited_at' => now(),
'expires_at' => null,
'accepted_at' => now(),
'revoked_at' => null,
]
);
$this->syncCollaboratorsCount($collection);
}
public function inviteMember(Collection $collection, User $actor, User $invitee, string $role, ?string $note = null, ?int $expiresInDays = null, ?string $expiresAt = null): CollectionMember
{
$this->guardManageMembers($collection, $actor);
$this->expirePendingInvites();
if (! in_array($role, [Collection::MEMBER_ROLE_EDITOR, Collection::MEMBER_ROLE_CONTRIBUTOR, Collection::MEMBER_ROLE_VIEWER], true)) {
throw ValidationException::withMessages([
'role' => 'Choose a valid collaborator role.',
]);
}
if ($collection->isOwnedBy($invitee)) {
throw ValidationException::withMessages([
'username' => 'The collection owner is already a collaborator.',
]);
}
$member = DB::transaction(function () use ($collection, $actor, $invitee, $role, $note, $expiresInDays, $expiresAt): CollectionMember {
$inviteExpiresAt = $this->resolveInviteExpiry($expiresInDays, $expiresAt);
$member = CollectionMember::query()->updateOrCreate(
[
'collection_id' => $collection->id,
'user_id' => $invitee->id,
],
[
'invited_by_user_id' => $actor->id,
'role' => $role,
'status' => Collection::MEMBER_STATUS_PENDING,
'note' => $note,
'invited_at' => now(),
'expires_at' => $inviteExpiresAt,
'accepted_at' => null,
'revoked_at' => null,
]
);
$this->syncCollaboratorsCount($collection);
return $member->fresh(['user.profile', 'invitedBy.profile']);
});
$this->notifications->notifyCollectionInvite($invitee, $actor, $collection, $role);
return $member;
}
public function acceptInvite(CollectionMember $member, User $user): CollectionMember
{
$this->expireMemberIfNeeded($member);
if ((int) $member->user_id !== (int) $user->id || $member->status !== Collection::MEMBER_STATUS_PENDING) {
throw ValidationException::withMessages([
'member' => 'This invitation cannot be accepted.',
]);
}
$member->forceFill([
'status' => Collection::MEMBER_STATUS_ACTIVE,
'expires_at' => null,
'accepted_at' => now(),
'revoked_at' => null,
])->save();
$this->syncCollaboratorsCount($member->collection);
return $member->fresh(['user.profile', 'invitedBy.profile']);
}
public function declineInvite(CollectionMember $member, User $user): CollectionMember
{
$this->expireMemberIfNeeded($member);
if ((int) $member->user_id !== (int) $user->id || $member->status !== Collection::MEMBER_STATUS_PENDING) {
throw ValidationException::withMessages([
'member' => 'This invitation cannot be declined.',
]);
}
$member->forceFill([
'status' => Collection::MEMBER_STATUS_REVOKED,
'accepted_at' => null,
'revoked_at' => now(),
])->save();
$this->syncCollaboratorsCount($member->collection);
return $member->fresh(['user.profile', 'invitedBy.profile']);
}
public function updateMemberRole(CollectionMember $member, User $actor, string $role): CollectionMember
{
$this->guardManageMembers($member->collection, $actor);
if ($member->role === Collection::MEMBER_ROLE_OWNER) {
throw ValidationException::withMessages([
'member' => 'The collection owner role cannot be changed.',
]);
}
$member->forceFill(['role' => $role])->save();
return $member->fresh(['user.profile', 'invitedBy.profile']);
}
public function revokeMember(CollectionMember $member, User $actor): void
{
$this->guardManageMembers($member->collection, $actor);
if ($member->role === Collection::MEMBER_ROLE_OWNER) {
throw ValidationException::withMessages([
'member' => 'The collection owner cannot be removed.',
]);
}
$member->forceFill([
'status' => Collection::MEMBER_STATUS_REVOKED,
'expires_at' => null,
'revoked_at' => now(),
])->save();
$this->syncCollaboratorsCount($member->collection);
}
public function transferOwnership(Collection $collection, CollectionMember $member, User $actor): Collection
{
if (! $collection->isOwnedBy($actor) && ! $actor->isAdmin()) {
throw ValidationException::withMessages([
'member' => 'Only the collection owner can transfer ownership.',
]);
}
if ((int) $member->collection_id !== (int) $collection->id) {
throw ValidationException::withMessages([
'member' => 'This collaborator does not belong to the selected collection.',
]);
}
if ($member->status !== Collection::MEMBER_STATUS_ACTIVE) {
throw ValidationException::withMessages([
'member' => 'Only active collaborators can become the new owner.',
]);
}
if ($collection->type === Collection::TYPE_EDITORIAL && $collection->editorial_owner_mode !== Collection::EDITORIAL_OWNER_CREATOR) {
throw ValidationException::withMessages([
'member' => 'System-owned and staff-account editorials cannot be transferred through collaborator controls.',
]);
}
return DB::transaction(function () use ($collection, $member, $actor): Collection {
$previousOwnerId = (int) $collection->user_id;
CollectionMember::query()
->where('collection_id', $collection->id)
->where('user_id', $previousOwnerId)
->update([
'role' => Collection::MEMBER_ROLE_EDITOR,
'updated_at' => now(),
]);
$member->forceFill([
'role' => Collection::MEMBER_ROLE_OWNER,
'status' => Collection::MEMBER_STATUS_ACTIVE,
'expires_at' => null,
'accepted_at' => $member->accepted_at ?? now(),
])->save();
$collection->forceFill([
'user_id' => $member->user_id,
'managed_by_user_id' => (int) $actor->id === (int) $member->user_id ? null : $actor->id,
'editorial_owner_mode' => $collection->type === Collection::TYPE_EDITORIAL ? Collection::EDITORIAL_OWNER_CREATOR : $collection->editorial_owner_mode,
'editorial_owner_user_id' => $collection->type === Collection::TYPE_EDITORIAL ? null : $collection->editorial_owner_user_id,
'editorial_owner_label' => $collection->type === Collection::TYPE_EDITORIAL ? null : $collection->editorial_owner_label,
])->save();
$this->ensureOwnerMembership($collection->fresh());
$this->syncCollaboratorsCount($collection->fresh());
return $collection->fresh(['user.profile']);
});
}
public function expirePendingInvites(): int
{
return CollectionMember::query()
->where('status', Collection::MEMBER_STATUS_PENDING)
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->update([
'status' => Collection::MEMBER_STATUS_REVOKED,
'revoked_at' => now(),
'updated_at' => now(),
]);
}
public function activeContributorIds(Collection $collection): array
{
$activeIds = $collection->members()
->where('status', Collection::MEMBER_STATUS_ACTIVE)
->whereIn('role', [
Collection::MEMBER_ROLE_OWNER,
Collection::MEMBER_ROLE_EDITOR,
Collection::MEMBER_ROLE_CONTRIBUTOR,
])
->pluck('user_id')
->map(static fn ($id) => (int) $id)
->all();
if (! in_array((int) $collection->user_id, $activeIds, true)) {
$activeIds[] = (int) $collection->user_id;
}
return array_values(array_unique($activeIds));
}
public function mapMembers(Collection $collection, ?User $viewer = null): array
{
$this->expirePendingInvites();
$members = $collection->members()
->with(['user.profile', 'invitedBy.profile'])
->orderByRaw("CASE role WHEN 'owner' THEN 0 WHEN 'editor' THEN 1 WHEN 'contributor' THEN 2 ELSE 3 END")
->orderBy('created_at')
->get();
return $members->map(function (CollectionMember $member) use ($collection, $viewer): array {
$user = $member->user;
return [
'id' => (int) $member->id,
'user_id' => (int) $member->user_id,
'role' => (string) $member->role,
'status' => (string) $member->status,
'note' => $member->note,
'invited_at' => $member->invited_at?->toISOString(),
'expires_at' => $member->expires_at?->toISOString(),
'accepted_at' => $member->accepted_at?->toISOString(),
'is_expired' => $member->status === Collection::MEMBER_STATUS_REVOKED && $member->expires_at !== null && $member->expires_at->lte(now()) && $member->accepted_at === null,
'can_accept' => $viewer !== null && (int) $member->user_id === (int) $viewer->id && $member->status === Collection::MEMBER_STATUS_PENDING,
'can_decline' => $viewer !== null && (int) $member->user_id === (int) $viewer->id && $member->status === Collection::MEMBER_STATUS_PENDING,
'can_revoke' => $viewer !== null && $collection->canManageMembers($viewer) && $member->role !== Collection::MEMBER_ROLE_OWNER,
'can_transfer' => $viewer !== null
&& $collection->isOwnedBy($viewer)
&& $member->status === Collection::MEMBER_STATUS_ACTIVE
&& $member->role !== Collection::MEMBER_ROLE_OWNER,
'user' => [
'id' => (int) $user->id,
'name' => $user->name,
'username' => $user->username,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 72),
'profile_url' => route('profile.show', ['username' => strtolower((string) $user->username)]),
],
'invited_by' => $member->invitedBy ? [
'id' => (int) $member->invitedBy->id,
'username' => $member->invitedBy->username,
'name' => $member->invitedBy->name,
] : null,
];
})->all();
}
public function syncCollaboratorsCount(Collection $collection): void
{
$count = (int) $collection->members()
->where('status', Collection::MEMBER_STATUS_ACTIVE)
->count();
$collection->forceFill([
'collaborators_count' => $count,
])->save();
}
private function guardManageMembers(Collection $collection, User $actor): void
{
if (! $collection->canManageMembers($actor)) {
throw ValidationException::withMessages([
'collection' => 'You are not allowed to manage collaborators for this collection.',
]);
}
}
private function expireMemberIfNeeded(CollectionMember $member): void
{
if ($member->status !== Collection::MEMBER_STATUS_PENDING || ! $member->expires_at || $member->expires_at->isFuture()) {
return;
}
$member->forceFill([
'status' => Collection::MEMBER_STATUS_REVOKED,
'revoked_at' => now(),
])->save();
}
private function resolveInviteExpiry(?int $expiresInDays, ?string $expiresAt): Carbon
{
if ($expiresAt !== null && $expiresAt !== '') {
return Carbon::parse($expiresAt);
}
if ($expiresInDays !== null) {
return now()->addDays(max(1, $expiresInDays));
}
return now()->addDays(max(1, (int) config('collections.invites.expires_after_days', 7)));
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionComment;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class CollectionCommentService
{
public function __construct(
private readonly NotificationService $notifications,
) {
}
public function create(Collection $collection, User $actor, string $body, ?CollectionComment $parent = null): CollectionComment
{
if (! $collection->canReceiveCommentsFrom($actor)) {
throw ValidationException::withMessages([
'collection' => 'Comments are disabled for this collection.',
]);
}
$comment = CollectionComment::query()->create([
'collection_id' => $collection->id,
'user_id' => $actor->id,
'parent_id' => $parent?->id,
'body' => trim($body),
'rendered_body' => nl2br(e(trim($body))),
'status' => Collection::COMMENT_VISIBLE,
]);
$collection->increment('comments_count');
$collection->forceFill(['last_activity_at' => now()])->save();
if (! $collection->isOwnedBy($actor)) {
$this->notifications->notifyCollectionComment($collection->user, $actor, $collection, $comment);
}
return $comment->fresh(['user.profile', 'replies.user.profile']);
}
public function delete(CollectionComment $comment, User $actor): void
{
if ((int) $comment->user_id !== (int) $actor->id && ! $comment->collection->canBeManagedBy($actor)) {
throw ValidationException::withMessages([
'comment' => 'You are not allowed to remove this comment.',
]);
}
if ($comment->trashed()) {
return;
}
$comment->delete();
$comment->collection()->decrement('comments_count');
}
public function mapComments(Collection $collection, ?User $viewer = null): array
{
$comments = $collection->comments()
->whereNull('parent_id')
->where('status', Collection::COMMENT_VISIBLE)
->with(['user.profile', 'replies.user.profile'])
->latest()
->limit(30)
->get();
return $comments->map(fn (CollectionComment $comment) => $this->mapComment($comment, $viewer))->all();
}
private function mapComment(CollectionComment $comment, ?User $viewer = null): array
{
$user = $comment->user;
return [
'id' => (int) $comment->id,
'body' => (string) $comment->body,
'rendered_content' => (string) $comment->rendered_body,
'time_ago' => $comment->created_at?->diffForHumans(),
'created_at' => $comment->created_at?->toISOString(),
'can_delete' => $viewer !== null && ((int) $viewer->id === (int) $comment->user_id || $comment->collection->canBeManagedBy($viewer)),
'can_report' => $viewer !== null && (int) $viewer->id !== (int) $comment->user_id,
'user' => [
'id' => (int) $user->id,
'display' => (string) ($user->name ?: $user->username),
'username' => (string) $user->username,
'level' => (int) ($user->level ?? 0),
'rank' => (string) ($user->rank ?? ''),
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64),
'profile_url' => '/@' . Str::lower((string) $user->username),
],
'replies' => $comment->replies
->where('status', Collection::COMMENT_VISIBLE)
->map(fn (CollectionComment $reply) => $this->mapComment($reply, $viewer))
->values()
->all(),
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\User;
class CollectionDashboardService
{
public function __construct(
private readonly CollectionCampaignService $campaigns,
private readonly CollectionHealthService $health,
) {
}
public function build(User $user): array
{
$collections = Collection::query()
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->ownedBy((int) $user->id)
->orderByDesc('ranking_score')
->orderByDesc('updated_at')
->get();
$pendingSubmissions = $collections->sum(fn (Collection $collection) => $collection->submissions()->where('status', Collection::SUBMISSION_PENDING)->count());
return [
'summary' => [
'total' => $collections->count(),
'drafts' => $collections->where('lifecycle_state', Collection::LIFECYCLE_DRAFT)->count(),
'scheduled' => $collections->where('lifecycle_state', Collection::LIFECYCLE_SCHEDULED)->count(),
'published' => $collections->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED])->count(),
'archived' => $collections->where('lifecycle_state', Collection::LIFECYCLE_ARCHIVED)->count(),
'pending_submissions' => (int) $pendingSubmissions,
'needs_review' => $collections->where('health_state', Collection::HEALTH_NEEDS_REVIEW)->count(),
'duplicate_risk' => $collections->where('health_state', Collection::HEALTH_DUPLICATE_RISK)->count(),
'placement_blocked' => $collections->where('placement_eligibility', false)->count(),
],
'top_performing' => $collections->take(6),
'needs_attention' => $collections->filter(function (Collection $collection): bool {
return $collection->lifecycle_state === Collection::LIFECYCLE_DRAFT
|| (int) $collection->quality_score < 45
|| $collection->moderation_status !== Collection::MODERATION_ACTIVE
|| ($collection->health_state !== null && $collection->health_state !== Collection::HEALTH_HEALTHY)
|| ! $collection->isPlacementEligible();
})->take(6)->values(),
'expiring_campaigns' => $this->campaigns->expiringCampaignsForOwner($user),
'health_warnings' => $collections
->filter(fn (Collection $collection): bool => $collection->health_state !== null && $collection->health_state !== Collection::HEALTH_HEALTHY)
->take(8)
->map(fn (Collection $collection): array => [
'collection_id' => (int) $collection->id,
'title' => (string) $collection->title,
'health' => $this->health->summary($collection),
])
->values()
->all(),
];
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\Cache;
class CollectionDiscoveryService
{
private function publicBaseQuery()
{
return Collection::query()
->publicEligible()
->with([
'user:id,username,name',
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
]);
}
public function publicFeaturedCollections(int $limit = 18): EloquentCollection
{
$safeLimit = max(1, min($limit, 30));
$ttl = (int) config('collections.discovery.featured_cache_seconds', 120);
$cacheKey = sprintf('collections:featured:%d', $safeLimit);
return Cache::remember($cacheKey, $ttl, function () use ($safeLimit): EloquentCollection {
return $this->publicBaseQuery()
->featuredPublic()
->orderByDesc('featured_at')
->orderByDesc('likes_count')
->orderByDesc('followers_count')
->orderByDesc('updated_at')
->limit($safeLimit)
->get();
});
}
public function publicRecentCollections(int $limit = 8): EloquentCollection
{
return $this->publicBaseQuery()
->orderByDesc('published_at')
->orderByDesc('updated_at')
->limit(max(1, min($limit, 20)))
->get();
}
public function publicCollectionsByType(string $type, int $limit = 8): EloquentCollection
{
return $this->publicBaseQuery()
->where('type', $type)
->orderByDesc('is_featured')
->orderByDesc('followers_count')
->orderByDesc('saves_count')
->limit(max(1, min($limit, 20)))
->get();
}
public function publicTrendingCollections(int $limit = 12): EloquentCollection
{
return $this->publicBaseQuery()
->orderByRaw('(
(likes_count * 3)
+ (followers_count * 4)
+ (saves_count * 4)
+ (comments_count * 2)
+ (views_count * 0.05)
) desc')
->orderByDesc('last_activity_at')
->orderByDesc('published_at')
->limit(max(1, min($limit, 24)))
->get();
}
public function publicRecentlyActiveCollections(int $limit = 8): EloquentCollection
{
return $this->publicBaseQuery()
->orderByDesc('last_activity_at')
->orderByDesc('updated_at')
->limit(max(1, min($limit, 20)))
->get();
}
public function publicSeasonalCollections(int $limit = 12): EloquentCollection
{
return $this->publicBaseQuery()
->where(function ($query): void {
$query->whereNotNull('event_key')
->orWhereNotNull('season_key');
})
->orderByDesc('is_featured')
->orderByDesc('published_at')
->orderByDesc('updated_at')
->limit(max(1, min($limit, 24)))
->get();
}
public function publicCampaignCollections(string $campaignKey, int $limit = 18): EloquentCollection
{
$normalizedKey = trim($campaignKey);
return $this->publicBaseQuery()
->where('campaign_key', $normalizedKey)
->orderByDesc('is_featured')
->orderByDesc('ranking_score')
->orderByDesc('followers_count')
->orderByDesc('updated_at')
->limit(max(1, min($limit, 30)))
->get();
}
public function publicCampaignCollectionsByType(string $campaignKey, string $type, int $limit = 8): EloquentCollection
{
$normalizedKey = trim($campaignKey);
return $this->publicBaseQuery()
->where('campaign_key', $normalizedKey)
->where('type', $type)
->orderByDesc('is_featured')
->orderByDesc('ranking_score')
->orderByDesc('updated_at')
->limit(max(1, min($limit, 20)))
->get();
}
public function publicTrendingCampaignCollections(string $campaignKey, int $limit = 8): EloquentCollection
{
$normalizedKey = trim($campaignKey);
return $this->publicBaseQuery()
->where('campaign_key', $normalizedKey)
->orderByRaw('(
(likes_count * 3)
+ (followers_count * 4)
+ (saves_count * 4)
+ (comments_count * 2)
+ (views_count * 0.05)
) desc')
->orderByDesc('last_activity_at')
->orderByDesc('published_at')
->limit(max(1, min($limit, 20)))
->get();
}
public function publicRecentCampaignCollections(string $campaignKey, int $limit = 8): EloquentCollection
{
$normalizedKey = trim($campaignKey);
return $this->publicBaseQuery()
->where('campaign_key', $normalizedKey)
->orderByDesc('published_at')
->orderByDesc('updated_at')
->limit(max(1, min($limit, 20)))
->get();
}
public function relatedPublicCollections(Collection $collection, int $limit = 4): EloquentCollection
{
return $this->publicBaseQuery()
->where('id', '!=', $collection->id)
->where(function ($query) use ($collection): void {
$query->where('type', $collection->type)
->orWhere('user_id', $collection->user_id);
})
->orderByDesc('followers_count')
->orderByDesc('saves_count')
->orderByDesc('updated_at')
->limit(max(1, min($limit, 12)))
->get();
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\User;
class CollectionExperimentService
{
public function sync(Collection $collection, array $attributes, ?User $actor = null): Collection
{
$payload = [
'experiment_key' => array_key_exists('experiment_key', $attributes) ? ($attributes['experiment_key'] ?: null) : $collection->experiment_key,
'experiment_treatment' => array_key_exists('experiment_treatment', $attributes) ? ($attributes['experiment_treatment'] ?: null) : $collection->experiment_treatment,
'placement_variant' => array_key_exists('placement_variant', $attributes) ? ($attributes['placement_variant'] ?: null) : $collection->placement_variant,
'ranking_mode_variant' => array_key_exists('ranking_mode_variant', $attributes) ? ($attributes['ranking_mode_variant'] ?: null) : $collection->ranking_mode_variant,
'collection_pool_version' => array_key_exists('collection_pool_version', $attributes) ? ($attributes['collection_pool_version'] ?: null) : $collection->collection_pool_version,
'test_label' => array_key_exists('test_label', $attributes) ? ($attributes['test_label'] ?: null) : $collection->test_label,
];
$before = [
'experiment_key' => $collection->experiment_key,
'experiment_treatment' => $collection->experiment_treatment,
'placement_variant' => $collection->placement_variant,
'ranking_mode_variant' => $collection->ranking_mode_variant,
'collection_pool_version' => $collection->collection_pool_version,
'test_label' => $collection->test_label,
];
$collection->forceFill($payload)->save();
$fresh = $collection->fresh();
app(CollectionHistoryService::class)->record(
$fresh,
$actor,
'experiment_metadata_updated',
'Collection experiment metadata updated.',
$before,
$payload,
);
return $fresh;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Events\Collections\CollectionFollowed;
use App\Events\Collections\CollectionUnfollowed;
use App\Models\Collection;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CollectionFollowService
{
public function follow(User $actor, Collection $collection): bool
{
$this->guard($actor, $collection);
$inserted = false;
DB::transaction(function () use ($actor, $collection, &$inserted): void {
$rows = DB::table('collection_follows')->insertOrIgnore([
'collection_id' => $collection->id,
'user_id' => $actor->id,
'created_at' => now(),
]);
if ($rows === 0) {
return;
}
$inserted = true;
DB::table('collections')
->where('id', $collection->id)
->update([
'followers_count' => DB::raw('followers_count + 1'),
'last_activity_at' => now(),
'updated_at' => now(),
]);
});
if ($inserted) {
event(new CollectionFollowed($collection->fresh(), (int) $actor->id));
}
return $inserted;
}
public function unfollow(User $actor, Collection $collection): bool
{
$deleted = false;
DB::transaction(function () use ($actor, $collection, &$deleted): void {
$rows = DB::table('collection_follows')
->where('collection_id', $collection->id)
->where('user_id', $actor->id)
->delete();
if ($rows === 0) {
return;
}
$deleted = true;
DB::table('collections')
->where('id', $collection->id)
->where('followers_count', '>', 0)
->update([
'followers_count' => DB::raw('followers_count - 1'),
'updated_at' => now(),
]);
});
if ($deleted) {
event(new CollectionUnfollowed($collection->fresh(), (int) $actor->id));
}
return $deleted;
}
public function isFollowing(?User $viewer, Collection $collection): bool
{
if (! $viewer) {
return false;
}
return DB::table('collection_follows')
->where('collection_id', $collection->id)
->where('user_id', $viewer->id)
->exists();
}
private function guard(User $actor, Collection $collection): void
{
if (! $collection->isPubliclyEngageable()) {
throw ValidationException::withMessages([
'collection' => 'Only public collections can be followed.',
]);
}
if ($collection->isOwnedBy($actor)) {
throw ValidationException::withMessages([
'collection' => 'You cannot follow your own collection.',
]);
}
}
}

View File

@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\CollectionQualitySnapshot;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class CollectionHealthService
{
public function evaluate(Collection $collection): array
{
$metadataCompleteness = $this->metadataCompletenessScore($collection);
$freshness = $this->freshnessScore($collection);
$engagement = $this->engagementScore($collection);
$readiness = $this->editorialReadinessScore($collection, $metadataCompleteness, $freshness, $engagement);
$flags = $this->flags($collection, $metadataCompleteness, $freshness, $engagement, $readiness);
$healthState = $this->healthStateFromFlags($flags);
$healthScore = $this->healthScore($metadataCompleteness, $freshness, $engagement, $readiness, $flags);
$placementEligibility = $this->placementEligibility($collection, $healthState, $readiness);
return [
'metadata_completeness_score' => $metadataCompleteness,
'freshness_score' => $freshness,
'engagement_score' => $engagement,
'editorial_readiness_score' => $readiness,
'health_score' => $healthScore,
'health_state' => $healthState,
'health_flags_json' => $flags,
'readiness_state' => $this->readinessState($placementEligibility, $flags),
'placement_eligibility' => $placementEligibility,
'duplicate_cluster_key' => $this->duplicateClusterKey($collection),
'trust_tier' => $this->trustTier($collection, $healthScore),
'last_health_check_at' => now(),
];
}
public function refresh(Collection $collection, ?User $actor = null, string $reason = 'refresh'): Collection
{
$payload = $this->evaluate($collection->fresh());
$snapshotDate = now()->toDateString();
$collection->forceFill($payload)->save();
$snapshot = CollectionQualitySnapshot::query()
->where('collection_id', $collection->id)
->whereDate('snapshot_date', $snapshotDate)
->first();
if ($snapshot) {
$snapshot->forceFill([
'quality_score' => $collection->quality_score,
'health_score' => $payload['health_score'],
'metadata_completeness_score' => $payload['metadata_completeness_score'],
'freshness_score' => $payload['freshness_score'],
'engagement_score' => $payload['engagement_score'],
'readiness_score' => $payload['editorial_readiness_score'],
'flags_json' => $payload['health_flags_json'],
])->save();
} else {
CollectionQualitySnapshot::query()->create([
'collection_id' => $collection->id,
'snapshot_date' => $snapshotDate,
'quality_score' => $collection->quality_score,
'health_score' => $payload['health_score'],
'metadata_completeness_score' => $payload['metadata_completeness_score'],
'freshness_score' => $payload['freshness_score'],
'engagement_score' => $payload['engagement_score'],
'readiness_score' => $payload['editorial_readiness_score'],
'flags_json' => $payload['health_flags_json'],
]);
}
$fresh = $collection->fresh();
app(CollectionHistoryService::class)->record(
$fresh,
$actor,
'health_refreshed',
sprintf('Collection health refreshed via %s.', $reason),
null,
[
'health_state' => $fresh->health_state,
'readiness_state' => $fresh->readiness_state,
'placement_eligibility' => (bool) $fresh->placement_eligibility,
'health_score' => (float) ($fresh->health_score ?? 0),
'flags' => $fresh->health_flags_json,
]
);
return $fresh;
}
public function summary(Collection $collection): array
{
return [
'health_state' => $collection->health_state,
'readiness_state' => $collection->readiness_state,
'health_score' => $collection->health_score !== null ? (float) $collection->health_score : null,
'metadata_completeness_score' => $collection->metadata_completeness_score !== null ? (float) $collection->metadata_completeness_score : null,
'editorial_readiness_score' => $collection->editorial_readiness_score !== null ? (float) $collection->editorial_readiness_score : null,
'freshness_score' => $collection->freshness_score !== null ? (float) $collection->freshness_score : null,
'engagement_score' => $collection->engagement_score !== null ? (float) $collection->engagement_score : null,
'placement_eligibility' => (bool) $collection->placement_eligibility,
'flags' => is_array($collection->health_flags_json) ? $collection->health_flags_json : [],
'duplicate_cluster_key' => $collection->duplicate_cluster_key,
'canonical_collection_id' => $collection->canonical_collection_id ? (int) $collection->canonical_collection_id : null,
'last_health_check_at' => optional($collection->last_health_check_at)?->toISOString(),
];
}
private function metadataCompletenessScore(Collection $collection): float
{
$score = 0.0;
$score += filled($collection->title) ? 18.0 : 0.0;
$score += filled($collection->summary) ? 18.0 : 0.0;
$score += filled($collection->description) ? 14.0 : 0.0;
$score += $this->hasStrongCover($collection) ? 18.0 : ($collection->resolvedCoverArtwork(false) ? 8.0 : 0.0);
$score += (int) $collection->artworks_count >= 6 ? 16.0 : ((int) $collection->artworks_count >= 4 ? 10.0 : ((int) $collection->artworks_count >= 2 ? 5.0 : 0.0));
$score += $collection->usesPremiumPresentation() ? 8.0 : 0.0;
$score += filled($collection->campaign_key) || filled($collection->event_key) || filled($collection->season_key) ? 8.0 : 0.0;
return round(min(100.0, $score), 2);
}
private function freshnessScore(Collection $collection): float
{
$reference = $collection->last_activity_at ?: $collection->updated_at ?: $collection->published_at;
if ($reference === null) {
return 0.0;
}
$days = max(0, now()->diffInDays($reference));
if ($days >= 45) {
return 0.0;
}
return round(max(0.0, 100.0 - (($days / 45) * 100.0)), 2);
}
private function engagementScore(Collection $collection): float
{
$weighted = ((int) $collection->likes_count * 3.0)
+ ((int) $collection->followers_count * 4.5)
+ ((int) $collection->saves_count * 4.0)
+ ((int) $collection->comments_count * 2.0)
+ ((int) $collection->shares_count * 2.5)
+ ((int) $collection->views_count * 0.08);
return round(min(100.0, $weighted), 2);
}
private function editorialReadinessScore(Collection $collection, float $metadataCompleteness, float $freshness, float $engagement): float
{
$score = ($metadataCompleteness * 0.45) + ($freshness * 0.2) + ($engagement * 0.2);
$score += $collection->moderation_status === Collection::MODERATION_ACTIVE ? 10.0 : -20.0;
$score += $collection->visibility === Collection::VISIBILITY_PUBLIC ? 10.0 : -10.0;
$score += in_array((string) $collection->workflow_state, [Collection::WORKFLOW_APPROVED, Collection::WORKFLOW_PROGRAMMED], true) ? 10.0 : 0.0;
return round(max(0.0, min(100.0, $score)), 2);
}
private function flags(Collection $collection, float $metadataCompleteness, float $freshness, float $engagement, float $readiness): array
{
$flags = [];
$artworksCount = (int) $collection->artworks_count;
if ($metadataCompleteness < 55) {
$flags[] = Collection::HEALTH_NEEDS_METADATA;
}
if ($artworksCount < 6) {
$flags[] = Collection::HEALTH_LOW_CONTENT;
}
if (! $this->hasStrongCover($collection)) {
$flags[] = Collection::HEALTH_WEAK_COVER;
}
if ($freshness <= 0.0 && $collection->isPubliclyAccessible()) {
$flags[] = Collection::HEALTH_STALE;
}
if ($engagement < 15 && $collection->isPubliclyAccessible() && $collection->published_at?->lt(now()->subDays(21))) {
$flags[] = Collection::HEALTH_LOW_ENGAGEMENT;
}
if ($collection->moderation_status !== Collection::MODERATION_ACTIVE || (string) $collection->workflow_state === Collection::WORKFLOW_IN_REVIEW) {
$flags[] = Collection::HEALTH_NEEDS_REVIEW;
}
if ($this->brokenItemsRatio($collection) > 0.25) {
$flags[] = Collection::HEALTH_BROKEN_ITEMS;
}
if ($this->hasDuplicateRisk($collection)) {
$flags[] = Collection::HEALTH_DUPLICATE_RISK;
}
if ($collection->canonical_collection_id !== null) {
$flags[] = Collection::HEALTH_MERGE_CANDIDATE;
}
if ($readiness < 45 && $collection->type === Collection::TYPE_EDITORIAL && $artworksCount < 6) {
$flags[] = Collection::HEALTH_ATTRIBUTION_INCOMPLETE;
}
return array_values(array_unique($flags));
}
private function healthStateFromFlags(array $flags): string
{
foreach ([
Collection::HEALTH_MERGE_CANDIDATE,
Collection::HEALTH_DUPLICATE_RISK,
Collection::HEALTH_NEEDS_REVIEW,
Collection::HEALTH_BROKEN_ITEMS,
Collection::HEALTH_LOW_CONTENT,
Collection::HEALTH_WEAK_COVER,
Collection::HEALTH_NEEDS_METADATA,
Collection::HEALTH_STALE,
Collection::HEALTH_LOW_ENGAGEMENT,
Collection::HEALTH_ATTRIBUTION_INCOMPLETE,
] as $flag) {
if (in_array($flag, $flags, true)) {
return $flag;
}
}
return Collection::HEALTH_HEALTHY;
}
private function healthScore(float $metadataCompleteness, float $freshness, float $engagement, float $readiness, array $flags): float
{
$score = ($metadataCompleteness * 0.35) + ($freshness * 0.2) + ($engagement * 0.2) + ($readiness * 0.25);
$score -= count($flags) * 6.5;
return round(max(0.0, min(100.0, $score)), 2);
}
private function placementEligibility(Collection $collection, string $healthState, float $readiness): bool
{
if ($collection->visibility !== Collection::VISIBILITY_PUBLIC) {
return false;
}
if ($collection->moderation_status !== Collection::MODERATION_ACTIVE) {
return false;
}
if (! in_array($collection->lifecycle_state, [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED], true)) {
return false;
}
if (in_array($healthState, [Collection::HEALTH_BROKEN_ITEMS, Collection::HEALTH_MERGE_CANDIDATE], true)) {
return false;
}
if ($collection->workflow_state === Collection::WORKFLOW_IN_REVIEW) {
return false;
}
return $readiness >= 45.0;
}
private function readinessState(bool $placementEligibility, array $flags): string
{
if (! $placementEligibility) {
return Collection::READINESS_BLOCKED;
}
if ($flags !== []) {
return Collection::READINESS_NEEDS_WORK;
}
return Collection::READINESS_READY;
}
private function duplicateClusterKey(Collection $collection): ?string
{
$existing = trim((string) ($collection->duplicate_cluster_key ?? ''));
return $existing !== '' ? $existing : null;
}
private function trustTier(Collection $collection, float $healthScore): string
{
if ($collection->type === Collection::TYPE_EDITORIAL) {
return 'editorial';
}
if ($healthScore >= 80) {
return 'high';
}
if ($healthScore >= 50) {
return 'standard';
}
return 'limited';
}
private function brokenItemsRatio(Collection $collection): float
{
if ($collection->isSmart() || (int) $collection->artworks_count === 0) {
return 0.0;
}
$visibleCount = DB::table('collection_artwork as ca')
->join('artworks as a', 'a.id', '=', 'ca.artwork_id')
->where('ca.collection_id', $collection->id)
->whereNull('a.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNotNull('a.published_at')
->where('a.published_at', '<=', now())
->count();
return max(0.0, ((int) $collection->artworks_count - $visibleCount) / max(1, (int) $collection->artworks_count));
}
private function hasDuplicateRisk(Collection $collection): bool
{
return Collection::query()
->where('id', '!=', $collection->id)
->where('user_id', $collection->user_id)
->whereRaw('LOWER(title) = ?', [mb_strtolower(trim((string) $collection->title))])
->exists();
}
private function hasStrongCover(Collection $collection): bool
{
if (! $collection->cover_artwork_id) {
return false;
}
$cover = $collection->relationLoaded('coverArtwork')
? $collection->coverArtwork
: $collection->coverArtwork()->first();
if (! $cover instanceof Artwork) {
return false;
}
if ($collection->isPubliclyAccessible() && ! $cover->published_at) {
return false;
}
$width = (int) ($cover->width ?? 0);
$height = (int) ($cover->height ?? 0);
return $width >= 320 && $height >= 220;
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionHistory;
use App\Models\User;
use App\Services\CollectionHealthService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Validation\ValidationException;
class CollectionHistoryService
{
/**
* @var array<string, array<int, string>>
*/
private const RESTORABLE_FIELDS = [
'updated' => ['title', 'visibility', 'lifecycle_state'],
'workflow_updated' => ['workflow_state', 'program_key', 'partner_key', 'experiment_key', 'placement_eligibility'],
'partner_program_metadata_updated' => ['partner_key', 'trust_tier', 'promotion_tier', 'sponsorship_state', 'ownership_domain', 'commercial_review_state', 'legal_review_state'],
];
public function __construct(
private readonly CollectionHealthService $health,
) {
}
public function record(Collection $collection, ?User $actor, string $actionType, ?string $summary = null, ?array $before = null, ?array $after = null): void
{
CollectionHistory::query()->create([
'collection_id' => $collection->id,
'actor_user_id' => $actor?->id,
'action_type' => $actionType,
'summary' => $summary,
'before_json' => $before,
'after_json' => $after,
'created_at' => now(),
]);
$collection->forceFill([
'history_count' => (int) $collection->history_count + 1,
])->save();
}
public function historyFor(Collection $collection, int $perPage = 40): LengthAwarePaginator
{
return CollectionHistory::query()
->with('actor:id,username,name')
->where('collection_id', $collection->id)
->orderByDesc('created_at')
->paginate(max(10, min($perPage, 80)));
}
public function mapPaginator(LengthAwarePaginator $paginator): array
{
return [
'data' => collect($paginator->items())->map(function (CollectionHistory $entry): array {
$restorableFields = $this->restorablePayload($entry);
return [
'id' => (int) $entry->id,
'action_type' => $entry->action_type,
'summary' => $entry->summary,
'before' => $entry->before_json,
'after' => $entry->after_json,
'can_restore' => $restorableFields !== [],
'restore_fields' => array_keys($restorableFields),
'created_at' => $entry->created_at?->toISOString(),
'actor' => $entry->actor ? [
'id' => (int) $entry->actor->id,
'username' => $entry->actor->username,
'name' => $entry->actor->name,
] : null,
];
})->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
];
}
public function canRestore(CollectionHistory $entry): bool
{
return $this->restorablePayload($entry) !== [];
}
public function restore(Collection $collection, CollectionHistory $entry, ?User $actor = null): Collection
{
if ((int) $entry->collection_id !== (int) $collection->id) {
throw ValidationException::withMessages([
'history' => 'This history entry does not belong to the selected collection.',
]);
}
$payload = $this->restorablePayload($entry);
if ($payload === []) {
throw ValidationException::withMessages([
'history' => 'This history entry cannot be restored safely.',
]);
}
$working = $collection->fresh();
$before = [];
foreach (array_keys($payload) as $field) {
$before[$field] = $working->{$field};
}
$working->forceFill($payload);
$healthPayload = $this->health->evaluate($working);
$working->forceFill(array_merge($healthPayload, $payload, [
'last_activity_at' => now(),
]))->save();
$fresh = $working->fresh();
$this->record(
$fresh,
$actor,
'history_restored',
sprintf('Collection restored from history entry #%d.', $entry->id),
array_merge(['restored_history_id' => (int) $entry->id], $before),
array_merge(['restored_history_id' => (int) $entry->id], $payload),
);
return $fresh;
}
/**
* @return array<string, mixed>
*/
private function restorablePayload(CollectionHistory $entry): array
{
$before = is_array($entry->before_json) ? $entry->before_json : [];
$fields = self::RESTORABLE_FIELDS[$entry->action_type] ?? [];
$payload = [];
foreach ($fields as $field) {
if (array_key_exists($field, $before)) {
$payload[$field] = $before[$field];
}
}
return $payload;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use Illuminate\Support\Arr;
class CollectionLifecycleService
{
public function resolveState(Collection $collection): string
{
if ($collection->moderation_status === Collection::MODERATION_HIDDEN) {
return Collection::LIFECYCLE_HIDDEN;
}
if ($collection->moderation_status === Collection::MODERATION_RESTRICTED) {
return Collection::LIFECYCLE_RESTRICTED;
}
if ($collection->moderation_status === Collection::MODERATION_UNDER_REVIEW) {
return Collection::LIFECYCLE_UNDER_REVIEW;
}
if ($collection->expired_at && $collection->expired_at->lte(now())) {
return Collection::LIFECYCLE_EXPIRED;
}
if ($collection->archived_at !== null) {
return Collection::LIFECYCLE_ARCHIVED;
}
if ($collection->published_at && $collection->published_at->isFuture()) {
return Collection::LIFECYCLE_SCHEDULED;
}
if ($collection->visibility === Collection::VISIBILITY_PRIVATE) {
return Collection::LIFECYCLE_DRAFT;
}
if ($collection->is_featured) {
return Collection::LIFECYCLE_FEATURED;
}
return Collection::LIFECYCLE_PUBLISHED;
}
public function syncState(Collection $collection): Collection
{
$nextState = $this->resolveState($collection->fresh());
$collection->forceFill(['lifecycle_state' => $nextState])->save();
return $collection->fresh();
}
public function applyAttributes(Collection $collection, array $attributes): Collection
{
$collection->forceFill(Arr::only($attributes, [
'lifecycle_state',
'archived_at',
'expired_at',
'published_at',
'unpublished_at',
]))->save();
return $this->syncState($collection);
}
public function syncScheduledCollections(): array
{
$expired = Collection::query()
->whereNotNull('expired_at')
->where('expired_at', '<=', now())
->where('lifecycle_state', '!=', Collection::LIFECYCLE_EXPIRED)
->update([
'lifecycle_state' => Collection::LIFECYCLE_EXPIRED,
'is_featured' => false,
'featured_at' => null,
'updated_at' => now(),
]);
$scheduled = Collection::query()
->whereNotNull('published_at')
->where('published_at', '<=', now())
->whereIn('lifecycle_state', [Collection::LIFECYCLE_DRAFT, Collection::LIFECYCLE_SCHEDULED])
->where('moderation_status', Collection::MODERATION_ACTIVE)
->where('visibility', '!=', Collection::VISIBILITY_PRIVATE)
->update([
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
'updated_at' => now(),
]);
return [
'expired' => $expired,
'scheduled' => $scheduled,
];
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Events\Collections\CollectionLiked;
use App\Events\Collections\CollectionUnliked;
use App\Models\Collection;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CollectionLikeService
{
public function like(User $actor, Collection $collection): bool
{
$this->guard($actor, $collection);
$inserted = false;
DB::transaction(function () use ($actor, $collection, &$inserted): void {
$rows = DB::table('collection_likes')->insertOrIgnore([
'collection_id' => $collection->id,
'user_id' => $actor->id,
'created_at' => now(),
]);
if ($rows === 0) {
return;
}
$inserted = true;
DB::table('collections')
->where('id', $collection->id)
->update([
'likes_count' => DB::raw('likes_count + 1'),
'last_activity_at' => now(),
'updated_at' => now(),
]);
});
if ($inserted) {
event(new CollectionLiked($collection->fresh(), (int) $actor->id));
}
return $inserted;
}
public function unlike(User $actor, Collection $collection): bool
{
$deleted = false;
DB::transaction(function () use ($actor, $collection, &$deleted): void {
$rows = DB::table('collection_likes')
->where('collection_id', $collection->id)
->where('user_id', $actor->id)
->delete();
if ($rows === 0) {
return;
}
$deleted = true;
DB::table('collections')
->where('id', $collection->id)
->where('likes_count', '>', 0)
->update([
'likes_count' => DB::raw('likes_count - 1'),
'updated_at' => now(),
]);
});
if ($deleted) {
event(new CollectionUnliked($collection->fresh(), (int) $actor->id));
}
return $deleted;
}
public function isLiked(?User $viewer, Collection $collection): bool
{
if (! $viewer) {
return false;
}
return DB::table('collection_likes')
->where('collection_id', $collection->id)
->where('user_id', $viewer->id)
->exists();
}
private function guard(User $actor, Collection $collection): void
{
if (! $collection->isPubliclyEngageable()) {
throw ValidationException::withMessages([
'collection' => 'Only public collections can be liked.',
]);
}
if ($collection->isOwnedBy($actor)) {
throw ValidationException::withMessages([
'collection' => 'You cannot like your own collection.',
]);
}
}
}

View File

@@ -0,0 +1,514 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\Collection;
use App\Models\CollectionEntityLink;
use App\Models\Story;
use App\Models\Tag;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class CollectionLinkService
{
public const TYPE_CREATOR = 'creator';
public const TYPE_ARTWORK = 'artwork';
public const TYPE_STORY = 'story';
public const TYPE_CATEGORY = 'category';
public const TYPE_TAG = 'tag';
public const TYPE_CAMPAIGN = 'campaign';
public const TYPE_EVENT = 'event';
/**
* @return array<int, string>
*/
public static function supportedTypes(): array
{
return [
self::TYPE_CREATOR,
self::TYPE_ARTWORK,
self::TYPE_STORY,
self::TYPE_CATEGORY,
self::TYPE_TAG,
self::TYPE_CAMPAIGN,
self::TYPE_EVENT,
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function links(Collection $collection, bool $publicOnly = false): array
{
$links = CollectionEntityLink::query()
->where('collection_id', $collection->id)
->orderBy('id')
->get();
return $this->mapLinks($links, $publicOnly)->values()->all();
}
/**
* @return array<string, array<int, array<string, mixed>>>
*/
public function manageableOptions(Collection $collection): array
{
$existingIdsByType = CollectionEntityLink::query()
->where('collection_id', $collection->id)
->get()
->groupBy('linked_type')
->map(fn (SupportCollection $items): array => $items->pluck('linked_id')->map(fn ($id): int => (int) $id)->all());
$creatorOptions = User::query()
->whereNotNull('username')
->orderByDesc('id')
->limit(24)
->get()
->reject(fn (User $user): bool => in_array((int) $user->id, $existingIdsByType->get(self::TYPE_CREATOR, []), true))
->map(fn (User $user): array => [
'id' => (int) $user->id,
'label' => $user->name ?: (string) $user->username,
'description' => $user->username ? '@' . strtolower((string) $user->username) : 'Creator',
])
->values()
->all();
$artworkOptions = Artwork::query()
->with(['user:id,username,name', 'categories.contentType:id,name'])
->public()
->latest('published_at')
->latest('id')
->limit(24)
->get()
->reject(fn (Artwork $artwork): bool => in_array((int) $artwork->id, $existingIdsByType->get(self::TYPE_ARTWORK, []), true))
->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'label' => (string) $artwork->title,
'description' => collect([
$artwork->user?->username ? '@' . strtolower((string) $artwork->user->username) : null,
$artwork->categories->first()?->contentType?->name,
])->filter()->join(' • ') ?: 'Published artwork',
])
->values()
->all();
$storyOptions = Story::query()
->with('creator:id,username,name')
->published()
->orderByDesc('published_at')
->limit(24)
->get()
->reject(fn (Story $story): bool => in_array((int) $story->id, $existingIdsByType->get(self::TYPE_STORY, []), true))
->map(fn (Story $story): array => [
'id' => (int) $story->id,
'label' => (string) $story->title,
'description' => $story->creator?->username ? '@' . strtolower((string) $story->creator->username) : 'Published story',
])
->values()
->all();
$categoryOptions = Category::query()
->with('contentType:id,slug,name')
->active()
->orderBy('sort_order')
->orderBy('name')
->limit(24)
->get()
->reject(fn (Category $category): bool => in_array((int) $category->id, $existingIdsByType->get(self::TYPE_CATEGORY, []), true))
->map(fn (Category $category): array => [
'id' => (int) $category->id,
'label' => (string) $category->name,
'description' => $category->contentType?->name ? $category->contentType->name . ' category' : 'Category',
])
->values()
->all();
$tagOptions = Tag::query()
->where('is_active', true)
->orderByDesc('usage_count')
->orderBy('name')
->limit(24)
->get()
->reject(fn (Tag $tag): bool => in_array((int) $tag->id, $existingIdsByType->get(self::TYPE_TAG, []), true))
->map(fn (Tag $tag): array => [
'id' => (int) $tag->id,
'label' => (string) $tag->name,
'description' => '#' . strtolower((string) $tag->slug),
])
->values()
->all();
$campaignOptions = $this->syntheticLinkOptions(self::TYPE_CAMPAIGN);
$eventOptions = $this->syntheticLinkOptions(self::TYPE_EVENT);
return [
self::TYPE_CREATOR => $creatorOptions,
self::TYPE_ARTWORK => $artworkOptions,
self::TYPE_STORY => $storyOptions,
self::TYPE_CATEGORY => $categoryOptions,
self::TYPE_TAG => $tagOptions,
self::TYPE_CAMPAIGN => $campaignOptions,
self::TYPE_EVENT => $eventOptions,
];
}
/**
* @param array<int, array<string, mixed>> $links
*/
public function syncLinks(Collection $collection, User $actor, array $links): Collection
{
$normalized = collect($links)
->map(function ($item): ?array {
$type = is_array($item) ? (string) ($item['linked_type'] ?? '') : '';
$linkedId = is_array($item) ? (int) ($item['linked_id'] ?? 0) : 0;
$relationshipType = is_array($item) ? trim((string) ($item['relationship_type'] ?? '')) : '';
if (! in_array($type, self::supportedTypes(), true) || $linkedId <= 0) {
return null;
}
return [
'linked_type' => $type,
'linked_id' => $linkedId,
'relationship_type' => $relationshipType !== '' ? $relationshipType : null,
];
})
->filter()
->unique(fn (array $item): string => $item['linked_type'] . ':' . $item['linked_id'])
->values();
foreach (self::supportedTypes() as $type) {
$ids = $normalized->where('linked_type', $type)->pluck('linked_id')->map(fn ($id): int => (int) $id)->all();
if ($ids === []) {
continue;
}
if ($this->isSyntheticType($type)) {
$resolved = collect($ids)
->map(fn (int $id): ?array => $this->syntheticLinkDescriptorForId($type, $id))
->filter()
->values();
} else {
$resolved = $this->resolvedEntities($type, $ids, false);
}
if ($resolved->count() !== count($ids)) {
throw ValidationException::withMessages([
'entity_links' => 'Choose valid entities to link to this collection.',
]);
}
}
$before = $this->links($collection, false);
DB::transaction(function () use ($collection, $normalized): void {
CollectionEntityLink::query()
->where('collection_id', $collection->id)
->delete();
if ($normalized->isEmpty()) {
return;
}
$now = now();
CollectionEntityLink::query()->insert($normalized->map(fn (array $item): array => [
'collection_id' => (int) $collection->id,
'linked_type' => $item['linked_type'],
'linked_id' => (int) $item['linked_id'],
'relationship_type' => $item['relationship_type'],
'metadata_json' => $this->isSyntheticType((string) $item['linked_type'])
? json_encode($this->syntheticLinkDescriptorForId((string) $item['linked_type'], (int) $item['linked_id']), JSON_THROW_ON_ERROR)
: null,
'created_at' => $now,
'updated_at' => $now,
])->all());
});
$fresh = $collection->fresh(['user.profile', 'coverArtwork']);
app(\App\Services\CollectionHistoryService::class)->record(
$fresh,
$actor,
'entity_links_updated',
'Collection entity links updated.',
['entity_links' => $before],
['entity_links' => $this->links($fresh, false)]
);
return $fresh;
}
/**
* @param SupportCollection<int, CollectionEntityLink> $links
* @return SupportCollection<int, array<string, mixed>>
*/
private function mapLinks(SupportCollection $links, bool $publicOnly): SupportCollection
{
$entityMaps = collect(self::supportedTypes())
->reject(fn (string $type): bool => $this->isSyntheticType($type))
->mapWithKeys(function (string $type) use ($links, $publicOnly): array {
$ids = $links->where('linked_type', $type)->pluck('linked_id')->map(fn ($id): int => (int) $id)->all();
return [$type => $this->resolvedEntities($type, $ids, $publicOnly)];
});
return $links->map(function (CollectionEntityLink $link) use ($entityMaps): ?array {
if ($this->isSyntheticType((string) $link->linked_type)) {
return $this->mapSyntheticLink($link);
}
$entity = $entityMaps->get((string) $link->linked_type)?->get((int) $link->linked_id);
if (! $entity instanceof Model) {
return null;
}
return $this->mapLink($link, $entity);
})->filter()->values();
}
/**
* @param array<int, int> $ids
* @return SupportCollection<int, Model>
*/
private function resolvedEntities(string $type, array $ids, bool $publicOnly): SupportCollection
{
if ($ids === []) {
return collect();
}
return match ($type) {
self::TYPE_CREATOR => User::query()
->whereIn('id', $ids)
->whereNotNull('username')
->get()
->keyBy('id'),
self::TYPE_ARTWORK => Artwork::query()
->with(['user:id,username,name', 'categories.contentType:id,name'])
->whereIn('id', $ids)
->when($publicOnly, fn ($query) => $query->public())
->get()
->keyBy('id'),
self::TYPE_STORY => Story::query()
->with('creator:id,username,name')
->whereIn('id', $ids)
->when($publicOnly, fn ($query) => $query->published())
->get()
->keyBy('id'),
self::TYPE_CATEGORY => Category::query()
->with('contentType:id,slug,name')
->whereIn('id', $ids)
->when($publicOnly, fn ($query) => $query->active())
->get()
->keyBy('id'),
self::TYPE_TAG => Tag::query()
->whereIn('id', $ids)
->when($publicOnly, fn ($query) => $query->where('is_active', true))
->get()
->keyBy('id'),
default => collect(),
};
}
private function mapLink(CollectionEntityLink $link, Model $entity): array
{
return match ((string) $link->linked_type) {
self::TYPE_CREATOR => [
'id' => (int) $link->id,
'linked_type' => self::TYPE_CREATOR,
'linked_id' => (int) $entity->getKey(),
'relationship_type' => $link->relationship_type,
'title' => $entity->name ?: (string) $entity->username,
'subtitle' => $entity->username ? '@' . strtolower((string) $entity->username) : 'Creator',
'description' => $link->relationship_type ?: 'Linked creator',
'url' => route('profile.show', ['username' => strtolower((string) $entity->username)]),
'image_url' => AvatarUrl::forUser((int) $entity->id),
'meta' => 'Creator',
],
self::TYPE_ARTWORK => [
'id' => (int) $link->id,
'linked_type' => self::TYPE_ARTWORK,
'linked_id' => (int) $entity->getKey(),
'relationship_type' => $link->relationship_type,
'title' => (string) $entity->title,
'subtitle' => collect([
$entity->user?->username ? '@' . strtolower((string) $entity->user->username) : null,
$entity->categories->first()?->contentType?->name,
])->filter()->join(' • ') ?: 'Artwork',
'description' => $link->relationship_type ?: 'Linked artwork',
'url' => route('art.show', [
'id' => (int) $entity->id,
'slug' => Str::slug((string) ($entity->slug ?: $entity->title)) ?: (string) $entity->id,
]),
'image_url' => $entity->thumbUrl('md') ?? $entity->thumbnail_url,
'meta' => 'Artwork',
],
self::TYPE_STORY => [
'id' => (int) $link->id,
'linked_type' => self::TYPE_STORY,
'linked_id' => (int) $entity->getKey(),
'relationship_type' => $link->relationship_type,
'title' => (string) $entity->title,
'subtitle' => $entity->creator?->username ? '@' . strtolower((string) $entity->creator->username) : 'Story',
'description' => $entity->excerpt ?: ($link->relationship_type ?: 'Linked story'),
'url' => $entity->url,
'image_url' => $entity->cover_url,
'meta' => 'Story',
],
self::TYPE_CATEGORY => [
'id' => (int) $link->id,
'linked_type' => self::TYPE_CATEGORY,
'linked_id' => (int) $entity->getKey(),
'relationship_type' => $link->relationship_type,
'title' => (string) $entity->name,
'subtitle' => $entity->contentType?->name ? $entity->contentType->name . ' category' : 'Category',
'description' => $entity->description ?: ($link->relationship_type ?: 'Linked category'),
'url' => url($entity->url),
'image_url' => $entity->image ? asset($entity->image) : null,
'meta' => 'Category',
],
self::TYPE_TAG => [
'id' => (int) $link->id,
'linked_type' => self::TYPE_TAG,
'linked_id' => (int) $entity->getKey(),
'relationship_type' => $link->relationship_type,
'title' => (string) $entity->name,
'subtitle' => '#' . strtolower((string) $entity->slug),
'description' => $link->relationship_type ?: sprintf('Theme tag · %d uses', (int) $entity->usage_count),
'url' => route('tags.show', ['tag' => $entity]),
'image_url' => null,
'meta' => 'Tag',
],
default => [],
};
}
/**
* @return array<int, array{id:int,label:string,description:string}>
*/
private function syntheticLinkOptions(string $type): array
{
return $this->syntheticLinkDescriptors($type)
->map(fn (array $item): array => [
'id' => (int) $item['id'],
'label' => (string) $item['label'],
'description' => (string) $item['description'],
])
->values()
->all();
}
private function isSyntheticType(string $type): bool
{
return in_array($type, [self::TYPE_CAMPAIGN, self::TYPE_EVENT], true);
}
/**
* @return SupportCollection<int, array<string, mixed>>
*/
private function syntheticLinkDescriptors(string $type): SupportCollection
{
return match ($type) {
self::TYPE_CAMPAIGN => Collection::query()
->whereNotNull('campaign_key')
->where('campaign_key', '!=', '')
->orderBy('campaign_label')
->orderBy('campaign_key')
->get(['campaign_key', 'campaign_label'])
->unique('campaign_key')
->map(function (Collection $collection): array {
$key = (string) $collection->campaign_key;
return [
'id' => $this->syntheticLinkId(self::TYPE_CAMPAIGN, $key),
'key' => $key,
'label' => (string) ($collection->campaign_label ?: $this->humanizeToken($key)),
'description' => 'Campaign landing',
'subtitle' => $key,
'url' => route('collections.campaign.show', ['campaignKey' => $key]),
'meta' => 'Campaign',
];
})
->values(),
self::TYPE_EVENT => Collection::query()
->whereNotNull('event_key')
->where('event_key', '!=', '')
->orderBy('event_label')
->orderBy('event_key')
->get(['event_key', 'event_label', 'season_key'])
->unique('event_key')
->map(function (Collection $collection): array {
$key = (string) $collection->event_key;
$seasonKey = filled($collection->season_key) ? (string) $collection->season_key : null;
return [
'id' => $this->syntheticLinkId(self::TYPE_EVENT, $key),
'key' => $key,
'label' => (string) ($collection->event_label ?: $this->humanizeToken($key)),
'description' => $seasonKey ? 'Event context · ' . $this->humanizeToken($seasonKey) : 'Event context',
'subtitle' => $seasonKey ? 'Season ' . $this->humanizeToken($seasonKey) : $key,
'url' => null,
'meta' => 'Event',
'season_key' => $seasonKey,
];
})
->values(),
default => collect(),
};
}
private function syntheticLinkDescriptorForId(string $type, int $id): ?array
{
return $this->syntheticLinkDescriptors($type)
->first(fn (array $item): bool => (int) $item['id'] === $id);
}
private function syntheticLinkId(string $type, string $key): int
{
return (int) hexdec(substr(md5($type . ':' . mb_strtolower($key)), 0, 7));
}
private function mapSyntheticLink(CollectionEntityLink $link): ?array
{
$descriptor = is_array($link->metadata_json) && $link->metadata_json !== []
? $link->metadata_json
: $this->syntheticLinkDescriptorForId((string) $link->linked_type, (int) $link->linked_id);
if (! is_array($descriptor) || empty($descriptor['label'])) {
return null;
}
return [
'id' => (int) $link->id,
'linked_type' => (string) $link->linked_type,
'linked_id' => (int) $link->linked_id,
'relationship_type' => $link->relationship_type,
'title' => (string) $descriptor['label'],
'subtitle' => $descriptor['subtitle'] ?? ((string) ($descriptor['key'] ?? '')),
'description' => $link->relationship_type ?: (string) ($descriptor['description'] ?? 'Linked context'),
'url' => $descriptor['url'] ?? null,
'image_url' => null,
'meta' => (string) ($descriptor['meta'] ?? $this->humanizeToken((string) $link->linked_type)),
'context_key' => $descriptor['key'] ?? null,
'season_key' => $descriptor['season_key'] ?? null,
];
}
private function humanizeToken(string $value): string
{
return str($value)
->replace(['_', '-'], ' ')
->title()
->value();
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CollectionLinkedCollectionsService
{
public function linkedCollections(Collection $collection): EloquentCollection
{
return $collection->manualRelatedCollections()
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->get();
}
public function publicLinkedCollections(Collection $collection, int $limit = 6): EloquentCollection
{
return $collection->manualRelatedCollections()
->public()
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->limit(max(1, min($limit, 12)))
->get();
}
public function manageableLinkOptions(Collection $collection, User $actor, int $limit = 24): EloquentCollection
{
$linkedIds = $collection->manualRelatedCollections()->pluck('collections.id')->map(static fn ($id) => (int) $id)->all();
return Collection::query()
->with([
'user:id,username,name',
'members',
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
])
->where('id', '!=', $collection->id)
->whereNotIn('id', $linkedIds)
->orderByDesc('updated_at')
->get()
->filter(fn (Collection $candidate): bool => $candidate->canBeManagedBy($actor))
->take(max(1, min($limit, 48)))
->values();
}
/**
* @param array<int, int|string> $relatedCollectionIds
*/
public function syncLinks(Collection $collection, User $actor, array $relatedCollectionIds): Collection
{
$normalizedIds = collect($relatedCollectionIds)
->map(static fn ($id) => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->reject(fn (int $id): bool => $id === (int) $collection->id)
->unique()
->values();
$targets = $normalizedIds->isEmpty()
? collect()
: Collection::query()
->with('members')
->whereIn('id', $normalizedIds->all())
->get();
if ($targets->count() !== $normalizedIds->count()) {
throw ValidationException::withMessages([
'related_collection_ids' => 'Choose valid collections to link.',
]);
}
if ($targets->contains(fn (Collection $target): bool => ! $target->canBeManagedBy($actor))) {
throw ValidationException::withMessages([
'related_collection_ids' => 'You can only link collections that you can manage.',
]);
}
$before = $collection->manualRelatedCollections()->pluck('collections.id')->map(static fn ($id) => (int) $id)->all();
DB::transaction(function () use ($collection, $actor, $normalizedIds): void {
DB::table('collection_related_links')
->where('collection_id', $collection->id)
->delete();
if ($normalizedIds->isEmpty()) {
return;
}
$now = now();
DB::table('collection_related_links')->insert($normalizedIds->values()->map(
fn (int $relatedId, int $index): array => [
'collection_id' => $collection->id,
'related_collection_id' => $relatedId,
'sort_order' => $index,
'created_by_user_id' => $actor->id,
'created_at' => $now,
'updated_at' => $now,
]
)->all());
});
$fresh = $collection->fresh(['user.profile', 'coverArtwork']);
app(CollectionHistoryService::class)->record($fresh, $actor, 'linked_collections_updated', 'Manual linked collections updated.', [
'related_collection_ids' => $before,
], [
'related_collection_ids' => $normalizedIds->all(),
]);
return $fresh;
}
}

View File

@@ -0,0 +1,381 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionMergeAction;
use App\Models\User;
use App\Services\CollectionCanonicalService;
use App\Services\CollectionHealthService;
use App\Services\CollectionHistoryService;
use App\Services\CollectionService;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CollectionMergeService
{
public function __construct(
private readonly CollectionCanonicalService $canonical,
private readonly CollectionService $collections,
) {
}
public function duplicateCandidates(Collection $collection, int $limit = 5): EloquentCollection
{
$normalizedTitle = mb_strtolower(trim((string) $collection->title));
return Collection::query()
->where('id', '!=', $collection->id)
->whereNotExists(function ($query) use ($collection): void {
$query->select(DB::raw('1'))
->from('collection_merge_actions as cma')
->where('cma.action_type', 'rejected')
->where(function ($pair) use ($collection): void {
$pair->where(function ($forward) use ($collection): void {
$forward->where('cma.source_collection_id', $collection->id)
->whereColumn('cma.target_collection_id', 'collections.id');
})->orWhere(function ($reverse) use ($collection): void {
$reverse->where('cma.target_collection_id', $collection->id)
->whereColumn('cma.source_collection_id', 'collections.id');
});
});
})
->where(function ($query) use ($collection, $normalizedTitle): void {
$query->where('user_id', $collection->user_id)
->orWhere(function ($inner) use ($collection, $normalizedTitle): void {
$inner->whereRaw('LOWER(title) = ?', [$normalizedTitle])
->when(filled($collection->campaign_key), fn ($builder) => $builder->orWhere('campaign_key', $collection->campaign_key))
->when(filled($collection->series_key), fn ($builder) => $builder->orWhere('series_key', $collection->series_key));
});
})
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->orderByDesc('updated_at')
->limit(max(1, min($limit, 10)))
->get();
}
public function reviewCandidates(Collection $collection, bool $ownerView = true, int $limit = 5): array
{
$candidates = $this->duplicateCandidates($collection, $limit);
$candidateIds = $candidates->pluck('id')->map(static fn ($id): int => (int) $id)->values()->all();
$cards = collect($this->collections->mapCollectionCardPayloads($candidates, $ownerView))->keyBy('id');
$latestActions = $this->latestActionsForPair($collection, $candidateIds);
return $candidates->map(function (Collection $candidate) use ($collection, $cards, $latestActions): array {
return [
'collection' => $cards->get((int) $candidate->id),
'comparison' => $this->comparisonForCollections($collection, $candidate),
'decision' => $latestActions[$this->pairKey((int) $collection->id, (int) $candidate->id)] ?? null,
'is_current_canonical_target' => (int) ($collection->canonical_collection_id ?? 0) === (int) $candidate->id,
];
})->values()->all();
}
public function queueOverview(bool $ownerView = true, int $pendingLimit = 8, int $recentLimit = 8): array
{
$latestActions = CollectionMergeAction::query()
->with([
'sourceCollection.user:id,username,name',
'sourceCollection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
'targetCollection.user:id,username,name',
'targetCollection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
'actor:id,username,name',
])
->orderByDesc('id')
->get()
->filter(fn (CollectionMergeAction $action): bool => $action->sourceCollection !== null && $action->targetCollection !== null)
->groupBy(fn (CollectionMergeAction $action): string => $this->pairKey((int) $action->source_collection_id, (int) $action->target_collection_id))
->map(fn (SupportCollection $actions): CollectionMergeAction => $actions->first())
->values();
$pending = $latestActions
->filter(fn (CollectionMergeAction $action): bool => $action->action_type === 'suggested')
->sortByDesc(fn (CollectionMergeAction $action) => optional($action->updated_at)?->timestamp ?? 0)
->take($pendingLimit)
->values();
$recent = $latestActions
->filter(fn (CollectionMergeAction $action): bool => $action->action_type !== 'suggested')
->sortByDesc(fn (CollectionMergeAction $action) => optional($action->updated_at)?->timestamp ?? 0)
->take($recentLimit)
->values();
return [
'summary' => [
'pending' => $latestActions->where('action_type', 'suggested')->count(),
'approved' => $latestActions->where('action_type', 'approved')->count(),
'rejected' => $latestActions->where('action_type', 'rejected')->count(),
'completed' => $latestActions->where('action_type', 'completed')->count(),
],
'pending' => $pending->map(fn (CollectionMergeAction $action): array => $this->mapQueueAction($action, $ownerView))->values()->all(),
'recent' => $recent->map(fn (CollectionMergeAction $action): array => $this->mapQueueAction($action, $ownerView))->values()->all(),
];
}
public function syncSuggestedCandidates(Collection $collection, ?User $actor = null, int $limit = 5): array
{
$candidates = $this->duplicateCandidates($collection, $limit);
$candidateIds = $candidates->pluck('id')->map(static fn ($id): int => (int) $id)->values()->all();
$staleSuggestions = CollectionMergeAction::query()
->where('source_collection_id', $collection->id)
->where('action_type', 'suggested');
if ($candidateIds === []) {
$staleSuggestions->delete();
} else {
$staleSuggestions->whereNotIn('target_collection_id', $candidateIds)->delete();
}
foreach ($candidates as $candidate) {
CollectionMergeAction::query()->updateOrCreate(
[
'source_collection_id' => $collection->id,
'target_collection_id' => $candidate->id,
'action_type' => 'suggested',
],
[
'actor_user_id' => $actor?->id,
'summary' => 'Potential duplicate candidate detected.',
]
);
}
$this->syncDuplicateClusterKeys($collection, $candidateIds);
app(CollectionHistoryService::class)->record(
$collection->fresh(),
$actor,
'duplicate_candidates_synced',
sprintf('Collection duplicate candidates scanned. %d potential matches found.', count($candidateIds)),
null,
['candidate_collection_ids' => $candidateIds]
);
return [
'count' => count($candidateIds),
'items' => $candidates->map(fn (Collection $candidate): array => [
'id' => (int) $candidate->id,
'title' => (string) $candidate->title,
'slug' => (string) $candidate->slug,
])->values()->all(),
];
}
public function rejectCandidate(Collection $source, Collection $target, ?User $actor = null): Collection
{
if ((int) $source->id === (int) $target->id) {
throw ValidationException::withMessages([
'target_collection_id' => 'A collection cannot reject itself as a duplicate.',
]);
}
CollectionMergeAction::query()
->where(function ($query) use ($source, $target): void {
$query->where(function ($forward) use ($source, $target): void {
$forward->where('source_collection_id', $source->id)
->where('target_collection_id', $target->id);
})->orWhere(function ($reverse) use ($source, $target): void {
$reverse->where('source_collection_id', $target->id)
->where('target_collection_id', $source->id);
});
})
->whereIn('action_type', ['suggested'])
->delete();
CollectionMergeAction::query()->updateOrCreate(
[
'source_collection_id' => $source->id,
'target_collection_id' => $target->id,
'action_type' => 'rejected',
],
[
'actor_user_id' => $actor?->id,
'summary' => 'Marked as not a duplicate.',
]
);
app(CollectionHistoryService::class)->record(
$source->fresh(),
$actor,
'duplicate_rejected',
'Duplicate candidate dismissed.',
null,
['target_collection_id' => (int) $target->id]
);
$this->syncDuplicateClusterKeys($source->fresh(), $this->duplicateCandidates($source->fresh())->pluck('id')->map(static fn ($id): int => (int) $id)->all());
$this->syncDuplicateClusterKeys($target->fresh(), $this->duplicateCandidates($target->fresh())->pluck('id')->map(static fn ($id): int => (int) $id)->all());
return app(CollectionHealthService::class)->refresh($source->fresh(), $actor, 'duplicate-rejected');
}
public function mergeInto(Collection $source, Collection $target, ?User $actor = null): array
{
if ((int) $source->id === (int) $target->id) {
throw ValidationException::withMessages([
'target_collection_id' => 'A collection cannot merge into itself.',
]);
}
if ($target->isSmart()) {
throw ValidationException::withMessages([
'target_collection_id' => 'Target collection must be manual so merged artworks can be referenced safely.',
]);
}
return DB::transaction(function () use ($source, $target, $actor): array {
$artworkIds = $source->artworks()->pluck('artworks.id')->map(static fn ($id) => (int) $id)->all();
$this->collections->attachArtworkIds($target, $artworkIds);
$source = $this->canonical->designate($source->fresh(), $target->fresh(), $actor);
$source->forceFill([
'workflow_state' => Collection::WORKFLOW_ARCHIVED,
'lifecycle_state' => Collection::LIFECYCLE_ARCHIVED,
'archived_at' => now(),
'placement_eligibility' => false,
])->save();
CollectionMergeAction::query()->create([
'source_collection_id' => $source->id,
'target_collection_id' => $target->id,
'action_type' => 'completed',
'actor_user_id' => $actor?->id,
'summary' => 'Collection merge completed.',
]);
app(CollectionHistoryService::class)->record($target->fresh(), $actor, 'merged_into_target', 'Collection absorbed merge references.', null, [
'source_collection_id' => (int) $source->id,
'artwork_ids' => $artworkIds,
]);
app(CollectionHistoryService::class)->record($source->fresh(), $actor, 'merged_into_canonical', 'Collection archived after merge.', null, [
'target_collection_id' => (int) $target->id,
]);
return [
'source' => $source->fresh(),
'target' => $target->fresh(),
'attached_artwork_ids' => $artworkIds,
];
});
}
/**
* @param array<int, int> $candidateIds
* @return array<string, array<string, mixed>>
*/
private function latestActionsForPair(Collection $source, array $candidateIds): array
{
if ($candidateIds === []) {
return [];
}
return CollectionMergeAction::query()
->where(function ($query) use ($source, $candidateIds): void {
$query->where('source_collection_id', $source->id)
->whereIn('target_collection_id', $candidateIds)
->orWhere(function ($reverse) use ($source, $candidateIds): void {
$reverse->where('target_collection_id', $source->id)
->whereIn('source_collection_id', $candidateIds);
});
})
->orderByDesc('id')
->get()
->groupBy(function (CollectionMergeAction $action): string {
return $this->pairKey((int) $action->source_collection_id, (int) $action->target_collection_id);
})
->map(fn ($actions): array => [
'action_type' => (string) $actions->first()->action_type,
'summary' => $actions->first()->summary,
'updated_at' => optional($actions->first()->updated_at)?->toISOString(),
])
->all();
}
private function pairKey(int $leftId, int $rightId): string
{
$pair = [$leftId, $rightId];
sort($pair);
return implode(':', $pair);
}
private function comparisonForCollections(Collection $source, Collection $target): array
{
$sourceArtworkIds = $source->artworks()->pluck('artworks.id')->map(static fn ($id): int => (int) $id)->values()->all();
$targetArtworkIds = $target->artworks()->pluck('artworks.id')->map(static fn ($id): int => (int) $id)->values()->all();
$sharedArtworkIds = array_values(array_intersect($sourceArtworkIds, $targetArtworkIds));
$reasons = array_values(array_filter([
(int) $target->user_id === (int) $source->user_id ? 'same_owner' : null,
mb_strtolower(trim((string) $target->title)) === mb_strtolower(trim((string) $source->title)) ? 'same_title' : null,
filled($source->campaign_key) && $target->campaign_key === $source->campaign_key ? 'same_campaign' : null,
filled($source->series_key) && $target->series_key === $source->series_key ? 'same_series' : null,
$sharedArtworkIds !== [] ? 'shared_artworks' : null,
]));
return [
'same_owner' => (int) $target->user_id === (int) $source->user_id,
'same_title' => mb_strtolower(trim((string) $target->title)) === mb_strtolower(trim((string) $source->title)),
'same_campaign' => filled($source->campaign_key) && $target->campaign_key === $source->campaign_key,
'same_series' => filled($source->series_key) && $target->series_key === $source->series_key,
'shared_artworks_count' => count($sharedArtworkIds),
'source_artworks_count' => count($sourceArtworkIds),
'target_artworks_count' => count($targetArtworkIds),
'match_reasons' => $reasons,
];
}
/**
* @param array<int, int> $candidateIds
*/
private function syncDuplicateClusterKeys(Collection $collection, array $candidateIds): void
{
$clusterIds = collect([$collection->id])
->merge($candidateIds)
->map(static fn ($id): int => (int) $id)
->unique()
->values();
if ($clusterIds->count() <= 1) {
Collection::query()
->where('id', $collection->id)
->whereNull('canonical_collection_id')
->update(['duplicate_cluster_key' => null]);
return;
}
$clusterKey = sprintf('dup:%d:%d', $clusterIds->min(), $clusterIds->count());
Collection::query()
->whereIn('id', $clusterIds->all())
->whereNull('canonical_collection_id')
->update(['duplicate_cluster_key' => $clusterKey]);
}
private function mapQueueAction(CollectionMergeAction $action, bool $ownerView): array
{
$source = $action->sourceCollection;
$target = $action->targetCollection;
return [
'id' => (int) $action->id,
'action_type' => (string) $action->action_type,
'summary' => $action->summary,
'updated_at' => optional($action->updated_at)?->toISOString(),
'source' => $source ? $this->collections->mapCollectionCardPayloads([$source], $ownerView)[0] : null,
'target' => $target ? $this->collections->mapCollectionCardPayloads([$target], $ownerView)[0] : null,
'comparison' => ($source && $target) ? $this->comparisonForCollections($source, $target) : null,
'actor' => $action->actor ? [
'id' => (int) $action->actor->id,
'username' => (string) $action->actor->username,
'name' => $action->actor->name,
] : null,
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionMember;
use Illuminate\Validation\ValidationException;
class CollectionModerationService
{
public function __construct(
private readonly CollectionService $collections,
private readonly CollectionCollaborationService $collaborators,
) {
}
public function updateStatus(Collection $collection, string $status): Collection
{
if (! in_array($status, [
Collection::MODERATION_ACTIVE,
Collection::MODERATION_UNDER_REVIEW,
Collection::MODERATION_RESTRICTED,
Collection::MODERATION_HIDDEN,
], true)) {
throw ValidationException::withMessages([
'moderation_status' => 'Choose a valid moderation status.',
]);
}
return $this->collections->syncCollectionPublicState($collection, [
'moderation_status' => $status,
]);
}
public function updateInteractions(Collection $collection, array $attributes): Collection
{
return $this->collections->syncCollectionPublicState($collection, $attributes);
}
public function unfeature(Collection $collection): Collection
{
return $this->collections->unfeatureCollection($collection);
}
public function removeMember(Collection $collection, CollectionMember $member): void
{
if ((int) $member->collection_id !== (int) $collection->id) {
throw ValidationException::withMessages([
'member' => 'This member does not belong to the selected collection.',
]);
}
if ($member->role === Collection::MEMBER_ROLE_OWNER) {
throw ValidationException::withMessages([
'member' => 'The collection owner cannot be removed by moderation actions.',
]);
}
$member->forceFill([
'status' => Collection::MEMBER_STATUS_REVOKED,
'revoked_at' => now(),
])->save();
$this->collaborators->syncCollaboratorsCount($collection);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
class CollectionObservabilityService
{
public function surfaceCacheKey(string $surfaceKey, int $limit): string
{
return sprintf('collections:surface:%s:%d', $surfaceKey, $limit);
}
public function searchCacheKey(string $scope, array $filters): string
{
ksort($filters);
return sprintf('collections:search:%s:%s', $scope, md5(json_encode($filters, JSON_THROW_ON_ERROR)));
}
public function diagnostics(Collection $collection): array
{
return [
'collection_id' => (int) $collection->id,
'workflow_state' => $collection->workflow_state,
'health_state' => $collection->health_state,
'placement_eligibility' => (bool) $collection->placement_eligibility,
'experiment_key' => $collection->experiment_key,
'experiment_treatment' => $collection->experiment_treatment,
'placement_variant' => $collection->placement_variant,
'ranking_mode_variant' => $collection->ranking_mode_variant,
'collection_pool_version' => $collection->collection_pool_version,
'test_label' => $collection->test_label,
'partner_key' => $collection->partner_key,
'trust_tier' => $collection->trust_tier,
'promotion_tier' => $collection->promotion_tier,
'sponsorship_state' => $collection->sponsorship_state,
'ownership_domain' => $collection->ownership_domain,
'commercial_review_state' => $collection->commercial_review_state,
'legal_review_state' => $collection->legal_review_state,
'ranking_bucket' => $collection->ranking_bucket,
'recommendation_tier' => $collection->recommendation_tier,
'last_health_check_at' => optional($collection->last_health_check_at)?->toISOString(),
'last_recommendation_refresh_at' => optional($collection->last_recommendation_refresh_at)?->toISOString(),
];
}
public function summary(): array
{
$staleHealthCutoff = now()->subHours((int) config('collections.v5.queue.health_stale_after_hours', 24));
$staleRecommendationCutoff = now()->subHours((int) config('collections.v5.queue.recommendation_stale_after_hours', 12));
$watchlist = Collection::query()
->with([
'user:id,username,name',
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
])
->whereIn('health_state', [
Collection::HEALTH_NEEDS_REVIEW,
Collection::HEALTH_DUPLICATE_RISK,
Collection::HEALTH_MERGE_CANDIDATE,
Collection::HEALTH_STALE,
])
->orderByDesc('updated_at')
->limit(6)
->get();
return [
'counts' => [
'stale_health' => Collection::query()
->where(function ($query) use ($staleHealthCutoff): void {
$query->whereNull('last_health_check_at')
->orWhere('last_health_check_at', '<=', $staleHealthCutoff);
})
->count(),
'stale_recommendations' => Collection::query()
->where('placement_eligibility', true)
->where(function ($query) use ($staleRecommendationCutoff): void {
$query->whereNull('last_recommendation_refresh_at')
->orWhere('last_recommendation_refresh_at', '<=', $staleRecommendationCutoff);
})
->count(),
'placement_blocked' => Collection::query()->where('placement_eligibility', false)->count(),
'duplicate_risk' => Collection::query()->where('health_state', Collection::HEALTH_DUPLICATE_RISK)->count(),
],
'watchlist' => app(CollectionService::class)->mapCollectionCardPayloads($watchlist, true),
'generated_at' => now()->toISOString(),
];
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\User;
use App\Services\CollectionHistoryService;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class CollectionPartnerProgramService
{
public function sync(Collection $collection, array $attributes, ?User $actor = null): Collection
{
$adminOnlyKeys = [
'partner_key',
'trust_tier',
'sponsorship_state',
'ownership_domain',
'commercial_review_state',
'legal_review_state',
];
if ($actor && ! $actor->isAdmin() && collect($adminOnlyKeys)->contains(static fn (string $key): bool => array_key_exists($key, $attributes))) {
throw ValidationException::withMessages([
'partner_key' => 'Only admins can update partner or trust metadata.',
]);
}
$payload = [
'partner_key' => array_key_exists('partner_key', $attributes) ? ($attributes['partner_key'] ?: null) : $collection->partner_key,
'trust_tier' => array_key_exists('trust_tier', $attributes) ? ($attributes['trust_tier'] ?: null) : $collection->trust_tier,
'promotion_tier' => array_key_exists('promotion_tier', $attributes) ? ($attributes['promotion_tier'] ?: null) : $collection->promotion_tier,
'sponsorship_state' => array_key_exists('sponsorship_state', $attributes) ? ($attributes['sponsorship_state'] ?: null) : $collection->sponsorship_state,
'ownership_domain' => array_key_exists('ownership_domain', $attributes) ? ($attributes['ownership_domain'] ?: null) : $collection->ownership_domain,
'commercial_review_state' => array_key_exists('commercial_review_state', $attributes) ? ($attributes['commercial_review_state'] ?: null) : $collection->commercial_review_state,
'legal_review_state' => array_key_exists('legal_review_state', $attributes) ? ($attributes['legal_review_state'] ?: null) : $collection->legal_review_state,
];
$before = [
'partner_key' => $collection->partner_key,
'trust_tier' => $collection->trust_tier,
'promotion_tier' => $collection->promotion_tier,
'sponsorship_state' => $collection->sponsorship_state,
'ownership_domain' => $collection->ownership_domain,
'commercial_review_state' => $collection->commercial_review_state,
'legal_review_state' => $collection->legal_review_state,
];
$collection->forceFill($payload)->save();
$fresh = $collection->fresh();
app(CollectionHistoryService::class)->record(
$fresh,
$actor,
'partner_program_metadata_updated',
'Collection partner and governance metadata updated.',
$before,
$payload,
);
return $fresh;
}
public function publicLanding(string $programKey, int $limit = 18): array
{
$normalizedKey = trim($programKey);
if ($normalizedKey === '') {
return [
'program' => null,
'collections' => new EloquentCollection(),
'editorial_collections' => new EloquentCollection(),
'community_collections' => new EloquentCollection(),
'recent_collections' => new EloquentCollection(),
];
}
$baseQuery = Collection::query()
->public()
->where('program_key', $normalizedKey)
->where('placement_eligibility', true)
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at']);
$collections = (clone $baseQuery)
->orderByDesc('ranking_score')
->orderByDesc('health_score')
->limit(max(1, min($limit, 24)))
->get();
$leadCollection = $collections->first() ?: (clone $baseQuery)->first();
$partnerLabels = (clone $baseQuery)->whereNotNull('partner_label')->pluck('partner_label')->filter()->unique()->values()->all();
$sponsorshipLabels = (clone $baseQuery)->whereNotNull('sponsorship_label')->pluck('sponsorship_label')->filter()->unique()->values()->all();
return [
'program' => $leadCollection ? [
'key' => $normalizedKey,
'label' => $leadCollection->banner_text ?: $leadCollection->badge_label ?: Str::headline(str_replace(['_', '-'], ' ', $normalizedKey)),
'description' => $leadCollection->summary
?: $leadCollection->description
?: sprintf('Public collections grouped under the %s program, prepared for discovery, partner, and editorial surfaces.', Str::headline(str_replace(['_', '-'], ' ', $normalizedKey))),
'promotion_tier' => $leadCollection->promotion_tier,
'partner_labels' => $partnerLabels,
'sponsorship_labels' => $sponsorshipLabels,
'trust_tier' => $leadCollection->trust_tier,
'collections_count' => (clone $baseQuery)->count(),
] : null,
'collections' => $collections,
'editorial_collections' => (clone $baseQuery)->where('type', Collection::TYPE_EDITORIAL)->orderByDesc('ranking_score')->limit(6)->get(),
'community_collections' => (clone $baseQuery)->where('type', Collection::TYPE_COMMUNITY)->orderByDesc('ranking_score')->limit(6)->get(),
'recent_collections' => (clone $baseQuery)->latest('published_at')->limit(6)->get(),
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Services;
use App\Models\CollectionSurfacePlacement;
use Carbon\Carbon;
class CollectionPlacementService
{
public function activePlacementsForSurface(string $surfaceKey)
{
$now = Carbon::now();
return CollectionSurfacePlacement::where('surface_key', $surfaceKey)
->where('is_active', true)
->where(function ($q) use ($now) {
$q->whereNull('starts_at')->orWhere('starts_at', '<=', $now);
})
->where(function ($q) use ($now) {
$q->whereNull('ends_at')->orWhere('ends_at', '>=', $now);
})
->orderByDesc('priority')
->get();
}
}

View File

@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionProgramAssignment;
use App\Models\User;
use App\Services\CollectionCanonicalService;
use App\Services\CollectionExperimentService;
use App\Services\CollectionHealthService;
use App\Services\CollectionHistoryService;
use App\Services\CollectionObservabilityService;
use App\Services\CollectionPartnerProgramService;
use App\Services\CollectionWorkflowService;
use Illuminate\Support\Collection as SupportCollection;
class CollectionProgrammingService
{
public function __construct(
private readonly CollectionHealthService $health,
private readonly CollectionRankingService $ranking,
private readonly CollectionMergeService $merge,
private readonly CollectionCanonicalService $canonical,
private readonly CollectionWorkflowService $workflow,
private readonly CollectionExperimentService $experiments,
private readonly CollectionPartnerProgramService $partnerPrograms,
private readonly CollectionObservabilityService $observability,
) {
}
public function diagnostics(Collection $collection): array
{
return $this->observability->diagnostics($collection->fresh());
}
public function syncHooks(Collection $collection, array $attributes, ?User $actor = null): array
{
$workflowAttributes = array_intersect_key($attributes, array_flip([
'placement_eligibility',
]));
$experimentAttributes = array_intersect_key($attributes, array_flip([
'experiment_key',
'experiment_treatment',
'placement_variant',
'ranking_mode_variant',
'collection_pool_version',
'test_label',
]));
$partnerAttributes = array_intersect_key($attributes, array_flip([
'partner_key',
'trust_tier',
'promotion_tier',
'sponsorship_state',
'ownership_domain',
'commercial_review_state',
'legal_review_state',
]));
$updated = $collection->fresh();
if ($workflowAttributes !== []) {
$updated = $this->workflow->update($updated->loadMissing('user'), $workflowAttributes, $actor);
}
if ($experimentAttributes !== []) {
$updated = $this->experiments->sync($updated->loadMissing('user'), $experimentAttributes, $actor);
}
if ($partnerAttributes !== []) {
$updated = $this->partnerPrograms->sync($updated->loadMissing('user'), $partnerAttributes, $actor);
}
$updated = $updated->fresh();
return [
'collection' => $updated,
'diagnostics' => $this->observability->diagnostics($updated),
];
}
public function mergeQueue(bool $ownerView = true, int $pendingLimit = 8, int $recentLimit = 8): array
{
return $this->merge->queueOverview($ownerView, $pendingLimit, $recentLimit);
}
public function canonicalizePair(Collection $source, Collection $target, ?User $actor = null): array
{
$updatedSource = $this->canonical->designate($source->loadMissing('user'), $target->loadMissing('user'), $actor);
return [
'source' => $updatedSource,
'target' => $target->fresh(),
'mergeQueue' => $this->mergeQueue(true),
];
}
public function mergePair(Collection $source, Collection $target, ?User $actor = null): array
{
$result = $this->merge->mergeInto($source->loadMissing('user'), $target->loadMissing('user'), $actor);
return [
'source' => $result['source'],
'target' => $result['target'],
'attached_artwork_ids' => $result['attached_artwork_ids'],
'mergeQueue' => $this->mergeQueue(true),
];
}
public function rejectPair(Collection $source, Collection $target, ?User $actor = null): array
{
$updatedSource = $this->merge->rejectCandidate($source->loadMissing('user'), $target->loadMissing('user'), $actor);
return [
'source' => $updatedSource,
'target' => $target->fresh(),
'mergeQueue' => $this->mergeQueue(true),
];
}
public function assignments(): SupportCollection
{
return CollectionProgramAssignment::query()
->with(['collection.user:id,username,name', 'creator:id,username,name'])
->orderBy('program_key')
->orderByDesc('priority')
->orderBy('id')
->get();
}
public function upsertAssignment(array $attributes, ?User $actor = null): CollectionProgramAssignment
{
$assignmentId = isset($attributes['id']) ? (int) $attributes['id'] : null;
$payload = [
'collection_id' => (int) $attributes['collection_id'],
'program_key' => (string) $attributes['program_key'],
'campaign_key' => $attributes['campaign_key'] ?? null,
'placement_scope' => $attributes['placement_scope'] ?? null,
'starts_at' => $attributes['starts_at'] ?? null,
'ends_at' => $attributes['ends_at'] ?? null,
'priority' => (int) ($attributes['priority'] ?? 0),
'notes' => $attributes['notes'] ?? null,
'created_by_user_id' => $actor?->id,
];
if ($assignmentId > 0) {
$assignment = CollectionProgramAssignment::query()->findOrFail($assignmentId);
$assignment->fill($payload)->save();
} else {
$assignment = CollectionProgramAssignment::query()->create($payload);
}
$collection = Collection::query()->findOrFail((int) $payload['collection_id']);
$collection->forceFill(['program_key' => $payload['program_key']])->save();
app(CollectionHistoryService::class)->record(
$collection->fresh(),
$actor,
$assignmentId > 0 ? 'program_assignment_updated' : 'program_assignment_created',
'Collection program assignment updated.',
null,
$payload
);
return $assignment->fresh(['collection.user', 'creator']);
}
public function previewProgram(string $programKey, int $limit = 12): SupportCollection
{
return Collection::query()
->public()
->where('program_key', $programKey)
->where('placement_eligibility', true)
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->orderByDesc('ranking_score')
->orderByDesc('health_score')
->limit(max(1, min($limit, 24)))
->get();
}
public function refreshEligibility(?Collection $collection = null, ?User $actor = null): array
{
$items = $collection ? collect([$collection]) : Collection::query()->whereNotNull('program_key')->get();
$results = $items->map(function (Collection $item) use ($actor): array {
$fresh = $this->health->refresh($item, $actor, 'programming-eligibility');
return [
'collection_id' => (int) $fresh->id,
'placement_eligibility' => (bool) $fresh->placement_eligibility,
'health_state' => $fresh->health_state,
'readiness_state' => $fresh->readiness_state,
];
})->values();
return [
'count' => $results->count(),
'items' => $results->all(),
];
}
public function refreshRecommendations(?Collection $collection = null): array
{
$items = $collection ? collect([$collection]) : Collection::query()->where('placement_eligibility', true)->limit(100)->get();
$results = $items->map(function (Collection $item): array {
$fresh = $this->ranking->refresh($item);
return [
'collection_id' => (int) $fresh->id,
'recommendation_tier' => $fresh->recommendation_tier,
'ranking_bucket' => $fresh->ranking_bucket,
'search_boost_tier' => $fresh->search_boost_tier,
];
})->values();
return [
'count' => $results->count(),
'items' => $results->all(),
];
}
public function duplicateScan(?Collection $collection = null): array
{
$items = $collection ? collect([$collection]) : Collection::query()->whereNull('canonical_collection_id')->limit(100)->get();
$results = $items->map(function (Collection $item): array {
return [
'collection_id' => (int) $item->id,
'candidates' => $this->merge->duplicateCandidates($item)->map(fn (Collection $candidate) => [
'id' => (int) $candidate->id,
'title' => (string) $candidate->title,
'slug' => (string) $candidate->slug,
])->values()->all(),
];
})->filter(fn (array $row): bool => $row['candidates'] !== [])->values();
return [
'count' => $results->count(),
'items' => $results->all(),
];
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
class CollectionQualityService
{
public function sync(Collection $collection): Collection
{
$scores = $this->scores($collection->fresh());
$collection->forceFill($scores)->save();
return $collection->fresh();
}
public function scores(Collection $collection): array
{
$quality = 0.0;
$ranking = 0.0;
$titleLength = mb_strlen(trim((string) $collection->title));
$descriptionLength = mb_strlen(trim((string) ($collection->description ?? '')));
$summaryLength = mb_strlen(trim((string) ($collection->summary ?? '')));
$artworksCount = (int) $collection->artworks_count;
$engagement = ((int) $collection->likes_count * 1.6)
+ ((int) $collection->followers_count * 2.2)
+ ((int) $collection->saves_count * 2.4)
+ ((int) $collection->comments_count * 1.2)
+ ((int) $collection->shares_count * 1.8);
$quality += $titleLength >= 12 ? 12 : ($titleLength >= 6 ? 6 : 0);
$quality += $descriptionLength >= 120 ? 12 : ($descriptionLength >= 60 ? 6 : 0);
$quality += $summaryLength >= 60 ? 8 : ($summaryLength >= 20 ? 4 : 0);
$quality += $collection->resolvedCoverArtwork(false) ? 14 : 0;
$quality += min(24, $artworksCount * 2);
$quality += $collection->type === Collection::TYPE_EDITORIAL ? 6 : 0;
$quality += $collection->type === Collection::TYPE_COMMUNITY ? 4 : 0;
$quality += $collection->isCollaborative() ? min(6, (int) $collection->collaborators_count) : 0;
$quality += filled($collection->event_key) || filled($collection->season_key) || filled($collection->campaign_key) ? 4 : 0;
$quality += $collection->usesPremiumPresentation() ? 5 : 0;
$quality += $collection->moderation_status === Collection::MODERATION_ACTIVE ? 5 : -8;
$quality = max(0.0, min(100.0, $quality));
$recencyBoost = 0.0;
if ($collection->last_activity_at) {
$days = max(0, now()->diffInDays($collection->last_activity_at));
$recencyBoost = max(0.0, 12.0 - min(12.0, $days * 0.4));
}
$ranking = min(150.0, $quality + $recencyBoost + min(38.0, $engagement / 25));
return [
'quality_score' => round($quality, 2),
'ranking_score' => round($ranking, 2),
];
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionRecommendationSnapshot;
class CollectionRankingService
{
public function explain(Collection $collection, string $context = 'default'): array
{
$signals = [
'quality_score' => (float) ($collection->quality_score ?? 0),
'health_score' => (float) ($collection->health_score ?? 0),
'freshness_score' => (float) ($collection->freshness_score ?? 0),
'engagement_score' => (float) ($collection->engagement_score ?? 0),
'editorial_readiness_score' => (float) ($collection->editorial_readiness_score ?? 0),
];
$score = ($signals['quality_score'] * 0.3)
+ ($signals['health_score'] * 0.3)
+ ($signals['freshness_score'] * 0.15)
+ ($signals['engagement_score'] * 0.2)
+ ($signals['editorial_readiness_score'] * 0.05);
if ($collection->is_featured) {
$score += 5.0;
}
if ($context === 'campaign' && filled($collection->campaign_key)) {
$score += 7.5;
}
if ($context === 'evergreen' && $signals['freshness_score'] < 35 && $signals['quality_score'] >= 70) {
$score += 6.0;
}
$score = round(max(0.0, min(150.0, $score)), 2);
return [
'score' => $score,
'context' => $context,
'signals' => $signals,
'bucket' => $this->rankingBucket($score),
'recommendation_tier' => $this->recommendationTier($score),
'search_boost_tier' => $this->searchBoostTier($collection, $score),
'rationale' => [
sprintf('Quality %.1f', $signals['quality_score']),
sprintf('Health %.1f', $signals['health_score']),
sprintf('Freshness %.1f', $signals['freshness_score']),
sprintf('Engagement %.1f', $signals['engagement_score']),
],
];
}
public function refresh(Collection $collection, string $context = 'default'): Collection
{
$explanation = $this->explain($collection->fresh(), $context);
$snapshotDate = now()->toDateString();
$collection->forceFill([
'ranking_bucket' => $explanation['bucket'],
'recommendation_tier' => $explanation['recommendation_tier'],
'search_boost_tier' => $explanation['search_boost_tier'],
'last_recommendation_refresh_at' => now(),
])->save();
$snapshot = CollectionRecommendationSnapshot::query()
->where('collection_id', $collection->id)
->where('context_key', $context)
->whereDate('snapshot_date', $snapshotDate)
->first();
if ($snapshot) {
$snapshot->forceFill([
'recommendation_score' => $explanation['score'],
'rationale_json' => $explanation,
])->save();
} else {
CollectionRecommendationSnapshot::query()->create([
'collection_id' => $collection->id,
'context_key' => $context,
'recommendation_score' => $explanation['score'],
'rationale_json' => $explanation,
'snapshot_date' => $snapshotDate,
]);
}
return $collection->fresh();
}
private function rankingBucket(float $score): string
{
return match (true) {
$score >= 110 => 'elite',
$score >= 85 => 'strong',
$score >= 60 => 'steady',
$score >= 35 => 'emerging',
default => 'cold',
};
}
private function recommendationTier(float $score): string
{
return match (true) {
$score >= 105 => 'premium',
$score >= 80 => 'primary',
$score >= 55 => 'secondary',
default => 'fallback',
};
}
private function searchBoostTier(Collection $collection, float $score): string
{
if ($collection->type === Collection::TYPE_EDITORIAL && $score >= 80) {
return 'editorial';
}
if ($collection->placement_eligibility && $score >= 70) {
return 'high';
}
if ($score >= 45) {
return 'standard';
}
return 'low';
}
}

View File

@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\DB;
class CollectionRecommendationService
{
public function recommendedForUser(?User $user, int $limit = 12): EloquentCollection
{
$safeLimit = max(1, min($limit, 18));
if (! $user) {
return $this->fallbackPublicCollections($safeLimit);
}
$seedIds = collect()
->merge(DB::table('collection_saves')->where('user_id', $user->id)->pluck('collection_id'))
->merge(DB::table('collection_likes')->where('user_id', $user->id)->pluck('collection_id'))
->merge(DB::table('collection_follows')->where('user_id', $user->id)->pluck('collection_id'))
->map(static fn ($id) => (int) $id)
->unique()
->values();
$followedCreatorIds = DB::table('user_followers')
->where('follower_id', $user->id)
->pluck('user_id')
->map(static fn ($id) => (int) $id)
->unique()
->values();
$seedCollections = $seedIds->isEmpty()
? collect()
: Collection::query()
->publicEligible()
->whereIn('id', $seedIds->all())
->get(['id', 'type', 'event_key', 'campaign_key', 'season_key', 'user_id']);
$candidateQuery = Collection::query()
->publicEligible()
->with([
'user:id,username,name',
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
])
->when($seedIds->isNotEmpty(), fn ($query) => $query->whereNotIn('id', $seedIds->all()))
->orderByDesc('ranking_score')
->orderByDesc('followers_count')
->orderByDesc('saves_count')
->orderByDesc('updated_at')
->limit(max(24, $safeLimit * 4));
if ($seedCollections->isEmpty() && $followedCreatorIds->isNotEmpty()) {
$candidateQuery->whereIn('user_id', $followedCreatorIds->all());
}
$candidates = $candidateQuery->get();
if ($candidates->isEmpty()) {
return $this->fallbackPublicCollections($safeLimit);
}
$candidateIds = $candidates->pluck('id')->map(static fn ($id) => (int) $id)->all();
$creatorMap = $this->creatorMap($candidateIds);
$tagMap = $this->tagMap($candidateIds);
$seedTypes = $seedCollections->pluck('type')->filter()->unique()->values()->all();
$seedCampaigns = $seedCollections->pluck('campaign_key')->filter()->unique()->values()->all();
$seedEvents = $seedCollections->pluck('event_key')->filter()->unique()->values()->all();
$seedSeasons = $seedCollections->pluck('season_key')->filter()->unique()->values()->all();
$seedCreatorIds = $seedIds->isEmpty()
? []
: collect($this->creatorMap($seedIds->all()))
->flatten()
->map(static fn ($id) => (int) $id)
->unique()
->values()
->all();
$seedTagSlugs = $seedIds->isEmpty()
? []
: $seedCollections
->map(fn (Collection $collection) => $this->signalTagSlugs($collection, $this->tagMap([(int) $collection->id])[(int) $collection->id] ?? []))
->flatten()
->unique()
->values()
->all();
return new EloquentCollection($candidates
->map(function (Collection $candidate) use ($safeLimit, $seedTypes, $seedCampaigns, $seedEvents, $seedSeasons, $seedCreatorIds, $seedTagSlugs, $followedCreatorIds, $creatorMap, $tagMap): array {
$candidateCreators = $creatorMap[(int) $candidate->id] ?? [];
$candidateTags = $this->signalTagSlugs($candidate, $tagMap[(int) $candidate->id] ?? []);
$score = 0;
$score += in_array($candidate->type, $seedTypes, true) ? 5 : 0;
$score += ($candidate->campaign_key && in_array($candidate->campaign_key, $seedCampaigns, true)) ? 4 : 0;
$score += ($candidate->event_key && in_array($candidate->event_key, $seedEvents, true)) ? 4 : 0;
$score += ($candidate->season_key && in_array($candidate->season_key, $seedSeasons, true)) ? 3 : 0;
$score += in_array((int) $candidate->user_id, $followedCreatorIds->all(), true) ? 6 : 0;
$score += count(array_intersect($seedCreatorIds, $candidateCreators)) * 2;
$score += count(array_intersect($seedTagSlugs, $candidateTags));
$score += $candidate->is_featured ? 2 : 0;
$score += min(4, (int) floor(((int) $candidate->followers_count + (int) $candidate->saves_count) / 40));
$score += min(3, (int) floor((float) $candidate->ranking_score / 25));
return [
'score' => $score,
'collection' => $candidate,
];
})
->sortByDesc(fn (array $item) => sprintf('%08d-%s', $item['score'], optional($item['collection']->updated_at)?->timestamp ?? 0))
->take($safeLimit)
->pluck('collection')
->values()
->all());
}
public function relatedPublicCollections(Collection $collection, int $limit = 6): EloquentCollection
{
$safeLimit = max(1, min($limit, 12));
$candidates = Collection::query()
->publicEligible()
->where('id', '!=', $collection->id)
->with([
'user:id,username,name',
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
])
->orderByDesc('is_featured')
->orderByDesc('followers_count')
->orderByDesc('saves_count')
->orderByDesc('updated_at')
->limit(30)
->get();
if ($candidates->isEmpty()) {
return $candidates;
}
$candidateIds = $candidates->pluck('id')->map(static fn ($id) => (int) $id)->all();
$creatorMap = $this->creatorMap($candidateIds);
$tagMap = $this->tagMap($candidateIds);
$currentCreatorIds = $this->creatorMap([(int) $collection->id])[(int) $collection->id] ?? [];
$currentTagSlugs = $this->signalTagSlugs($collection, $this->tagMap([(int) $collection->id])[(int) $collection->id] ?? []);
return new EloquentCollection($candidates
->map(function (Collection $candidate) use ($collection, $creatorMap, $tagMap, $currentCreatorIds, $currentTagSlugs): array {
$candidateCreators = $creatorMap[(int) $candidate->id] ?? [];
$candidateTags = $this->signalTagSlugs($candidate, $tagMap[(int) $candidate->id] ?? []);
$score = 0;
$score += $candidate->type === $collection->type ? 4 : 0;
$score += (int) $candidate->user_id === (int) $collection->user_id ? 3 : 0;
$score += ($collection->event_key && $candidate->event_key === $collection->event_key) ? 4 : 0;
$score += $candidate->is_featured ? 1 : 0;
$score += count(array_intersect($currentCreatorIds, $candidateCreators)) * 2;
$score += count(array_intersect($currentTagSlugs, $candidateTags));
$score += min(2, (int) floor(((int) $candidate->saves_count + (int) $candidate->followers_count) / 25));
return [
'score' => $score,
'collection' => $candidate,
];
})
->sortByDesc(fn (array $item) => sprintf('%08d-%s', $item['score'], optional($item['collection']->updated_at)?->timestamp ?? 0))
->take($safeLimit)
->pluck('collection')
->values()
->all());
}
/**
* @param array<int, int> $collectionIds
* @return array<int, array<int, int>>
*/
private function creatorMap(array $collectionIds): array
{
return DB::table('collection_artwork as ca')
->join('artworks as a', 'a.id', '=', 'ca.artwork_id')
->whereIn('ca.collection_id', $collectionIds)
->whereNull('a.deleted_at')
->select('ca.collection_id', 'a.user_id')
->get()
->groupBy('collection_id')
->map(fn ($rows) => collect($rows)->pluck('user_id')->map(static fn ($id) => (int) $id)->unique()->values()->all())
->mapWithKeys(fn ($value, $key) => [(int) $key => $value])
->all();
}
/**
* @param array<int, int> $collectionIds
* @return array<int, array<int, string>>
*/
private function tagMap(array $collectionIds): array
{
return DB::table('collection_artwork as ca')
->join('artwork_tag as at', 'at.artwork_id', '=', 'ca.artwork_id')
->join('tags as t', 't.id', '=', 'at.tag_id')
->whereIn('ca.collection_id', $collectionIds)
->select('ca.collection_id', 't.slug')
->get()
->groupBy('collection_id')
->map(fn ($rows) => collect($rows)->pluck('slug')->map(static fn ($slug) => (string) $slug)->unique()->take(10)->values()->all())
->mapWithKeys(fn ($value, $key) => [(int) $key => $value])
->all();
}
/**
* @param array<int, string> $tagSlugs
* @return array<int, string>
*/
private function signalTagSlugs(Collection $collection, array $tagSlugs): array
{
if (! $collection->isSmart() || ! is_array($collection->smart_rules_json)) {
return $tagSlugs;
}
$ruleTags = collect($collection->smart_rules_json['rules'] ?? [])
->map(fn ($rule) => is_array($rule) ? ($rule['value'] ?? null) : null)
->filter(fn ($value) => is_string($value) && $value !== '')
->map(fn (string $value) => strtolower(trim($value)))
->take(10)
->all();
return array_values(array_unique(array_merge($tagSlugs, $ruleTags)));
}
private function fallbackPublicCollections(int $limit): EloquentCollection
{
return Collection::query()
->publicEligible()
->with([
'user:id,username,name',
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
])
->orderByDesc('ranking_score')
->orderByDesc('followers_count')
->orderByDesc('updated_at')
->limit($limit)
->get();
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CollectionSaveService
{
public function save(User $actor, Collection $collection, ?string $context = null, array $contextMeta = []): bool
{
$this->guard($actor, $collection);
$inserted = false;
DB::transaction(function () use ($actor, $collection, $context, $contextMeta, &$inserted): void {
$rows = DB::table('collection_saves')->insertOrIgnore([
'collection_id' => $collection->id,
'user_id' => $actor->id,
'created_at' => now(),
'last_viewed_at' => now(),
'save_context' => $context,
'save_context_meta_json' => $contextMeta === [] ? null : json_encode($contextMeta, JSON_THROW_ON_ERROR),
]);
if ($rows === 0) {
DB::table('collection_saves')
->where('collection_id', $collection->id)
->where('user_id', $actor->id)
->update([
'last_viewed_at' => now(),
'save_context' => $context,
'save_context_meta_json' => $contextMeta === [] ? null : json_encode($contextMeta, JSON_THROW_ON_ERROR),
]);
return;
}
$inserted = true;
DB::table('collections')
->where('id', $collection->id)
->update([
'saves_count' => DB::raw('saves_count + 1'),
'last_activity_at' => now(),
'updated_at' => now(),
]);
});
return $inserted;
}
public function touchSavedCollectionView(?User $actor, Collection $collection): void
{
if (! $actor) {
return;
}
DB::table('collection_saves')
->where('collection_id', $collection->id)
->where('user_id', $actor->id)
->update([
'last_viewed_at' => now(),
]);
}
public function unsave(User $actor, Collection $collection): bool
{
$deleted = false;
DB::transaction(function () use ($actor, $collection, &$deleted): void {
$rows = DB::table('collection_saves')
->where('collection_id', $collection->id)
->where('user_id', $actor->id)
->delete();
if ($rows === 0) {
return;
}
$deleted = true;
$savedListIds = DB::table('collection_saved_lists')
->where('user_id', $actor->id)
->pluck('id');
if ($savedListIds->isNotEmpty()) {
DB::table('collection_saved_list_items')
->whereIn('saved_list_id', $savedListIds->all())
->where('collection_id', $collection->id)
->delete();
}
DB::table('collections')
->where('id', $collection->id)
->where('saves_count', '>', 0)
->update([
'saves_count' => DB::raw('saves_count - 1'),
'updated_at' => now(),
]);
});
return $deleted;
}
public function isSaved(?User $viewer, Collection $collection): bool
{
if (! $viewer) {
return false;
}
return DB::table('collection_saves')
->where('collection_id', $collection->id)
->where('user_id', $viewer->id)
->exists();
}
private function guard(User $actor, Collection $collection): void
{
if (! $collection->canBeSavedBy($actor)) {
throw ValidationException::withMessages([
'collection' => 'This collection cannot be saved.',
]);
}
}
}

View File

@@ -0,0 +1,334 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionSave;
use App\Models\CollectionSavedNote;
use App\Models\CollectionSavedList;
use App\Models\CollectionSavedListItem;
use App\Models\User;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class CollectionSavedLibraryService
{
/**
* @param array<int, int> $collectionIds
* @return array<int, array{saved_because:?string,last_viewed_at:?string}>
*/
public function saveMetadataFor(User $user, array $collectionIds): array
{
if ($collectionIds === []) {
return [];
}
return CollectionSave::query()
->where('user_id', $user->id)
->whereIn('collection_id', $collectionIds)
->get(['collection_id', 'save_context', 'save_context_meta_json', 'last_viewed_at'])
->mapWithKeys(function (CollectionSave $save): array {
return [
(int) $save->collection_id => [
'saved_because' => $this->savedBecauseLabel($save),
'last_viewed_at' => $save->last_viewed_at?->toIso8601String(),
],
];
})
->all();
}
public function recentlyRevisited(User $user, int $limit = 6): SupportCollection
{
$savedIds = CollectionSave::query()
->where('user_id', $user->id)
->whereNotNull('last_viewed_at')
->orderByDesc('last_viewed_at')
->limit(max(1, min($limit, 12)))
->pluck('collection_id')
->map(static fn ($id): int => (int) $id)
->all();
if ($savedIds === []) {
return collect();
}
$collections = Collection::query()
->public()
->with([
'user:id,username,name',
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
])
->whereIn('id', $savedIds)
->get()
->keyBy('id');
return collect($savedIds)
->map(fn (int $collectionId) => $collections->get($collectionId))
->filter()
->values();
}
public function listsFor(User $user): array
{
return CollectionSavedList::query()
->withCount('items')
->where('user_id', $user->id)
->orderBy('title')
->get()
->map(fn (CollectionSavedList $list) => [
'id' => (int) $list->id,
'title' => $list->title,
'slug' => $list->slug,
'items_count' => (int) $list->items_count,
])
->all();
}
public function findListBySlugForUser(User $user, string $slug): CollectionSavedList
{
return CollectionSavedList::query()
->withCount('items')
->where('user_id', $user->id)
->where('slug', $slug)
->firstOrFail();
}
/**
* @param array<int, int> $collectionIds
* @return array<int, array<int, int>>
*/
public function membershipsFor(User $user, array $collectionIds): array
{
if ($collectionIds === []) {
return [];
}
return DB::table('collection_saved_list_items as items')
->join('collection_saved_lists as lists', 'lists.id', '=', 'items.saved_list_id')
->where('lists.user_id', $user->id)
->whereIn('items.collection_id', $collectionIds)
->orderBy('items.saved_list_id')
->get(['items.collection_id', 'items.saved_list_id'])
->groupBy('collection_id')
->map(fn ($rows) => collect($rows)->pluck('saved_list_id')->map(static fn ($id) => (int) $id)->values()->all())
->mapWithKeys(fn ($listIds, $collectionId) => [(int) $collectionId => $listIds])
->all();
}
/**
* @param array<int, int> $collectionIds
* @return array<int, string>
*/
public function notesFor(User $user, array $collectionIds): array
{
if ($collectionIds === []) {
return [];
}
return CollectionSavedNote::query()
->where('user_id', $user->id)
->whereIn('collection_id', $collectionIds)
->pluck('note', 'collection_id')
->mapWithKeys(fn ($note, $collectionId) => [(int) $collectionId => (string) $note])
->all();
}
/**
* @return array<int, int>
*/
public function collectionIdsForList(User $user, CollectionSavedList $list): array
{
abort_unless((int) $list->user_id === (int) $user->id, 403);
return CollectionSavedListItem::query()
->where('saved_list_id', $list->id)
->orderBy('order_num')
->pluck('collection_id')
->map(static fn ($id) => (int) $id)
->all();
}
public function createList(User $user, string $title): CollectionSavedList
{
$slug = $this->uniqueSlug($user, $title);
return CollectionSavedList::query()->create([
'user_id' => $user->id,
'title' => $title,
'slug' => $slug,
]);
}
public function addToList(User $user, CollectionSavedList $list, Collection $collection): CollectionSavedListItem
{
abort_unless((int) $list->user_id === (int) $user->id, 403);
$nextOrder = (int) (CollectionSavedListItem::query()->where('saved_list_id', $list->id)->max('order_num') ?? -1) + 1;
return CollectionSavedListItem::query()->firstOrCreate(
[
'saved_list_id' => $list->id,
'collection_id' => $collection->id,
],
[
'order_num' => $nextOrder,
'created_at' => now(),
]
);
}
public function removeFromList(User $user, CollectionSavedList $list, Collection $collection): bool
{
abort_unless((int) $list->user_id === (int) $user->id, 403);
$deleted = CollectionSavedListItem::query()
->where('saved_list_id', $list->id)
->where('collection_id', $collection->id)
->delete();
if ($deleted > 0) {
$this->normalizeOrder($list);
}
return $deleted > 0;
}
/**
* @param array<int, int|string> $orderedCollectionIds
*/
public function reorderList(User $user, CollectionSavedList $list, array $orderedCollectionIds): void
{
abort_unless((int) $list->user_id === (int) $user->id, 403);
$normalizedIds = collect($orderedCollectionIds)
->map(static fn ($id) => (int) $id)
->filter(static fn (int $id) => $id > 0)
->values();
$currentIds = collect($this->collectionIdsForList($user, $list))->values();
if ($normalizedIds->count() !== $currentIds->count() || $normalizedIds->diff($currentIds)->isNotEmpty() || $currentIds->diff($normalizedIds)->isNotEmpty()) {
throw ValidationException::withMessages([
'collection_ids' => 'The submitted saved-list order is invalid.',
]);
}
DB::transaction(function () use ($list, $normalizedIds): void {
/** @var SupportCollection<int, int> $itemIds */
$itemIds = CollectionSavedListItem::query()
->where('saved_list_id', $list->id)
->whereIn('collection_id', $normalizedIds->all())
->pluck('id', 'collection_id')
->mapWithKeys(static fn ($id, $collectionId) => [(int) $collectionId => (int) $id]);
foreach ($normalizedIds as $index => $collectionId) {
$itemId = $itemIds->get($collectionId);
if (! $itemId) {
continue;
}
CollectionSavedListItem::query()
->whereKey($itemId)
->update(['order_num' => $index]);
}
});
}
public function itemsCount(CollectionSavedList $list): int
{
return (int) CollectionSavedListItem::query()
->where('saved_list_id', $list->id)
->count();
}
public function upsertNote(User $user, Collection $collection, ?string $note): ?CollectionSavedNote
{
$hasSavedCollection = DB::table('collection_saves')
->where('user_id', $user->id)
->where('collection_id', $collection->id)
->exists();
if (! $hasSavedCollection) {
throw ValidationException::withMessages([
'collection' => 'You can only add notes to collections saved in your library.',
]);
}
$normalizedNote = trim((string) ($note ?? ''));
if ($normalizedNote === '') {
CollectionSavedNote::query()
->where('user_id', $user->id)
->where('collection_id', $collection->id)
->delete();
return null;
}
return CollectionSavedNote::query()->updateOrCreate(
[
'user_id' => $user->id,
'collection_id' => $collection->id,
],
[
'note' => $normalizedNote,
]
);
}
private function uniqueSlug(User $user, string $title): string
{
$base = Str::slug(Str::limit($title, 80, '')) ?: 'saved-list';
$slug = $base;
$suffix = 2;
while (CollectionSavedList::query()->where('user_id', $user->id)->where('slug', $slug)->exists()) {
$slug = $base . '-' . $suffix;
$suffix++;
}
return $slug;
}
private function normalizeOrder(CollectionSavedList $list): void
{
$itemIds = CollectionSavedListItem::query()
->where('saved_list_id', $list->id)
->orderBy('order_num')
->orderBy('id')
->pluck('id');
foreach ($itemIds as $index => $itemId) {
CollectionSavedListItem::query()
->whereKey($itemId)
->update(['order_num' => $index]);
}
}
private function savedBecauseLabel(CollectionSave $save): ?string
{
$context = trim((string) ($save->save_context ?? ''));
$meta = is_array($save->save_context_meta_json) ? $save->save_context_meta_json : [];
return match ($context) {
'collection_detail' => 'Saved from the collection page',
'featured_collections' => 'Saved from featured collections',
'featured_landing' => 'Saved from featured collections',
'recommended_landing' => 'Saved from recommended collections',
'trending_landing' => 'Saved from trending collections',
'community_landing' => 'Saved from community collections',
'editorial_landing' => 'Saved from editorial collections',
'seasonal_landing' => 'Saved from seasonal collections',
'collection_search' => ! empty($meta['query']) ? sprintf('Saved from search for "%s"', (string) $meta['query']) : 'Saved from collection search',
'community_row', 'trending_row', 'editorial_row', 'seasonal_row', 'recent_row' => ! empty($meta['surface_label']) ? sprintf('Saved from %s', (string) $meta['surface_label']) : 'Saved from a collection rail',
'program_landing' => ! empty($meta['program_label']) ? sprintf('Saved from the %s program', (string) $meta['program_label']) : (! empty($meta['program_key']) ? sprintf('Saved from the %s program', (string) $meta['program_key']) : 'Saved from a program landing'),
'campaign_landing' => ! empty($meta['campaign_label']) ? sprintf('Saved during %s', (string) $meta['campaign_label']) : (! empty($meta['campaign_key']) ? sprintf('Saved during %s', (string) $meta['campaign_key']) : 'Saved from a campaign landing'),
default => $context !== '' ? str_replace('_', ' ', ucfirst($context)) : null,
};
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Category;
use App\Models\Collection;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class CollectionSearchService
{
public function publicSearch(array $filters, int $perPage = 18): LengthAwarePaginator
{
$query = Collection::query()
->publicEligible()
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at']);
$this->applySharedFilters($query, $filters, false);
$this->applyPublicSort($query, (string) ($filters['sort'] ?? 'trending'));
return $query->paginate(max(1, min($perPage, 24)))->withQueryString();
}
public function ownerSearch(User $user, array $filters, int $perPage = 20): LengthAwarePaginator
{
$query = Collection::query()
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->when(! $user->isAdmin() && ! $user->isModerator(), fn ($builder) => $builder->where('user_id', $user->id));
$this->applySharedFilters($query, $filters, true);
return $query
->orderByDesc('updated_at')
->paginate(max(1, min($perPage, 50)))
->withQueryString();
}
public function publicFilterOptions(): array
{
$themeOptions = Collection::query()
->publicEligible()
->whereNotNull('theme_token')
->where('theme_token', '!=', '')
->select('theme_token')
->distinct()
->orderBy('theme_token')
->limit(12)
->pluck('theme_token')
->map(fn ($token): array => [
'value' => (string) $token,
'label' => $this->humanizeToken((string) $token),
])
->values()
->all();
$categoryOptions = Category::query()
->active()
->orderBy('sort_order')
->orderBy('name')
->limit(16)
->get(['slug', 'name'])
->map(fn (Category $category): array => [
'value' => (string) $category->slug,
'label' => (string) $category->name,
])
->values()
->all();
$styleOptions = collect((array) config('collections.smart_rules.style_terms', []))
->map(fn ($term): array => [
'value' => (string) $term,
'label' => $this->humanizeToken((string) $term),
])
->values()
->all();
$colorOptions = collect((array) config('collections.smart_rules.color_terms', []))
->map(fn ($term): array => [
'value' => (string) $term,
'label' => $this->humanizeToken((string) $term),
])
->values()
->all();
return [
'category' => $categoryOptions,
'style' => $styleOptions,
'theme' => $themeOptions,
'color' => $colorOptions,
'quality_tier' => [
['value' => 'editorial', 'label' => 'Editorial'],
['value' => 'high', 'label' => 'High'],
['value' => 'standard', 'label' => 'Standard'],
['value' => 'limited', 'label' => 'Limited'],
],
];
}
private function applySharedFilters($query, array $filters, bool $includeInternal): void
{
if (filled($filters['q'] ?? null)) {
$term = '%' . trim((string) $filters['q']) . '%';
$query->where(function ($builder) use ($term): void {
$builder->where('title', 'like', $term)
->orWhere('summary', 'like', $term)
->orWhere('description', 'like', $term)
->orWhere('campaign_label', 'like', $term)
->orWhere('series_title', 'like', $term)
->orWhereHas('user', function (Builder $userQuery) use ($term): void {
$userQuery->where('username', 'like', $term)
->orWhere('name', 'like', $term);
});
});
}
foreach (['type', 'visibility', 'lifecycle_state', 'mode', 'campaign_key', 'program_key', 'workflow_state', 'health_state'] as $field) {
if (filled($filters[$field] ?? null)) {
$query->where($field, (string) $filters[$field]);
}
}
if (filled($filters['quality_tier'] ?? null)) {
$query->where('trust_tier', (string) $filters['quality_tier']);
}
if (filled($filters['theme'] ?? null)) {
$theme = trim((string) $filters['theme']);
$themeLike = '%' . mb_strtolower($theme) . '%';
$query->where(function (Builder $builder) use ($theme, $themeLike): void {
$builder->whereRaw('LOWER(theme_token) = ?', [mb_strtolower($theme)])
->orWhereHas('entityLinks', function (Builder $linkQuery) use ($themeLike): void {
$linkQuery->where('linked_type', CollectionLinkService::TYPE_TAG)
->whereExists(function ($tagQuery) use ($themeLike): void {
$tagQuery->select(DB::raw('1'))
->from('tags')
->whereColumn('tags.id', 'collection_entity_links.linked_id')
->where(function ($tagBuilder) use ($themeLike): void {
$tagBuilder->whereRaw('LOWER(tags.slug) like ?', [$themeLike])
->orWhereRaw('LOWER(tags.name) like ?', [$themeLike]);
});
});
})
->orWhereHas('artworks.tags', function (Builder $tagQuery) use ($themeLike): void {
$tagQuery->whereRaw('LOWER(tags.slug) like ?', [$themeLike])
->orWhereRaw('LOWER(tags.name) like ?', [$themeLike]);
});
});
}
if (filled($filters['category'] ?? null)) {
$category = trim((string) $filters['category']);
$categoryLike = '%' . mb_strtolower($category) . '%';
$query->where(function (Builder $builder) use ($categoryLike): void {
$builder->whereHas('entityLinks', function (Builder $linkQuery) use ($categoryLike): void {
$linkQuery->where('linked_type', CollectionLinkService::TYPE_CATEGORY)
->whereExists(function ($categoryQuery) use ($categoryLike): void {
$categoryQuery->select(DB::raw('1'))
->from('categories')
->whereColumn('categories.id', 'collection_entity_links.linked_id')
->where(function ($inner) use ($categoryLike): void {
$inner->whereRaw('LOWER(categories.slug) like ?', [$categoryLike])
->orWhereRaw('LOWER(categories.name) like ?', [$categoryLike]);
});
});
})->orWhereHas('artworks.categories', function (Builder $categoryQuery) use ($categoryLike): void {
$categoryQuery->whereRaw('LOWER(categories.slug) like ?', [$categoryLike])
->orWhereRaw('LOWER(categories.name) like ?', [$categoryLike]);
});
});
}
if (filled($filters['style'] ?? null)) {
$style = trim((string) $filters['style']);
$styleLike = '%' . mb_strtolower($style) . '%';
$query->where(function (Builder $builder) use ($styleLike): void {
$builder->whereRaw('LOWER(spotlight_style) like ?', [$styleLike])
->orWhereRaw('LOWER(presentation_style) like ?', [$styleLike])
->orWhereHas('artworks.tags', function (Builder $tagQuery) use ($styleLike): void {
$tagQuery->whereRaw('LOWER(tags.slug) like ?', [$styleLike])
->orWhereRaw('LOWER(tags.name) like ?', [$styleLike]);
});
});
}
if (filled($filters['color'] ?? null)) {
$color = trim((string) $filters['color']);
$colorLike = '%' . mb_strtolower($color) . '%';
$query->where(function (Builder $builder) use ($colorLike): void {
$builder->whereRaw('LOWER(theme_token) like ?', [$colorLike])
->orWhereHas('artworks.tags', function (Builder $tagQuery) use ($colorLike): void {
$tagQuery->whereRaw('LOWER(tags.slug) like ?', [$colorLike])
->orWhereRaw('LOWER(tags.name) like ?', [$colorLike]);
});
});
}
if (filled($filters['placement_eligibility'] ?? null) && $includeInternal) {
$query->where('placement_eligibility', filter_var($filters['placement_eligibility'], FILTER_VALIDATE_BOOLEAN));
}
if (filled($filters['partner_key'] ?? null) && $includeInternal) {
$query->where('partner_key', (string) $filters['partner_key']);
}
if (filled($filters['experiment_key'] ?? null) && $includeInternal) {
$query->where('experiment_key', (string) $filters['experiment_key']);
}
}
private function applyPublicSort($query, string $sort): void
{
match ($sort) {
'recent' => $query->orderByDesc('published_at')->orderByDesc('updated_at'),
'quality' => $query->orderByDesc('health_score')->orderByDesc('quality_score'),
'evergreen' => $query->orderByDesc('quality_score')->orderByDesc('followers_count'),
default => $query->orderByDesc('ranking_score')->orderByDesc('health_score')->orderByDesc('updated_at'),
};
}
private function humanizeToken(string $value): string
{
return str($value)
->replace(['_', '-'], ' ')
->title()
->value();
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\User;
use Illuminate\Support\Collection as SupportCollection;
class CollectionSeriesService
{
public function __construct(
private readonly CollectionService $collections,
) {
}
public function updateSeries(Collection $collection, array $attributes, ?User $actor = null): Collection
{
return $this->collections->updateCollection(
$collection->loadMissing('user'),
$this->normalizeAttributes($attributes),
$actor,
);
}
public function metadataFor(Collection|SupportCollection $seriesSource): array
{
$items = $seriesSource instanceof Collection
? collect([$seriesSource])->when($seriesSource->series_key, fn (SupportCollection $current) => $current->concat($this->publicSeriesItems((string) $seriesSource->series_key)))
: $seriesSource;
$metaSource = $items
->first(fn (Collection $item) => filled($item->series_title) || filled($item->series_description));
return [
'title' => $metaSource?->series_title,
'description' => $metaSource?->series_description,
];
}
public function seriesContext(Collection $collection): array
{
if (! $collection->inSeries()) {
return [
'key' => null,
'title' => null,
'description' => null,
'items' => [],
'previous' => null,
'next' => null,
];
}
$items = Collection::query()
->public()
->where('series_key', $collection->series_key)
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->orderBy('series_order')
->orderBy('id')
->get();
$index = $items->search(fn (Collection $item) => (int) $item->id === (int) $collection->id);
return [
'key' => $collection->series_key,
'title' => $this->metadataFor($items->prepend($collection))['title'],
'description' => $this->metadataFor($items->prepend($collection))['description'],
'items' => $items,
'previous' => $index !== false && $index > 0 ? $items->get($index - 1) : null,
'next' => $index !== false ? $items->get($index + 1) : null,
];
}
public function publicSeriesItems(string $seriesKey): SupportCollection
{
return Collection::query()
->public()
->where('series_key', $seriesKey)
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->orderByRaw('CASE WHEN series_order IS NULL THEN 1 ELSE 0 END')
->orderBy('series_order')
->orderBy('id')
->get();
}
public function summary(Collection $collection): array
{
$context = $collection->inSeries()
? $this->seriesContext($collection)
: ['key' => null, 'title' => null, 'description' => null, 'items' => [], 'previous' => null, 'next' => null];
return [
'key' => $context['key'] ?? $collection->series_key,
'title' => $context['title'] ?? $collection->series_title,
'description' => $context['description'] ?? $collection->series_description,
'order' => $collection->series_order,
'siblings_count' => max(0, count($context['items'] ?? [])),
'previous_id' => $context['previous']?->id,
'next_id' => $context['next']?->id,
'public_url' => filled($collection->series_key)
? route('collections.series.show', ['seriesKey' => $collection->series_key])
: null,
];
}
private function normalizeAttributes(array $attributes): array
{
if (blank($attributes['series_key'] ?? null)) {
return [
'series_key' => null,
'series_title' => null,
'series_description' => null,
'series_order' => null,
];
}
return $attributes;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\CollectionSubmission;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CollectionSubmissionService
{
public function __construct(
private readonly CollectionService $collections,
private readonly NotificationService $notifications,
) {
}
public function submit(Collection $collection, User $actor, Artwork $artwork, ?string $message = null): CollectionSubmission
{
if (! $collection->canReceiveSubmissionsFrom($actor)) {
throw ValidationException::withMessages([
'collection' => 'This collection is not accepting submissions.',
]);
}
if ((int) $artwork->user_id !== (int) $actor->id) {
throw ValidationException::withMessages([
'artwork_id' => 'You can only submit your own artwork.',
]);
}
if ($collection->mode !== Collection::MODE_MANUAL) {
throw ValidationException::withMessages([
'collection' => 'Submissions are only supported for manual collections.',
]);
}
$this->guardAgainstSubmissionSpam($collection, $actor, $artwork);
$submission = CollectionSubmission::query()->firstOrNew([
'collection_id' => $collection->id,
'artwork_id' => $artwork->id,
'user_id' => $actor->id,
]);
if ($submission->exists && $submission->status === Collection::SUBMISSION_PENDING) {
throw ValidationException::withMessages([
'artwork_id' => 'This artwork already has a pending submission.',
]);
}
$submission->fill([
'message' => $message,
'status' => Collection::SUBMISSION_PENDING,
'reviewed_by_user_id' => null,
'reviewed_at' => null,
])->save();
$this->notifications->notifyCollectionSubmission($collection->user, $actor, $collection, $artwork);
return $submission->fresh(['user.profile', 'artwork']);
}
private function guardAgainstSubmissionSpam(Collection $collection, User $actor, Artwork $artwork): void
{
$perHourLimit = max(1, (int) config('collections.submissions.max_per_hour', 8));
$duplicateCooldown = max(1, (int) config('collections.submissions.duplicate_cooldown_minutes', 15));
$recentSubmissions = CollectionSubmission::query()
->where('user_id', $actor->id)
->where('created_at', '>=', now()->subHour())
->count();
if ($recentSubmissions >= $perHourLimit) {
throw ValidationException::withMessages([
'collection' => 'You have reached the collection submission limit for the last hour. Please wait before submitting again.',
]);
}
$duplicateAttempt = CollectionSubmission::query()
->where('collection_id', $collection->id)
->where('artwork_id', $artwork->id)
->where('user_id', $actor->id)
->where('created_at', '>=', now()->subMinutes($duplicateCooldown))
->whereIn('status', [
Collection::SUBMISSION_PENDING,
Collection::SUBMISSION_REJECTED,
Collection::SUBMISSION_APPROVED,
])
->exists();
if ($duplicateAttempt) {
throw ValidationException::withMessages([
'artwork_id' => 'This artwork was submitted recently. Please wait before trying again.',
]);
}
}
public function approve(CollectionSubmission $submission, User $actor): CollectionSubmission
{
$collection = $submission->collection()->with('user')->firstOrFail();
if (! $collection->canBeManagedBy($actor)) {
throw ValidationException::withMessages([
'submission' => 'You are not allowed to review submissions for this collection.',
]);
}
DB::transaction(function () use ($submission, $collection, $actor): void {
$this->collections->attachArtworkIds($collection, [(int) $submission->artwork_id]);
$submission->forceFill([
'status' => Collection::SUBMISSION_APPROVED,
'reviewed_by_user_id' => $actor->id,
'reviewed_at' => now(),
])->save();
});
return $submission->fresh(['user.profile', 'artwork', 'reviewedBy']);
}
public function reject(CollectionSubmission $submission, User $actor): CollectionSubmission
{
$collection = $submission->collection;
if (! $collection->canBeManagedBy($actor)) {
throw ValidationException::withMessages([
'submission' => 'You are not allowed to review submissions for this collection.',
]);
}
$submission->forceFill([
'status' => Collection::SUBMISSION_REJECTED,
'reviewed_by_user_id' => $actor->id,
'reviewed_at' => now(),
])->save();
return $submission->fresh(['user.profile', 'artwork', 'reviewedBy']);
}
public function withdraw(CollectionSubmission $submission, User $actor): void
{
if ((int) $submission->user_id !== (int) $actor->id || $submission->status !== Collection::SUBMISSION_PENDING) {
throw ValidationException::withMessages([
'submission' => 'This submission cannot be withdrawn.',
]);
}
$submission->forceFill([
'status' => Collection::SUBMISSION_WITHDRAWN,
'reviewed_at' => now(),
])->save();
}
public function mapSubmissions(Collection $collection, ?User $viewer = null): array
{
$submissions = $collection->submissions()
->with(['user.profile', 'artwork', 'reviewedBy'])
->latest()
->get();
return $submissions->map(function (CollectionSubmission $submission) use ($collection, $viewer): array {
$user = $submission->user;
return [
'id' => (int) $submission->id,
'status' => (string) $submission->status,
'message' => $submission->message,
'created_at' => $submission->created_at?->toISOString(),
'reviewed_at' => $submission->reviewed_at?->toISOString(),
'artwork' => $submission->artwork ? [
'id' => (int) $submission->artwork->id,
'title' => (string) $submission->artwork->title,
'thumb' => $submission->artwork->thumbUrl('sm'),
'url' => route('art.show', ['id' => $submission->artwork->id, 'slug' => $submission->artwork->slug]),
] : null,
'user' => [
'id' => (int) $user->id,
'username' => $user->username,
'name' => $user->name,
],
'can_review' => $viewer !== null && $collection->canBeManagedBy($viewer) && $submission->status === Collection::SUBMISSION_PENDING,
'can_withdraw' => $viewer !== null && (int) $submission->user_id === (int) $viewer->id && $submission->status === Collection::SUBMISSION_PENDING,
'can_report' => $viewer !== null && (int) $submission->user_id !== (int) $viewer->id,
];
})->all();
}
}

View File

@@ -0,0 +1,423 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionSurfaceDefinition;
use App\Models\CollectionSurfacePlacement;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection as SupportCollection;
class CollectionSurfaceService
{
public function definitions(): SupportCollection
{
return CollectionSurfaceDefinition::query()->orderBy('surface_key')->get();
}
public function placements(?string $surfaceKey = null): SupportCollection
{
$query = CollectionSurfacePlacement::query()
->with([
'collection.user:id,username,name',
'collection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
])
->orderBy('surface_key')
->orderByDesc('priority')
->orderBy('id');
if ($surfaceKey !== null && $surfaceKey !== '') {
$query->where('surface_key', $surfaceKey);
}
return $query->get();
}
public function placementConflicts(?string $surfaceKey = null): SupportCollection
{
return $this->placements($surfaceKey)
->where('is_active', true)
->groupBy('surface_key')
->flatMap(function (SupportCollection $placements, string $key): array {
$conflicts = [];
$values = $placements->values();
$count = $values->count();
for ($leftIndex = 0; $leftIndex < $count; $leftIndex++) {
$left = $values[$leftIndex];
for ($rightIndex = $leftIndex + 1; $rightIndex < $count; $rightIndex++) {
$right = $values[$rightIndex];
if (! $this->placementsOverlap($left, $right)) {
continue;
}
$conflicts[] = [
'surface_key' => $key,
'placement_ids' => [(int) $left->id, (int) $right->id],
'collection_ids' => [(int) $left->collection_id, (int) $right->collection_id],
'collection_titles' => [
$left->collection?->title ?? 'Unknown collection',
$right->collection?->title ?? 'Unknown collection',
],
'summary' => sprintf(
'%s overlaps with %s on %s.',
$left->collection?->title ?? 'Unknown collection',
$right->collection?->title ?? 'Unknown collection',
$key,
),
'window' => [
'starts_at' => $this->earliestStart($left, $right)?->toISOString(),
'ends_at' => $this->latestEnd($left, $right)?->toISOString(),
],
];
}
}
return $conflicts;
})
->values();
}
public function upsertDefinition(array $attributes): CollectionSurfaceDefinition
{
return CollectionSurfaceDefinition::query()->updateOrCreate(
['surface_key' => (string) $attributes['surface_key']],
[
'title' => (string) $attributes['title'],
'description' => $attributes['description'] ?? null,
'mode' => (string) ($attributes['mode'] ?? 'manual'),
'rules_json' => $attributes['rules_json'] ?? null,
'ranking_mode' => (string) ($attributes['ranking_mode'] ?? 'ranking_score'),
'max_items' => (int) ($attributes['max_items'] ?? 12),
'is_active' => (bool) ($attributes['is_active'] ?? true),
'starts_at' => $attributes['starts_at'] ?? null,
'ends_at' => $attributes['ends_at'] ?? null,
'fallback_surface_key' => $attributes['fallback_surface_key'] ?? null,
]
);
}
public function populateSurface(string $surfaceKey, int $fallbackLimit = 12): SupportCollection
{
return $this->resolveSurfaceItems($surfaceKey, $fallbackLimit);
}
public function resolveSurfaceItems(string $surfaceKey, int $fallbackLimit = 12): SupportCollection
{
return $this->resolveSurfaceItemsInternal($surfaceKey, $fallbackLimit, []);
}
public function syncPlacements(): int
{
return CollectionSurfacePlacement::query()
->where('is_active', true)
->where(function ($query): void {
$query->whereNotNull('ends_at')->where('ends_at', '<=', now());
})
->update([
'is_active' => false,
'updated_at' => now(),
]);
}
public function upsertPlacement(array $attributes): CollectionSurfacePlacement
{
$placementId = isset($attributes['id']) ? (int) $attributes['id'] : null;
$payload = [
'surface_key' => (string) $attributes['surface_key'],
'collection_id' => (int) $attributes['collection_id'],
'placement_type' => (string) ($attributes['placement_type'] ?? 'manual'),
'priority' => (int) ($attributes['priority'] ?? 0),
'starts_at' => $attributes['starts_at'] ?? null,
'ends_at' => $attributes['ends_at'] ?? null,
'is_active' => (bool) ($attributes['is_active'] ?? true),
'campaign_key' => $attributes['campaign_key'] ?? null,
'notes' => $attributes['notes'] ?? null,
'created_by_user_id' => isset($attributes['created_by_user_id']) ? (int) $attributes['created_by_user_id'] : null,
];
if ($placementId) {
$placement = CollectionSurfacePlacement::query()->findOrFail($placementId);
$placement->fill($payload)->save();
return $placement->refresh();
}
return CollectionSurfacePlacement::query()->create($payload);
}
public function deletePlacement(CollectionSurfacePlacement $placement): void
{
$placement->delete();
}
private function resolveSurfaceItemsInternal(string $surfaceKey, int $fallbackLimit, array $visited): SupportCollection
{
if (in_array($surfaceKey, $visited, true)) {
return collect();
}
$visited[] = $surfaceKey;
$definition = CollectionSurfaceDefinition::query()->where('surface_key', $surfaceKey)->first();
$limit = max(1, min((int) ($definition?->max_items ?? $fallbackLimit), 24));
$mode = (string) ($definition?->mode ?? 'manual');
if ($definition && ! $this->definitionIsActive($definition)) {
return $this->resolveFallbackSurface($definition, $fallbackLimit, $visited);
}
$manual = CollectionSurfacePlacement::query()
->with([
'collection.user:id,username,name',
'collection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
])
->where('surface_key', $surfaceKey)
->where('is_active', true)
->where(function ($query): void {
$query->whereNull('starts_at')->orWhere('starts_at', '<=', now());
})
->where(function ($query): void {
$query->whereNull('ends_at')->orWhere('ends_at', '>', now());
})
->orderByDesc('priority')
->orderBy('id')
->limit($limit)
->get()
->pluck('collection')
->filter(fn (?Collection $collection) => $collection && $collection->isFeatureablePublicly())
->values();
if ($mode === 'manual') {
return $manual->isEmpty()
? $this->resolveFallbackSurface($definition, $fallbackLimit, $visited)
: $manual;
}
$query = Collection::query()->publicEligible();
$rules = is_array($definition?->rules_json) ? $definition->rules_json : [];
$this->applyAutomaticRules($query, $rules);
$rankingMode = (string) ($definition?->ranking_mode ?? 'ranking_score');
if ($rankingMode === 'recent_activity') {
$query->orderByDesc('last_activity_at');
} elseif ($rankingMode === 'quality_score') {
$query->orderByDesc('quality_score');
} else {
$query->orderByDesc('ranking_score');
}
$auto = $query
->when($mode === 'hybrid', fn ($builder) => $builder->whereNotIn('id', $manual->pluck('id')->all()))
->with([
'user:id,username,name',
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
])
->limit($mode === 'hybrid' ? max(0, $limit - $manual->count()) : $limit)
->get();
if ($mode === 'automatic') {
return $auto->isEmpty()
? $this->resolveFallbackSurface($definition, $fallbackLimit, $visited)
: $auto->values();
}
if ($manual->count() >= $limit) {
return $manual;
}
$resolved = $manual->concat($auto)->values();
return $resolved->isEmpty()
? $this->resolveFallbackSurface($definition, $fallbackLimit, $visited)
: $resolved;
}
private function applyAutomaticRules(Builder $query, array $rules): void
{
$this->applyExactOrListRule($query, 'type', $rules['type'] ?? null);
$this->applyExactOrListRule($query, 'campaign_key', $rules['campaign_key'] ?? null);
$this->applyExactOrListRule($query, 'event_key', $rules['event_key'] ?? null);
$this->applyExactOrListRule($query, 'season_key', $rules['season_key'] ?? null);
$this->applyExactOrListRule($query, 'presentation_style', $rules['presentation_style'] ?? null);
$this->applyExactOrListRule($query, 'theme_token', $rules['theme_token'] ?? null);
$this->applyExactOrListRule($query, 'collaboration_mode', $rules['collaboration_mode'] ?? null);
$this->applyExactOrListRule($query, 'promotion_tier', $rules['promotion_tier'] ?? null);
if ($this->ruleEnabled($rules['featured_only'] ?? false)) {
$query->where('is_featured', true);
}
if ($this->ruleEnabled($rules['commercial_eligible_only'] ?? false)) {
$query->where('commercial_eligibility', true);
}
if ($this->ruleEnabled($rules['analytics_enabled_only'] ?? false)) {
$query->where('analytics_enabled', true);
}
if (($minQualityScore = $this->numericRule($rules['min_quality_score'] ?? null)) !== null) {
$query->where('quality_score', '>=', $minQualityScore);
}
if (($minRankingScore = $this->numericRule($rules['min_ranking_score'] ?? null)) !== null) {
$query->where('ranking_score', '>=', $minRankingScore);
}
$includeIds = $this->integerRuleList($rules['include_collection_ids'] ?? null);
if ($includeIds !== []) {
$query->whereIn('id', $includeIds);
}
$excludeIds = $this->integerRuleList($rules['exclude_collection_ids'] ?? null);
if ($excludeIds !== []) {
$query->whereNotIn('id', $excludeIds);
}
$ownerUsernames = $this->stringRuleList($rules['owner_usernames'] ?? ($rules['owner_username'] ?? null));
if ($ownerUsernames !== []) {
$normalized = array_map(static fn (string $value): string => mb_strtolower($value), $ownerUsernames);
$query->whereHas('user', function (Builder $builder) use ($normalized): void {
$builder->whereIn('username', $normalized);
});
}
}
private function applyExactOrListRule(Builder $query, string $column, mixed $value): void
{
$values = $this->stringRuleList($value);
if ($values === []) {
return;
}
if (count($values) === 1) {
$query->where($column, $values[0]);
return;
}
$query->whereIn($column, $values);
}
private function stringRuleList(mixed $value): array
{
$values = is_array($value) ? $value : [$value];
return array_values(array_unique(array_filter(array_map(static function ($item): ?string {
if (! is_string($item) && ! is_numeric($item)) {
return null;
}
$normalized = trim((string) $item);
return $normalized !== '' ? $normalized : null;
}, $values))));
}
private function integerRuleList(mixed $value): array
{
$values = is_array($value) ? $value : [$value];
return array_values(array_unique(array_filter(array_map(static function ($item): ?int {
if (! is_numeric($item)) {
return null;
}
$normalized = (int) $item;
return $normalized > 0 ? $normalized : null;
}, $values))));
}
private function numericRule(mixed $value): ?float
{
if (! is_numeric($value)) {
return null;
}
return (float) $value;
}
private function ruleEnabled(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int) $value === 1;
}
if (is_string($value)) {
return in_array(mb_strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
}
return false;
}
private function definitionIsActive(CollectionSurfaceDefinition $definition): bool
{
if (! $definition->is_active) {
return false;
}
if ($definition->starts_at && $definition->starts_at->isFuture()) {
return false;
}
if ($definition->ends_at && $definition->ends_at->lessThanOrEqualTo(now())) {
return false;
}
return true;
}
private function resolveFallbackSurface(?CollectionSurfaceDefinition $definition, int $fallbackLimit, array $visited): SupportCollection
{
$fallbackKey = $definition?->fallback_surface_key;
if (! is_string($fallbackKey) || trim($fallbackKey) === '') {
return collect();
}
return $this->resolveSurfaceItemsInternal(trim($fallbackKey), $fallbackLimit, $visited);
}
private function placementsOverlap(CollectionSurfacePlacement $left, CollectionSurfacePlacement $right): bool
{
$leftStart = $left->starts_at?->getTimestamp() ?? PHP_INT_MIN;
$leftEnd = $left->ends_at?->getTimestamp() ?? PHP_INT_MAX;
$rightStart = $right->starts_at?->getTimestamp() ?? PHP_INT_MIN;
$rightEnd = $right->ends_at?->getTimestamp() ?? PHP_INT_MAX;
return $leftStart < $rightEnd && $rightStart < $leftEnd;
}
private function earliestStart(CollectionSurfacePlacement $left, CollectionSurfacePlacement $right)
{
if ($left->starts_at === null) {
return $right->starts_at;
}
if ($right->starts_at === null) {
return $left->starts_at;
}
return $left->starts_at->lessThanOrEqualTo($right->starts_at) ? $left->starts_at : $right->starts_at;
}
private function latestEnd(CollectionSurfacePlacement $left, CollectionSurfacePlacement $right)
{
if ($left->ends_at === null || $right->ends_at === null) {
return null;
}
return $left->ends_at->greaterThanOrEqualTo($right->ends_at) ? $left->ends_at : $right->ends_at;
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\User;
use App\Services\CollectionHealthService;
use App\Services\CollectionHistoryService;
use App\Services\CollectionQualityService;
use Illuminate\Validation\ValidationException;
class CollectionWorkflowService
{
public function __construct(
private readonly CollectionHealthService $health,
) {
}
public function update(Collection $collection, array $attributes, ?User $actor = null): Collection
{
$nextWorkflow = isset($attributes['workflow_state']) ? (string) $attributes['workflow_state'] : (string) $collection->workflow_state;
$this->assertTransition((string) $collection->workflow_state, $nextWorkflow);
$before = [
'workflow_state' => $collection->workflow_state,
'program_key' => $collection->program_key,
'partner_key' => $collection->partner_key,
'experiment_key' => $collection->experiment_key,
'placement_eligibility' => (bool) $collection->placement_eligibility,
];
$collection->forceFill([
'workflow_state' => $nextWorkflow !== '' ? $nextWorkflow : null,
'program_key' => array_key_exists('program_key', $attributes) ? ($attributes['program_key'] ?: null) : $collection->program_key,
'partner_key' => array_key_exists('partner_key', $attributes) ? ($attributes['partner_key'] ?: null) : $collection->partner_key,
'experiment_key' => array_key_exists('experiment_key', $attributes) ? ($attributes['experiment_key'] ?: null) : $collection->experiment_key,
'placement_eligibility' => array_key_exists('placement_eligibility', $attributes) ? (bool) $attributes['placement_eligibility'] : $collection->placement_eligibility,
])->save();
$fresh = $this->health->refresh($collection->fresh(), $actor, 'workflow');
app(CollectionHistoryService::class)->record(
$fresh,
$actor,
'workflow_updated',
'Collection workflow updated.',
$before,
[
'workflow_state' => $fresh->workflow_state,
'program_key' => $fresh->program_key,
'partner_key' => $fresh->partner_key,
'experiment_key' => $fresh->experiment_key,
'placement_eligibility' => (bool) $fresh->placement_eligibility,
]
);
return $fresh;
}
public function qualityRefresh(Collection $collection, ?User $actor = null): Collection
{
$collection = app(CollectionQualityService::class)->sync($collection->fresh());
$collection = $this->health->refresh($collection, $actor, 'quality-refresh');
$collection = app(CollectionRankingService::class)->refresh($collection);
app(CollectionHistoryService::class)->record($collection, $actor, 'quality_refreshed', 'Collection quality and ranking refreshed.');
return $collection;
}
private function assertTransition(string $current, string $next): void
{
if ($next === '' || $next === $current) {
return;
}
$allowed = [
'' => [Collection::WORKFLOW_DRAFT, Collection::WORKFLOW_APPROVED],
Collection::WORKFLOW_DRAFT => [Collection::WORKFLOW_IN_REVIEW, Collection::WORKFLOW_APPROVED],
Collection::WORKFLOW_IN_REVIEW => [Collection::WORKFLOW_DRAFT, Collection::WORKFLOW_APPROVED],
Collection::WORKFLOW_APPROVED => [Collection::WORKFLOW_PROGRAMMED, Collection::WORKFLOW_ARCHIVED, Collection::WORKFLOW_IN_REVIEW],
Collection::WORKFLOW_PROGRAMMED => [Collection::WORKFLOW_APPROVED, Collection::WORKFLOW_ARCHIVED],
Collection::WORKFLOW_ARCHIVED => [Collection::WORKFLOW_APPROVED],
];
if (! in_array($next, $allowed[$current] ?? [], true)) {
throw ValidationException::withMessages([
'workflow_state' => 'This workflow transition is not allowed.',
]);
}
}
}

View File

@@ -0,0 +1,621 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Enums\ReactionType;
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\CommentReaction;
use App\Models\Story;
use App\Models\User;
use App\Models\UserMention;
use App\Services\ThumbnailPresenter;
use App\Support\AvatarUrl;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class CommunityActivityService
{
public const DEFAULT_PER_PAGE = 20;
private const FILTER_ALL = 'all';
private const FILTER_COMMENTS = 'comments';
private const FILTER_REPLIES = 'replies';
private const FILTER_FOLLOWING = 'following';
private const FILTER_MY = 'my';
public function getFeed(?User $viewer, string $filter = self::FILTER_ALL, int $page = 1, int $perPage = self::DEFAULT_PER_PAGE, ?int $actorUserId = null): array
{
$normalizedFilter = $this->normalizeFilter($filter);
$resolvedPage = max(1, $page);
$resolvedPerPage = max(1, min(50, $perPage));
$cacheKey = sprintf(
'community_activity:%s:%d:%d:%d:%d',
$normalizedFilter,
(int) ($viewer?->id ?? 0),
(int) ($actorUserId ?? 0),
$resolvedPage,
$resolvedPerPage,
);
return Cache::remember($cacheKey, now()->addSeconds(30), function () use ($viewer, $normalizedFilter, $resolvedPage, $resolvedPerPage, $actorUserId): array {
return $this->buildFeed($viewer, $normalizedFilter, $resolvedPage, $resolvedPerPage, $actorUserId);
});
}
public function requiresAuthentication(string $filter): bool
{
return in_array($this->normalizeFilter($filter), [self::FILTER_FOLLOWING, self::FILTER_MY], true);
}
public function normalizeFilter(string $filter): string
{
return match (strtolower(trim($filter))) {
self::FILTER_COMMENTS => self::FILTER_COMMENTS,
self::FILTER_REPLIES => self::FILTER_REPLIES,
self::FILTER_FOLLOWING => self::FILTER_FOLLOWING,
self::FILTER_MY => self::FILTER_MY,
default => self::FILTER_ALL,
};
}
private function buildFeed(?User $viewer, string $filter, int $page, int $perPage, ?int $actorUserId): array
{
$sourceLimit = max(80, $page * $perPage * 6);
$followingIds = $filter === self::FILTER_FOLLOWING && $viewer
? $viewer->following()->pluck('users.id')->map(fn ($id) => (int) $id)->all()
: [];
$commentModels = $this->fetchCommentModels($sourceLimit, repliesOnly: false);
$replyModels = $this->fetchCommentModels($sourceLimit, repliesOnly: true);
$reactionModels = $this->fetchReactionModels($sourceLimit);
$recordedActivities = $this->fetchRecordedActivities($sourceLimit);
$commentActivities = $commentModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'comment'));
$replyActivities = $replyModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'reply'));
$reactionActivities = $reactionModels->map(fn (CommentReaction $reaction) => $this->mapReactionActivity($reaction));
$mentionActivities = $this->fetchMentionActivities($sourceLimit);
$merged = $recordedActivities
->concat($commentActivities)
->concat($replyActivities)
->concat($reactionActivities)
->concat($mentionActivities)
->filter(function (array $activity) use ($filter, $viewer, $followingIds, $actorUserId): bool {
$actorId = (int) ($activity['user']['id'] ?? 0);
$mentionedUserId = (int) ($activity['mentioned_user']['id'] ?? 0);
if ($actorUserId !== null && $actorId !== (int) $actorUserId) {
return false;
}
return match ($filter) {
self::FILTER_COMMENTS => $activity['type'] === 'comment',
self::FILTER_REPLIES => $activity['type'] === 'reply',
self::FILTER_FOLLOWING => in_array($actorId, $followingIds, true),
self::FILTER_MY => $viewer !== null
&& ($actorId === (int) $viewer->id || ($activity['type'] === 'mention' && $mentionedUserId === (int) $viewer->id)),
default => true,
};
})
->sortByDesc(fn (array $activity) => $activity['sort_timestamp'] ?? $activity['created_at'])
->values();
$total = $merged->count();
$offset = ($page - 1) * $perPage;
$pageItems = $merged->slice($offset, $perPage)->values();
$reactionTotals = $this->loadCommentReactionTotals(
$pageItems->pluck('comment.id')->filter()->map(fn ($id) => (int) $id)->unique()->all(),
$viewer?->id,
);
$data = $pageItems->map(function (array $activity) use ($reactionTotals): array {
$commentId = (int) ($activity['comment']['id'] ?? 0);
if ($commentId > 0) {
$activity['comment']['reactions'] = $reactionTotals[$commentId] ?? $this->defaultReactionTotals();
}
unset($activity['sort_timestamp']);
return $activity;
})->all();
return [
'data' => $data,
'meta' => [
'current_page' => $page,
'last_page' => (int) max(1, ceil($total / $perPage)),
'per_page' => $perPage,
'total' => $total,
'has_more' => $offset + $perPage < $total,
],
'filter' => $filter,
];
}
private function fetchRecordedActivities(int $limit): Collection
{
$events = ActivityEvent::query()
->select(['id', 'actor_id', 'type', 'target_type', 'target_id', 'meta', 'created_at'])
->with([
'actor' => function ($query) {
$query
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
},
])
->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
->latest('created_at')
->limit($limit)
->get();
if ($events->isEmpty()) {
return collect();
}
$artworkIds = $events
->where('target_type', ActivityEvent::TARGET_ARTWORK)
->pluck('target_id')
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
$storyIds = $events
->where('target_type', ActivityEvent::TARGET_STORY)
->pluck('target_id')
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
$targetUserIds = $events
->where('target_type', ActivityEvent::TARGET_USER)
->pluck('target_id')
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
$artworks = empty($artworkIds)
? collect()
: Artwork::query()
->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved')
->whereIn('id', $artworkIds)
->public()
->published()
->whereNull('deleted_at')
->get()
->keyBy('id');
$stories = empty($storyIds)
? collect()
: Story::query()
->select('id', 'creator_id', 'title', 'slug', 'cover_image', 'published_at', 'status')
->whereIn('id', $storyIds)
->published()
->get()
->keyBy('id');
$targetUsers = empty($targetUserIds)
? collect()
: User::query()
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks')
->whereIn('id', $targetUserIds)
->where('is_active', true)
->whereNull('deleted_at')
->get()
->keyBy('id');
return $events
->map(fn (ActivityEvent $event) => $this->mapRecordedActivity($event, $artworks, $stories, $targetUsers))
->filter()
->values();
}
private function fetchCommentModels(int $limit, bool $repliesOnly): Collection
{
return ArtworkComment::query()
->select([
'id',
'artwork_id',
'user_id',
'parent_id',
'content',
'raw_content',
'rendered_content',
'created_at',
'is_approved',
])
->with([
'user' => function ($query) {
$query
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
},
'artwork' => function ($query) {
$query->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
},
])
->where('is_approved', true)
->whereNull('deleted_at')
->when($repliesOnly, fn ($query) => $query->whereNotNull('parent_id'), fn ($query) => $query->whereNull('parent_id'))
->whereHas('user', function ($query) {
$query->where('is_active', true)->whereNull('deleted_at');
})
->whereHas('artwork', function ($query) {
$query->public()->published()->whereNull('deleted_at');
})
->latest('created_at')
->limit($limit)
->get();
}
private function fetchReactionModels(int $limit): Collection
{
return CommentReaction::query()
->select(['id', 'comment_id', 'user_id', 'reaction', 'created_at'])
->with([
'user' => function ($query) {
$query
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
},
'comment' => function ($query) {
$query
->select('id', 'artwork_id', 'user_id', 'parent_id', 'content', 'raw_content', 'rendered_content', 'created_at', 'is_approved')
->with([
'user' => function ($userQuery) {
$userQuery
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
},
'artwork' => function ($artworkQuery) {
$artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
},
]);
},
])
->whereHas('user', function ($query) {
$query->where('is_active', true)->whereNull('deleted_at');
})
->whereHas('comment', function ($query) {
$query
->where('is_approved', true)
->whereNull('deleted_at')
->whereHas('user', function ($userQuery) {
$userQuery->where('is_active', true)->whereNull('deleted_at');
})
->whereHas('artwork', function ($artworkQuery) {
$artworkQuery->public()->published()->whereNull('deleted_at');
});
})
->latest('created_at')
->limit($limit)
->get();
}
private function mapCommentActivity(ArtworkComment $comment, string $type): array
{
$artwork = $comment->artwork;
$iso = $comment->created_at?->toIso8601String();
return [
'id' => $type . ':' . $comment->id,
'type' => $type,
'user' => $this->buildUserPayload($comment->user),
'comment' => $this->buildCommentPayload($comment),
'artwork' => $this->buildArtworkPayload($artwork),
'created_at' => $iso,
'time_ago' => $comment->created_at?->diffForHumans(),
'sort_timestamp' => $iso,
];
}
private function mapReactionActivity(CommentReaction $reaction): array
{
$comment = $reaction->comment;
$artwork = $comment?->artwork;
$reactionType = ReactionType::tryFrom((string) $reaction->reaction);
$iso = $reaction->created_at?->toIso8601String();
return [
'id' => 'reaction:' . $reaction->id,
'type' => 'reaction',
'user' => $this->buildUserPayload($reaction->user),
'comment' => $comment ? $this->buildCommentPayload($comment) : null,
'artwork' => $this->buildArtworkPayload($artwork),
'reaction' => [
'slug' => $reactionType?->value ?? (string) $reaction->reaction,
'emoji' => $reactionType?->emoji() ?? '👍',
'label' => $reactionType?->label() ?? Str::headline((string) $reaction->reaction),
],
'created_at' => $iso,
'time_ago' => $reaction->created_at?->diffForHumans(),
'sort_timestamp' => $iso,
];
}
private function mapRecordedActivity(ActivityEvent $event, Collection $artworks, Collection $stories, Collection $targetUsers): ?array
{
if ($event->type === ActivityEvent::TYPE_COMMENT && $event->target_type === ActivityEvent::TARGET_ARTWORK) {
return null;
}
$artwork = $event->target_type === ActivityEvent::TARGET_ARTWORK
? $artworks->get((int) $event->target_id)
: null;
$story = $event->target_type === ActivityEvent::TARGET_STORY
? $stories->get((int) $event->target_id)
: null;
$targetUser = $event->target_type === ActivityEvent::TARGET_USER
? $targetUsers->get((int) $event->target_id)
: null;
if ($event->target_type === ActivityEvent::TARGET_ARTWORK && ! $artwork) {
return null;
}
if ($event->target_type === ActivityEvent::TARGET_STORY && ! $story) {
return null;
}
if ($event->target_type === ActivityEvent::TARGET_USER && ! $targetUser) {
return null;
}
$iso = $event->created_at?->toIso8601String();
return [
'id' => 'event:' . $event->id,
'type' => (string) $event->type,
'user' => $this->buildUserPayload($event->actor),
'artwork' => $this->buildArtworkPayload($artwork),
'story' => $this->buildStoryPayload($story),
'target_user' => $this->buildUserPayload($targetUser),
'meta' => is_array($event->meta) ? $event->meta : [],
'created_at' => $iso,
'time_ago' => $event->created_at?->diffForHumans(),
'sort_timestamp' => $iso,
];
}
private function fetchMentionActivities(int $limit): Collection
{
if (! Schema::hasTable('user_mentions')) {
return collect();
}
return UserMention::query()
->select(['id', 'user_id', 'mentioned_user_id', 'artwork_id', 'comment_id', 'created_at'])
->with([
'actor' => function ($query) {
$query
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
},
'mentionedUser' => function ($query) {
$query
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
},
'comment' => function ($query) {
$query
->select('id', 'artwork_id', 'user_id', 'parent_id', 'content', 'raw_content', 'rendered_content', 'created_at', 'is_approved')
->with([
'user' => function ($userQuery) {
$userQuery
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
},
'artwork' => function ($artworkQuery) {
$artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
},
]);
},
])
->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
->whereHas('mentionedUser', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
->whereHas('comment', function ($query) {
$query
->where('is_approved', true)
->whereNull('deleted_at')
->whereHas('artwork', fn ($artworkQuery) => $artworkQuery->public()->published()->whereNull('deleted_at'));
})
->latest('created_at')
->limit($limit)
->get()
->map(function (UserMention $mention): array {
$iso = $mention->created_at?->toIso8601String();
return [
'id' => 'mention:' . $mention->id,
'type' => 'mention',
'user' => $this->buildUserPayload($mention->actor),
'mentioned_user' => $this->buildUserPayload($mention->mentionedUser),
'comment' => $mention->comment ? $this->buildCommentPayload($mention->comment) : null,
'artwork' => $this->buildArtworkPayload($mention->comment?->artwork),
'created_at' => $iso,
'time_ago' => $mention->created_at?->diffForHumans(),
'sort_timestamp' => $iso,
];
})
->values();
}
private function buildUserPayload(?User $user): ?array
{
if (! $user) {
return null;
}
$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 !== '' ? '/@' . $username : null,
'avatar_url' => $user->profile?->avatar_url ?: AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64),
'badge' => $this->resolveBadge($user),
];
}
private function resolveBadge(User $user): ?array
{
if ($user->isAdmin()) {
return ['label' => 'Admin', 'tone' => 'rose'];
}
if ($user->isModerator()) {
return ['label' => 'Moderator', 'tone' => 'amber'];
}
if ((int) ($user->artworks_count ?? 0) > 0) {
return ['label' => 'Creator', 'tone' => 'sky'];
}
return null;
}
private function buildArtworkPayload(?Artwork $artwork): ?array
{
if (! $artwork) {
return null;
}
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
if ($slug === '') {
$slug = (string) $artwork->id;
}
$thumb = ThumbnailPresenter::present($artwork, 'md');
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' => $thumb['url'] ?? null,
];
}
private function buildStoryPayload(?Story $story): ?array
{
if (! $story) {
return null;
}
return [
'id' => (int) $story->id,
'title' => html_entity_decode((string) ($story->title ?? 'Story'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('stories.show', ['slug' => $story->slug]),
'cover_url' => $story->cover_url,
];
}
private function buildCommentPayload(ArtworkComment $comment): array
{
$artwork = $this->buildArtworkPayload($comment->artwork);
$commentUrl = $artwork ? $artwork['url'] . '#comment-' . $comment->id : null;
return [
'id' => (int) $comment->id,
'parent_id' => $comment->parent_id ? (int) $comment->parent_id : null,
'body' => $this->excerptComment($comment),
'body_html' => $comment->getDisplayHtml(),
'url' => $commentUrl,
'author' => $this->buildUserPayload($comment->user),
];
}
private function excerptComment(ArtworkComment $comment): string
{
$raw = $comment->raw_content ?? $comment->content ?? '';
$plain = trim(preg_replace('/\s+/', ' ', strip_tags(html_entity_decode((string) $raw, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ?? '');
return Str::limit($plain, 180, '…');
}
private function loadCommentReactionTotals(array $commentIds, ?int $viewerId): array
{
if ($commentIds === []) {
return [];
}
$rows = DB::table('comment_reactions')
->whereIn('comment_id', $commentIds)
->selectRaw('comment_id, reaction, COUNT(*) as total')
->groupBy('comment_id', 'reaction')
->get();
$viewerReactions = [];
if ($viewerId) {
$viewerReactions = DB::table('comment_reactions')
->whereIn('comment_id', $commentIds)
->where('user_id', $viewerId)
->get(['comment_id', 'reaction'])
->groupBy('comment_id')
->map(fn (Collection $items) => $items->pluck('reaction')->all())
->all();
}
$totalsByComment = [];
foreach ($commentIds as $commentId) {
$totalsByComment[(int) $commentId] = $this->defaultReactionTotals();
}
foreach ($rows as $row) {
$commentId = (int) $row->comment_id;
$slug = (string) $row->reaction;
if (! isset($totalsByComment[$commentId][$slug])) {
continue;
}
$totalsByComment[$commentId][$slug]['count'] = (int) $row->total;
}
foreach ($viewerReactions as $commentId => $slugs) {
foreach ($slugs as $slug) {
if (isset($totalsByComment[(int) $commentId][(string) $slug])) {
$totalsByComment[(int) $commentId][(string) $slug]['mine'] = true;
}
}
}
return $totalsByComment;
}
private function defaultReactionTotals(): array
{
$totals = [];
foreach (ReactionType::cases() as $type) {
$totals[$type->value] = [
'emoji' => $type->emoji(),
'label' => $type->label(),
'count' => 0,
'mine' => false,
];
}
return $totals;
}
}

View File

@@ -0,0 +1,346 @@
<?php
namespace App\Services;
use App\Services\LegacySmileyMapper;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\MarkdownConverter;
/**
* Sanitizes and renders user-submitted content.
*
* Pipeline:
* 1. Strip any raw HTML tags from input (we don't allow HTML)
* 2. Convert legacy <br> / <b> / <i> hints from really old legacy content
* 3. Parse subset of Markdown (bold, italic, code, links, line breaks)
* 4. Sanitize the rendered HTML: whitelist-only tags, strip attributes
* 5. Return safe HTML ready for storage or display
*/
class ContentSanitizer
{
/** Maximum number of emoji allowed before triggering a flood error. */
public const EMOJI_COUNT_MAX = 50;
/**
* Maximum ratio of emoji-to-total-characters before content is considered
* an emoji flood (applies only when emoji count > 5 to avoid false positives
* on very short strings like a single reaction comment).
*/
public const EMOJI_DENSITY_MAX = 0.40;
// HTML tags we allow in the final rendered output
private const ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'code', 'pre',
'a', 'ul', 'ol', 'li', 'blockquote', 'del',
];
// Allowed attributes per tag
private const ALLOWED_ATTRS = [
'a' => ['href', 'title', 'rel', 'target'],
];
private static ?MarkdownConverter $converter = null;
// ─────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert raw user input (legacy or new) to sanitized HTML.
*
* @param string|null $raw
* @return string Safe HTML
*/
public static function render(?string $raw): string
{
if ($raw === null || trim($raw) === '') {
return '';
}
// 1. Convert legacy HTML fragments to Markdown-friendly text
$text = static::legacyHtmlToMarkdown($raw);
// 2. Parse Markdown → HTML
$html = static::parseMarkdown($text);
// 3. Sanitize HTML (strip disallowed tags / attrs)
$html = static::sanitizeHtml($html);
return $html;
}
/**
* Normalize previously rendered HTML for display-time policy changes.
* This is useful when stored HTML predates current link attributes or
* when display rules depend on the author rather than the raw content.
*/
public static function sanitizeRenderedHtml(?string $html, bool $allowLinks = true): string
{
if ($html === null || trim($html) === '') {
return '';
}
return static::sanitizeHtml($html, $allowLinks);
}
/**
* Strip ALL HTML from input, returning plain text with newlines preserved.
*/
public static function stripToPlain(?string $html): string
{
if ($html === null) {
return '';
}
// Convert <br> and <p> to line breaks before stripping
$text = preg_replace(['/<br\s*\/?>/i', '/<\/p>/i'], "\n", $html);
$text = strip_tags($text ?? '');
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim($text);
}
/**
* Validate that a Markdown-lite string does not contain disallowed patterns.
* Returns an array of validation errors (empty = OK).
*/
public static function validate(string $raw): array
{
$errors = [];
if (mb_strlen($raw) > 10_000) {
$errors[] = 'Content exceeds maximum length of 10,000 characters.';
}
// Detect raw HTML tags (we forbid them)
if (preg_match('/<[a-z][^>]*>/i', $raw)) {
$errors[] = 'HTML tags are not allowed. Use Markdown formatting instead.';
}
// Count emoji to prevent absolute spam
$emojiCount = static::countEmoji($raw);
if ($emojiCount > self::EMOJI_COUNT_MAX) {
$errors[] = 'Too many emoji. Please limit emoji usage.';
}
// Reject emoji-flood content: density guard catches e.g. 15 emoji in a
// 20-char string even when the absolute count is below EMOJI_COUNT_MAX.
if ($emojiCount > 5) {
$totalChars = mb_strlen($raw);
if ($totalChars > 0 && ($emojiCount / $totalChars) > self::EMOJI_DENSITY_MAX) {
$errors[] = 'Content is mostly emoji. Please add some text.';
}
}
return $errors;
}
/**
* Collapse consecutive runs of the same emoji in $text.
*
* Delegates to LegacySmileyMapper::collapseFlood() so the behaviour is
* consistent between new submissions and migrated legacy content.
*
* Example: "🍺 🍺 🍺 🍺 🍺 🍺 🍺" (7×) "🍺 🍺 🍺 🍺 🍺 ×7"
*
* @param int $maxRun Keep at most this many consecutive identical emoji.
*/
public static function collapseFlood(string $text, int $maxRun = 5): string
{
return LegacySmileyMapper::collapseFlood($text, $maxRun);
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert legacy HTML-style formatting to Markdown equivalents.
* This runs BEFORE Markdown parsing to handle old content gracefully.
*/
private static function legacyHtmlToMarkdown(string $html): string
{
$replacements = [
// Bold
'/<b>(.*?)<\/b>/is' => '**$1**',
'/<strong>(.*?)<\/strong>/is' => '**$1**',
// Italic
'/<i>(.*?)<\/i>/is' => '*$1*',
'/<em>(.*?)<\/em>/is' => '*$1*',
// Line breaks → actual newlines
'/<br\s*\/?>/i' => "\n",
// Paragraphs
'/<p>(.*?)<\/p>/is' => "$1\n\n",
// Strip remaining tags
'/<[^>]+>/' => '',
];
$result = $html;
foreach ($replacements as $pattern => $replacement) {
$result = preg_replace($pattern, $replacement, $result) ?? $result;
}
// Decode HTML entities (e.g. &amp; → &)
$result = html_entity_decode($result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $result;
}
/**
* Parse Markdown-lite subset to HTML.
*/
private static function parseMarkdown(string $text): string
{
$converter = static::getConverter();
$result = $converter->convert($text);
return (string) $result->getContent();
}
/**
* Whitelist-based HTML sanitizer.
* Removes all tags not in ALLOWED_TAGS, and strips disallowed attributes.
*/
private static function sanitizeHtml(string $html, bool $allowLinks = true): string
{
// Parse with DOMDocument
$doc = new \DOMDocument('1.0', 'UTF-8');
// Suppress warnings from malformed fragments
libxml_use_internal_errors(true);
$doc->loadHTML(
'<?xml encoding="UTF-8"><html><body>' . $html . '</body></html>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
static::cleanNode($doc->getElementsByTagName('body')->item(0), $allowLinks);
// Serialize back, removing the wrapping html/body
$body = $doc->getElementsByTagName('body')->item(0);
$inner = '';
foreach ($body->childNodes as $child) {
$inner .= $doc->saveHTML($child);
}
// Fix self-closing <a></a> etc.
return trim($inner);
}
/**
* Recursively clean a DOMNode strip forbidden tags/attributes.
*/
private static function cleanNode(\DOMNode $node, bool $allowLinks = true): void
{
$toRemove = [];
$toUnwrap = [];
foreach ($node->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
if (! $child instanceof \DOMElement) {
continue;
}
$tag = strtolower($child->nodeName);
if (! in_array($tag, self::ALLOWED_TAGS, true)) {
// Replace element with its text content
$toUnwrap[] = $child;
} else {
// Strip disallowed attributes
$allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? [];
$attrsToRemove = [];
foreach ($child->attributes as $attr) {
if (! in_array($attr->nodeName, $allowedAttrs, true)) {
$attrsToRemove[] = $attr->nodeName;
}
}
foreach ($attrsToRemove as $attrName) {
$child->removeAttribute($attrName);
}
// Force external links to be safe
if ($tag === 'a') {
if (! $allowLinks) {
$toUnwrap[] = $child;
continue;
}
$href = $child->getAttribute('href');
if ($href && ! static::isSafeUrl($href)) {
$toUnwrap[] = $child;
continue;
}
$child->setAttribute('rel', 'noopener noreferrer nofollow');
$child->setAttribute('target', '_blank');
}
// Recurse
static::cleanNode($child, $allowLinks);
}
}
}
// Unwrap forbidden elements (replace with their children)
foreach ($toUnwrap as $el) {
while ($el->firstChild) {
$node->insertBefore($el->firstChild, $el);
}
$node->removeChild($el);
}
}
/**
* Very conservative URL whitelist.
*/
private static function isSafeUrl(string $url): bool
{
$lower = strtolower(trim($url));
// Allow relative paths and anchors
if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
return true;
}
// Only allow http(s)
return str_starts_with($lower, 'http://') || str_starts_with($lower, 'https://');
}
/**
* Count Unicode emoji in a string (basic heuristic).
*/
private static function countEmoji(string $text): int
{
// Match common emoji ranges
preg_match_all(
'/[\x{1F300}-\x{1FAD6}\x{2600}-\x{27BF}\x{FE00}-\x{FEFF}]/u',
$text,
$matches
);
return count($matches[0]);
}
/**
* Lazy-load and cache the Markdown converter.
*/
private static function getConverter(): MarkdownConverter
{
if (static::$converter === null) {
$env = new Environment([
'html_input' => 'strip',
'allow_unsafe_links' => false,
'max_nesting_level' => 10,
]);
$env->addExtension(new CommonMarkCoreExtension());
$env->addExtension(new AutolinkExtension());
$env->addExtension(new StrikethroughExtension());
static::$converter = new MarkdownConverter($env);
}
return static::$converter;
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\ContentType;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
final class ContentTypeAssetService
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
public function storeUploadedAsset(ContentType $contentType, UploadedFile $file, string $kind): string
{
$mime = strtolower((string) ($file->getMimeType() ?: ''));
$extension = $this->safeExtension($file, $mime);
$path = sprintf(
'content-types/%d/%s-%s.%s',
(int) $contentType->id,
trim($kind) !== '' ? trim($kind) : 'asset',
(string) Str::uuid(),
$extension,
);
$stream = fopen((string) ($file->getRealPath() ?: $file->getPathname()), 'rb');
if ($stream === false) {
throw new RuntimeException('Unable to open uploaded content type asset.');
}
try {
$written = Storage::disk($this->diskName())->put($path, $stream, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => $mime !== '' ? $mime : $this->mimeTypeForExtension($extension),
]);
} finally {
fclose($stream);
}
if ($written !== true) {
throw new RuntimeException('Unable to store content type asset.');
}
return $path;
}
public function deleteIfManaged(?string $path): void
{
$trimmed = trim((string) $path);
if ($trimmed === '' || str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://') || str_starts_with($trimmed, '/')) {
return;
}
if (! str_starts_with($trimmed, 'content-types/')) {
return;
}
Storage::disk($this->diskName())->delete($trimmed);
}
private function diskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function safeExtension(UploadedFile $file, string $mime): string
{
$extension = strtolower((string) $file->getClientOriginalExtension());
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported content type asset upload type.');
}
return match ($extension) {
'jpg', 'jpeg' => 'jpg',
'png' => 'png',
default => 'webp',
};
}
private function mimeTypeForExtension(string $extension): string
{
return match ($extension) {
'jpg' => 'image/jpeg',
'png' => 'image/png',
default => 'image/webp',
};
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Services\ContentTypes;
use App\Models\ContentType;
class ContentTypeSlugResolution
{
public function __construct(
public readonly string $requestedSlug,
public readonly ?ContentType $contentType = null,
public readonly ?string $redirectSlug = null,
public readonly bool $isVirtual = false,
public readonly ?string $virtualType = null,
) {
}
public function found(): bool
{
return $this->contentType !== null || $this->isVirtual;
}
public function requiresRedirect(): bool
{
return $this->redirectSlug !== null && $this->redirectSlug !== '' && $this->redirectSlug !== $this->requestedSlug;
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services\ContentTypes;
use App\Models\ContentType;
use App\Models\ContentTypeSlugHistory;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class ContentTypeSlugResolver
{
public function publicContentTypes(): Collection
{
return Cache::rememberForever($this->publicListCacheKey(), function () {
return ContentType::query()
->ordered()
->get(['id', 'name', 'slug', 'description', 'order', 'hide_from_menu']);
});
}
public function toolbarContentTypes(): Collection
{
return $this->publicContentTypes()
->reject(static fn (ContentType $contentType): bool => (bool) $contentType->hide_from_menu)
->values();
}
public function resolve(string $slug, bool $allowVirtual = false): ContentTypeSlugResolution
{
$normalizedSlug = strtolower(trim($slug));
if ($allowVirtual && $this->isVirtualSlug($normalizedSlug)) {
return new ContentTypeSlugResolution(
requestedSlug: $normalizedSlug,
isVirtual: true,
virtualType: $normalizedSlug,
);
}
$slugMap = $this->currentSlugMap();
if (isset($slugMap[$normalizedSlug])) {
return new ContentTypeSlugResolution(
requestedSlug: $normalizedSlug,
contentType: $this->publicContentTypes()->firstWhere('id', $slugMap[$normalizedSlug]),
);
}
$historyMap = $this->historySlugMap();
$redirectSlug = $historyMap[$normalizedSlug] ?? null;
if ($redirectSlug !== null) {
$contentTypeId = $slugMap[$redirectSlug] ?? null;
return new ContentTypeSlugResolution(
requestedSlug: $normalizedSlug,
contentType: $contentTypeId !== null ? $this->publicContentTypes()->firstWhere('id', $contentTypeId) : null,
redirectSlug: $redirectSlug,
);
}
return new ContentTypeSlugResolution(requestedSlug: $normalizedSlug);
}
public function reservedSlugs(): array
{
return array_values(array_unique(array_map(
static fn (string $slug): string => strtolower(trim($slug)),
(array) config('content_types.reserved_slugs', [])
)));
}
public function isReservedSlug(string $slug): bool
{
return in_array(strtolower(trim($slug)), $this->reservedSlugs(), true);
}
public function historicalSlugExists(string $slug, ?int $ignoreContentTypeId = null): bool
{
$query = ContentTypeSlugHistory::query()->where('old_slug', strtolower(trim($slug)));
if ($ignoreContentTypeId !== null) {
$query->where('content_type_id', '!=', $ignoreContentTypeId);
}
return $query->exists();
}
public function flushCaches(): void
{
Cache::forget($this->publicListCacheKey());
Cache::forget($this->slugMapCacheKey());
Cache::forget($this->historyMapCacheKey());
}
public function dynamicSitemapContentTypes(): Collection
{
return $this->publicContentTypes();
}
private function currentSlugMap(): array
{
return Cache::rememberForever($this->slugMapCacheKey(), function () {
return ContentType::query()
->ordered()
->pluck('id', 'slug')
->mapWithKeys(static fn ($id, $slug) => [strtolower((string) $slug) => (int) $id])
->all();
});
}
private function historySlugMap(): array
{
return Cache::rememberForever($this->historyMapCacheKey(), function () {
$currentSlugById = ContentType::query()
->pluck('slug', 'id')
->mapWithKeys(static fn ($slug, $id) => [(int) $id => strtolower((string) $slug)])
->all();
return ContentTypeSlugHistory::query()
->orderByDesc('id')
->get(['content_type_id', 'old_slug'])
->mapWithKeys(function (ContentTypeSlugHistory $history) use ($currentSlugById) {
$currentSlug = $currentSlugById[(int) $history->content_type_id] ?? null;
return $currentSlug !== null
? [strtolower((string) $history->old_slug) => $currentSlug]
: [];
})
->all();
});
}
private function isVirtualSlug(string $slug): bool
{
return array_key_exists($slug, (array) config('content_types.virtual_types', []));
}
private function publicListCacheKey(): string
{
return (string) config('content_types.cache.public_list_key', 'content-types.public-list');
}
private function slugMapCacheKey(): string
{
return (string) config('content_types.cache.slug_map_key', 'content-types.slug-map');
}
private function historyMapCacheKey(): string
{
return (string) config('content_types.cache.history_map_key', 'content-types.slug-history-map');
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Services\Countries;
use App\Models\Country;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
final class CountryCatalogService
{
public const ACTIVE_ALL_CACHE_KEY = 'countries.active.all';
public const PROFILE_SELECT_CACHE_KEY = 'countries.profile.select';
/**
* @return Collection<int, Country>
*/
public function activeCountries(): Collection
{
if (! Schema::hasTable('countries')) {
return collect();
}
/** @var Collection<int, Country> $countries */
$countries = Cache::remember(
self::ACTIVE_ALL_CACHE_KEY,
max(60, (int) config('skinbase-countries.cache_ttl', 86400)),
fn (): Collection => Country::query()->active()->ordered()->get(),
);
return $countries;
}
/**
* @return array<int, array<string, mixed>>
*/
public function profileSelectOptions(): array
{
return Cache::remember(
self::PROFILE_SELECT_CACHE_KEY,
max(60, (int) config('skinbase-countries.cache_ttl', 86400)),
fn (): array => $this->activeCountries()
->map(fn (Country $country): array => [
'id' => $country->id,
'iso2' => $country->iso2,
'name' => $country->name_common,
'flag_emoji' => $country->flag_emoji,
'flag_css_class' => $country->flag_css_class,
'is_featured' => $country->is_featured,
'flag_path' => $country->local_flag_path,
])
->values()
->all(),
);
}
public function findById(?int $countryId): ?Country
{
if ($countryId === null || $countryId <= 0 || ! Schema::hasTable('countries')) {
return null;
}
return Country::query()->find($countryId);
}
public function findByIso2(?string $iso2): ?Country
{
$normalized = strtoupper(trim((string) $iso2));
if ($normalized === '' || ! preg_match('/^[A-Z]{2}$/', $normalized) || ! Schema::hasTable('countries')) {
return null;
}
return Country::query()->where('iso2', $normalized)->first();
}
public function resolveUserCountry(User $user): ?Country
{
if ($user->relationLoaded('country') && $user->country instanceof Country) {
return $user->country;
}
if (! empty($user->country_id)) {
return $this->findById((int) $user->country_id);
}
$countryCode = strtoupper((string) ($user->profile?->country_code ?? ''));
return $countryCode !== '' ? $this->findByIso2($countryCode) : null;
}
public function flushCache(): void
{
Cache::forget(self::ACTIVE_ALL_CACHE_KEY);
Cache::forget(self::PROFILE_SELECT_CACHE_KEY);
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Services\Countries;
use Illuminate\Http\Client\Factory as HttpFactory;
use RuntimeException;
final class CountryRemoteProvider implements CountryRemoteProviderInterface
{
public function __construct(
private readonly HttpFactory $http,
) {
}
public function fetchAll(): array
{
$endpoint = trim((string) config('skinbase-countries.endpoint', ''));
if ($endpoint === '') {
throw new RuntimeException('Country sync endpoint is not configured.');
}
$response = $this->http->acceptJson()
->connectTimeout(max(1, (int) config('skinbase-countries.connect_timeout', 5)))
->timeout(max(1, (int) config('skinbase-countries.timeout', 10)))
->retry(
max(0, (int) config('skinbase-countries.retry_times', 2)),
max(0, (int) config('skinbase-countries.retry_sleep_ms', 250)),
throw: false,
)
->get($endpoint);
if (! $response->successful()) {
throw new RuntimeException(sprintf('Country sync request failed with status %d.', $response->status()));
}
$payload = $response->json();
if (! is_array($payload)) {
throw new RuntimeException('Country sync response was not a JSON array.');
}
return $this->normalizePayload($payload);
}
public function normalizePayload(array $payload): array
{
$normalized = [];
foreach ($payload as $record) {
if (! is_array($record)) {
continue;
}
$country = $this->normalizeRecord($record);
if ($country !== null) {
$normalized[] = $country;
}
}
return $normalized;
}
/**
* @param array<string, mixed> $record
* @return array<string, mixed>|null
*/
private function normalizeRecord(array $record): ?array
{
$iso2 = strtoupper(trim((string) ($record['cca2'] ?? $record['iso2'] ?? '')));
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
return null;
}
$iso3 = strtoupper(trim((string) ($record['cca3'] ?? $record['iso3'] ?? '')));
$iso3 = preg_match('/^[A-Z]{3}$/', $iso3) ? $iso3 : null;
$numericCode = trim((string) ($record['ccn3'] ?? $record['numeric_code'] ?? ''));
$numericCode = preg_match('/^\d{1,3}$/', $numericCode)
? str_pad($numericCode, 3, '0', STR_PAD_LEFT)
: null;
$name = $record['name'] ?? [];
$nameCommon = trim((string) ($name['common'] ?? $record['name_common'] ?? ''));
if ($nameCommon === '') {
return null;
}
$nameOfficial = trim((string) ($name['official'] ?? $record['name_official'] ?? ''));
$flags = $record['flags'] ?? [];
$flagSvgUrl = trim((string) ($flags['svg'] ?? $record['flag_svg_url'] ?? ''));
$flagPngUrl = trim((string) ($flags['png'] ?? $record['flag_png_url'] ?? ''));
$flagEmoji = trim((string) ($record['flag'] ?? $record['flag_emoji'] ?? ''));
$region = trim((string) ($record['region'] ?? ''));
$subregion = trim((string) ($record['subregion'] ?? ''));
return [
'iso2' => $iso2,
'iso3' => $iso3,
'numeric_code' => $numericCode,
'name_common' => $nameCommon,
'name_official' => $nameOfficial !== '' ? $nameOfficial : null,
'region' => $region !== '' ? $region : null,
'subregion' => $subregion !== '' ? $subregion : null,
'flag_svg_url' => $flagSvgUrl !== '' ? $flagSvgUrl : null,
'flag_png_url' => $flagPngUrl !== '' ? $flagPngUrl : null,
'flag_emoji' => $flagEmoji !== '' ? $flagEmoji : null,
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Services\Countries;
interface CountryRemoteProviderInterface
{
/**
* Fetch and normalize all remote countries.
*
* @return array<int, array<string, mixed>>
*/
public function fetchAll(): array;
/**
* Normalize a raw payload into syncable country records.
*
* @param array<int, mixed> $payload
* @return array<int, array<string, mixed>>
*/
public function normalizePayload(array $payload): array;
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Services\Countries;
use App\Models\Country;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use JsonException;
use RuntimeException;
use Throwable;
final class CountrySyncService
{
public function __construct(
private readonly CountryRemoteProviderInterface $remoteProvider,
private readonly CountryCatalogService $catalog,
) {
}
/**
* @return array<string, int|string|null>
*/
public function sync(bool $allowFallback = true, ?bool $deactivateMissing = null): array
{
if (! (bool) config('skinbase-countries.enabled', true)) {
throw new RuntimeException('Countries sync is disabled by configuration.');
}
$summary = [
'source' => null,
'total_fetched' => 0,
'inserted' => 0,
'updated' => 0,
'skipped' => 0,
'invalid' => 0,
'deactivated' => 0,
'backfilled_users' => 0,
];
try {
$records = $this->remoteProvider->fetchAll();
$summary['source'] = (string) config('skinbase-countries.remote_source', 'remote');
} catch (Throwable $exception) {
if (! $allowFallback || ! (bool) config('skinbase-countries.fallback_seed_enabled', true)) {
throw new RuntimeException('Country sync failed: '.$exception->getMessage(), previous: $exception);
}
$records = $this->loadFallbackRecords();
$summary['source'] = 'fallback';
}
if ($records === []) {
throw new RuntimeException('Country sync did not yield any valid country records.');
}
$summary['total_fetched'] = count($records);
$seenIso2 = [];
$featured = array_values(array_filter(array_map(
static fn (mixed $iso2): string => strtoupper(trim((string) $iso2)),
(array) config('skinbase-countries.featured_countries', []),
)));
$featuredOrder = array_flip($featured);
DB::transaction(function () use (&$summary, $records, &$seenIso2, $featuredOrder, $deactivateMissing): void {
foreach ($records as $record) {
$iso2 = strtoupper((string) ($record['iso2'] ?? ''));
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
$summary['invalid']++;
continue;
}
if (isset($seenIso2[$iso2])) {
$summary['skipped']++;
continue;
}
$seenIso2[$iso2] = true;
$country = Country::query()->firstOrNew(['iso2' => $iso2]);
$exists = $country->exists;
$featuredIndex = $featuredOrder[$iso2] ?? null;
$country->fill([
'iso' => $iso2,
'iso3' => $record['iso3'] ?? null,
'numeric_code' => $record['numeric_code'] ?? null,
'name' => $record['name_common'],
'native' => $record['name_official'] ?? null,
'continent' => $this->continentCode($record['region'] ?? null),
'name_common' => $record['name_common'],
'name_official' => $record['name_official'] ?? null,
'region' => $record['region'] ?? null,
'subregion' => $record['subregion'] ?? null,
'flag_svg_url' => $record['flag_svg_url'] ?? null,
'flag_png_url' => $record['flag_png_url'] ?? null,
'flag_emoji' => $record['flag_emoji'] ?? null,
'active' => true,
'is_featured' => $featuredIndex !== null,
'sort_order' => $featuredIndex !== null ? $featuredIndex + 1 : 1000,
]);
if (! $exists) {
$country->save();
$summary['inserted']++;
continue;
}
if ($country->isDirty()) {
$country->save();
$summary['updated']++;
continue;
}
$summary['skipped']++;
}
if ($deactivateMissing ?? (bool) config('skinbase-countries.deactivate_missing', false)) {
$summary['deactivated'] = Country::query()
->where('active', true)
->whereNotIn('iso2', array_keys($seenIso2))
->update(['active' => false]);
}
});
$summary['backfilled_users'] = $this->backfillUsersFromLegacyProfileCodes();
$this->catalog->flushCache();
return $summary;
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadFallbackRecords(): array
{
$path = (string) config('skinbase-countries.fallback_seed_path', database_path('data/countries-fallback.json'));
if (! is_file($path)) {
throw new RuntimeException('Country fallback dataset is missing.');
}
try {
$decoded = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('Country fallback dataset is invalid JSON.', previous: $exception);
}
if (! is_array($decoded)) {
throw new RuntimeException('Country fallback dataset is not a JSON array.');
}
return $this->remoteProvider->normalizePayload($decoded);
}
private function backfillUsersFromLegacyProfileCodes(): int
{
if (! Schema::hasTable('user_profiles') || ! Schema::hasTable('users') || ! Schema::hasColumn('users', 'country_id')) {
return 0;
}
$rows = DB::table('users as users')
->join('user_profiles as profiles', 'profiles.user_id', '=', 'users.id')
->join('countries as countries', 'countries.iso2', '=', 'profiles.country_code')
->whereNull('users.country_id')
->whereNotNull('profiles.country_code')
->select(['users.id as user_id', 'countries.id as country_id'])
->get();
foreach ($rows as $row) {
DB::table('users')
->where('id', (int) $row->user_id)
->update(['country_id' => (int) $row->country_id]);
}
return $rows->count();
}
private function continentCode(?string $region): ?string
{
return Arr::get([
'Africa' => 'AF',
'Americas' => 'AM',
'Asia' => 'AS',
'Europe' => 'EU',
'Oceania' => 'OC',
'Antarctic' => 'AN',
], trim((string) $region));
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* ActivityLayer (§8 Optional)
*
* Surfaces real site-activity signals as human-readable summaries.
* All data is genuine no fabrication, no fake counts.
*
* Examples:
* "🔥 Trending this week: 24 artworks"
* "📈 Rising in Wallpapers"
* "🌟 5 new creators joined this month"
* "🎨 38 artworks published recently"
*
* Only active when EarlyGrowth::activityLayerEnabled() returns true.
*/
final class ActivityLayer
{
/**
* Return an array of activity signal strings for use in UI badges/widgets.
* Empty array when ActivityLayer is disabled.
*
* @return array<int, array{icon: string, text: string, type: string}>
*/
public function getSignals(): array
{
if (! EarlyGrowth::activityLayerEnabled()) {
return [];
}
$ttl = (int) config('early_growth.cache_ttl.activity', 1800);
return Cache::remember('egs.activity_signals', $ttl, fn (): array => $this->buildSignals());
}
// ─── Signal builders ─────────────────────────────────────────────────────
private function buildSignals(): array
{
$signals = [];
// §8: "X artworks published recently"
$recentCount = $this->recentArtworkCount(7);
if ($recentCount > 0) {
$signals[] = [
'icon' => '🎨',
'text' => "{$recentCount} artwork" . ($recentCount !== 1 ? 's' : '') . ' published this week',
'type' => 'uploads',
];
}
// §8: "X new creators joined this month"
$newCreators = $this->newCreatorsThisMonth();
if ($newCreators > 0) {
$signals[] = [
'icon' => '🌟',
'text' => "{$newCreators} new creator" . ($newCreators !== 1 ? 's' : '') . ' joined this month',
'type' => 'creators',
];
}
// §8: "Trending this week"
$trendingCount = $this->recentArtworkCount(7);
if ($trendingCount > 0) {
$signals[] = [
'icon' => '🔥',
'text' => 'Trending this week',
'type' => 'trending',
];
}
// §8: "Rising in Wallpapers" (first content type with recent uploads)
$risingType = $this->getRisingContentType();
if ($risingType !== null) {
$signals[] = [
'icon' => '📈',
'text' => "Rising in {$risingType}",
'type' => 'rising',
];
}
return array_values($signals);
}
/**
* Count approved public artworks published in the last N days.
*/
private function recentArtworkCount(int $days): int
{
try {
return Artwork::query()
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->where('published_at', '>=', now()->subDays($days))
->count();
} catch (\Throwable) {
return 0;
}
}
/**
* Count users who registered (email_verified_at set) this calendar month.
*/
private function newCreatorsThisMonth(): int
{
try {
return User::query()
->whereNotNull('email_verified_at')
->where('email_verified_at', '>=', now()->startOfMonth())
->count();
} catch (\Throwable) {
return 0;
}
}
/**
* Returns the name of the content type with the most uploads in the last 30 days,
* or null if the content_types table isn't available.
*/
private function getRisingContentType(): ?string
{
try {
$row = DB::table('artworks')
->join('content_types', 'content_types.id', '=', 'artworks.content_type_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNull('artworks.deleted_at')
->where('artworks.published_at', '>=', now()->subDays(30))
->selectRaw('content_types.name, COUNT(*) as cnt')
->groupBy('content_types.id', 'content_types.name')
->orderByDesc('cnt')
->first();
return $row ? (string) $row->name : null;
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use App\Models\Artwork;
use Illuminate\Support\Facades\Cache;
/**
* AdaptiveTimeWindow
*
* Dynamically widens the look-back window used by trending / rising feeds
* when recent upload volume is below configured thresholds.
*
* This only affects RANKING QUERIES it never modifies artwork timestamps,
* canonical URLs, or any stored data.
*
* Behaviour:
* uploads/day narrow_threshold normal window (7 d)
* uploads/day wide_threshold medium window (30 d)
* uploads/day < wide_threshold wide window (90 d)
*
* All thresholds and window sizes are configurable in config/early_growth.php.
*/
final class AdaptiveTimeWindow
{
/**
* Return the number of look-back days to use for trending / rising queries.
*
* @param int $defaultDays Returned as-is when EGS is disabled.
*/
public function getTrendingWindowDays(int $defaultDays = 30): int
{
if (! EarlyGrowth::adaptiveWindowEnabled()) {
return $defaultDays;
}
$uploadsPerDay = $this->getUploadsPerDay();
$narrowThreshold = (int) config('early_growth.thresholds.uploads_per_day_narrow', 10);
$wideThreshold = (int) config('early_growth.thresholds.uploads_per_day_wide', 3);
$narrowDays = (int) config('early_growth.thresholds.window_narrow_days', 7);
$mediumDays = (int) config('early_growth.thresholds.window_medium_days', 30);
$wideDays = (int) config('early_growth.thresholds.window_wide_days', 90);
if ($uploadsPerDay >= $narrowThreshold) {
return $narrowDays; // Healthy activity → normal 7-day window
}
if ($uploadsPerDay >= $wideThreshold) {
return $mediumDays; // Moderate activity → expand to 30 days
}
return $wideDays; // Low activity → expand to 90 days
}
/**
* Rolling 7-day average of approved public uploads per day.
* Cached for `early_growth.cache_ttl.time_window` seconds.
*/
public function getUploadsPerDay(): float
{
$ttl = (int) config('early_growth.cache_ttl.time_window', 600);
return (float) Cache::remember('egs.uploads_per_day', $ttl, function (): float {
$count = Artwork::query()
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->whereNotNull('published_at')
->where('published_at', '>=', now()->subDays(7))
->count();
return (float) round($count / 7, 2);
});
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use App\Models\Artwork;
use Illuminate\Support\Facades\Cache;
/**
* EarlyGrowth
*
* Central service for the Early-Stage Growth System.
* All other EGS modules consult this class for feature-flag status.
*
* Toggle via .env:
* NOVA_EARLY_GROWTH_ENABLED=true
* NOVA_EARLY_GROWTH_MODE=light # off | light | aggressive
*
* Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to
* normal production behaviour across every integration point.
*/
final class EarlyGrowth
{
// ─── Feature-flag helpers ─────────────────────────────────────────────────
/**
* Is the entire Early Growth System active?
* Checks master enabled flag AND that mode is not 'off'.
*/
public static function enabled(): bool
{
if (! (bool) config('early_growth.enabled', false)) {
return false;
}
// Auto-disable check (optional)
if ((bool) config('early_growth.auto_disable.enabled', false) && self::shouldAutoDisable()) {
return false;
}
return self::mode() !== 'off';
}
/**
* Current operating mode: off | light | aggressive
*/
public static function mode(): string
{
$mode = (string) config('early_growth.mode', 'off');
return in_array($mode, ['off', 'light', 'aggressive'], true) ? $mode : 'off';
}
/** Is the AdaptiveTimeWindow module active? */
public static function adaptiveWindowEnabled(): bool
{
return self::enabled() && (bool) config('early_growth.adaptive_time_window', true);
}
/** Is the GridFiller module active? */
public static function gridFillerEnabled(): bool
{
return self::enabled() && (bool) config('early_growth.grid_filler', true);
}
/** Is the SpotlightEngine module active? */
public static function spotlightEnabled(): bool
{
return self::enabled() && (bool) config('early_growth.spotlight', true);
}
/** Is the optional ActivityLayer module active? */
public static function activityLayerEnabled(): bool
{
return self::enabled() && (bool) config('early_growth.activity_layer', false);
}
/**
* Blend ratios for the current mode.
* Returns proportions for fresh / curated / spotlight slices.
*/
public static function blendRatios(): array
{
$mode = self::mode();
return config("early_growth.blend_ratios.{$mode}", [
'fresh' => 1.0,
'curated' => 0.0,
'spotlight' => 0.0,
]);
}
// ─── Auto-disable logic ───────────────────────────────────────────────────
/**
* Check whether upload volume or active-user count has crossed the
* configured threshold for organic scale, and the system should self-disable.
* Result is cached for 10 minutes to avoid constant DB polling.
*/
private static function shouldAutoDisable(): bool
{
return (bool) Cache::remember('egs.auto_disable_check', 600, function (): bool {
$uploadsThreshold = (int) config('early_growth.auto_disable.uploads_per_day', 50);
$usersThreshold = (int) config('early_growth.auto_disable.active_users', 500);
// Average daily uploads over the last 7 days
$recentUploads = Artwork::query()
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->where('published_at', '>=', now()->subDays(7))
->count();
$uploadsPerDay = $recentUploads / 7;
if ($uploadsPerDay >= $uploadsThreshold) {
return true;
}
// Active users: verified accounts who uploaded in last 30 days
$activeCreators = Artwork::query()
->where('is_public', true)
->where('is_approved', true)
->where('published_at', '>=', now()->subDays(30))
->distinct('user_id')
->count('user_id');
return $activeCreators >= $usersThreshold;
});
}
// ─── Status summary ──────────────────────────────────────────────────────
/**
* Return a summary array suitable for admin panels / logging.
*/
public static function status(): array
{
return [
'enabled' => self::enabled(),
'mode' => self::mode(),
'adaptive_window' => self::adaptiveWindowEnabled(),
'grid_filler' => self::gridFillerEnabled(),
'spotlight' => self::spotlightEnabled(),
'activity_layer' => self::activityLayerEnabled(),
];
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use App\Services\EarlyGrowth\SpotlightEngineInterface;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* FeedBlender
*
* Blends real fresh uploads with curated older content and spotlight picks
* to make early-stage feeds feel alive and diverse without faking engagement.
*
* Rules:
* - ONLY applied to page 1 deeper pages use the real feed untouched.
* - No fake artworks, timestamps, or metrics.
* - Duplicates removed before merging.
* - The original paginator's total / path / page-name are preserved so
* pagination links and SEO canonical/prev/next remain correct.
*
* Mode blend ratios are defined in config/early_growth.php:
* light 60% fresh / 25% curated / 15% spotlight
* aggressive 30% fresh / 50% curated / 20% spotlight
*/
final class FeedBlender
{
public function __construct(
private readonly SpotlightEngineInterface $spotlight,
) {}
/**
* Blend a LengthAwarePaginator of fresh artworks with curated and spotlight content.
*
* @param LengthAwarePaginator $freshResults Original fresh-upload paginator
* @param int $perPage Items per page
* @param int $page Current page number
* @return LengthAwarePaginator Blended paginator (page 1) or original (page > 1)
*/
public function blend(
LengthAwarePaginator $freshResults,
int $perPage = 24,
int $page = 1,
): LengthAwarePaginator {
// Only blend on page 1; real pagination takes over for deeper pages
if (! EarlyGrowth::enabled() || $page > 1) {
return $freshResults;
}
$ratios = EarlyGrowth::blendRatios();
if (($ratios['curated'] + $ratios['spotlight']) < 0.001) {
// Mode is effectively "fresh only" — nothing to blend
return $freshResults;
}
$fresh = $freshResults->getCollection();
$freshIds = $fresh->pluck('id')->toArray();
// Calculate absolute item counts from ratios
[$freshCount, $curatedCount, $spotlightCount] = $this->allocateCounts($ratios, $perPage);
// Fetch sources — over-fetch to account for deduplication losses
$curated = $this->spotlight
->getCurated($curatedCount + 6)
->filter(fn ($a) => ! in_array($a->id, $freshIds, true))
->take($curatedCount)
->values();
$curatedIds = $curated->pluck('id')->toArray();
$spotlightItems = $this->spotlight
->getSpotlight($spotlightCount + 6)
->filter(fn ($a) => ! in_array($a->id, $freshIds, true))
->filter(fn ($a) => ! in_array($a->id, $curatedIds, true))
->take($spotlightCount)
->values();
// Compose blended page
$blended = $fresh->take($freshCount)
->concat($curated)
->concat($spotlightItems)
->unique('id')
->values();
// Pad back to $perPage with leftover fresh items if any source ran short
if ($blended->count() < $perPage) {
$usedIds = $blended->pluck('id')->toArray();
$pad = $fresh
->filter(fn ($a) => ! in_array($a->id, $usedIds, true))
->take($perPage - $blended->count());
$blended = $blended->concat($pad)->unique('id')->values();
}
// Rebuild paginator preserving the real total so pagination links remain stable
return new LengthAwarePaginator(
$blended->take($perPage)->all(),
$freshResults->total(), // ← real total, not blended count
$perPage,
$page,
[
'path' => $freshResults->path(),
'pageName' => $freshResults->getPageName(),
]
);
}
// ─── Private helpers ─────────────────────────────────────────────────────
/**
* Distribute $perPage slots across fresh / curated / spotlight.
* Returns [freshCount, curatedCount, spotlightCount].
*/
private function allocateCounts(array $ratios, int $perPage): array
{
$total = max(0.001, ($ratios['fresh'] ?? 0) + ($ratios['curated'] ?? 0) + ($ratios['spotlight'] ?? 0));
$freshN = (int) round($perPage * ($ratios['fresh'] ?? 1.0) / $total);
$curatedN = (int) round($perPage * ($ratios['curated'] ?? 0.0) / $total);
$spotN = $perPage - $freshN - $curatedN;
return [max(0, $freshN), max(0, $curatedN), max(0, $spotN)];
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use App\Models\Artwork;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* GridFiller
*
* Ensures that browse / discover grids never appear half-empty.
* When real results fall below the configured minimum, it backfills
* with real trending artworks from the general pool.
*
* Rules (per spec):
* - Fill only the visible first page never mix page-number scopes.
* - Filler is always real content (no fake items).
* - The original total is not reduced (pagination links stay stable).
* - Content is not labelled as "filler" in the UI it is just valid content.
*/
final class GridFiller
{
/**
* Ensure a LengthAwarePaginator contains at least $minimum items on page 1.
* Returns the original paginator unchanged when:
* - EGS is disabled
* - Page is > 1
* - Real result count already meets the minimum
*/
public function fill(
LengthAwarePaginator $results,
int $minimum = 0,
int $page = 1,
): LengthAwarePaginator {
if (! EarlyGrowth::gridFillerEnabled() || $page > 1) {
return $results;
}
$minimum = $minimum > 0
? $minimum
: (int) config('early_growth.grid_min_results', 12);
$items = $results->getCollection();
$count = $items->count();
if ($count >= $minimum) {
return $results;
}
$needed = $minimum - $count;
$exclude = $items->pluck('id')->all();
$filler = $this->fetchTrendingFiller($needed + 6, $exclude)->take($needed);
$merged = $items
->concat($filler)
->unique('id')
->values();
return new LengthAwarePaginator(
$merged->all(),
max((int) $results->total(), $merged->count()), // never shrink reported total
$results->perPage(),
$page,
[
'path' => $results->path(),
'pageName' => $results->getPageName(),
]
);
}
/**
* Fill a plain Collection (for non-paginated grids like homepage sections).
*/
public function fillCollection(Collection $items, int $minimum = 0): Collection
{
if (! EarlyGrowth::gridFillerEnabled()) {
return $items;
}
$minimum = $minimum > 0
? $minimum
: (int) config('early_growth.grid_min_results', 12);
if ($items->count() >= $minimum) {
return $items;
}
$needed = $minimum - $items->count();
$exclude = $items->pluck('id')->all();
$filler = $this->fetchTrendingFiller($needed + 6, $exclude)->take($needed);
return $items->concat($filler)->unique('id')->values();
}
// ─── Private ─────────────────────────────────────────────────────────────
/**
* Pull high-ranking artworks as grid filler.
* Cache key includes an exclude-hash so different grids get distinct content.
*/
private function fetchTrendingFiller(int $limit, array $excludeIds): Collection
{
$ttl = (int) config('early_growth.cache_ttl.feed_blend', 300);
$excludeHash = md5(implode(',', array_slice(array_unique($excludeIds), 0, 50)));
$cacheKey = "egs.grid_filler.{$excludeHash}.{$limit}";
return Cache::remember($cacheKey, $ttl, function () use ($limit, $excludeIds): Collection {
return Artwork::query()
->public()
->published()
->withoutMissingThumbnails()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
])
->leftJoin('artwork_stats as _gf_stats', '_gf_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->when(! empty($excludeIds), fn ($q) => $q->whereNotIn('artworks.id', $excludeIds))
->orderByDesc('_gf_stats.ranking_score')
->limit($limit)
->get()
->values();
});
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use App\Models\Artwork;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* SpotlightEngine
*
* Selects and rotates curated spotlight artworks for use in feed blending,
* grid filling, and dedicated spotlight sections.
*
* Selection is date-seeded so the spotlight rotates daily without DB writes.
* No artwork timestamps or engagement metrics are modified this is purely
* a read-and-present layer.
*/
final class SpotlightEngine implements SpotlightEngineInterface
{
/**
* Return spotlight artworks for the current day.
* Cached for `early_growth.cache_ttl.spotlight` seconds (default 1 hour).
* Rotates daily via a date-seeded RAND() expression.
*
* Returns empty collection when SpotlightEngine is disabled.
*/
public function getSpotlight(int $limit = 6): Collection
{
if (! EarlyGrowth::spotlightEnabled()) {
return collect();
}
$ttl = (int) config('early_growth.cache_ttl.spotlight', 3600);
$cacheKey = 'egs.spotlight.' . now()->format('Y-m-d') . ".{$limit}";
return Cache::remember($cacheKey, $ttl, fn (): Collection => $this->selectSpotlight($limit));
}
/**
* Return high-quality older artworks for feed blending ("curated" pool).
* Excludes artworks newer than $olderThanDays to keep them out of the
* "fresh" section yet available for blending.
*
* Cached per (limit, olderThanDays) tuple and rotated daily.
*/
public function getCurated(int $limit = 12, int $olderThanDays = 7): Collection
{
if (! EarlyGrowth::enabled()) {
return collect();
}
$ttl = (int) config('early_growth.cache_ttl.spotlight', 3600);
$cacheKey = 'egs.curated.' . now()->format('Y-m-d') . ".{$limit}.{$olderThanDays}";
return Cache::remember($cacheKey, $ttl, fn (): Collection => $this->selectCurated($limit, $olderThanDays));
}
// ─── Private selection logic ──────────────────────────────────────────────
/**
* Select spotlight artworks.
* Uses a date-based seed for deterministic daily rotation.
* Fetches 3× the needed count and selects the top-ranked subset.
*/
private function selectSpotlight(int $limit): Collection
{
$seed = (int) now()->format('Ymd');
$randomExpr = $this->dailyRandomExpression('artworks.id', $seed);
// Artworks published > 7 days ago with meaningful ranking score
return Artwork::query()
->public()
->published()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
])
->leftJoin('artwork_stats as _ast', '_ast.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '<=', now()->subDays(7))
// Blend ranking quality with daily-seeded randomness so spotlight varies
->orderByRaw("COALESCE(_ast.ranking_score, 0) * 0.6 + {$randomExpr} * 0.4 DESC")
->limit($limit * 3)
->get()
->sortByDesc(fn ($a) => optional($a->artworkStats)->ranking_score ?? 0)
->take($limit)
->values();
}
/**
* Select curated older artworks for feed blending.
*/
private function selectCurated(int $limit, int $olderThanDays): Collection
{
$seed = (int) now()->format('Ymd');
$randomExpr = $this->dailyRandomExpression('artworks.id', $seed);
return Artwork::query()
->public()
->published()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
])
->leftJoin('artwork_stats as _ast2', '_ast2.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '<=', now()->subDays($olderThanDays))
->orderByRaw("COALESCE(_ast2.ranking_score, 0) * 0.7 + {$randomExpr} * 0.3 DESC")
->limit($limit)
->get()
->values();
}
private function dailyRandomExpression(string $idColumn, int $seed): string
{
if (DB::connection()->getDriverName() === 'sqlite') {
return "(ABS((({$idColumn} * 1103515245) + {$seed}) % 2147483647) / 2147483647.0)";
}
return "RAND({$seed} + {$idColumn})";
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use Illuminate\Support\Collection;
/**
* Contract for spotlight / curated content selection.
* Allows test doubles and alternative implementations.
*/
interface SpotlightEngineInterface
{
public function getSpotlight(int $limit = 6): Collection;
public function getCurated(int $limit = 12, int $olderThanDays = 7): Collection;
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
class EditorialAutomationService
{
public function __construct(
private readonly CollectionAiCurationService $curation,
private readonly CollectionRecommendationService $recommendations,
private readonly CollectionQualityService $quality,
private readonly CollectionCampaignService $campaigns,
) {
}
public function qualityReview(Collection $collection): array
{
$scores = $this->quality->scores($collection);
return [
'quality_score' => $scores['quality_score'],
'ranking_score' => $scores['ranking_score'],
'missing_metadata' => $this->missingMetadata($collection),
'attention_flags' => $this->attentionFlags($collection),
'campaign_summary' => $this->campaigns->campaignSummary($collection),
'suggested_surface_assignments' => $this->campaigns->suggestedSurfaceAssignments($collection),
'suggested_cover' => $this->curation->suggestCover($collection),
'suggested_summary' => $this->curation->suggestSummary($collection),
'suggested_related_collections' => $this->recommendations->relatedPublicCollections($collection, 4)->map(fn (Collection $item) => [
'id' => (int) $item->id,
'title' => $item->title,
'slug' => $item->slug,
])->values()->all(),
'source' => 'editorial-automation-v1',
];
}
private function missingMetadata(Collection $collection): array
{
$missing = [];
if (blank($collection->summary)) {
$missing[] = 'Add a sharper summary for social previews and homepage modules.';
}
if (! $collection->resolvedCoverArtwork(false)) {
$missing[] = 'Choose a cover artwork to strengthen click-through and placement eligibility.';
}
if ((int) $collection->artworks_count < 4) {
$missing[] = 'Expand the collection with a few more artworks to improve curation depth.';
}
if (blank($collection->campaign_key) && blank($collection->event_key) && blank($collection->season_key)) {
$missing[] = 'Set campaign or seasonal metadata if this collection should power event-aware discovery.';
}
if (blank($collection->brand_safe_status) && (bool) $collection->commercial_eligibility) {
$missing[] = 'Mark brand-safe status before using this collection for commercial or partner-facing placements.';
}
return $missing;
}
private function attentionFlags(Collection $collection): array
{
$flags = [];
if ($collection->last_activity_at?->lt(now()->subDays(90)) && $collection->isPubliclyAccessible()) {
$flags[] = [
'key' => 'stale_collection',
'severity' => 'medium',
'message' => 'This public collection has not been updated recently and may need editorial review.',
];
}
if ($this->hasDuplicateTitleWithinOwner($collection)) {
$flags[] = [
'key' => 'possible_duplicate',
'severity' => 'medium',
'message' => 'Another collection from the same owner has the same title, so this set may duplicate an existing curation theme.',
];
}
if ($collection->moderation_status !== Collection::MODERATION_ACTIVE) {
$flags[] = [
'key' => 'moderation_block',
'severity' => 'high',
'message' => 'Moderation status currently blocks this collection from editorial promotion.',
];
}
if ($collection->unpublished_at?->between(now(), now()->addDays(14))) {
$flags[] = [
'key' => 'campaign_expiring',
'severity' => 'low',
'message' => 'This collection is approaching the end of its scheduled campaign window.',
];
}
return $flags;
}
private function hasDuplicateTitleWithinOwner(Collection $collection): bool
{
return Collection::query()
->where('id', '!=', $collection->id)
->where('user_id', $collection->user_id)
->whereRaw('LOWER(title) = ?', [mb_strtolower(trim((string) $collection->title))])
->exists();
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use App\Models\User;
use App\Models\BlogPost;
use App\Services\ThumbnailPresenter;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
/**
* ErrorSuggestionService
*
* Supplies lightweight contextual suggestions for error pages.
* All queries are cheap, results cached to TTL 5 min.
*/
final class ErrorSuggestionService
{
private const CACHE_TTL = 300; // 5 minutes
// ── Trending artworks (max 6) ─────────────────────────────────────────────
public function trendingArtworks(int $limit = 6): Collection
{
$limit = min($limit, 6);
return Cache::remember("error_suggestions.artworks.{$limit}", self::CACHE_TTL, function () use ($limit) {
return Artwork::query()
->with(['user', 'stats'])
->public()
->published()
->orderByDesc('trending_score_7d')
->limit($limit)
->get()
->map(fn (Artwork $a) => $this->artworkCard($a));
});
}
// ── Similar tags by slug prefix / Levenshtein approximation (max 10) ─────
public function similarTags(string $slug, int $limit = 10): Collection
{
$limit = min($limit, 10);
$prefix = substr($slug, 0, 3);
return Cache::remember("error_suggestions.similar_tags.{$slug}.{$limit}", self::CACHE_TTL, function () use ($slug, $limit, $prefix) {
return Tag::query()
->withCount('artworks')
->where('slug', '!=', $slug)
->where(function ($q) use ($prefix, $slug) {
$q->where('slug', 'like', $prefix . '%')
->orWhere('slug', 'like', '%' . substr($slug, -3) . '%');
})
->orderByDesc('artworks_count')
->limit($limit)
->get(['id', 'name', 'slug', 'artworks_count']);
});
}
// ── Trending tags (max 10) ────────────────────────────────────────────────
public function trendingTags(int $limit = 10): Collection
{
$limit = min($limit, 10);
return Cache::remember("error_suggestions.tags.{$limit}", self::CACHE_TTL, function () use ($limit) {
return Tag::query()
->withCount('artworks')
->orderByDesc('artworks_count')
->limit($limit)
->get(['id', 'name', 'slug', 'artworks_count']);
});
}
// ── Trending creators (max 6) ─────────────────────────────────────────────
public function trendingCreators(int $limit = 6): Collection
{
$limit = min($limit, 6);
return Cache::remember("error_suggestions.creators.{$limit}", self::CACHE_TTL, function () use ($limit) {
return User::query()
->with('profile')
->withCount(['artworks' => fn ($q) => $q->public()->published()])
->having('artworks_count', '>', 0)
->orderByDesc('artworks_count')
->limit($limit)
->get(['users.id', 'users.name', 'users.username'])
->map(fn (User $u) => $this->creatorCard($u, $u->artworks_count));
});
}
// ── Recently joined creators (max 6) ─────────────────────────────────────
public function recentlyJoinedCreators(int $limit = 6): Collection
{
$limit = min($limit, 6);
return Cache::remember("error_suggestions.creators.recent.{$limit}", self::CACHE_TTL, function () use ($limit) {
return User::query()
->with('profile')
->withCount(['artworks' => fn ($q) => $q->public()->published()])
->having('artworks_count', '>', 0)
->orderByDesc('users.id')
->limit($limit)
->get(['users.id', 'users.name', 'users.username'])
->map(fn (User $u) => $this->creatorCard($u, $u->artworks_count));
});
}
// ── Latest blog posts (max 6) ─────────────────────────────────────────────
public function latestBlogPosts(int $limit = 6): Collection
{
$limit = min($limit, 6);
return Cache::remember("error_suggestions.blog.{$limit}", self::CACHE_TTL, function () use ($limit) {
return BlogPost::published()
->orderByDesc('published_at')
->limit($limit)
->get(['id', 'title', 'slug', 'excerpt', 'published_at'])
->map(fn ($p) => [
'id' => $p->id,
'title' => $p->title,
'excerpt' => Str::limit($p->excerpt ?? '', 100),
'url' => '/blog/' . $p->slug,
'published_at' => $p->published_at?->diffForHumans(),
]);
});
}
// ── Private helpers ───────────────────────────────────────────────────────
private function artworkCard(Artwork $a): array
{
$slug = Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id;
$md = ThumbnailPresenter::present($a, 'md');
return [
'id' => $a->id,
'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
'thumb' => $md['url'] ?? null,
'thumb_srcset' => $md['srcset'] ?? null,
];
}
private function creatorCard(User $u, int $artworksCount = 0): array
{
return [
'id' => $u->id,
'name' => $u->name ?: $u->username,
'username' => $u->username,
'url' => '/@' . $u->username,
'avatar_url' => \App\Support\AvatarUrl::forUser(
(int) $u->id,
optional($u->profile)->avatar_hash,
64
),
'artworks_count' => $artworksCount,
];
}
}

View File

@@ -0,0 +1,479 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkFeature;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Str;
class FeaturedArtworkAdminService
{
public function __construct(private readonly ArtworkService $artworks)
{
}
/**
* @return array<string, mixed>
*/
public function pageProps(): array
{
$now = Carbon::now();
$features = ArtworkFeature::query()
->with([
'artwork' => fn ($query) => $query->withTrashed()->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
]),
])
->orderByDesc('priority')
->orderByDesc('featured_at')
->orderByDesc('id')
->get();
$duplicateCounts = $features->countBy(fn (ArtworkFeature $feature): int => (int) $feature->artwork_id);
$entries = $features
->map(fn (ArtworkFeature $feature): array => $this->mapFeature($feature, $duplicateCounts, $now))
->sort(function (array $left, array $right): int {
$comparisons = [
(int) $right['eligibility']['is_eligible'] <=> (int) $left['eligibility']['is_eligible'],
(int) $right['is_force_hero'] <=> (int) $left['is_force_hero'],
(int) $right['is_active'] <=> (int) $left['is_active'],
(int) $left['is_expired'] <=> (int) $right['is_expired'],
(int) $right['priority'] <=> (int) $left['priority'],
(int) $right['medals']['score_30d'] <=> (int) $left['medals']['score_30d'],
$this->timestamp($right['featured_at']) <=> $this->timestamp($left['featured_at']),
$this->timestamp($right['artwork']['published_at']) <=> $this->timestamp($left['artwork']['published_at']),
(int) $right['id'] <=> (int) $left['id'],
];
foreach ($comparisons as $comparison) {
if ($comparison !== 0) {
return $comparison;
}
}
return 0;
})
->values();
$eligibleEntries = $this->sortForHeroSelection(
$entries->filter(fn (array $entry): bool => (bool) $entry['eligibility']['is_eligible'])->values()
);
$sharedWinnerArtworkId = $this->artworks->getFeaturedArtworkWinner()?->id;
$winner = $sharedWinnerArtworkId
? $entries->first(fn (array $entry): bool => (int) $entry['artwork_id'] === (int) $sharedWinnerArtworkId)
: null;
if (! is_array($winner) && $eligibleEntries->isNotEmpty()) {
$winner = $eligibleEntries->first();
}
$winnerReason = is_array($winner) ? $this->buildWinnerReason($winner, $eligibleEntries) : null;
$winnerId = is_array($winner) ? (int) $winner['id'] : null;
$entries = $entries
->map(function (array $entry) use ($winnerId, $winnerReason): array {
$isWinner = $winnerId !== null && (int) $entry['id'] === $winnerId;
if ($isWinner) {
array_unshift($entry['status_badges'], [
'label' => 'Winner',
'tone' => 'amber',
]);
}
$entry['is_winner'] = $isWinner;
$entry['winner_reason'] = $isWinner ? $winnerReason : null;
return $entry;
})
->values();
$winner = is_array($winner)
? array_merge($winner, ['selection_reason' => $winnerReason])
: null;
return [
'entries' => $entries->all(),
'winner' => $winner,
'stats' => [
'total' => $entries->count(),
'active' => $entries->where('is_active', true)->count(),
'inactive' => $entries->where('is_active', false)->count(),
'expired' => $entries->where('is_expired', true)->count(),
'eligible' => $entries->filter(fn (array $entry): bool => (bool) $entry['eligibility']['is_eligible'])->count(),
'ineligible' => $entries->filter(fn (array $entry): bool => ! $entry['eligibility']['is_eligible'])->count(),
],
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function searchArtworks(string $term, int $limit = 12): array
{
$term = trim($term);
if ($term === '') {
return [];
}
$now = Carbon::now();
$artworks = Artwork::query()
->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
])
->where(function ($query) use ($term): void {
if (ctype_digit($term)) {
$query->where('artworks.id', (int) $term);
}
$query->orWhere('artworks.title', 'like', '%' . $term . '%')
->orWhere('artworks.slug', 'like', '%' . $term . '%')
->orWhereHas('user', function ($userQuery) use ($term): void {
$userQuery->where('username', 'like', '%' . $term . '%')
->orWhere('name', 'like', '%' . $term . '%');
})
->orWhereHas('group', function ($groupQuery) use ($term): void {
$groupQuery->where('name', 'like', '%' . $term . '%')
->orWhere('slug', 'like', '%' . $term . '%');
});
});
if (ctype_digit($term)) {
$artworks->orderByRaw('CASE WHEN artworks.id = ? THEN 0 ELSE 1 END', [(int) $term]);
}
$artworks = $artworks
->orderByDesc('published_at')
->limit($limit)
->get();
$featureCounts = ArtworkFeature::query()
->whereNull('deleted_at')
->whereIn('artwork_id', $artworks->pluck('id'))
->selectRaw('artwork_id, COUNT(*) as aggregate')
->groupBy('artwork_id')
->pluck('aggregate', 'artwork_id');
return $artworks
->map(fn (Artwork $artwork): array => $this->mapArtworkCandidate($artwork, (int) ($featureCounts[(int) $artwork->id] ?? 0), $now))
->values()
->all();
}
/**
* @param SupportCollection<int, int> $duplicateCounts
* @return array<string, mixed>
*/
private function mapFeature(ArtworkFeature $feature, SupportCollection $duplicateCounts, Carbon $now): array
{
$context = $this->mapArtworkContext($feature->artwork, $now);
$isExpired = $feature->expires_at !== null && $feature->expires_at->lte($now);
$isEligible = (bool) $feature->is_active && ! $isExpired && (bool) $context['eligibility']['is_eligible'];
$eligibilityReasons = $context['eligibility']['reasons'];
if (! $feature->is_active) {
$eligibilityReasons[] = 'Inactive';
}
if ($isExpired) {
$eligibilityReasons[] = 'Expired';
}
$statusBadges = [];
if ($feature->is_active && ! $isExpired) {
$statusBadges[] = ['label' => 'Active', 'tone' => 'emerald'];
}
if ((bool) $feature->force_hero) {
$statusBadges[] = ['label' => 'Force Hero', 'tone' => 'amber'];
}
if (! $feature->is_active) {
$statusBadges[] = ['label' => 'Inactive', 'tone' => 'slate'];
}
if ($isExpired) {
$statusBadges[] = ['label' => 'Expired', 'tone' => 'amber'];
}
$statusBadges[] = $isEligible
? ['label' => 'Eligible', 'tone' => 'sky']
: ['label' => 'Not eligible', 'tone' => 'rose'];
if ((bool) $context['flags']['is_private']) {
$statusBadges[] = ['label' => 'Private', 'tone' => 'slate'];
}
if ((bool) $context['flags']['is_unpublished']) {
$statusBadges[] = ['label' => 'Unpublished', 'tone' => 'slate'];
}
if ((bool) $context['flags']['missing_preview']) {
$statusBadges[] = ['label' => 'Missing preview', 'tone' => 'rose'];
}
if ((bool) $context['flags']['is_deleted']) {
$statusBadges[] = ['label' => 'Deleted', 'tone' => 'slate'];
}
if ((int) $duplicateCounts->get((int) $feature->artwork_id, 0) > 1) {
$statusBadges[] = ['label' => 'Duplicate', 'tone' => 'sky'];
}
return [
'id' => (int) $feature->id,
'artwork_id' => (int) $feature->artwork_id,
'priority' => (int) $feature->priority,
'featured_at' => $feature->featured_at?->toIsoString(),
'expires_at' => $feature->expires_at?->toIsoString(),
'created_at' => $feature->created_at?->toIsoString(),
'updated_at' => $feature->updated_at?->toIsoString(),
'is_active' => (bool) $feature->is_active,
'is_force_hero' => (bool) $feature->force_hero,
'is_expired' => $isExpired,
'duplicate_count' => (int) $duplicateCounts->get((int) $feature->artwork_id, 0),
'artwork' => $context['artwork'],
'medals' => $context['medals'],
'eligibility' => [
'is_eligible' => $isEligible,
'reasons' => array_values(array_unique($eligibilityReasons)),
],
'status_badges' => $statusBadges,
'is_winner' => false,
'winner_reason' => null,
];
}
/**
* @return array<string, mixed>
*/
private function mapArtworkCandidate(Artwork $artwork, int $existingFeatureCount, Carbon $now): array
{
$context = $this->mapArtworkContext($artwork, $now);
return array_merge($context['artwork'], [
'medals' => $context['medals'],
'eligibility' => $context['eligibility'],
'existing_feature_count' => $existingFeatureCount,
'already_featured' => $existingFeatureCount > 0,
]);
}
/**
* @return array<string, mixed>
*/
private function mapArtworkContext(?Artwork $artwork, Carbon $now): array
{
if (! $artwork instanceof Artwork) {
return [
'artwork' => [
'id' => null,
'title' => 'Missing artwork',
'slug' => null,
'canonical_url' => null,
'thumbnail' => ThumbnailPresenter::present(['id' => null, 'name' => 'Missing artwork'], 'sm'),
'published_at' => null,
'visibility' => null,
'is_public' => false,
'is_approved' => false,
'has_missing_preview' => true,
'is_deleted' => true,
'owner' => null,
],
'medals' => [
'score_30d' => 0,
],
'eligibility' => [
'is_eligible' => false,
'reasons' => ['Deleted'],
],
'flags' => [
'is_private' => false,
'is_unpublished' => true,
'missing_preview' => true,
'is_deleted' => true,
],
];
}
$isDeleted = $artwork->deleted_at !== null;
$isPublic = ! $isDeleted && (bool) $artwork->is_public;
$isApproved = ! $isDeleted && (bool) $artwork->is_approved;
$isPublished = ! $isDeleted && $artwork->published_at !== null && $artwork->published_at->lte($now);
$hasPreview = ! (bool) $artwork->has_missing_thumbnails;
$owner = $this->mapOwner($artwork);
$reasons = [];
if ($isDeleted) {
$reasons[] = 'Deleted';
}
if (! $isPublic) {
$reasons[] = 'Private';
}
if (! $isApproved) {
$reasons[] = 'Not approved';
}
if (! $isPublished) {
$reasons[] = 'Unpublished';
}
if (! $hasPreview) {
$reasons[] = 'Missing preview';
}
return [
'artwork' => [
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'slug' => (string) $artwork->slug,
'canonical_url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $this->artworkSlug($artwork)]),
'thumbnail' => ThumbnailPresenter::present($artwork, 'sm'),
'published_at' => $artwork->published_at?->toIsoString(),
'visibility' => (string) ($artwork->visibility ?? ''),
'is_public' => (bool) $artwork->is_public,
'is_approved' => (bool) $artwork->is_approved,
'has_missing_preview' => (bool) $artwork->has_missing_thumbnails,
'is_deleted' => $isDeleted,
'owner' => $owner,
],
'medals' => [
'score_30d' => (int) ($artwork->awardStat?->score_30d ?? 0),
],
'eligibility' => [
'is_eligible' => $isPublic && $isApproved && $isPublished && $hasPreview,
'reasons' => $reasons,
],
'flags' => [
'is_private' => ! $isPublic,
'is_unpublished' => ! $isPublished,
'missing_preview' => ! $hasPreview,
'is_deleted' => $isDeleted,
],
];
}
/**
* @param SupportCollection<int, array<string, mixed>> $entries
* @return SupportCollection<int, array<string, mixed>>
*/
private function sortForHeroSelection(SupportCollection $entries): SupportCollection
{
return $entries
->sort(function (array $left, array $right): int {
$comparisons = [
(int) $right['is_force_hero'] <=> (int) $left['is_force_hero'],
(int) $right['priority'] <=> (int) $left['priority'],
(int) $right['medals']['score_30d'] <=> (int) $left['medals']['score_30d'],
$this->timestamp($right['featured_at']) <=> $this->timestamp($left['featured_at']),
$this->timestamp($right['artwork']['published_at']) <=> $this->timestamp($left['artwork']['published_at']),
(int) $right['id'] <=> (int) $left['id'],
];
foreach ($comparisons as $comparison) {
if ($comparison !== 0) {
return $comparison;
}
}
return 0;
})
->values();
}
/**
* @param array<string, mixed> $winner
* @param SupportCollection<int, array<string, mixed>> $eligibleEntries
*/
private function buildWinnerReason(array $winner, SupportCollection $eligibleEntries): string
{
if ((bool) ($winner['is_force_hero'] ?? false)) {
return 'Forced hero override is enabled for this featured artwork.';
}
$runnerUp = $eligibleEntries->skip(1)->first();
if (! is_array($runnerUp)) {
return 'Only eligible featured artwork right now.';
}
if ((int) $winner['priority'] > (int) $runnerUp['priority']) {
return 'Highest priority among active, eligible featured artworks.';
}
if ((int) $winner['medals']['score_30d'] > (int) $runnerUp['medals']['score_30d']) {
return 'Tied on priority, won on higher 30-day medal score.';
}
if ($this->timestamp($winner['featured_at']) > $this->timestamp($runnerUp['featured_at'])) {
return 'Tied on priority and medal score, won on newer featured date.';
}
if ($this->timestamp($winner['artwork']['published_at']) > $this->timestamp($runnerUp['artwork']['published_at'])) {
return 'Tied on priority, medal score, and featured date, won on newer published date.';
}
return 'Selected by the shared homepage hero ordering.';
}
/**
* @return array<string, mixed>|null
*/
private function mapOwner(Artwork $artwork): ?array
{
if ($artwork->group) {
return [
'type' => 'group',
'display_name' => (string) $artwork->group->name,
'username' => (string) $artwork->group->slug,
'profile_url' => $artwork->group->publicUrl(),
];
}
if (! $artwork->user) {
return null;
}
return [
'type' => 'user',
'display_name' => (string) ($artwork->user->name ?: '@' . $artwork->user->username),
'username' => (string) $artwork->user->username,
'profile_url' => $artwork->user->username !== '' ? '/@' . $artwork->user->username : null,
];
}
private function artworkSlug(Artwork $artwork): string
{
$slug = trim((string) $artwork->slug);
if ($slug !== '') {
return $slug;
}
$titleSlug = Str::slug((string) $artwork->title);
return $titleSlug !== '' ? $titleSlug : (string) $artwork->id;
}
private function timestamp(?string $value): int
{
if (! is_string($value) || trim($value) === '') {
return 0;
}
return (int) (strtotime($value) ?: 0);
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class FollowAnalyticsService
{
public function recordFollow(int $actorId, int $targetId): void
{
if (! Schema::hasTable('user_follow_analytics')) {
return;
}
$this->increment($actorId, 'follows_made');
$this->increment($targetId, 'followers_gained');
}
public function recordUnfollow(int $actorId, int $targetId): void
{
if (! Schema::hasTable('user_follow_analytics')) {
return;
}
$this->increment($actorId, 'unfollows_made');
$this->increment($targetId, 'followers_lost');
}
public function summaryForUser(int $userId, int $currentFollowersCount = 0): array
{
if (! Schema::hasTable('user_follow_analytics')) {
return $this->emptySummary();
}
$today = now()->toDateString();
$weekStart = now()->subDays(6)->toDateString();
$todayRow = DB::table('user_follow_analytics')
->where('user_id', $userId)
->whereDate('date', $today)
->first();
$weekly = DB::table('user_follow_analytics')
->where('user_id', $userId)
->whereBetween('date', [$weekStart, $today])
->selectRaw('COALESCE(SUM(followers_gained), 0) as gained, COALESCE(SUM(followers_lost), 0) as lost')
->first();
$dailyGained = (int) ($todayRow->followers_gained ?? 0);
$dailyLost = (int) ($todayRow->followers_lost ?? 0);
$weeklyGained = (int) ($weekly->gained ?? 0);
$weeklyLost = (int) ($weekly->lost ?? 0);
$weeklyNet = $weeklyGained - $weeklyLost;
$baseline = max(1, $currentFollowersCount - $weeklyNet);
return [
'daily' => [
'gained' => $dailyGained,
'lost' => $dailyLost,
'net' => $dailyGained - $dailyLost,
],
'weekly' => [
'gained' => $weeklyGained,
'lost' => $weeklyLost,
'net' => $weeklyNet,
'growth_rate' => round(($weeklyNet / $baseline) * 100, 1),
],
];
}
private function increment(int $userId, string $column): void
{
DB::table('user_follow_analytics')->updateOrInsert(
[
'user_id' => $userId,
'date' => now()->toDateString(),
],
[
$column => DB::raw("COALESCE({$column}, 0) + 1"),
'updated_at' => now(),
'created_at' => now(),
]
);
}
private function emptySummary(): array
{
return [
'daily' => ['gained' => 0, 'lost' => 0, 'net' => 0],
'weekly' => ['gained' => 0, 'lost' => 0, 'net' => 0, 'growth_rate' => 0.0],
];
}
}

View File

@@ -0,0 +1,326 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Services\Activity\UserActivityService;
use App\Events\Achievements\AchievementCheckRequested;
use App\Services\FollowAnalyticsService;
use App\Support\AvatarUrl;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* FollowService
*
* Manages follow / unfollow operations on the user_followers table.
* Convention:
* follower_id = the user doing the following
* user_id = the user being followed
*
* Counters in user_statistics are kept in sync atomically inside a transaction.
*/
final class FollowService
{
public function __construct(
private readonly XPService $xp,
private readonly NotificationService $notifications,
private readonly FollowAnalyticsService $analytics,
) {
}
/**
* Follow $targetId on behalf of $actorId.
*
* @return bool true if a new follow was created, false if already following
*
* @throws \InvalidArgumentException if self-follow attempted
*/
public function follow(int $actorId, int $targetId): bool
{
if ($actorId === $targetId) {
throw new \InvalidArgumentException('Cannot follow yourself.');
}
$inserted = false;
DB::transaction(function () use ($actorId, $targetId, &$inserted) {
$rows = DB::table('user_followers')->insertOrIgnore([
'user_id' => $targetId,
'follower_id' => $actorId,
'created_at' => now(),
]);
if ($rows === 0) {
// Already following nothing to do
return;
}
$inserted = true;
// Increment following_count for actor, followers_count for target
$this->incrementCounter($actorId, 'following_count');
$this->incrementCounter($targetId, 'followers_count');
});
// Record activity event outside the transaction to avoid deadlocks
if ($inserted) {
try {
\App\Models\ActivityEvent::record(
actorId: $actorId,
type: \App\Models\ActivityEvent::TYPE_FOLLOW,
targetType: \App\Models\ActivityEvent::TARGET_USER,
targetId: $targetId,
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logFollow($actorId, $targetId);
} catch (\Throwable) {}
$targetUser = User::query()->find($targetId);
$actorUser = User::query()->find($actorId);
if ($targetUser && $actorUser) {
$this->notifications->notifyUserFollowed($targetUser->loadMissing('profile'), $actorUser->loadMissing('profile'));
}
$this->analytics->recordFollow($actorId, $targetId);
$this->xp->awardFollowerReceived($targetId, $actorId);
event(new AchievementCheckRequested($targetId));
}
return $inserted;
}
/**
* Unfollow $targetId on behalf of $actorId.
*
* @return bool true if a follow row was removed, false if wasn't following
*/
public function unfollow(int $actorId, int $targetId): bool
{
if ($actorId === $targetId) {
return false;
}
$deleted = false;
DB::transaction(function () use ($actorId, $targetId, &$deleted) {
$rows = DB::table('user_followers')
->where('user_id', $targetId)
->where('follower_id', $actorId)
->delete();
if ($rows === 0) {
return;
}
$deleted = true;
$this->decrementCounter($actorId, 'following_count');
$this->decrementCounter($targetId, 'followers_count');
});
if ($deleted) {
$this->analytics->recordUnfollow($actorId, $targetId);
}
return $deleted;
}
/**
* Toggle follow state. Returns the new following state.
*/
public function toggle(int $actorId, int $targetId): bool
{
if ($this->isFollowing($actorId, $targetId)) {
$this->unfollow($actorId, $targetId);
return false;
}
$this->follow($actorId, $targetId);
return true;
}
public function isFollowing(int $actorId, int $targetId): bool
{
return DB::table('user_followers')
->where('user_id', $targetId)
->where('follower_id', $actorId)
->exists();
}
/**
* Current followers_count for a user (from cached column, not live count).
*/
public function followersCount(int $userId): int
{
return (int) DB::table('user_statistics')
->where('user_id', $userId)
->value('followers_count');
}
public function followingCount(int $userId): int
{
return (int) DB::table('user_statistics')
->where('user_id', $userId)
->value('following_count');
}
public function getMutualFollowers(int $userA, int $userB, int $limit = 13): array
{
$rows = DB::table('user_followers as left_follow')
->join('user_followers as right_follow', 'right_follow.follower_id', '=', 'left_follow.follower_id')
->join('users as u', 'u.id', '=', 'left_follow.follower_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('left_follow.user_id', $userA)
->where('right_follow.user_id', $userB)
->where('left_follow.follower_id', '!=', $userA)
->where('left_follow.follower_id', '!=', $userB)
->whereNull('u.deleted_at')
->where('u.is_active', true)
->orderByDesc('left_follow.created_at')
->limit(max(1, $limit))
->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash'])
->get();
return $this->mapUsers($rows);
}
public function relationshipContext(int $viewerId, int $targetId): array
{
if ($viewerId === $targetId) {
return [
'follower_overlap' => null,
'shared_following' => null,
'mutual_followers' => [
'count' => 0,
'users' => [],
],
];
}
$followerOverlap = $this->buildFollowerOverlapSummary($viewerId, $targetId);
$sharedFollowing = $this->buildSharedFollowingSummary($viewerId, $targetId);
$mutualFollowers = $this->getMutualFollowers($viewerId, $targetId, 6);
return [
'follower_overlap' => $followerOverlap,
'shared_following' => $sharedFollowing,
'mutual_followers' => [
'count' => count($mutualFollowers),
'users' => $mutualFollowers,
],
];
}
// ─── Private helpers ─────────────────────────────────────────────────────
private function incrementCounter(int $userId, string $column): void
{
DB::table('user_statistics')->updateOrInsert(
['user_id' => $userId],
[
$column => DB::raw("COALESCE({$column}, 0) + 1"),
'updated_at' => now(),
'created_at' => now(), // ignored on update
]
);
}
private function decrementCounter(int $userId, string $column): void
{
DB::table('user_statistics')
->where('user_id', $userId)
->where($column, '>', 0)
->update([
$column => DB::raw("{$column} - 1"),
'updated_at' => now(),
]);
}
private function buildFollowerOverlapSummary(int $viewerId, int $targetId): ?array
{
$preview = DB::table('user_followers as viewer_following')
->join('user_followers as target_followers', 'target_followers.follower_id', '=', 'viewer_following.user_id')
->join('users as u', 'u.id', '=', 'viewer_following.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('viewer_following.follower_id', $viewerId)
->where('target_followers.user_id', $targetId)
->whereNull('u.deleted_at')
->where('u.is_active', true)
->orderByDesc('target_followers.created_at')
->limit(3)
->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash'])
->get();
if ($preview->isEmpty()) {
return null;
}
$count = DB::table('user_followers as viewer_following')
->join('user_followers as target_followers', 'target_followers.follower_id', '=', 'viewer_following.user_id')
->where('viewer_following.follower_id', $viewerId)
->where('target_followers.user_id', $targetId)
->count();
$lead = $preview->first();
$label = $count > 1
? sprintf('Followed by %s and %d other%s', $lead->username ?? $lead->name ?? 'someone', $count - 1, $count - 1 === 1 ? '' : 's')
: sprintf('Followed by %s', $lead->username ?? $lead->name ?? 'someone');
return [
'count' => (int) $count,
'label' => $label,
'users' => $this->mapUsers($preview),
];
}
private function buildSharedFollowingSummary(int $viewerId, int $targetId): ?array
{
$preview = DB::table('user_followers as viewer_following')
->join('user_followers as target_following', 'target_following.user_id', '=', 'viewer_following.user_id')
->join('users as u', 'u.id', '=', 'viewer_following.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('viewer_following.follower_id', $viewerId)
->where('target_following.follower_id', $targetId)
->whereNull('u.deleted_at')
->where('u.is_active', true)
->orderByDesc('viewer_following.created_at')
->limit(3)
->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash'])
->get();
if ($preview->isEmpty()) {
return null;
}
$count = DB::table('user_followers as viewer_following')
->join('user_followers as target_following', 'target_following.user_id', '=', 'viewer_following.user_id')
->where('viewer_following.follower_id', $viewerId)
->where('target_following.follower_id', $targetId)
->count();
$lead = $preview->first();
$label = $count > 1
? sprintf('You both follow %s and %d other%s', $lead->username ?? $lead->name ?? 'someone', $count - 1, $count - 1 === 1 ? '' : 's')
: sprintf('You both follow %s', $lead->username ?? $lead->name ?? 'someone');
return [
'count' => (int) $count,
'label' => $label,
'users' => $this->mapUsers($preview),
];
}
private function mapUsers(Collection $rows): array
{
return $rows->map(fn ($row) => [
'id' => (int) $row->id,
'username' => (string) ($row->username ?? ''),
'name' => (string) ($row->name ?? $row->username ?? ''),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash ?? null, 48),
])->values()->all();
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupActivityItem;
use App\Models\GroupAsset;
use App\Models\GroupChallenge;
use App\Models\GroupEvent;
use App\Models\GroupPost;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\User;
use Illuminate\Support\Collection;
class GroupActivityService
{
public function record(
Group $group,
?User $actor,
string $type,
string $subjectType,
?int $subjectId,
string $headline,
?string $summary = null,
string $visibility = GroupActivityItem::VISIBILITY_PUBLIC,
): GroupActivityItem {
return GroupActivityItem::query()->create([
'group_id' => (int) $group->id,
'type' => $type,
'visibility' => $visibility,
'actor_user_id' => $actor?->id,
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'headline' => $headline,
'summary' => $summary,
'is_pinned' => false,
'occurred_at' => now(),
]);
}
public function pin(GroupActivityItem $item, User $actor, bool $isPinned = true): GroupActivityItem
{
$item->forceFill([
'is_pinned' => $isPinned,
])->save();
app(GroupHistoryService::class)->record(
$item->group,
$actor,
$isPinned ? 'activity_pinned' : 'activity_unpinned',
sprintf('%s group activity item.', $isPinned ? 'Pinned' : 'Unpinned'),
'group_activity_item',
(int) $item->id,
['is_pinned' => ! $isPinned],
['is_pinned' => $isPinned],
);
return $item->fresh(['actor']);
}
public function publicFeed(Group $group, int $limit = 8): array
{
return $this->mapItems(
GroupActivityItem::query()
->with('actor:id,name,username')
->where('group_id', $group->id)
->where('visibility', GroupActivityItem::VISIBILITY_PUBLIC)
->orderByDesc('is_pinned')
->orderByDesc('occurred_at')
->limit(max(1, min(24, $limit)))
->get(),
$group
);
}
public function studioFeed(Group $group, User $viewer, int $limit = 20): array
{
if (! $group->canViewStudio($viewer)) {
return [];
}
return $this->mapItems(
GroupActivityItem::query()
->with('actor:id,name,username')
->where('group_id', $group->id)
->orderByDesc('is_pinned')
->orderByDesc('occurred_at')
->limit(max(1, min(50, $limit)))
->get(),
$group
);
}
private function mapItems(Collection $items, Group $group): array
{
$subjects = $this->loadSubjects($items);
return $items->map(function (GroupActivityItem $item) use ($group, $subjects): array {
$subject = $subjects[$item->subject_type][$item->subject_id] ?? null;
return [
'id' => (int) $item->id,
'type' => (string) $item->type,
'visibility' => (string) $item->visibility,
'headline' => (string) $item->headline,
'summary' => $item->summary,
'is_pinned' => (bool) $item->is_pinned,
'occurred_at' => $item->occurred_at?->toISOString(),
'actor' => $item->actor ? [
'id' => (int) $item->actor->id,
'name' => $item->actor->name,
'username' => $item->actor->username,
] : null,
'subject' => $subject ? [
'type' => (string) $item->subject_type,
'id' => (int) $item->subject_id,
'title' => $subject->title ?? null,
'url' => $this->subjectUrl($group, (string) $item->subject_type, $subject),
] : null,
];
})->values()->all();
}
private function loadSubjects(Collection $items): array
{
$grouped = $items
->filter(fn (GroupActivityItem $item): bool => $item->subject_id !== null)
->groupBy('subject_type')
->map(fn (Collection $chunk): array => $chunk->pluck('subject_id')->map(fn ($id): int => (int) $id)->unique()->values()->all());
return [
'artwork' => Artwork::query()->whereIn('id', $grouped->get('artwork', []))->get()->keyBy('id')->all(),
'group_post' => GroupPost::query()->whereIn('id', $grouped->get('group_post', []))->get()->keyBy('id')->all(),
'group_project' => GroupProject::query()->whereIn('id', $grouped->get('group_project', []))->get()->keyBy('id')->all(),
'group_release' => GroupRelease::query()->whereIn('id', $grouped->get('group_release', []))->get()->keyBy('id')->all(),
'group_challenge' => GroupChallenge::query()->whereIn('id', $grouped->get('group_challenge', []))->get()->keyBy('id')->all(),
'group_event' => GroupEvent::query()->whereIn('id', $grouped->get('group_event', []))->get()->keyBy('id')->all(),
'group_asset' => GroupAsset::query()->whereIn('id', $grouped->get('group_asset', []))->get()->keyBy('id')->all(),
];
}
private function subjectUrl(Group $group, string $subjectType, object $subject): ?string
{
return match ($subjectType) {
'artwork' => route('art.show', ['id' => $subject->id, 'slug' => $subject->slug ?: $subject->id]),
'group_post' => route('groups.posts.show', ['group' => $group, 'post' => $subject]),
'group_project' => route('groups.projects.show', ['group' => $group, 'project' => $subject]),
'group_release' => route('groups.releases.show', ['group' => $group, 'release' => $subject]),
'group_challenge' => route('groups.challenges.show', ['group' => $group, 'challenge' => $subject]),
'group_event' => route('groups.events.show', ['group' => $group, 'event' => $subject]),
'group_asset' => route('groups.assets.download', ['group' => $group, 'asset' => $subject]),
default => null,
};
}
}

View File

@@ -0,0 +1,408 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\Group;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupArtworkReviewService
{
public function __construct(
private readonly ArtworkAttributionService $attribution,
private readonly GroupHistoryService $history,
private readonly NotificationService $notifications,
private readonly GroupMembershipService $memberships,
) {
}
public function submit(Group $group, Artwork $artwork, User $actor, array $attributes): Artwork
{
if (! $group->canSubmitArtworkForReview($actor)) {
throw ValidationException::withMessages([
'group' => 'You are not allowed to submit artwork for this group.',
]);
}
if ($group->status !== Group::LIFECYCLE_ACTIVE) {
throw ValidationException::withMessages([
'group' => 'Archived or suspended groups cannot accept new submissions.',
]);
}
if ((int) $artwork->user_id !== (int) $actor->id && (int) ($artwork->uploaded_by_user_id ?? 0) !== (int) $actor->id) {
throw ValidationException::withMessages([
'artwork' => 'You can only submit your own group draft for review.',
]);
}
$before = [
'group_review_status' => $artwork->group_review_status,
'artwork_status' => $artwork->artwork_status,
];
$this->applyDraftMetadata($artwork, $actor, $attributes);
$artwork->save();
$artwork = $this->attribution->apply($artwork->fresh(['group.members']), $actor, $attributes, false);
$artwork->forceFill([
'visibility' => (string) ($attributes['visibility'] ?? $artwork->visibility ?? Artwork::VISIBILITY_PUBLIC),
'is_public' => false,
'is_approved' => false,
'published_at' => null,
'publish_at' => null,
'artwork_status' => 'draft',
'group_review_status' => 'submitted',
'group_review_submitted_at' => now(),
'group_reviewed_by_user_id' => null,
'group_reviewed_at' => null,
'group_review_notes' => null,
])->save();
$this->syncSearchIndex($artwork);
$this->history->record(
$group,
$actor,
'artwork_submitted_for_review',
sprintf('Submitted "%s" for group review.', $artwork->title),
'artwork',
(int) $artwork->id,
$before,
[
'group_review_status' => 'submitted',
'visibility' => $artwork->visibility,
],
);
foreach ($this->reviewRecipients($group, $actor->id) as $recipient) {
$this->notifications->notifyGroupArtworkSubmittedForReview($recipient, $actor, $group, $artwork);
}
return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']);
}
public function approve(Group $group, Artwork $artwork, User $actor, ?string $notes = null): Artwork
{
$this->guardReviewAbility($group, $artwork, $actor);
$before = [
'group_review_status' => $artwork->group_review_status,
'artwork_status' => $artwork->artwork_status,
'published_at' => optional($artwork->published_at)->toISOString(),
];
$artwork->forceFill([
'group_review_status' => 'approved',
'group_reviewed_by_user_id' => $actor->id,
'group_reviewed_at' => now(),
'group_review_notes' => $notes,
'is_approved' => true,
'artwork_status' => 'published',
'published_at' => now(),
'publish_at' => null,
'is_public' => ($artwork->visibility ?: Artwork::VISIBILITY_PUBLIC) !== Artwork::VISIBILITY_PRIVATE,
])->save();
$this->syncSearchIndex($artwork);
$this->history->record(
$group,
$actor,
'artwork_submission_approved',
sprintf('Approved group submission "%s".', $artwork->title),
'artwork',
(int) $artwork->id,
$before,
[
'group_review_status' => 'approved',
'artwork_status' => 'published',
'published_at' => optional($artwork->published_at)->toISOString(),
],
);
app(GroupActivityService::class)->record(
$group,
$actor,
'artwork_published',
'artwork',
(int) $artwork->id,
sprintf('%s published new artwork: %s', $group->name, $artwork->title),
$notes,
'public',
);
if ($artwork->uploadedBy) {
$this->notifications->notifyGroupArtworkApproved($artwork->uploadedBy, $actor, $group, $artwork);
}
return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']);
}
public function requestChanges(Group $group, Artwork $artwork, User $actor, ?string $notes = null): Artwork
{
$this->guardReviewAbility($group, $artwork, $actor);
$before = [
'group_review_status' => $artwork->group_review_status,
];
$artwork->forceFill([
'group_review_status' => 'needs_changes',
'group_reviewed_by_user_id' => $actor->id,
'group_reviewed_at' => now(),
'group_review_notes' => $notes,
'is_public' => false,
'published_at' => null,
'artwork_status' => 'draft',
])->save();
$this->syncSearchIndex($artwork);
$this->history->record(
$group,
$actor,
'artwork_submission_changes_requested',
sprintf('Requested changes for "%s".', $artwork->title),
'artwork',
(int) $artwork->id,
$before,
['group_review_status' => 'needs_changes'],
);
if ($artwork->uploadedBy) {
$this->notifications->notifyGroupArtworkNeedsChanges($artwork->uploadedBy, $actor, $group, $artwork);
}
return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']);
}
public function reject(Group $group, Artwork $artwork, User $actor, ?string $notes = null): Artwork
{
$this->guardReviewAbility($group, $artwork, $actor);
$before = [
'group_review_status' => $artwork->group_review_status,
];
$artwork->forceFill([
'group_review_status' => 'rejected',
'group_reviewed_by_user_id' => $actor->id,
'group_reviewed_at' => now(),
'group_review_notes' => $notes,
'is_public' => false,
'published_at' => null,
'artwork_status' => 'draft',
])->save();
$this->syncSearchIndex($artwork);
$this->history->record(
$group,
$actor,
'artwork_submission_rejected',
sprintf('Rejected group submission "%s".', $artwork->title),
'artwork',
(int) $artwork->id,
$before,
['group_review_status' => 'rejected'],
);
if ($artwork->uploadedBy) {
$this->notifications->notifyGroupArtworkRejected($artwork->uploadedBy, $actor, $group, $artwork);
}
return $artwork->fresh(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']);
}
public function pendingCount(Group $group): int
{
return (int) Artwork::query()
->where('group_id', $group->id)
->where('group_review_status', 'submitted')
->whereNull('deleted_at')
->count();
}
public function listing(Group $group, User $viewer, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'submitted');
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50);
$canReviewAll = $group->canReviewSubmissions($viewer);
$query = Artwork::query()
->with(['uploadedBy.profile', 'primaryAuthor.profile'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->whereIn('group_review_status', ['submitted', 'needs_changes', 'approved', 'rejected']);
if (! $canReviewAll) {
$query->where(function ($builder) use ($viewer): void {
$builder->where('uploaded_by_user_id', $viewer->id)
->orWhere('user_id', $viewer->id);
});
}
if ($bucket !== 'all') {
$query->where('group_review_status', $bucket);
}
$paginator = $query->orderByRaw("CASE group_review_status WHEN 'submitted' THEN 0 WHEN 'needs_changes' THEN 1 ELSE 2 END")
->orderByDesc('group_review_submitted_at')
->paginate($perPage, ['*'], 'page', $page);
return [
'items' => collect($paginator->items())->map(fn (Artwork $artwork): array => $this->mapReviewItem($group, $artwork, $viewer))->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'filters' => [
'bucket' => $bucket,
],
'bucket_options' => [
['value' => 'submitted', 'label' => 'Submitted'],
['value' => 'needs_changes', 'label' => 'Needs changes'],
['value' => 'approved', 'label' => 'Approved'],
['value' => 'rejected', 'label' => 'Rejected'],
['value' => 'all', 'label' => 'All'],
],
'can_review_all' => $canReviewAll,
];
}
public function mapReviewItem(Group $group, Artwork $artwork, User $viewer): array
{
return [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'thumb' => $artwork->thumbUrl('sm'),
'group_review_status' => (string) ($artwork->group_review_status ?: 'none'),
'group_review_notes' => $artwork->group_review_notes,
'submitted_at' => $artwork->group_review_submitted_at?->toISOString(),
'reviewed_at' => $artwork->group_reviewed_at?->toISOString(),
'visibility' => (string) ($artwork->visibility ?: Artwork::VISIBILITY_PUBLIC),
'uploader' => $artwork->uploadedBy ? [
'id' => (int) $artwork->uploadedBy->id,
'name' => $artwork->uploadedBy->name,
'username' => $artwork->uploadedBy->username,
] : null,
'primary_author' => $artwork->primaryAuthor ? [
'id' => (int) $artwork->primaryAuthor->id,
'name' => $artwork->primaryAuthor->name,
'username' => $artwork->primaryAuthor->username,
] : null,
'urls' => [
'edit' => route('studio.artworks.edit', ['id' => $artwork->id]),
'approve' => route('studio.groups.artworks.approve', ['group' => $group, 'artwork' => $artwork]),
'reject' => route('studio.groups.artworks.reject', ['group' => $group, 'artwork' => $artwork]),
'needs_changes' => route('studio.groups.artworks.needs-changes', ['group' => $group, 'artwork' => $artwork]),
],
'can_review' => $group->canReviewSubmissions($viewer),
];
}
private function guardReviewAbility(Group $group, Artwork $artwork, User $actor): void
{
if ((int) ($artwork->group_id ?? 0) !== (int) $group->id) {
throw ValidationException::withMessages([
'artwork' => 'This artwork does not belong to the selected group.',
]);
}
if (! $group->canReviewSubmissions($actor)) {
throw ValidationException::withMessages([
'group' => 'You are not allowed to review submissions for this group.',
]);
}
if (! in_array((string) $artwork->group_review_status, ['submitted', 'needs_changes'], true)) {
throw ValidationException::withMessages([
'artwork' => 'This artwork is not currently awaiting review.',
]);
}
}
private function applyDraftMetadata(Artwork $artwork, User $actor, array $validated): void
{
$title = trim((string) ($validated['title'] ?? $artwork->title ?? ''));
if ($title === '') {
$title = 'Untitled artwork';
}
$slugBase = Str::slug($title);
if ($slugBase === '') {
$slugBase = 'artwork';
}
$artwork->title = $title;
if (array_key_exists('description', $validated)) {
$artwork->description = $validated['description'];
}
if (array_key_exists('is_mature', $validated)) {
$artwork->is_mature = (bool) $validated['is_mature'];
}
$artwork->slug = Str::limit($slugBase, 160, '');
$artwork->artwork_timezone = $validated['timezone'] ?? $artwork->artwork_timezone;
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $actor->id;
$artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $actor->id;
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
if ($categoryId > 0 && Category::query()->where('id', $categoryId)->exists()) {
$artwork->categories()->sync([$categoryId]);
}
if (array_key_exists('tags', $validated) && is_array($validated['tags'])) {
$tagIds = [];
foreach ($validated['tags'] as $tagSlug) {
$tag = Tag::firstOrCreate(
['slug' => Str::slug((string) $tagSlug)],
['name' => (string) $tagSlug, 'is_active' => true, 'usage_count' => 0]
);
$tagIds[$tag->id] = ['source' => 'user', 'confidence' => 1.0];
}
$artwork->tags()->sync($tagIds);
}
}
private function reviewRecipients(Group $group, int $excludeUserId): array
{
return User::query()
->whereIn('id', $this->memberships->activeContributorIds($group))
->get()
->filter(fn (User $member): bool => (int) $member->id !== $excludeUserId && $group->canReviewSubmissions($member))
->values()
->all();
}
private function syncSearchIndex(Artwork $artwork): void
{
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && ! empty($artwork->published_at)) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $exception) {
Log::warning('Failed to sync artwork search index for group review workflow', [
'artwork_id' => (int) $artwork->id,
'error' => $exception->getMessage(),
]);
}
}
}

View File

@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupAsset;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class GroupAssetService
{
private const STORAGE_DISK = 'local';
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupActivityService $activity,
) {
}
public function store(Group $group, User $actor, array $attributes): GroupAsset
{
$file = $attributes['file'];
if (! $file instanceof UploadedFile) {
throw ValidationException::withMessages([
'file' => 'A file upload is required for group assets.',
]);
}
$extension = strtolower((string) ($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin'));
$filename = (string) Str::uuid() . '.' . $extension;
$directory = 'group-assets/' . (int) $group->id;
$storedPath = $file->storeAs($directory, $filename, self::STORAGE_DISK);
$mime = strtolower((string) ($file->getMimeType() ?: 'application/octet-stream'));
$asset = GroupAsset::query()->create([
'group_id' => (int) $group->id,
'title' => trim((string) $attributes['title']),
'description' => $this->nullableString($attributes['description'] ?? null),
'category' => (string) ($attributes['category'] ?? GroupAsset::CATEGORY_MISC),
'file_path' => (string) $storedPath,
'preview_path' => null,
'visibility' => (string) ($attributes['visibility'] ?? GroupAsset::VISIBILITY_MEMBERS_ONLY),
'status' => (string) ($attributes['status'] ?? GroupAsset::STATUS_ACTIVE),
'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null),
'uploaded_by_user_id' => (int) $actor->id,
'approved_by_user_id' => $group->canManageAssets($actor) ? (int) $actor->id : null,
'is_featured' => (bool) ($attributes['is_featured'] ?? false),
'file_meta_json' => [
'original_name' => $file->getClientOriginalName(),
'mime_type' => $mime,
'size' => (int) $file->getSize(),
'extension' => $extension,
],
]);
$this->history->record(
$group,
$actor,
'asset_uploaded',
sprintf('Uploaded asset "%s".', $asset->title),
'group_asset',
(int) $asset->id,
null,
$asset->only(['title', 'category', 'visibility', 'status'])
);
$this->activity->record(
$group,
$actor,
'asset_uploaded',
'group_asset',
(int) $asset->id,
sprintf('%s uploaded a new group asset: %s', $actor->name ?: $actor->username ?: 'A member', $asset->title),
$asset->description,
$asset->visibility === GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD ? 'public' : 'internal',
);
return $asset->fresh(['group', 'uploader.profile', 'approver.profile', 'linkedProject']);
}
public function update(GroupAsset $asset, User $actor, array $attributes): GroupAsset
{
$before = $asset->only(['title', 'description', 'category', 'visibility', 'status', 'linked_project_id', 'is_featured']);
$wasActive = $asset->status === GroupAsset::STATUS_ACTIVE;
$asset->fill([
'title' => trim((string) ($attributes['title'] ?? $asset->title)),
'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $asset->description,
'category' => (string) ($attributes['category'] ?? $asset->category),
'visibility' => (string) ($attributes['visibility'] ?? $asset->visibility),
'status' => (string) ($attributes['status'] ?? $asset->status),
'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($asset->group, $attributes['linked_project_id']) : $asset->linked_project_id,
'is_featured' => (bool) ($attributes['is_featured'] ?? $asset->is_featured),
'approved_by_user_id' => (string) ($attributes['status'] ?? $asset->status) === GroupAsset::STATUS_ACTIVE ? (int) $actor->id : $asset->approved_by_user_id,
])->save();
if (! $wasActive && $asset->status === GroupAsset::STATUS_ACTIVE && $asset->uploader && (int) $asset->uploader->id !== (int) $actor->id) {
app(NotificationService::class)->notifyGroupAssetApproved($asset->uploader, $actor, $asset->group, $asset);
}
$this->history->record(
$asset->group,
$actor,
'asset_updated',
sprintf('Updated asset "%s".', $asset->title),
'group_asset',
(int) $asset->id,
$before,
$asset->only(['title', 'description', 'category', 'visibility', 'status', 'linked_project_id', 'is_featured'])
);
return $asset->fresh(['group', 'uploader.profile', 'approver.profile', 'linkedProject']);
}
public function studioListing(Group $group, User $viewer, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'all');
$category = (string) ($filters['category'] ?? 'all');
$search = trim((string) ($filters['q'] ?? ''));
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50);
$query = GroupAsset::query()
->with(['uploader.profile', 'approver.profile', 'linkedProject'])
->where('group_id', $group->id);
if ($bucket !== 'all') {
$query->where('visibility', $bucket);
}
if ($category !== 'all') {
$query->where('category', $category);
}
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$builder->where('title', 'like', '%' . $search . '%')
->orWhere('description', 'like', '%' . $search . '%')
->orWhere('file_meta_json->original_name', 'like', '%' . $search . '%');
});
}
if (! $group->canViewInternalAssets($viewer)) {
$query->whereIn('visibility', [GroupAsset::VISIBILITY_MEMBERS_ONLY, GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD]);
}
$paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);
return [
'items' => collect($paginator->items())->map(fn (GroupAsset $asset): array => $this->mapStudioAsset($asset))->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'filters' => [
'bucket' => $bucket,
'category' => $category,
'q' => $search,
],
'bucket_options' => [
['value' => 'all', 'label' => 'All'],
['value' => GroupAsset::VISIBILITY_INTERNAL, 'label' => 'Internal'],
['value' => GroupAsset::VISIBILITY_MEMBERS_ONLY, 'label' => 'Members only'],
['value' => GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD, 'label' => 'Public download'],
],
];
}
public function publicListing(Group $group, int $limit = 12): array
{
return GroupAsset::query()
->with(['uploader.profile', 'linkedProject'])
->where('group_id', $group->id)
->where('status', GroupAsset::STATUS_ACTIVE)
->where('visibility', GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD)
->latest('updated_at')
->limit($limit)
->get()
->map(fn (GroupAsset $asset): array => $this->mapPublicAsset($asset))
->values()
->all();
}
public function downloadResponse(GroupAsset $asset): StreamedResponse
{
$name = (string) ($asset->file_meta_json['original_name'] ?? basename((string) $asset->file_path));
$mime = (string) ($asset->file_meta_json['mime_type'] ?? 'application/octet-stream');
return Storage::disk(self::STORAGE_DISK)->download((string) $asset->file_path, $name, [
'Content-Type' => $mime,
]);
}
public function mapStudioAsset(GroupAsset $asset): array
{
return [
'id' => (int) $asset->id,
'title' => (string) $asset->title,
'description' => $asset->description,
'category' => (string) $asset->category,
'visibility' => (string) $asset->visibility,
'status' => (string) $asset->status,
'is_featured' => (bool) $asset->is_featured,
'file_meta' => $asset->file_meta_json ?? [],
'linked_project' => $asset->linkedProject ? ['id' => (int) $asset->linkedProject->id, 'title' => $asset->linkedProject->title] : null,
'download_url' => route('groups.assets.download', ['group' => $asset->group, 'asset' => $asset]),
'urls' => [
'edit' => route('studio.groups.assets.update', ['group' => $asset->group, 'asset' => $asset]),
],
];
}
public function mapPublicAsset(GroupAsset $asset): array
{
return [
'id' => (int) $asset->id,
'title' => (string) $asset->title,
'description' => $asset->description,
'category' => (string) $asset->category,
'download_url' => route('groups.assets.download', ['group' => $asset->group, 'asset' => $asset]),
];
}
private function normalizeProjectId(Group $group, mixed $projectId): ?int
{
$id = (int) $projectId;
return $id > 0 && $group->projects()->where('id', $id)->exists() ? $id : null;
}
private function nullableString(mixed $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : null;
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Support\Str;
class GroupCardService
{
public function __construct(
private readonly GroupRecruitmentService $recruitment,
private readonly GroupReputationService $reputation,
private readonly GroupFollowService $follows,
private readonly GroupMembershipService $memberships,
) {
}
public function mapGroupCard(Group $group, ?User $viewer = null): array
{
$owner = $group->relationLoaded('owner') ? $group->owner : $group->owner()->with('profile')->first();
$recruitment = $this->recruitment->payloadForGroup($group);
$canManage = $viewer ? $group->canManage($viewer) : false;
$canManageMembers = $viewer ? $group->canManageMembers($viewer) : false;
$canPublishArtworks = $viewer ? $group->canPublishArtworks($viewer) : false;
$canManageCollections = $viewer ? $group->canManageCollections($viewer) : false;
$canRequestJoin = $viewer ? $group->canRequestJoin($viewer) : false;
$canReviewJoinRequests = $viewer ? $group->canReviewJoinRequests($viewer) : false;
$canReviewSubmissions = $viewer ? $group->canReviewSubmissions($viewer) : false;
$canManageRecruitment = $viewer ? $group->canManageRecruitment($viewer) : false;
$canManagePosts = $viewer ? $group->canManagePosts($viewer) : false;
$canPublishPosts = $viewer ? $group->canPublishPosts($viewer) : false;
$canPinPosts = $viewer ? $group->canPinPosts($viewer) : false;
$canManageMemberPermissions = $viewer ? $group->canManageMemberPermissions($viewer) : false;
$canManageProjects = $viewer ? $group->canManageProjects($viewer) : false;
$canManageReleases = $viewer ? $group->canManageReleases($viewer) : false;
$canPublishReleases = $viewer ? $group->canPublishReleases($viewer) : false;
$canManageMilestones = $viewer ? $group->canManageMilestones($viewer) : false;
$canViewReputationDashboard = $viewer ? $group->canViewReputationDashboard($viewer) : false;
$canManageBadges = $viewer ? $group->canManageBadges($viewer) : false;
$canViewInternalTrustMetrics = $viewer ? $group->canViewInternalTrustMetrics($viewer) : false;
$canManageChallenges = $viewer ? $group->canManageChallenges($viewer) : false;
$canManageEvents = $viewer ? $group->canManageEvents($viewer) : false;
$canPublishEventUpdates = $viewer ? $group->canPublishEventUpdates($viewer) : false;
$canManageAssets = $viewer ? $group->canManageAssets($viewer) : false;
$canViewInternalAssets = $viewer ? $group->canViewInternalAssets($viewer) : false;
$canPinActivity = $viewer ? $group->canPinActivity($viewer) : false;
$trustSignals = $this->reputation->trustSignals($group);
$badges = $this->reputation->groupBadges($group, 6);
return [
'id' => (int) $group->id,
'entity_type' => 'group',
'name' => (string) $group->name,
'slug' => (string) $group->slug,
'headline' => $group->headline,
'bio_excerpt' => Str::limit((string) ($group->bio ?? ''), 180),
'visibility' => (string) $group->visibility,
'status' => (string) ($group->status ?? Group::LIFECYCLE_ACTIVE),
'membership_policy' => (string) ($group->membership_policy ?? Group::MEMBERSHIP_INVITE_ONLY),
'type' => $group->type,
'is_verified' => (bool) $group->is_verified,
'is_recruiting' => (bool) ($recruitment['is_recruiting'] ?? false),
'recruitment_headline' => $recruitment['headline'] ?? null,
'avatar_url' => $group->avatarUrl(),
'banner_url' => $group->bannerUrl(),
'owner' => [
'id' => (int) ($owner?->id ?? 0),
'name' => $owner?->name,
'username' => $owner?->username,
'avatar_url' => $owner ? AvatarUrl::forUser((int) $owner->id, $owner->profile?->avatar_hash, 72) : null,
'profile_url' => $owner?->username ? route('profile.show', ['username' => strtolower((string) $owner->username)]) : null,
],
'counts' => [
'artworks' => (int) $group->artworks_count,
'collections' => (int) $group->collections_count,
'followers' => (int) $group->followers_count,
'members' => $group->relationLoaded('members')
? (int) $group->members->where('status', Group::STATUS_ACTIVE)->count()
: (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count(),
],
'permissions' => [
'can_manage' => $canManage,
'can_manage_members' => $canManageMembers,
'can_publish_artworks' => $canPublishArtworks,
'can_manage_collections' => $canManageCollections,
'can_request_join' => $canRequestJoin,
'can_review_join_requests' => $canReviewJoinRequests,
'can_submit_artwork_for_review' => $viewer ? $group->canSubmitArtworkForReview($viewer) : false,
'can_review_submissions' => $canReviewSubmissions,
'can_manage_recruitment' => $canManageRecruitment,
'can_manage_posts' => $canManagePosts,
'can_publish_posts' => $canPublishPosts,
'can_pin_posts' => $canPinPosts,
'can_manage_member_permissions' => $canManageMemberPermissions,
'can_manage_projects' => $canManageProjects,
'can_manage_releases' => $canManageReleases,
'can_publish_releases' => $canPublishReleases,
'can_manage_milestones' => $canManageMilestones,
'can_view_reputation_dashboard' => $canViewReputationDashboard,
'can_manage_badges' => $canManageBadges,
'can_view_internal_trust_metrics' => $canViewInternalTrustMetrics,
'can_manage_challenges' => $canManageChallenges,
'can_manage_events' => $canManageEvents,
'can_publish_event_updates' => $canPublishEventUpdates,
'can_manage_assets' => $canManageAssets,
'can_view_internal_assets' => $canViewInternalAssets,
'can_pin_activity' => $canPinActivity,
],
'trust_signals' => $trustSignals,
'badges' => $badges,
'badge_keys' => array_values(array_filter(array_map(
static fn (array $badge): ?string => $badge['key'] ?? null,
$badges,
))),
'viewer' => [
'role' => $viewer ? $group->activeRoleFor($viewer) : null,
'role_label' => $viewer ? Group::displayRole($group->activeRoleFor($viewer)) : null,
'is_following' => $viewer ? $this->follows->isFollowing($group, $viewer) : false,
'permission_overrides' => $viewer ? $group->permissionOverridesFor($viewer) : [],
],
'urls' => [
'public' => $group->publicUrl(),
'studio' => route('studio.groups.show', ['group' => $group]),
'studio_artworks' => route('studio.groups.artworks', ['group' => $group]),
'studio_collections' => route('studio.groups.collections', ['group' => $group]),
'studio_members' => route('studio.groups.members', ['group' => $group]),
'studio_invitations' => $canManageMembers ? route('studio.groups.invitations', ['group' => $group]) : null,
'studio_join_requests' => $canReviewJoinRequests ? route('studio.groups.join-requests', ['group' => $group]) : null,
'studio_review' => $canReviewSubmissions ? route('studio.groups.review', ['group' => $group]) : null,
'studio_recruitment' => $canManageRecruitment ? route('studio.groups.recruitment', ['group' => $group]) : null,
'studio_posts' => $canManagePosts ? route('studio.groups.posts.index', ['group' => $group]) : null,
'studio_projects' => $canManageProjects ? route('studio.groups.projects.index', ['group' => $group]) : null,
'studio_releases' => $canManageReleases ? route('studio.groups.releases.index', ['group' => $group]) : null,
'studio_reputation' => $canViewReputationDashboard ? route('studio.groups.reputation', ['group' => $group]) : null,
'studio_challenges' => $canManageChallenges ? route('studio.groups.challenges.index', ['group' => $group]) : null,
'studio_events' => ($canManageEvents || $canPublishEventUpdates) ? route('studio.groups.events.index', ['group' => $group]) : null,
'studio_assets' => ($canManageAssets || $canViewInternalAssets) ? route('studio.groups.assets.index', ['group' => $group]) : null,
'studio_activity' => route('studio.groups.activity', ['group' => $group]),
'studio_settings' => $canManage ? route('studio.groups.settings', ['group' => $group]) : null,
'upload' => ($canPublishArtworks || ($viewer && $group->canCreateArtworkDrafts($viewer))) ? route('upload', ['group' => $group->slug]) : null,
'collection_create' => $canManageCollections ? route('settings.collections.create', ['group' => $group->slug]) : null,
'follow' => route('groups.follow', ['group' => $group]),
'unfollow' => route('groups.unfollow', ['group' => $group]),
'join_request_store' => $canRequestJoin ? route('groups.join-requests.store', ['group' => $group]) : null,
'join_request_withdraw_pattern' => $viewer ? route('groups.join-requests.destroy', ['group' => $group, 'joinRequest' => '__JOIN_REQUEST__']) : null,
'posts' => route('groups.section', ['group' => $group, 'section' => 'posts']),
'projects' => route('groups.section', ['group' => $group, 'section' => 'projects']),
'releases' => route('groups.section', ['group' => $group, 'section' => 'releases']),
'challenges' => route('groups.section', ['group' => $group, 'section' => 'challenges']),
'events' => route('groups.section', ['group' => $group, 'section' => 'events']),
'activity' => route('groups.section', ['group' => $group, 'section' => 'activity']),
],
'pending_invites_count' => $this->memberships->pendingInviteCount($group),
];
}
}

View File

@@ -0,0 +1,407 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Models\GroupChallengeArtwork;
use App\Models\User;
use App\Support\ThumbnailPresenter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupChallengeService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupActivityService $activity,
private readonly GroupMediaService $media,
private readonly NotificationService $notifications,
) {
}
public function create(Group $group, User $actor, array $attributes): GroupChallenge
{
$coverPath = null;
try {
$challenge = DB::transaction(function () use ($group, $actor, $attributes, &$coverPath): GroupChallenge {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->media->storeUploadedEntityImage($group, $attributes['cover_file'], 'challenges');
}
return GroupChallenge::query()->create([
'group_id' => (int) $group->id,
'title' => trim((string) $attributes['title']),
'slug' => $this->makeUniqueSlug((string) $attributes['title']),
'summary' => $this->nullableString($attributes['summary'] ?? null),
'description' => $this->nullableString($attributes['description'] ?? null),
'cover_path' => $coverPath ?: $this->nullableString($attributes['cover_path'] ?? null),
'visibility' => (string) ($attributes['visibility'] ?? GroupChallenge::VISIBILITY_PUBLIC),
'participation_scope' => (string) ($attributes['participation_scope'] ?? GroupChallenge::PARTICIPATION_GROUP_ONLY),
'status' => (string) ($attributes['status'] ?? GroupChallenge::STATUS_DRAFT),
'start_at' => $attributes['start_at'] ?? null,
'end_at' => $attributes['end_at'] ?? null,
'rules_text' => $this->nullableString($attributes['rules_text'] ?? null),
'submission_instructions' => $this->nullableString($attributes['submission_instructions'] ?? null),
'judging_mode' => $this->nullableString($attributes['judging_mode'] ?? null),
'linked_collection_id' => $this->normalizeCollectionId($group, $attributes['linked_collection_id'] ?? null),
'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null),
'created_by_user_id' => (int) $actor->id,
'featured_artwork_id' => null,
]);
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($coverPath);
throw $exception;
}
$this->history->record(
$group,
$actor,
'challenge_created',
sprintf('Created challenge "%s".', $challenge->title),
'group_challenge',
(int) $challenge->id,
null,
$challenge->only(['title', 'status', 'visibility', 'participation_scope'])
);
$this->activity->record(
$group,
$actor,
'challenge_created',
'group_challenge',
(int) $challenge->id,
sprintf('%s launched a new challenge draft: %s', $actor->name ?: $actor->username ?: 'A member', $challenge->title),
$challenge->summary,
$challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile']);
}
public function update(GroupChallenge $challenge, User $actor, array $attributes): GroupChallenge
{
$coverPath = null;
$oldCoverPath = $challenge->cover_path;
$before = $challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']);
try {
DB::transaction(function () use ($challenge, $attributes, &$coverPath): void {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->media->storeUploadedEntityImage($challenge->group, $attributes['cover_file'], 'challenges');
}
$title = trim((string) ($attributes['title'] ?? $challenge->title));
$challenge->fill([
'title' => $title,
'slug' => $title !== $challenge->title ? $this->makeUniqueSlug($title, (int) $challenge->id) : $challenge->slug,
'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $challenge->summary,
'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $challenge->description,
'cover_path' => $coverPath ?: (array_key_exists('cover_path', $attributes) ? $this->nullableString($attributes['cover_path']) : $challenge->cover_path),
'visibility' => (string) ($attributes['visibility'] ?? $challenge->visibility),
'participation_scope' => (string) ($attributes['participation_scope'] ?? $challenge->participation_scope),
'status' => (string) ($attributes['status'] ?? $challenge->status),
'start_at' => $attributes['start_at'] ?? $challenge->start_at,
'end_at' => $attributes['end_at'] ?? $challenge->end_at,
'rules_text' => array_key_exists('rules_text', $attributes) ? $this->nullableString($attributes['rules_text']) : $challenge->rules_text,
'submission_instructions' => array_key_exists('submission_instructions', $attributes) ? $this->nullableString($attributes['submission_instructions']) : $challenge->submission_instructions,
'judging_mode' => array_key_exists('judging_mode', $attributes) ? $this->nullableString($attributes['judging_mode']) : $challenge->judging_mode,
'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($challenge->group, $attributes['linked_collection_id']) : $challenge->linked_collection_id,
'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($challenge->group, $attributes['linked_project_id']) : $challenge->linked_project_id,
'featured_artwork_id' => array_key_exists('featured_artwork_id', $attributes) ? $this->normalizeArtworkId($challenge->group, $attributes['featured_artwork_id']) : $challenge->featured_artwork_id,
])->save();
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($coverPath);
throw $exception;
}
if ($coverPath !== null && $oldCoverPath !== $challenge->cover_path) {
$this->media->deleteIfManaged($oldCoverPath);
}
$challenge->refresh();
$this->history->record(
$challenge->group,
$actor,
'challenge_updated',
sprintf('Updated challenge "%s".', $challenge->title),
'group_challenge',
(int) $challenge->id,
$before,
$challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id'])
);
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
}
public function publish(GroupChallenge $challenge, User $actor): GroupChallenge
{
if ($challenge->group->status !== Group::LIFECYCLE_ACTIVE) {
throw ValidationException::withMessages([
'group' => 'Archived or suspended groups cannot publish challenges.',
]);
}
if (! $challenge->start_at || ! $challenge->end_at || $challenge->end_at->lt($challenge->start_at)) {
throw ValidationException::withMessages([
'timeline' => 'Challenges need a valid start and end date before they can be published.',
]);
}
$challenge->forceFill([
'status' => $challenge->start_at->lte(now()) ? GroupChallenge::STATUS_ACTIVE : GroupChallenge::STATUS_PUBLISHED,
])->save();
$this->history->record(
$challenge->group,
$actor,
'challenge_published',
sprintf('Published challenge "%s".', $challenge->title),
'group_challenge',
(int) $challenge->id,
['status' => GroupChallenge::STATUS_DRAFT],
['status' => $challenge->status]
);
$this->activity->record(
$challenge->group,
$actor,
'challenge_published',
'group_challenge',
(int) $challenge->id,
sprintf('%s launched the challenge %s', $challenge->group->name, $challenge->title),
$challenge->summary,
$challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
if ($challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC) {
foreach ($challenge->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupChallengePublished($follow->user, $actor, $challenge->group, $challenge);
}
}
}
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
}
public function attachArtwork(GroupChallenge $challenge, Artwork $artwork, User $actor): GroupChallenge
{
if (! $this->canAttachArtwork($challenge, $artwork, $actor)) {
throw ValidationException::withMessages([
'artwork' => 'This artwork is not eligible for this challenge.',
]);
}
GroupChallengeArtwork::query()->updateOrCreate(
[
'group_challenge_id' => (int) $challenge->id,
'artwork_id' => (int) $artwork->id,
],
[
'submitted_by_user_id' => (int) $actor->id,
'sort_order' => (int) $challenge->artworkLinks()->count(),
]
);
$this->history->record(
$challenge->group,
$actor,
'challenge_artwork_attached',
sprintf('Attached artwork "%s" to challenge "%s".', $artwork->title, $challenge->title),
'group_challenge',
(int) $challenge->id,
null,
['artwork_id' => (int) $artwork->id]
);
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
}
public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->visibleQuery($group, $viewer)
->with(['creator.profile', 'linkedCollection', 'linkedProject'])
->latest('start_at')
->limit($limit)
->get()
->map(fn (GroupChallenge $challenge): array => $this->mapPublicChallenge($challenge))
->values()
->all();
}
public function activeChallenge(Group $group, ?User $viewer = null): ?array
{
$challenge = $this->visibleQuery($group, $viewer)
->with(['creator.profile', 'linkedCollection', 'linkedProject'])
->whereIn('status', [GroupChallenge::STATUS_ACTIVE, GroupChallenge::STATUS_PUBLISHED])
->orderByRaw("CASE status WHEN 'active' THEN 0 ELSE 1 END")
->orderBy('start_at')
->first();
return $challenge ? $this->mapPublicChallenge($challenge) : null;
}
public function studioListing(Group $group, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'all');
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50);
$query = GroupChallenge::query()
->with(['creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile'])
->where('group_id', $group->id);
if ($bucket !== 'all') {
$query->where('status', $bucket);
}
$paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);
return [
'items' => collect($paginator->items())->map(fn (GroupChallenge $challenge): array => $this->mapStudioChallenge($challenge))->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'filters' => ['bucket' => $bucket],
'bucket_options' => [
['value' => 'all', 'label' => 'All'],
['value' => GroupChallenge::STATUS_DRAFT, 'label' => 'Drafts'],
['value' => GroupChallenge::STATUS_PUBLISHED, 'label' => 'Published'],
['value' => GroupChallenge::STATUS_ACTIVE, 'label' => 'Active'],
['value' => GroupChallenge::STATUS_ENDED, 'label' => 'Ended'],
['value' => GroupChallenge::STATUS_ARCHIVED, 'label' => 'Archived'],
],
];
}
public function detailPayload(GroupChallenge $challenge, ?User $viewer = null): array
{
$challenge->loadMissing(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
return array_merge($this->mapPublicChallenge($challenge), [
'description' => $challenge->description,
'rules_text' => $challenge->rules_text,
'submission_instructions' => $challenge->submission_instructions,
'featured_artwork' => $challenge->featuredArtwork ? [
'id' => (int) $challenge->featuredArtwork->id,
'title' => $challenge->featuredArtwork->title,
'url' => route('art.show', ['id' => $challenge->featuredArtwork->id, 'slug' => $challenge->featuredArtwork->slug ?: $challenge->featuredArtwork->id]),
] : null,
'artworks' => $challenge->artworks->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]),
])->values()->all(),
]);
}
public function mapPublicChallenge(GroupChallenge $challenge): array
{
return [
'id' => (int) $challenge->id,
'title' => (string) $challenge->title,
'slug' => (string) $challenge->slug,
'summary' => $challenge->summary,
'status' => (string) $challenge->status,
'visibility' => (string) $challenge->visibility,
'participation_scope' => (string) $challenge->participation_scope,
'cover_url' => $challenge->coverUrl(),
'start_at' => $challenge->start_at?->toISOString(),
'end_at' => $challenge->end_at?->toISOString(),
'rules_text' => $challenge->rules_text,
'entry_count' => (int) $challenge->artworkLinks()->count(),
'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]),
];
}
public function mapStudioChallenge(GroupChallenge $challenge): array
{
return array_merge($this->mapPublicChallenge($challenge), [
'description' => $challenge->description,
'urls' => [
'public' => $challenge->visibility !== GroupChallenge::VISIBILITY_PRIVATE ? route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]) : null,
'edit' => route('studio.groups.challenges.edit', ['group' => $challenge->group, 'challenge' => $challenge]),
'publish' => route('studio.groups.challenges.publish', ['group' => $challenge->group, 'challenge' => $challenge]),
'attach_artwork' => route('studio.groups.challenges.attach-artwork', ['group' => $challenge->group, 'challenge' => $challenge]),
],
]);
}
private function visibleQuery(Group $group, ?User $viewer = null)
{
return GroupChallenge::query()
->where('group_id', $group->id)
->when(! ($viewer && $group->canViewStudio($viewer)), function ($query): void {
$query->where('visibility', GroupChallenge::VISIBILITY_PUBLIC)
->where('status', '!=', GroupChallenge::STATUS_DRAFT);
});
}
private function canAttachArtwork(GroupChallenge $challenge, Artwork $artwork, User $actor): bool
{
if ($challenge->participation_scope === GroupChallenge::PARTICIPATION_PUBLIC) {
return (int) $artwork->user_id === (int) $actor->id
|| (int) ($artwork->uploaded_by_user_id ?? 0) === (int) $actor->id
|| (int) ($artwork->primary_author_user_id ?? 0) === (int) $actor->id
|| ((int) $artwork->group_id === (int) $challenge->group_id && $challenge->group->hasActiveMember($actor));
}
return $challenge->group->hasActiveMember($actor) && (int) $artwork->group_id === (int) $challenge->group_id;
}
private function makeUniqueSlug(string $source, ?int $ignoreId = null): string
{
$base = Str::slug(Str::limit($source, 150, '')) ?: 'challenge';
$slug = $base;
$suffix = 2;
while (GroupChallenge::query()->where('slug', $slug)->when($ignoreId !== null, fn ($query) => $query->where('id', '!=', $ignoreId))->exists()) {
$slug = Str::limit($base, 180, '') . '-' . $suffix;
$suffix++;
}
return $slug;
}
private function normalizeCollectionId(Group $group, mixed $collectionId): ?int
{
$id = (int) $collectionId;
return $id > 0 && $group->collections()->where('id', $id)->exists() ? $id : null;
}
private function normalizeProjectId(Group $group, mixed $projectId): ?int
{
$id = (int) $projectId;
return $id > 0 && $group->projects()->where('id', $id)->exists() ? $id : null;
}
private function normalizeArtworkId(Group $group, mixed $artworkId): ?int
{
$id = (int) $artworkId;
return $id > 0 && $group->artworks()->where('id', $id)->whereNull('deleted_at')->exists() ? $id : null;
}
private function nullableString(mixed $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : null;
}
}

View File

@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\GroupChallenge;
use App\Models\Group;
use App\Models\GroupActivityItem;
use App\Models\GroupDiscoveryMetric;
use App\Models\GroupEvent;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class GroupDiscoveryService
{
public function __construct(
private readonly GroupCardService $cards,
) {
}
public function refresh(Group $group): GroupDiscoveryMetric
{
$publicReleaseCount = (int) $group->releases()
->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
->where('status', GroupRelease::STATUS_RELEASED)
->count();
$recentReleaseCount = (int) $group->releases()
->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
->where('status', GroupRelease::STATUS_RELEASED)
->where('released_at', '>=', now()->subDays(60))
->count();
$recentPublicActivity = (int) GroupActivityItem::query()
->where('group_id', $group->id)
->where('visibility', GroupActivityItem::VISIBILITY_PUBLIC)
->where('occurred_at', '>=', now()->subDays(30))
->count();
$publishedArtworks = (int) Artwork::query()
->where('group_id', $group->id)
->where('artwork_status', 'published')
->count();
$activeMembers = (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count() + 1;
$freshnessScore = $this->freshnessScore($group);
$activityScore = min(100, ($recentPublicActivity * 12) + ($publishedArtworks * 0.5));
$releaseScore = min(100, ($publicReleaseCount * 14) + ($recentReleaseCount * 12));
$collaborationScore = min(100, ($activeMembers * 10) + ($group->contributorStats()->count() * 4));
$trustScore = $group->status === Group::LIFECYCLE_SUSPENDED
? 0
: min(100, 25 + ($group->is_verified ? 20 : 0) + ($publicReleaseCount * 10) + ($publishedArtworks * 0.35) + ($group->followers_count * 0.2));
return GroupDiscoveryMetric::query()->updateOrCreate(
['group_id' => (int) $group->id],
[
'freshness_score' => $freshnessScore,
'activity_score' => round($activityScore, 2),
'release_score' => round($releaseScore, 2),
'collaboration_score' => round($collaborationScore, 2),
'trust_score' => round($trustScore, 2),
'last_calculated_at' => now(),
]
);
}
public function publicListing(?User $viewer, string $surface = 'featured', int $page = 1, int $perPage = 24): LengthAwarePaginator
{
$groups = $this->publicGroupBaseQuery()->get();
$sorted = $this->sortGroups($groups, $surface);
$page = max(1, $page);
$perPage = max(1, min($perPage, 48));
$slice = $sorted->forPage($page, $perPage)->values();
return new LengthAwarePaginator($slice, $sorted->count(), $perPage, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
}
public function spotlightCard(?User $viewer = null, string $surface = 'featured'): ?array
{
return $this->surfaceCards($viewer, $surface, 1)[0] ?? null;
}
public function surfaceCards(?User $viewer = null, string $surface = 'featured', int $limit = 6): array
{
return $this->sortGroups($this->publicGroupBaseQuery()->get(), $surface)
->take(max(1, $limit))
->map(fn (Group $group): array => $this->cards->mapGroupCard($group, $viewer))
->values()
->all();
}
public function searchCards(string $query, ?User $viewer = null, int $limit = 8): array
{
$normalized = mb_strtolower(trim($query));
if (mb_strlen($normalized) < 2) {
return [];
}
$groups = $this->publicGroupBaseQuery()
->where(function (Builder $builder) use ($normalized): void {
$builder->whereRaw('LOWER(name) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(slug) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(headline) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(bio) LIKE ?', ['%' . $normalized . '%'])
->orWhereHas('recruitmentProfile', function (Builder $recruitmentQuery) use ($normalized): void {
$recruitmentQuery->whereRaw('LOWER(headline) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(roles_json) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(skills_json) LIKE ?', ['%' . $normalized . '%']);
})
->orWhereHas('releases', function (Builder $releaseQuery) use ($normalized): void {
$releaseQuery->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
->where('status', GroupRelease::STATUS_RELEASED)
->where(function (Builder $nestedQuery) use ($normalized): void {
$nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(release_notes) LIKE ?', ['%' . $normalized . '%']);
});
})
->orWhereHas('projects', function (Builder $projectQuery) use ($normalized): void {
$projectQuery->where('visibility', GroupProject::VISIBILITY_PUBLIC)
->where(function (Builder $nestedQuery) use ($normalized): void {
$nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']);
});
})
->orWhereHas('challenges', function (Builder $challengeQuery) use ($normalized): void {
$challengeQuery->where('visibility', GroupChallenge::VISIBILITY_PUBLIC)
->whereIn('status', [GroupChallenge::STATUS_PUBLISHED, GroupChallenge::STATUS_ACTIVE])
->where(function (Builder $nestedQuery) use ($normalized): void {
$nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']);
});
})
->orWhereHas('events', function (Builder $eventQuery) use ($normalized): void {
$eventQuery->where('visibility', GroupEvent::VISIBILITY_PUBLIC)
->where('status', GroupEvent::STATUS_PUBLISHED)
->where(function (Builder $nestedQuery) use ($normalized): void {
$nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']);
});
})
->orWhereHas('badges', function (Builder $badgeQuery) use ($normalized): void {
$badgeQuery->whereRaw('LOWER(badge_key) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw("LOWER(REPLACE(badge_key, '_', ' ')) LIKE ?", ['%' . $normalized . '%']);
})
->orWhereHas('members.user', function (Builder $userQuery) use ($normalized): void {
$userQuery->whereRaw('LOWER(name) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(username) LIKE ?', ['%' . $normalized . '%']);
});
})
->limit(max($limit * 3, 12))
->get();
return $groups
->sortByDesc(fn (Group $group): float => $this->searchWeight($group, $normalized))
->take(max(1, $limit))
->map(fn (Group $group): array => $this->cards->mapGroupCard($group, $viewer))
->values()
->all();
}
public function publicGroupCount(): int
{
return Group::query()->public()->count();
}
public function availableSurfaces(): array
{
return [
['value' => 'featured', 'label' => 'Featured'],
['value' => 'recruiting', 'label' => 'Recruiting'],
['value' => 'new_rising', 'label' => 'New & Rising'],
['value' => 'trusted', 'label' => 'Trusted'],
['value' => 'recent_releases', 'label' => 'Recent releases'],
['value' => 'featured_projects', 'label' => 'Featured projects'],
['value' => 'current_challenges', 'label' => 'Current challenges'],
['value' => 'upcoming_events', 'label' => 'Upcoming events'],
];
}
private function publicGroupBaseQuery(): Builder
{
return Group::query()
->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges'])
->withCount([
'members as active_members_count' => fn (Builder $query) => $query->where('status', Group::STATUS_ACTIVE),
'releases as public_releases_count' => fn (Builder $query) => $query
->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
->where('status', GroupRelease::STATUS_RELEASED),
'releases as recent_public_releases_count' => fn (Builder $query) => $query
->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
->where('status', GroupRelease::STATUS_RELEASED)
->where('released_at', '>=', now()->subDays(60)),
'projects as public_projects_count' => fn (Builder $query) => $query
->where('visibility', GroupProject::VISIBILITY_PUBLIC)
->whereIn('status', [GroupProject::STATUS_ACTIVE, GroupProject::STATUS_REVIEW, GroupProject::STATUS_RELEASED]),
'challenges as active_public_challenges_count' => fn (Builder $query) => $query
->where('visibility', GroupChallenge::VISIBILITY_PUBLIC)
->whereIn('status', [GroupChallenge::STATUS_PUBLISHED, GroupChallenge::STATUS_ACTIVE]),
'events as upcoming_public_events_count' => fn (Builder $query) => $query
->where('visibility', GroupEvent::VISIBILITY_PUBLIC)
->where('status', GroupEvent::STATUS_PUBLISHED)
->where('start_at', '>=', now()),
'activityItems as public_activity_30d_count' => fn (Builder $query) => $query
->where('visibility', GroupActivityItem::VISIBILITY_PUBLIC)
->where('occurred_at', '>=', now()->subDays(30)),
'contributorStats as contributor_stats_count',
])
->withMax([
'releases as latest_public_release_at' => fn (Builder $query) => $query
->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
->where('status', GroupRelease::STATUS_RELEASED),
], 'released_at')
->public();
}
private function sortGroups(Collection $groups, string $surface): Collection
{
return (match ($surface) {
'recent_releases' => $groups->sortByDesc(fn (Group $group): string => (string) ($group->latest_public_release_at ?? '')),
'featured_projects' => $groups->sortByDesc(fn (Group $group): float => ((int) ($group->public_projects_count ?? 0) > 0 ? 1000 : 0) + $this->discoveryWeight($group, 'collaboration') + $this->discoveryWeight($group, 'activity')),
'current_challenges' => $groups->sortByDesc(fn (Group $group): float => ((int) ($group->active_public_challenges_count ?? 0) > 0 ? 1000 : 0) + $this->discoveryWeight($group, 'freshness') + $this->discoveryWeight($group, 'activity')),
'upcoming_events' => $groups->sortByDesc(fn (Group $group): float => ((int) ($group->upcoming_public_events_count ?? 0) > 0 ? 1000 : 0) + $this->discoveryWeight($group, 'activity') + $this->discoveryWeight($group, 'trust')),
'recruiting' => $groups->sortByDesc(fn (Group $group): float => (($group->recruitmentProfile?->is_recruiting ?? false) ? 1000 : 0) + $this->discoveryWeight($group, 'activity') + ($group->followers_count * 0.03)),
'new_rising' => $groups->sortByDesc(fn (Group $group): float => ($this->freshnessScore($group) * 1.2) + min(20, max(0, 50 - ((int) $group->followers_count / 2)))),
'trusted' => $groups->sortByDesc(fn (Group $group): float => $this->discoveryWeight($group, 'trust') + $this->discoveryWeight($group, 'release')),
default => $groups->sortByDesc(fn (Group $group): float => $this->discoveryWeight($group, 'trust') + $this->discoveryWeight($group, 'activity') + $this->discoveryWeight($group, 'collaboration')),
})->values();
}
private function searchWeight(Group $group, string $query): float
{
$name = mb_strtolower((string) $group->name);
$slug = mb_strtolower((string) $group->slug);
$headline = mb_strtolower((string) ($group->headline ?? ''));
$bio = mb_strtolower((string) ($group->bio ?? ''));
$exact = $name === $query || $slug === $query ? 1800 : 0;
$prefix = str_starts_with($name, $query) || str_starts_with($slug, $query) ? 600 : 0;
$contains = str_contains($name, $query) || str_contains($slug, $query) ? 240 : 0;
$descriptive = str_contains($headline, $query) || str_contains($bio, $query) ? 90 : 0;
return $exact
+ $prefix
+ $contains
+ $descriptive
+ ($this->discoveryWeight($group, 'trust') * 1.25)
+ $this->discoveryWeight($group, 'activity')
+ ($this->discoveryWeight($group, 'release') * 0.8)
+ ((float) ($group->followers_count ?? 0) * 0.08)
+ (($group->recruitmentProfile?->is_recruiting ?? false) ? 15 : 0);
}
private function discoveryWeight(Group $group, string $dimension): float
{
$metric = $group->relationLoaded('discoveryMetric') ? $group->discoveryMetric : $group->discoveryMetric()->first();
if (! $metric) {
$metric = $this->refresh($group);
}
return match ($dimension) {
'activity' => (float) $metric->activity_score,
'release' => (float) $metric->release_score,
'collaboration' => (float) $metric->collaboration_score,
'freshness' => (float) $metric->freshness_score,
default => (float) $metric->trust_score,
};
}
private function freshnessScore(Group $group): float
{
if (! $group->last_activity_at) {
return 20.0;
}
$days = $group->last_activity_at->diffInDays(now());
return match (true) {
$days <= 7 => 100.0,
$days <= 14 => 80.0,
$days <= 30 => 60.0,
$days <= 60 => 40.0,
default => 20.0,
};
}
}

View File

@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupEvent;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupEventService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupActivityService $activity,
private readonly GroupMediaService $media,
private readonly NotificationService $notifications,
) {
}
public function create(Group $group, User $actor, array $attributes): GroupEvent
{
$coverPath = null;
try {
$event = DB::transaction(function () use ($group, $actor, $attributes, &$coverPath): GroupEvent {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->media->storeUploadedEntityImage($group, $attributes['cover_file'], 'events');
}
return GroupEvent::query()->create([
'group_id' => (int) $group->id,
'title' => trim((string) $attributes['title']),
'slug' => $this->makeUniqueSlug((string) $attributes['title']),
'summary' => $this->nullableString($attributes['summary'] ?? null),
'description' => $this->nullableString($attributes['description'] ?? null),
'event_type' => (string) ($attributes['event_type'] ?? GroupEvent::TYPE_LAUNCH),
'visibility' => (string) ($attributes['visibility'] ?? GroupEvent::VISIBILITY_PUBLIC),
'start_at' => $attributes['start_at'] ?? null,
'end_at' => $attributes['end_at'] ?? null,
'timezone' => (string) ($attributes['timezone'] ?? 'UTC'),
'cover_path' => $coverPath ?: $this->nullableString($attributes['cover_path'] ?? null),
'location' => $this->nullableString($attributes['location'] ?? null),
'external_url' => $this->nullableString($attributes['external_url'] ?? null),
'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null),
'linked_collection_id' => $this->normalizeCollectionId($group, $attributes['linked_collection_id'] ?? null),
'linked_challenge_id' => $this->normalizeChallengeId($group, $attributes['linked_challenge_id'] ?? null),
'status' => (string) ($attributes['status'] ?? GroupEvent::STATUS_DRAFT),
'is_featured' => (bool) ($attributes['is_featured'] ?? false),
'created_by_user_id' => (int) $actor->id,
'published_at' => null,
]);
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($coverPath);
throw $exception;
}
$this->history->record(
$group,
$actor,
'event_created',
sprintf('Created event "%s".', $event->title),
'group_event',
(int) $event->id,
null,
$event->only(['title', 'event_type', 'visibility', 'status'])
);
return $event->fresh(['group', 'creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge']);
}
public function update(GroupEvent $event, User $actor, array $attributes): GroupEvent
{
$coverPath = null;
$oldCoverPath = $event->cover_path;
$shouldNotifyFollowers = $event->status === GroupEvent::STATUS_PUBLISHED && $event->visibility === GroupEvent::VISIBILITY_PUBLIC;
$before = $event->only(['title', 'summary', 'description', 'event_type', 'visibility', 'start_at', 'end_at', 'timezone', 'location', 'external_url', 'linked_project_id', 'linked_collection_id', 'linked_challenge_id', 'status', 'is_featured']);
try {
DB::transaction(function () use ($event, $attributes, &$coverPath): void {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->media->storeUploadedEntityImage($event->group, $attributes['cover_file'], 'events');
}
$title = trim((string) ($attributes['title'] ?? $event->title));
$event->fill([
'title' => $title,
'slug' => $title !== $event->title ? $this->makeUniqueSlug($title, (int) $event->id) : $event->slug,
'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $event->summary,
'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $event->description,
'event_type' => (string) ($attributes['event_type'] ?? $event->event_type),
'visibility' => (string) ($attributes['visibility'] ?? $event->visibility),
'start_at' => $attributes['start_at'] ?? $event->start_at,
'end_at' => $attributes['end_at'] ?? $event->end_at,
'timezone' => (string) ($attributes['timezone'] ?? $event->timezone),
'cover_path' => $coverPath ?: (array_key_exists('cover_path', $attributes) ? $this->nullableString($attributes['cover_path']) : $event->cover_path),
'location' => array_key_exists('location', $attributes) ? $this->nullableString($attributes['location']) : $event->location,
'external_url' => array_key_exists('external_url', $attributes) ? $this->nullableString($attributes['external_url']) : $event->external_url,
'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($event->group, $attributes['linked_project_id']) : $event->linked_project_id,
'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($event->group, $attributes['linked_collection_id']) : $event->linked_collection_id,
'linked_challenge_id' => array_key_exists('linked_challenge_id', $attributes) ? $this->normalizeChallengeId($event->group, $attributes['linked_challenge_id']) : $event->linked_challenge_id,
'status' => (string) ($attributes['status'] ?? $event->status),
'is_featured' => (bool) ($attributes['is_featured'] ?? $event->is_featured),
])->save();
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($coverPath);
throw $exception;
}
if ($coverPath !== null && $oldCoverPath !== $event->cover_path) {
$this->media->deleteIfManaged($oldCoverPath);
}
$event->refresh();
$this->history->record(
$event->group,
$actor,
'event_updated',
sprintf('Updated event "%s".', $event->title),
'group_event',
(int) $event->id,
$before,
$event->only(['title', 'summary', 'description', 'event_type', 'visibility', 'start_at', 'end_at', 'timezone', 'location', 'external_url', 'linked_project_id', 'linked_collection_id', 'linked_challenge_id', 'status', 'is_featured'])
);
if ($shouldNotifyFollowers && $event->status === GroupEvent::STATUS_PUBLISHED && $event->visibility === GroupEvent::VISIBILITY_PUBLIC) {
foreach ($event->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupEventUpdated($follow->user, $actor, $event->group, $event);
}
}
}
return $event->fresh(['group', 'creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge']);
}
public function publish(GroupEvent $event, User $actor): GroupEvent
{
if ($event->group->status !== Group::LIFECYCLE_ACTIVE) {
throw ValidationException::withMessages([
'group' => 'Archived or suspended groups cannot publish events.',
]);
}
if (! $event->start_at || ($event->end_at && $event->end_at->lt($event->start_at))) {
throw ValidationException::withMessages([
'start_at' => 'Events need a valid start date before they can be published.',
]);
}
$event->forceFill([
'status' => GroupEvent::STATUS_PUBLISHED,
'published_at' => now(),
])->save();
$this->history->record(
$event->group,
$actor,
'event_published',
sprintf('Published event "%s".', $event->title),
'group_event',
(int) $event->id,
['status' => GroupEvent::STATUS_DRAFT],
['status' => GroupEvent::STATUS_PUBLISHED]
);
$this->activity->record(
$event->group,
$actor,
'event_published',
'group_event',
(int) $event->id,
sprintf('%s announced an event: %s', $event->group->name, $event->title),
$event->summary,
$event->visibility === GroupEvent::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
if ($event->visibility === GroupEvent::VISIBILITY_PUBLIC) {
foreach ($event->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupEventPublished($follow->user, $actor, $event->group, $event);
}
}
}
return $event->fresh(['group', 'creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge']);
}
public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->visibleQuery($group, $viewer)
->with(['creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge'])
->latest('start_at')
->limit($limit)
->get()
->map(fn (GroupEvent $event): array => $this->mapPublicEvent($event))
->values()
->all();
}
public function upcomingEvent(Group $group, ?User $viewer = null): ?array
{
$event = $this->visibleQuery($group, $viewer)
->with(['creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge'])
->where('start_at', '>=', now()->subDay())
->orderBy('start_at')
->first();
return $event ? $this->mapPublicEvent($event) : null;
}
public function studioListing(Group $group, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'all');
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50);
$query = GroupEvent::query()
->with(['creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge'])
->where('group_id', $group->id);
if ($bucket !== 'all') {
$query->where('status', $bucket);
}
$paginator = $query->latest('start_at')->paginate($perPage, ['*'], 'page', $page);
return [
'items' => collect($paginator->items())->map(fn (GroupEvent $event): array => $this->mapStudioEvent($event))->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'filters' => ['bucket' => $bucket],
'bucket_options' => [
['value' => 'all', 'label' => 'All'],
['value' => GroupEvent::STATUS_DRAFT, 'label' => 'Drafts'],
['value' => GroupEvent::STATUS_PUBLISHED, 'label' => 'Published'],
['value' => GroupEvent::STATUS_ARCHIVED, 'label' => 'Archived'],
['value' => GroupEvent::STATUS_CANCELLED, 'label' => 'Cancelled'],
],
];
}
public function detailPayload(GroupEvent $event): array
{
$event->loadMissing(['group', 'creator.profile', 'linkedProject', 'linkedCollection', 'linkedChallenge']);
return array_merge($this->mapPublicEvent($event), [
'description' => $event->description,
'location' => $event->location,
'external_url' => $event->external_url,
]);
}
public function mapPublicEvent(GroupEvent $event): array
{
return [
'id' => (int) $event->id,
'title' => (string) $event->title,
'slug' => (string) $event->slug,
'summary' => $event->summary,
'event_type' => (string) $event->event_type,
'status' => (string) $event->status,
'visibility' => (string) $event->visibility,
'cover_url' => $event->coverUrl(),
'start_at' => $event->start_at?->toISOString(),
'end_at' => $event->end_at?->toISOString(),
'timezone' => (string) $event->timezone,
'location' => $event->location,
'external_url' => $event->external_url,
'is_featured' => (bool) $event->is_featured,
'url' => route('groups.events.show', ['group' => $event->group, 'event' => $event]),
];
}
public function mapStudioEvent(GroupEvent $event): array
{
return array_merge($this->mapPublicEvent($event), [
'description' => $event->description,
'urls' => [
'public' => $event->visibility === GroupEvent::VISIBILITY_PUBLIC ? route('groups.events.show', ['group' => $event->group, 'event' => $event]) : null,
'edit' => route('studio.groups.events.edit', ['group' => $event->group, 'event' => $event]),
'publish' => route('studio.groups.events.publish', ['group' => $event->group, 'event' => $event]),
],
]);
}
private function visibleQuery(Group $group, ?User $viewer = null)
{
return GroupEvent::query()
->where('group_id', $group->id)
->when(! ($viewer && $group->canViewStudio($viewer)), function ($query): void {
$query->where('visibility', GroupEvent::VISIBILITY_PUBLIC)
->where('status', GroupEvent::STATUS_PUBLISHED);
});
}
private function makeUniqueSlug(string $source, ?int $ignoreId = null): string
{
$base = Str::slug(Str::limit($source, 150, '')) ?: 'event';
$slug = $base;
$suffix = 2;
while (GroupEvent::query()->where('slug', $slug)->when($ignoreId !== null, fn ($query) => $query->where('id', '!=', $ignoreId))->exists()) {
$slug = Str::limit($base, 180, '') . '-' . $suffix;
$suffix++;
}
return $slug;
}
private function normalizeProjectId(Group $group, mixed $projectId): ?int
{
$id = (int) $projectId;
return $id > 0 && $group->projects()->where('id', $id)->exists() ? $id : null;
}
private function normalizeCollectionId(Group $group, mixed $collectionId): ?int
{
$id = (int) $collectionId;
return $id > 0 && $group->collections()->where('id', $id)->exists() ? $id : null;
}
private function normalizeChallengeId(Group $group, mixed $challengeId): ?int
{
$id = (int) $challengeId;
return $id > 0 && $group->challenges()->where('id', $id)->exists() ? $id : null;
}
private function nullableString(mixed $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : null;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupFollow;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class GroupFollowService
{
public function follow(Group $group, User $user): bool
{
if ($group->hasActiveMember($user)) {
return false;
}
return DB::transaction(function () use ($group, $user): bool {
$created = GroupFollow::query()->firstOrCreate([
'group_id' => $group->id,
'user_id' => $user->id,
]);
$this->syncFollowerCount($group);
return $created->wasRecentlyCreated;
});
}
public function unfollow(Group $group, User $user): bool
{
$deleted = GroupFollow::query()
->where('group_id', $group->id)
->where('user_id', $user->id)
->delete() > 0;
if ($deleted) {
$this->syncFollowerCount($group);
}
return $deleted;
}
public function isFollowing(Group $group, ?User $user): bool
{
if (! $user) {
return false;
}
return GroupFollow::query()
->where('group_id', $group->id)
->where('user_id', $user->id)
->exists();
}
public function syncFollowerCount(Group $group): void
{
$group->forceFill([
'followers_count' => GroupFollow::query()->where('group_id', $group->id)->count(),
'last_activity_at' => now(),
])->save();
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupHistory;
use App\Models\User;
class GroupHistoryService
{
public function record(
Group $group,
?User $actor,
string $actionType,
?string $summary = null,
?string $targetType = null,
?int $targetId = null,
?array $before = null,
?array $after = null,
): GroupHistory {
return GroupHistory::query()->create([
'group_id' => $group->id,
'actor_user_id' => $actor?->id,
'action_type' => $actionType,
'target_type' => $targetType,
'target_id' => $targetId,
'summary' => $summary,
'before_json' => $before,
'after_json' => $after,
'created_at' => now(),
]);
}
public function recentFor(Group $group, int $limit = 12): array
{
return GroupHistory::query()
->with('actor:id,username,name')
->where('group_id', $group->id)
->orderByDesc('created_at')
->limit(max(1, min(50, $limit)))
->get()
->map(fn (GroupHistory $entry): array => [
'id' => (int) $entry->id,
'action_type' => (string) $entry->action_type,
'target_type' => $entry->target_type,
'target_id' => $entry->target_id ? (int) $entry->target_id : null,
'summary' => $entry->summary,
'created_at' => $entry->created_at?->toISOString(),
'actor' => $entry->actor ? [
'id' => (int) $entry->actor->id,
'username' => $entry->actor->username,
'name' => $entry->actor->name,
] : null,
])
->values()
->all();
}
}

View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupJoinRequest;
use App\Models\GroupMember;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class GroupJoinRequestService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupMembershipService $memberships,
private readonly NotificationService $notifications,
) {
}
public function submit(Group $group, User $actor, array $attributes): GroupJoinRequest
{
if (! $group->canRequestJoin($actor)) {
throw ValidationException::withMessages([
'group' => 'This group is not accepting join requests.',
]);
}
if ($group->hasActiveMember($actor)) {
throw ValidationException::withMessages([
'group' => 'You are already a member of this group.',
]);
}
$pendingRequest = GroupJoinRequest::query()
->where('group_id', $group->id)
->where('user_id', $actor->id)
->whereIn('status', [
GroupJoinRequest::STATUS_PENDING,
])
->exists();
if ($pendingRequest) {
throw ValidationException::withMessages([
'group' => 'You already have a pending request for this group.',
]);
}
$request = GroupJoinRequest::query()->create([
'group_id' => $group->id,
'user_id' => $actor->id,
'message' => $attributes['message'] ?? null,
'portfolio_url' => $attributes['portfolio_url'] ?? null,
'desired_role' => isset($attributes['desired_role'])
? Group::normalizeMemberRole((string) $attributes['desired_role'])
: null,
'skills_json' => $attributes['skills_json'] ?? null,
'status' => GroupJoinRequest::STATUS_PENDING,
'expires_at' => now()->addDays(max(3, (int) config('groups.join_requests.expires_after_days', 21))),
]);
$this->history->record(
$group,
$actor,
'join_request_submitted',
sprintf('%s requested to join the group.', $actor->name ?: $actor->username ?: 'A user'),
'group_join_request',
(int) $request->id,
null,
[
'desired_role' => $request->desired_role,
'portfolio_url' => $request->portfolio_url,
],
);
foreach ($this->reviewRecipients($group, $actor->id) as $recipient) {
$this->notifications->notifyGroupJoinRequestReceived($recipient, $actor, $group, $request);
}
if ($group->membership_policy === Group::MEMBERSHIP_OPEN) {
GroupMember::query()->updateOrCreate(
[
'group_id' => $group->id,
'user_id' => $actor->id,
],
[
'invited_by_user_id' => $group->owner_user_id,
'role' => Group::normalizeMemberRole((string) ($request->desired_role ?: Group::ROLE_MEMBER)),
'status' => Group::STATUS_ACTIVE,
'note' => 'Auto-approved by open membership policy.',
'invited_at' => now(),
'accepted_at' => now(),
'revoked_at' => null,
],
);
$request->forceFill([
'status' => GroupJoinRequest::STATUS_APPROVED,
'review_notes' => 'Auto-approved by open membership policy.',
'reviewed_at' => now(),
])->save();
$this->history->record(
$group,
$actor,
'join_request_auto_approved',
'Auto-approved join request because the group uses open membership.',
'group_join_request',
(int) $request->id,
['status' => GroupJoinRequest::STATUS_PENDING],
['status' => GroupJoinRequest::STATUS_APPROVED],
);
return $request->fresh(['group', 'user.profile']);
}
return $request->fresh(['group', 'user.profile']);
}
public function approve(GroupJoinRequest $request, User $actor, ?string $role = null, ?string $notes = null): GroupJoinRequest
{
$group = $request->group()->with('members')->firstOrFail();
if (! $group->canReviewJoinRequests($actor)) {
throw ValidationException::withMessages([
'request' => 'You are not allowed to review join requests for this group.',
]);
}
if ($request->status !== GroupJoinRequest::STATUS_PENDING) {
throw ValidationException::withMessages([
'request' => 'Only pending join requests can be approved.',
]);
}
$resolvedRole = Group::normalizeMemberRole((string) ($role ?: $request->desired_role ?: Group::ROLE_MEMBER));
if (! in_array($resolvedRole, [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER], true)) {
$resolvedRole = Group::ROLE_MEMBER;
}
DB::transaction(function () use ($group, $request, $actor, $resolvedRole, $notes): void {
GroupMember::query()->updateOrCreate(
[
'group_id' => $group->id,
'user_id' => $request->user_id,
],
[
'invited_by_user_id' => $actor->id,
'role' => $resolvedRole,
'status' => Group::STATUS_ACTIVE,
'note' => $notes,
'invited_at' => now(),
'accepted_at' => now(),
'revoked_at' => null,
],
);
$request->forceFill([
'status' => GroupJoinRequest::STATUS_APPROVED,
'reviewed_by_user_id' => $actor->id,
'review_notes' => $notes,
'reviewed_at' => now(),
])->save();
});
$request->refresh();
$this->history->record(
$group,
$actor,
'join_request_approved',
sprintf('Approved %s to join the group.', $request->user?->name ?: $request->user?->username ?: 'a user'),
'group_join_request',
(int) $request->id,
['status' => GroupJoinRequest::STATUS_PENDING],
['status' => GroupJoinRequest::STATUS_APPROVED, 'role' => $resolvedRole],
);
app(GroupActivityService::class)->record(
$group,
$actor,
'member_joined',
'group_join_request',
(int) $request->id,
sprintf('%s joined %s', $request->user?->name ?: $request->user?->username ?: 'A member', $group->name),
'Membership approved through group join requests.',
'public',
);
$this->notifications->notifyGroupJoinRequestApproved($request->user, $actor, $group, $resolvedRole, $request);
return $request->fresh(['group', 'user.profile', 'reviewedBy.profile']);
}
public function reject(GroupJoinRequest $request, User $actor, ?string $notes = null): GroupJoinRequest
{
$group = $request->group()->with('members')->firstOrFail();
if (! $group->canReviewJoinRequests($actor)) {
throw ValidationException::withMessages([
'request' => 'You are not allowed to review join requests for this group.',
]);
}
if ($request->status !== GroupJoinRequest::STATUS_PENDING) {
throw ValidationException::withMessages([
'request' => 'Only pending join requests can be rejected.',
]);
}
$request->forceFill([
'status' => GroupJoinRequest::STATUS_REJECTED,
'reviewed_by_user_id' => $actor->id,
'review_notes' => $notes,
'reviewed_at' => now(),
])->save();
$this->history->record(
$group,
$actor,
'join_request_rejected',
sprintf('Rejected join request from %s.', $request->user?->name ?: $request->user?->username ?: 'a user'),
'group_join_request',
(int) $request->id,
['status' => GroupJoinRequest::STATUS_PENDING],
['status' => GroupJoinRequest::STATUS_REJECTED],
);
$this->notifications->notifyGroupJoinRequestRejected($request->user, $actor, $group, $request);
return $request->fresh(['group', 'user.profile', 'reviewedBy.profile']);
}
public function withdraw(GroupJoinRequest $request, User $actor): GroupJoinRequest
{
if ((int) $request->user_id !== (int) $actor->id || $request->status !== GroupJoinRequest::STATUS_PENDING) {
throw ValidationException::withMessages([
'request' => 'This join request cannot be withdrawn.',
]);
}
$request->forceFill([
'status' => GroupJoinRequest::STATUS_WITHDRAWN,
'reviewed_at' => now(),
])->save();
$this->history->record(
$request->group,
$actor,
'join_request_withdrawn',
'Join request withdrawn.',
'group_join_request',
(int) $request->id,
['status' => GroupJoinRequest::STATUS_PENDING],
['status' => GroupJoinRequest::STATUS_WITHDRAWN],
);
return $request->fresh(['group', 'user.profile']);
}
public function pendingCount(Group $group): int
{
return (int) GroupJoinRequest::query()
->where('group_id', $group->id)
->where('status', GroupJoinRequest::STATUS_PENDING)
->count();
}
public function currentRequestFor(Group $group, ?User $viewer): ?array
{
if (! $viewer) {
return null;
}
$request = GroupJoinRequest::query()
->where('group_id', $group->id)
->where('user_id', $viewer->id)
->latest('created_at')
->first();
return $request ? $this->mapRequest($request) : null;
}
public function mapRequests(Group $group, ?User $viewer = null, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'pending');
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50);
$query = GroupJoinRequest::query()
->with(['user.profile', 'reviewedBy.profile'])
->where('group_id', $group->id);
if ($bucket !== 'all') {
$query->where('status', $bucket);
}
$paginator = $query->latest('created_at')->paginate($perPage, ['*'], 'page', $page);
return [
'items' => collect($paginator->items())->map(fn (GroupJoinRequest $request): array => $this->mapRequest($request, $group, $viewer))->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'filters' => [
'bucket' => $bucket,
],
'bucket_options' => [
['value' => 'pending', 'label' => 'Pending'],
['value' => 'approved', 'label' => 'Approved'],
['value' => 'rejected', 'label' => 'Rejected'],
['value' => 'withdrawn', 'label' => 'Withdrawn'],
['value' => 'all', 'label' => 'All'],
],
];
}
public function mapRequest(GroupJoinRequest $request, ?Group $group = null, ?User $viewer = null): array
{
$resolvedGroup = $group ?: $request->group;
return [
'id' => (int) $request->id,
'status' => (string) $request->status,
'message' => $request->message,
'portfolio_url' => $request->portfolio_url,
'desired_role' => $request->desired_role,
'desired_role_label' => Group::displayRole($request->desired_role),
'skills' => array_values(array_filter($request->skills_json ?? [])),
'review_notes' => $request->review_notes,
'created_at' => $request->created_at?->toISOString(),
'reviewed_at' => $request->reviewed_at?->toISOString(),
'expires_at' => $request->expires_at?->toISOString(),
'user' => $request->user ? [
'id' => (int) $request->user->id,
'name' => $request->user->name,
'username' => $request->user->username,
'avatar_url' => AvatarUrl::forUser((int) $request->user->id, $request->user->profile?->avatar_hash, 72),
'profile_url' => route('profile.show', ['username' => strtolower((string) $request->user->username)]),
] : null,
'reviewed_by' => $request->reviewedBy ? [
'id' => (int) $request->reviewedBy->id,
'name' => $request->reviewedBy->name,
'username' => $request->reviewedBy->username,
] : null,
'can_approve' => $viewer !== null && $resolvedGroup !== null && $resolvedGroup->canReviewJoinRequests($viewer) && $request->status === GroupJoinRequest::STATUS_PENDING,
'can_reject' => $viewer !== null && $resolvedGroup !== null && $resolvedGroup->canReviewJoinRequests($viewer) && $request->status === GroupJoinRequest::STATUS_PENDING,
];
}
private function reviewRecipients(Group $group, int $excludeUserId): array
{
return User::query()
->whereIn('id', $this->memberships->activeContributorIds($group))
->get()
->filter(fn (User $member): bool => (int) $member->id !== $excludeUserId && $group->canReviewJoinRequests($member))
->values()
->all();
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class GroupMediaService
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
public function storeUploadedImage(Group $group, UploadedFile $file, string $variant): string
{
return $this->storeUploadedEntityImage($group, $file, $variant === 'banner' ? 'banner' : 'avatar');
}
public function storeUploadedEntityImage(Group $group, UploadedFile $file, string $section): string
{
$mime = strtolower((string) ($file->getMimeType() ?: ''));
$extension = $this->safeExtension($file, $mime);
$path = sprintf(
'groups/%d/%s/%s.%s',
(int) $group->id,
trim($section) !== '' ? trim($section) : 'media',
(string) Str::uuid(),
$extension,
);
$stream = fopen((string) ($file->getRealPath() ?: $file->getPathname()), 'rb');
if ($stream === false) {
throw new \RuntimeException('Unable to open uploaded group image.');
}
try {
$written = Storage::disk($this->diskName())->put($path, $stream, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => $mime !== '' ? $mime : $this->mimeTypeForExtension($extension),
]);
} finally {
fclose($stream);
}
if ($written !== true) {
throw new \RuntimeException('Unable to store uploaded group image.');
}
return $path;
}
public function deleteIfManaged(?string $path): void
{
$trimmed = trim((string) $path);
if ($trimmed === '' || str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
return;
}
if (! str_starts_with($trimmed, 'groups/')) {
return;
}
Storage::disk($this->diskName())->delete($trimmed);
}
private function diskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function safeExtension(UploadedFile $file, string $mime): string
{
$extension = strtolower((string) $file->getClientOriginalExtension());
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new \RuntimeException('Unsupported group image upload type.');
}
return match ($extension) {
'jpg', 'jpeg' => 'jpg',
'png' => 'png',
default => 'webp',
};
}
private function mimeTypeForExtension(string $extension): string
{
return match ($extension) {
'jpg' => 'image/jpeg',
'png' => 'image/png',
default => 'image/webp',
};
}
}

View File

@@ -0,0 +1,762 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupInvitation;
use App\Models\GroupMember;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupMembershipService
{
public function __construct(
private readonly NotificationService $notifications,
) {
}
public function ensureOwnerMembership(Group $group): void
{
GroupMember::query()
->where('group_id', $group->id)
->where('role', Group::ROLE_OWNER)
->where('user_id', '!=', $group->owner_user_id)
->update([
'role' => Group::ROLE_ADMIN,
'updated_at' => now(),
]);
GroupMember::query()->updateOrCreate(
[
'group_id' => $group->id,
'user_id' => $group->owner_user_id,
],
[
'invited_by_user_id' => $group->owner_user_id,
'role' => Group::ROLE_OWNER,
'status' => Group::STATUS_ACTIVE,
'invited_at' => now(),
'expires_at' => null,
'accepted_at' => now(),
'revoked_at' => null,
]
);
}
public function inviteMember(Group $group, User $actor, User $invitee, string $role, ?string $note = null, ?int $expiresInDays = null): GroupInvitation
{
$this->guardManageMembers($group, $actor);
$this->expirePendingInvites();
$role = Group::normalizeMemberRole($role);
if (! in_array($role, [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER], true)) {
throw ValidationException::withMessages([
'role' => 'Choose a valid group role.',
]);
}
if ($group->isOwnedBy($invitee)) {
throw ValidationException::withMessages([
'username' => 'The group owner is already a member.',
]);
}
$existingMembership = GroupMember::query()
->where('group_id', $group->id)
->where('user_id', $invitee->id)
->where('status', Group::STATUS_ACTIVE)
->exists();
if ($existingMembership) {
throw ValidationException::withMessages([
'username' => 'This user is already an active member of the group.',
]);
}
$invitation = DB::transaction(function () use ($group, $actor, $invitee, $role, $note, $expiresInDays): GroupInvitation {
$now = now();
GroupInvitation::query()
->where('group_id', $group->id)
->where('invited_user_id', $invitee->id)
->where('status', GroupInvitation::STATUS_PENDING)
->update([
'status' => GroupInvitation::STATUS_REVOKED,
'responded_at' => $now,
'revoked_at' => $now,
'updated_at' => $now,
]);
$invitation = GroupInvitation::query()->create([
'group_id' => $group->id,
'invited_user_id' => $invitee->id,
'invited_by_user_id' => $actor->id,
'role' => $role,
'status' => GroupInvitation::STATUS_PENDING,
'token' => Str::random(64),
'note' => $note,
'invited_at' => $now,
'expires_at' => $now->copy()->addDays(max(1, (int) ($expiresInDays ?? config('groups.invites.expires_after_days', 7)))),
'responded_at' => null,
'accepted_at' => null,
'revoked_at' => null,
]);
return $invitation->fresh(['group.owner.profile', 'invitedUser.profile', 'invitedBy.profile']);
});
$this->notifications->notifyGroupInvite($invitee, $actor, $group, $role, $invitation);
return $invitation;
}
public function acceptInvitation(GroupInvitation $invitation, User $user): GroupMember
{
$this->expireInvitationIfNeeded($invitation);
if ((int) $invitation->invited_user_id !== (int) $user->id || $invitation->status !== GroupInvitation::STATUS_PENDING) {
throw ValidationException::withMessages([
'invitation' => 'This invitation cannot be accepted.',
]);
}
$member = DB::transaction(function () use ($invitation): GroupMember {
$acceptedAt = now();
$member = GroupMember::query()->updateOrCreate(
[
'group_id' => $invitation->group_id,
'user_id' => $invitation->invited_user_id,
],
[
'invited_by_user_id' => $invitation->invited_by_user_id,
'role' => $invitation->role,
'status' => Group::STATUS_ACTIVE,
'note' => $invitation->note,
'invited_at' => $invitation->invited_at ?? $acceptedAt,
'expires_at' => null,
'accepted_at' => $acceptedAt,
'revoked_at' => null,
]
);
$invitation->forceFill([
'status' => GroupInvitation::STATUS_ACCEPTED,
'responded_at' => $acceptedAt,
'accepted_at' => $acceptedAt,
'revoked_at' => null,
])->save();
$this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_ACTIVE, $acceptedAt);
GroupInvitation::query()
->where('group_id', $invitation->group_id)
->where('invited_user_id', $invitation->invited_user_id)
->where('status', GroupInvitation::STATUS_PENDING)
->where('id', '!=', $invitation->id)
->update([
'status' => GroupInvitation::STATUS_REVOKED,
'responded_at' => $acceptedAt,
'revoked_at' => $acceptedAt,
'updated_at' => $acceptedAt,
]);
return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']);
});
$recipient = $member->invitedBy ?: $member->group?->owner;
if ($recipient) {
$this->notifications->notifyGroupInviteAccepted($recipient, $user, $member->group);
}
if ($member->group) {
app(GroupActivityService::class)->record(
$member->group,
$user,
'member_joined',
'group_member',
(int) $member->id,
sprintf('%s joined %s', $user->name ?: $user->username ?: 'A member', $member->group->name),
'Accepted a group invitation.',
'public',
);
}
return $member;
}
public function declineInvitation(GroupInvitation $invitation, User $user): GroupInvitation
{
$this->expireInvitationIfNeeded($invitation);
if ((int) $invitation->invited_user_id !== (int) $user->id || $invitation->status !== GroupInvitation::STATUS_PENDING) {
throw ValidationException::withMessages([
'invitation' => 'This invitation cannot be declined.',
]);
}
$declinedAt = now();
$invitation->forceFill([
'status' => GroupInvitation::STATUS_DECLINED,
'responded_at' => $declinedAt,
'accepted_at' => null,
'revoked_at' => null,
])->save();
$this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $declinedAt);
return $invitation->fresh(['group.owner.profile', 'invitedUser.profile', 'invitedBy.profile']);
}
public function acceptLegacyInvite(GroupMember $member, User $user): GroupMember
{
$this->expireMemberIfNeeded($member);
if ((int) $member->user_id !== (int) $user->id || $member->status !== Group::STATUS_PENDING) {
throw ValidationException::withMessages([
'member' => 'This invitation cannot be accepted.',
]);
}
$acceptedAt = now();
$member->forceFill([
'status' => Group::STATUS_ACTIVE,
'expires_at' => null,
'accepted_at' => $acceptedAt,
'revoked_at' => null,
])->save();
GroupInvitation::query()
->where('source_group_member_id', $member->id)
->where('status', GroupInvitation::STATUS_PENDING)
->update([
'status' => GroupInvitation::STATUS_ACCEPTED,
'responded_at' => $acceptedAt,
'accepted_at' => $acceptedAt,
'revoked_at' => null,
'updated_at' => $acceptedAt,
]);
$recipient = $member->invitedBy ?: $member->group?->owner;
if ($recipient) {
$this->notifications->notifyGroupInviteAccepted($recipient, $user, $member->group);
}
if ($member->group) {
app(GroupActivityService::class)->record(
$member->group,
$user,
'member_joined',
'group_member',
(int) $member->id,
sprintf('%s joined %s', $user->name ?: $user->username ?: 'A member', $member->group->name),
'Accepted a legacy group invitation.',
'public',
);
}
return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']);
}
public function declineLegacyInvite(GroupMember $member, User $user): GroupMember
{
$this->expireMemberIfNeeded($member);
if ((int) $member->user_id !== (int) $user->id || $member->status !== Group::STATUS_PENDING) {
throw ValidationException::withMessages([
'member' => 'This invitation cannot be declined.',
]);
}
$declinedAt = now();
$member->forceFill([
'status' => Group::STATUS_REVOKED,
'accepted_at' => null,
'revoked_at' => $declinedAt,
])->save();
GroupInvitation::query()
->where('source_group_member_id', $member->id)
->where('status', GroupInvitation::STATUS_PENDING)
->update([
'status' => GroupInvitation::STATUS_DECLINED,
'responded_at' => $declinedAt,
'accepted_at' => null,
'updated_at' => $declinedAt,
]);
return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']);
}
public function updateMemberRole(GroupMember $member, User $actor, string $role): GroupMember
{
$this->guardManageMembers($member->group, $actor);
$role = Group::normalizeMemberRole($role);
if ($member->role === Group::ROLE_OWNER) {
throw ValidationException::withMessages([
'member' => 'The group owner role cannot be changed.',
]);
}
if (! in_array($role, [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER], true)) {
throw ValidationException::withMessages([
'role' => 'Choose a valid group role.',
]);
}
$member->forceFill(['role' => $role])->save();
$this->notifications->notifyGroupRoleChanged($member->user, $actor, $member->group, Group::displayRole($role) ?? $role);
return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']);
}
public function updatePermissionOverrides(GroupMember $member, User $actor, array $overrides): GroupMember
{
if (! $member->group->canManageMemberPermissions($actor) && ! $actor->isAdmin()) {
throw ValidationException::withMessages([
'member' => 'You are not allowed to manage group member permissions.',
]);
}
if ($member->role === Group::ROLE_OWNER) {
throw ValidationException::withMessages([
'member' => 'The group owner already has full permissions.',
]);
}
$normalized = collect($overrides)
->map(function ($override): ?array {
if (is_string($override)) {
$key = trim($override);
return $key !== '' && in_array($key, Group::allowedPermissionOverrides(), true)
? ['key' => $key, 'is_allowed' => true]
: null;
}
if (! is_array($override)) {
return null;
}
$key = trim((string) ($override['key'] ?? ''));
if ($key === '' || ! in_array($key, Group::allowedPermissionOverrides(), true)) {
return null;
}
return [
'key' => $key,
'is_allowed' => (bool) ($override['is_allowed'] ?? false),
];
})
->filter()
->unique(fn (array $override): string => $override['key'])
->values()
->all();
$member->forceFill([
'permission_overrides_json' => $normalized,
])->save();
return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']);
}
public function revokeMember(GroupMember $member, User $actor): void
{
$this->guardManageMembers($member->group, $actor);
$wasActiveMember = $member->status === Group::STATUS_ACTIVE;
if ($member->role === Group::ROLE_OWNER) {
throw ValidationException::withMessages([
'member' => 'The group owner cannot be removed.',
]);
}
$member->forceFill([
'status' => Group::STATUS_REVOKED,
'expires_at' => null,
'revoked_at' => now(),
])->save();
if ($wasActiveMember) {
$this->notifications->notifyGroupMemberRemoved($member->user, $actor, $member->group);
}
}
public function revokeInvitation(GroupInvitation $invitation, User $actor): GroupInvitation
{
$this->guardManageMembers($invitation->group, $actor);
if ($invitation->status !== GroupInvitation::STATUS_PENDING) {
throw ValidationException::withMessages([
'invitation' => 'Only pending invitations can be revoked.',
]);
}
$revokedAt = now();
$invitation->forceFill([
'status' => GroupInvitation::STATUS_REVOKED,
'responded_at' => $revokedAt,
'revoked_at' => $revokedAt,
])->save();
$this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $revokedAt);
return $invitation->fresh(['group.owner.profile', 'invitedUser.profile', 'invitedBy.profile']);
}
public function transferOwnership(Group $group, GroupMember $member, User $actor): Group
{
if (! $group->isOwnedBy($actor) && ! $actor->isAdmin()) {
throw ValidationException::withMessages([
'member' => 'Only the group owner can transfer ownership.',
]);
}
if ((int) $member->group_id !== (int) $group->id) {
throw ValidationException::withMessages([
'member' => 'This member does not belong to the selected group.',
]);
}
if ($member->status !== Group::STATUS_ACTIVE) {
throw ValidationException::withMessages([
'member' => 'Only active members can become the new owner.',
]);
}
return DB::transaction(function () use ($group, $member): Group {
GroupMember::query()
->where('group_id', $group->id)
->where('user_id', $group->owner_user_id)
->update([
'role' => Group::ROLE_ADMIN,
'updated_at' => now(),
]);
$member->forceFill([
'role' => Group::ROLE_OWNER,
'status' => Group::STATUS_ACTIVE,
'expires_at' => null,
'accepted_at' => $member->accepted_at ?? now(),
])->save();
$group->forceFill([
'owner_user_id' => $member->user_id,
'last_activity_at' => now(),
])->save();
$this->ensureOwnerMembership($group->fresh());
return $group->fresh(['owner.profile']);
});
}
public function expirePendingInvites(): int
{
$expired = GroupInvitation::query()
->with('sourceGroupMember')
->where('status', GroupInvitation::STATUS_PENDING)
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->get();
foreach ($expired as $invitation) {
$expiredAt = now();
$invitation->forceFill([
'status' => GroupInvitation::STATUS_EXPIRED,
'responded_at' => $expiredAt,
'revoked_at' => $expiredAt,
])->save();
$this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $expiredAt);
}
return $expired->count();
}
public function activeContributorIds(Group $group): array
{
$activeIds = $group->members()
->where('status', Group::STATUS_ACTIVE)
->whereIn('role', [Group::ROLE_OWNER, Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER])
->pluck('user_id')
->map(static fn ($id): int => (int) $id)
->all();
if (! in_array((int) $group->owner_user_id, $activeIds, true)) {
$activeIds[] = (int) $group->owner_user_id;
}
return array_values(array_unique($activeIds));
}
public function mapMembers(Group $group, ?User $viewer = null): array
{
$this->expirePendingInvites();
$members = $group->members()
->with(['user.profile', 'invitedBy.profile'])
->where('status', Group::STATUS_ACTIVE)
->orderByRaw("CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'editor' THEN 2 ELSE 3 END")
->orderBy('created_at')
->get();
return $members->map(fn (GroupMember $member): array => $this->mapMemberRow($member, $group, $viewer))->all();
}
public function mapInvitations(Group $group, ?User $viewer = null): array
{
$this->expirePendingInvites();
return $group->invitations()
->with(['invitedUser.profile', 'invitedBy.profile'])
->whereIn('status', [
GroupInvitation::STATUS_PENDING,
GroupInvitation::STATUS_REVOKED,
GroupInvitation::STATUS_DECLINED,
GroupInvitation::STATUS_EXPIRED,
])
->orderByDesc('invited_at')
->orderByDesc('updated_at')
->get()
->map(fn (GroupInvitation $invitation): array => $this->mapInvitationRow($invitation, $group, $viewer))
->values()
->all();
}
public function pendingInviteCount(Group $group): int
{
$this->expirePendingInvites();
return (int) $group->invitations()
->where('status', GroupInvitation::STATUS_PENDING)
->count();
}
public function pendingInvitationsForUser(User $user): array
{
$this->expirePendingInvites();
return $user->groupInvitations()
->with(['group.owner.profile', 'group.members', 'invitedBy.profile'])
->where('status', GroupInvitation::STATUS_PENDING)
->orderByDesc('invited_at')
->get()
->map(fn (GroupInvitation $invitation): array => [
'id' => (int) $invitation->id,
'group' => $invitation->group ? [
'id' => (int) $invitation->group->id,
'name' => (string) $invitation->group->name,
'slug' => (string) $invitation->group->slug,
'avatar_url' => $invitation->group->avatarUrl(),
'counts' => [
'artworks' => (int) $invitation->group->artworks_count,
'collections' => (int) $invitation->group->collections_count,
'followers' => (int) $invitation->group->followers_count,
],
] : null,
'role' => Group::displayRole((string) $invitation->role) ?? (string) $invitation->role,
'invited_at' => $invitation->invited_at?->toISOString(),
'expires_at' => $invitation->expires_at?->toISOString(),
'accept_url' => route('studio.groups.invitations.accept', ['invitation' => $invitation]),
'decline_url' => route('studio.groups.invitations.decline', ['invitation' => $invitation]),
'invited_by' => $invitation->invitedBy ? [
'name' => $invitation->invitedBy->name,
'username' => $invitation->invitedBy->username,
] : null,
])
->values()
->all();
}
public function contributorOptions(Group $group): array
{
return User::query()
->with('profile:user_id,avatar_hash')
->whereIn('id', $this->activeContributorIds($group))
->orderBy('username')
->get()
->map(fn (User $user): array => [
'id' => (int) $user->id,
'name' => $user->name,
'username' => $user->username,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64),
])
->values()
->all();
}
private function guardManageMembers(Group $group, User $actor): void
{
if (! $group->canManageMembers($actor) && ! $actor->isAdmin()) {
throw ValidationException::withMessages([
'group' => 'You are not allowed to manage this group.',
]);
}
}
private function expireMemberIfNeeded(GroupMember $member): void
{
if ($member->status !== Group::STATUS_PENDING || ! $member->expires_at || $member->expires_at->isFuture()) {
return;
}
$member->forceFill([
'status' => Group::STATUS_REVOKED,
'revoked_at' => Carbon::now(),
])->save();
GroupInvitation::query()
->where('source_group_member_id', $member->id)
->where('status', GroupInvitation::STATUS_PENDING)
->update([
'status' => GroupInvitation::STATUS_EXPIRED,
'responded_at' => now(),
'revoked_at' => now(),
'updated_at' => now(),
]);
}
private function expireInvitationIfNeeded(GroupInvitation $invitation): void
{
if ($invitation->status !== GroupInvitation::STATUS_PENDING || ! $invitation->expires_at || $invitation->expires_at->isFuture()) {
return;
}
$expiredAt = Carbon::now();
$invitation->forceFill([
'status' => GroupInvitation::STATUS_EXPIRED,
'responded_at' => $expiredAt,
'revoked_at' => $expiredAt,
])->save();
$this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $expiredAt);
}
private function mapMemberRow(GroupMember $member, Group $group, ?User $viewer = null): array
{
$user = $member->user;
return [
'id' => (int) $member->id,
'user_id' => (int) $member->user_id,
'role' => (string) $member->role,
'role_label' => Group::displayRole((string) $member->role),
'status' => (string) $member->status,
'permission_overrides' => collect($member->permission_overrides_json ?? [])
->map(function ($override): ?array {
if (is_array($override)) {
$key = trim((string) ($override['key'] ?? ''));
return $key !== '' ? ['key' => $key, 'is_allowed' => (bool) ($override['is_allowed'] ?? false)] : null;
}
$key = trim((string) $override);
return $key !== '' ? ['key' => $key, 'is_allowed' => true] : null;
})
->filter()
->values()
->all(),
'note' => $member->note,
'invited_at' => $member->invited_at?->toISOString(),
'expires_at' => $member->expires_at?->toISOString(),
'accepted_at' => $member->accepted_at?->toISOString(),
'is_expired' => $member->status === Group::STATUS_REVOKED && $member->expires_at !== null && $member->expires_at->lte(now()) && $member->accepted_at === null,
'can_accept' => $viewer !== null && (int) $member->user_id === (int) $viewer->id && $member->status === Group::STATUS_PENDING,
'can_decline' => $viewer !== null && (int) $member->user_id === (int) $viewer->id && $member->status === Group::STATUS_PENDING,
'can_revoke' => $viewer !== null && $group->canManageMembers($viewer) && $member->role !== Group::ROLE_OWNER,
'can_transfer' => $viewer !== null
&& $group->isOwnedBy($viewer)
&& $member->status === Group::STATUS_ACTIVE
&& $member->role !== Group::ROLE_OWNER,
'can_manage_permissions' => $viewer !== null
&& $group->canManageMemberPermissions($viewer)
&& $member->status === Group::STATUS_ACTIVE
&& $member->role !== Group::ROLE_OWNER,
'user' => [
'id' => (int) $user->id,
'name' => $user->name,
'username' => $user->username,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 72),
'profile_url' => route('profile.show', ['username' => strtolower((string) $user->username)]),
],
'invited_by' => $member->invitedBy ? [
'id' => (int) $member->invitedBy->id,
'username' => $member->invitedBy->username,
'name' => $member->invitedBy->name,
] : null,
];
}
private function mapInvitationRow(GroupInvitation $invitation, Group $group, ?User $viewer = null): array
{
$user = $invitation->invitedUser;
$displayStatus = $invitation->status === GroupInvitation::STATUS_PENDING ? GroupInvitation::STATUS_PENDING : Group::STATUS_REVOKED;
return [
'id' => (int) $invitation->id,
'user_id' => (int) $invitation->invited_user_id,
'role' => (string) $invitation->role,
'role_label' => Group::displayRole((string) $invitation->role),
'status' => $displayStatus,
'status_raw' => (string) $invitation->status,
'note' => $invitation->note,
'invited_at' => $invitation->invited_at?->toISOString(),
'expires_at' => $invitation->expires_at?->toISOString(),
'accepted_at' => $invitation->accepted_at?->toISOString(),
'is_expired' => $invitation->status === GroupInvitation::STATUS_EXPIRED,
'can_accept' => $viewer !== null && (int) $invitation->invited_user_id === (int) $viewer->id && $invitation->status === GroupInvitation::STATUS_PENDING,
'can_decline' => $viewer !== null && (int) $invitation->invited_user_id === (int) $viewer->id && $invitation->status === GroupInvitation::STATUS_PENDING,
'can_revoke' => $viewer !== null && $group->canManageMembers($viewer) && $invitation->status === GroupInvitation::STATUS_PENDING,
'can_transfer' => false,
'accept_url' => $invitation->status === GroupInvitation::STATUS_PENDING ? route('studio.groups.invitations.accept', ['invitation' => $invitation]) : null,
'decline_url' => $invitation->status === GroupInvitation::STATUS_PENDING ? route('studio.groups.invitations.decline', ['invitation' => $invitation]) : null,
'revoke_url' => $viewer !== null && $group->canManageMembers($viewer) ? route('studio.groups.invitations.destroy', ['group' => $group, 'invitation' => $invitation]) : null,
'user' => $user ? [
'id' => (int) $user->id,
'name' => $user->name,
'username' => $user->username,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 72),
'profile_url' => route('profile.show', ['username' => strtolower((string) $user->username)]),
] : null,
'invited_by' => $invitation->invitedBy ? [
'id' => (int) $invitation->invitedBy->id,
'username' => $invitation->invitedBy->username,
'name' => $invitation->invitedBy->name,
] : null,
];
}
private function syncLegacyMemberFromInvitation(GroupInvitation $invitation, string $memberStatus, Carbon $timestamp): void
{
if (! $invitation->source_group_member_id) {
return;
}
$member = $invitation->sourceGroupMember()->first();
if (! $member) {
return;
}
$member->forceFill([
'status' => $memberStatus,
'expires_at' => $memberStatus === Group::STATUS_ACTIVE ? null : $member->expires_at,
'accepted_at' => $memberStatus === Group::STATUS_ACTIVE ? $timestamp : null,
'revoked_at' => $memberStatus === Group::STATUS_ACTIVE ? null : $timestamp,
])->save();
}
}

View File

@@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupPost;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupPostService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly NotificationService $notifications,
) {
}
public function create(Group $group, User $actor, array $attributes): GroupPost
{
$post = GroupPost::query()->create([
'group_id' => $group->id,
'author_user_id' => $actor->id,
'type' => (string) ($attributes['type'] ?? GroupPost::TYPE_ANNOUNCEMENT),
'title' => trim((string) $attributes['title']),
'slug' => $this->makeUniqueSlug((string) $attributes['title']),
'excerpt' => $this->normalizeExcerpt($attributes['excerpt'] ?? null, $attributes['content'] ?? null),
'content' => $this->sanitizeContent($attributes['content'] ?? null),
'cover_path' => $attributes['cover_path'] ?? null,
'status' => GroupPost::STATUS_DRAFT,
'is_pinned' => false,
'published_at' => null,
]);
$this->history->record(
$group,
$actor,
'post_created',
sprintf('Created draft post "%s".', $post->title),
'group_post',
(int) $post->id,
null,
$post->only(['type', 'title', 'status']),
);
return $post->fresh(['group', 'author.profile']);
}
public function update(GroupPost $post, User $actor, array $attributes): GroupPost
{
$before = $post->only(['type', 'title', 'excerpt', 'content', 'cover_path', 'status', 'is_pinned']);
$title = trim((string) ($attributes['title'] ?? $post->title));
$post->fill([
'type' => (string) ($attributes['type'] ?? $post->type),
'title' => $title,
'slug' => $title !== $post->title ? $this->makeUniqueSlug($title, (int) $post->id) : $post->slug,
'excerpt' => array_key_exists('excerpt', $attributes)
? $this->normalizeExcerpt($attributes['excerpt'], $attributes['content'] ?? $post->content)
: $post->excerpt,
'content' => array_key_exists('content', $attributes)
? $this->sanitizeContent($attributes['content'])
: $post->content,
'cover_path' => array_key_exists('cover_path', $attributes) ? ($attributes['cover_path'] ?: null) : $post->cover_path,
])->save();
$this->history->record(
$post->group,
$actor,
'post_updated',
sprintf('Updated post "%s".', $post->title),
'group_post',
(int) $post->id,
$before,
$post->only(['type', 'title', 'excerpt', 'content', 'cover_path', 'status', 'is_pinned']),
);
return $post->fresh(['group', 'author.profile']);
}
public function publish(GroupPost $post, User $actor): GroupPost
{
if ($post->group->status !== Group::LIFECYCLE_ACTIVE) {
throw ValidationException::withMessages([
'group' => 'Archived or suspended groups cannot publish posts.',
]);
}
if (trim((string) $post->title) === '') {
throw ValidationException::withMessages([
'title' => 'A published post needs a title.',
]);
}
$before = $post->only(['status', 'published_at']);
$post->forceFill([
'status' => GroupPost::STATUS_PUBLISHED,
'published_at' => now(),
])->save();
$this->history->record(
$post->group,
$actor,
'post_published',
sprintf('Published post "%s".', $post->title),
'group_post',
(int) $post->id,
$before,
['status' => GroupPost::STATUS_PUBLISHED, 'published_at' => $post->published_at?->toISOString()],
);
app(GroupActivityService::class)->record(
$post->group,
$actor,
'post_published',
'group_post',
(int) $post->id,
sprintf('%s published a new group post: %s', $post->group->name, $post->title),
$post->excerpt,
'public',
);
foreach ($post->group->follows()->with('user')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupPostPublished($follow->user, $actor, $post->group, $post);
}
}
return $post->fresh(['group', 'author.profile']);
}
public function pin(GroupPost $post, User $actor, bool $isPinned = true): GroupPost
{
DB::transaction(function () use ($post, $actor, $isPinned): void {
if ($isPinned) {
GroupPost::query()
->where('group_id', $post->group_id)
->where('id', '!=', $post->id)
->where('is_pinned', true)
->update([
'is_pinned' => false,
'updated_at' => now(),
]);
}
$before = ['is_pinned' => (bool) $post->is_pinned];
$post->forceFill([
'is_pinned' => $isPinned,
])->save();
$this->history->record(
$post->group,
$actor,
$isPinned ? 'post_pinned' : 'post_unpinned',
sprintf('%s post "%s".', $isPinned ? 'Pinned' : 'Unpinned', $post->title),
'group_post',
(int) $post->id,
$before,
['is_pinned' => $isPinned],
);
});
return $post->fresh(['group', 'author.profile']);
}
public function archive(GroupPost $post, User $actor): GroupPost
{
$before = $post->only(['status', 'is_pinned']);
$post->forceFill([
'status' => GroupPost::STATUS_ARCHIVED,
'is_pinned' => false,
])->save();
$this->history->record(
$post->group,
$actor,
'post_archived',
sprintf('Archived post "%s".', $post->title),
'group_post',
(int) $post->id,
$before,
['status' => GroupPost::STATUS_ARCHIVED, 'is_pinned' => false],
);
return $post->fresh(['group', 'author.profile']);
}
public function publicPosts(Group $group, int $limit = 12): array
{
return GroupPost::query()
->with('author.profile')
->where('group_id', $group->id)
->where('status', GroupPost::STATUS_PUBLISHED)
->orderByDesc('is_pinned')
->orderByDesc('published_at')
->limit($limit)
->get()
->map(fn (GroupPost $post): array => $this->mapPublicPost($group, $post))
->values()
->all();
}
public function pinnedPost(Group $group): ?array
{
$post = GroupPost::query()
->with('author.profile')
->where('group_id', $group->id)
->where('status', GroupPost::STATUS_PUBLISHED)
->where('is_pinned', true)
->latest('published_at')
->first();
return $post ? $this->mapPublicPost($group, $post) : null;
}
public function recentPosts(Group $group, int $limit = 3): array
{
return GroupPost::query()
->with('author.profile')
->where('group_id', $group->id)
->where('status', GroupPost::STATUS_PUBLISHED)
->latest('published_at')
->limit($limit)
->get()
->map(fn (GroupPost $post): array => $this->mapPublicPost($group, $post))
->values()
->all();
}
public function studioListing(Group $group, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'all');
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50);
$query = GroupPost::query()
->with('author.profile')
->where('group_id', $group->id);
if ($bucket !== 'all') {
$query->where('status', $bucket);
}
$paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);
return [
'items' => collect($paginator->items())->map(fn (GroupPost $post): array => $this->mapStudioPost($group, $post))->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'filters' => [
'bucket' => $bucket,
],
'bucket_options' => [
['value' => 'all', 'label' => 'All'],
['value' => GroupPost::STATUS_DRAFT, 'label' => 'Drafts'],
['value' => GroupPost::STATUS_PUBLISHED, 'label' => 'Published'],
['value' => GroupPost::STATUS_ARCHIVED, 'label' => 'Archived'],
],
];
}
public function mapPublicPost(Group $group, GroupPost $post): array
{
return [
'id' => (int) $post->id,
'type' => (string) $post->type,
'title' => (string) $post->title,
'slug' => (string) $post->slug,
'excerpt' => $post->excerpt,
'content' => $post->content,
'cover_url' => $post->cover_path,
'is_pinned' => (bool) $post->is_pinned,
'published_at' => $post->published_at?->toISOString(),
'author' => $post->author ? [
'id' => (int) $post->author->id,
'name' => $post->author->name,
'username' => $post->author->username,
] : null,
'url' => route('groups.posts.show', ['group' => $group, 'post' => $post]),
];
}
public function mapStudioPost(Group $group, GroupPost $post): array
{
return [
'id' => (int) $post->id,
'type' => (string) $post->type,
'title' => (string) $post->title,
'excerpt' => $post->excerpt,
'content' => $post->content,
'cover_url' => $post->cover_path,
'status' => (string) $post->status,
'is_pinned' => (bool) $post->is_pinned,
'published_at' => $post->published_at?->toISOString(),
'updated_at' => $post->updated_at?->toISOString(),
'author' => $post->author ? [
'id' => (int) $post->author->id,
'name' => $post->author->name,
'username' => $post->author->username,
] : null,
'urls' => [
'public' => $post->status === GroupPost::STATUS_PUBLISHED ? route('groups.posts.show', ['group' => $group, 'post' => $post]) : null,
'edit' => route('studio.groups.posts.edit', ['group' => $group, 'post' => $post]),
'publish' => route('studio.groups.posts.publish', ['group' => $group, 'post' => $post]),
'pin' => route('studio.groups.posts.pin', ['group' => $group, 'post' => $post]),
'archive' => route('studio.groups.posts.archive', ['group' => $group, 'post' => $post]),
],
];
}
private function sanitizeContent(?string $content): ?string
{
$trimmed = trim(strip_tags((string) ($content ?? '')));
return $trimmed !== '' ? $trimmed : null;
}
private function normalizeExcerpt(?string $excerpt, ?string $content): ?string
{
$trimmed = trim((string) ($excerpt ?? ''));
if ($trimmed !== '') {
return Str::limit($trimmed, 320, '');
}
$body = $this->sanitizeContent($content);
return $body ? Str::limit($body, 280) : null;
}
private function makeUniqueSlug(string $source, ?int $ignorePostId = null): string
{
$base = Str::slug(Str::limit($source, 180, ''));
$base = $base !== '' ? $base : 'group-post';
$slug = $base;
$suffix = 2;
while (GroupPost::query()
->where('slug', $slug)
->when($ignorePostId !== null, fn ($query) => $query->where('id', '!=', $ignorePostId))
->exists()) {
$slug = Str::limit($base, 172, '') . '-' . $suffix;
$suffix++;
}
return $slug;
}
}

View File

@@ -0,0 +1,696 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupAsset;
use App\Models\GroupPost;
use App\Models\GroupProject;
use App\Models\GroupProjectArtwork;
use App\Models\GroupProjectMilestone;
use App\Models\GroupProjectMember;
use App\Models\User;
use App\Support\ThumbnailPresenter;
use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupProjectService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupActivityService $activity,
private readonly GroupMediaService $media,
private readonly NotificationService $notifications,
) {
}
public function create(Group $group, User $actor, array $attributes): GroupProject
{
$coverPath = null;
try {
$project = DB::transaction(function () use ($group, $actor, $attributes, &$coverPath): GroupProject {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->media->storeUploadedEntityImage($group, $attributes['cover_file'], 'projects');
}
$project = GroupProject::query()->create([
'group_id' => (int) $group->id,
'title' => trim((string) $attributes['title']),
'slug' => $this->makeUniqueSlug((string) $attributes['title']),
'summary' => $this->nullableString($attributes['summary'] ?? null),
'description' => $this->nullableString($attributes['description'] ?? null),
'cover_path' => $coverPath ?: $this->nullableString($attributes['cover_path'] ?? null),
'status' => (string) ($attributes['status'] ?? GroupProject::STATUS_PLANNED),
'visibility' => (string) ($attributes['visibility'] ?? GroupProject::VISIBILITY_PUBLIC),
'start_date' => $attributes['start_date'] ?? null,
'target_date' => $attributes['target_date'] ?? null,
'released_at' => null,
'created_by_user_id' => (int) $actor->id,
'lead_user_id' => $this->normalizeLeadUserId($group, $attributes['lead_user_id'] ?? null),
'linked_collection_id' => $this->normalizeCollectionId($group, $attributes['linked_collection_id'] ?? null),
'linked_featured_artwork_id' => $this->normalizeArtworkId($group, $attributes['linked_featured_artwork_id'] ?? null),
'pinned_post_id' => $this->normalizePostId($group, $attributes['pinned_post_id'] ?? null),
]);
$this->syncMembers($project, $group, $attributes['member_user_ids'] ?? []);
return $project->fresh(['group', 'creator.profile', 'lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost', 'memberLinks.user.profile']);
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($coverPath);
throw $exception;
}
$this->history->record(
$group,
$actor,
'project_created',
sprintf('Created project "%s".', $project->title),
'group_project',
(int) $project->id,
null,
$project->only(['title', 'status', 'visibility'])
);
$this->activity->record(
$group,
$actor,
'project_created',
'group_project',
(int) $project->id,
sprintf('%s created a new project: %s', $actor->name ?: $actor->username ?: 'A member', $project->title),
$project->summary,
$project->visibility === GroupProject::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
return $project;
}
public function update(GroupProject $project, User $actor, array $attributes): GroupProject
{
$coverPath = null;
$oldCoverPath = $project->cover_path;
$before = $project->only(['title', 'summary', 'description', 'status', 'visibility', 'lead_user_id', 'linked_collection_id', 'linked_featured_artwork_id', 'pinned_post_id']);
try {
DB::transaction(function () use ($project, $actor, $attributes, &$coverPath): void {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->media->storeUploadedEntityImage($project->group, $attributes['cover_file'], 'projects');
}
$title = trim((string) ($attributes['title'] ?? $project->title));
$project->fill([
'title' => $title,
'slug' => $title !== $project->title ? $this->makeUniqueSlug($title, (int) $project->id) : $project->slug,
'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $project->summary,
'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $project->description,
'cover_path' => $coverPath ?: (array_key_exists('cover_path', $attributes) ? $this->nullableString($attributes['cover_path']) : $project->cover_path),
'visibility' => (string) ($attributes['visibility'] ?? $project->visibility),
'start_date' => $attributes['start_date'] ?? $project->start_date,
'target_date' => $attributes['target_date'] ?? $project->target_date,
'lead_user_id' => array_key_exists('lead_user_id', $attributes) ? $this->normalizeLeadUserId($project->group, $attributes['lead_user_id']) : $project->lead_user_id,
'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($project->group, $attributes['linked_collection_id']) : $project->linked_collection_id,
'linked_featured_artwork_id' => array_key_exists('linked_featured_artwork_id', $attributes) ? $this->normalizeArtworkId($project->group, $attributes['linked_featured_artwork_id']) : $project->linked_featured_artwork_id,
'pinned_post_id' => array_key_exists('pinned_post_id', $attributes) ? $this->normalizePostId($project->group, $attributes['pinned_post_id']) : $project->pinned_post_id,
])->save();
if (array_key_exists('member_user_ids', $attributes)) {
$this->syncMembers($project, $project->group, $attributes['member_user_ids']);
}
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($coverPath);
throw $exception;
}
if ($coverPath !== null && $oldCoverPath !== $project->cover_path) {
$this->media->deleteIfManaged($oldCoverPath);
}
$project->refresh();
$this->history->record(
$project->group,
$actor,
'project_updated',
sprintf('Updated project "%s".', $project->title),
'group_project',
(int) $project->id,
$before,
$project->only(['title', 'summary', 'description', 'status', 'visibility', 'lead_user_id', 'linked_collection_id', 'linked_featured_artwork_id', 'pinned_post_id'])
);
$this->activity->record(
$project->group,
$actor,
'project_updated',
'group_project',
(int) $project->id,
sprintf('%s updated project %s', $actor->name ?: $actor->username ?: 'A member', $project->title),
$project->summary,
$project->visibility === GroupProject::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
return $project->fresh(['group', 'creator.profile', 'lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost', 'memberLinks.user.profile', 'artworks.primaryAuthor.profile', 'assets.uploader.profile']);
}
public function updateStatus(GroupProject $project, User $actor, string $status): GroupProject
{
$before = $project->only(['status', 'released_at']);
$previousStatus = (string) $project->status;
$project->forceFill([
'status' => $status,
'released_at' => $status === GroupProject::STATUS_RELEASED ? now() : $project->released_at,
])->save();
$this->history->record(
$project->group,
$actor,
'project_status_updated',
sprintf('Marked project "%s" as %s.', $project->title, $status),
'group_project',
(int) $project->id,
$before,
['status' => $project->status, 'released_at' => $project->released_at?->toISOString()]
);
$activityType = $status === GroupProject::STATUS_RELEASED ? 'project_released' : 'project_updated';
$this->activity->record(
$project->group,
$actor,
$activityType,
'group_project',
(int) $project->id,
$status === GroupProject::STATUS_RELEASED
? sprintf('%s released project %s', $project->group->name, $project->title)
: sprintf('%s updated project status for %s', $actor->name ?: $actor->username ?: 'A member', $project->title),
$project->summary,
$project->visibility === GroupProject::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
if ($status === GroupProject::STATUS_RELEASED && $project->visibility === GroupProject::VISIBILITY_PUBLIC) {
foreach ($project->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupProjectReleased($follow->user, $actor, $project->group, $project);
}
}
} elseif ($previousStatus !== $status && $project->visibility === GroupProject::VISIBILITY_PUBLIC) {
foreach ($project->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupProjectStatusChanged($follow->user, $actor, $project->group, $project);
}
}
}
return $project->fresh(['group', 'creator.profile', 'lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost', 'memberLinks.user.profile', 'artworks.primaryAuthor.profile', 'assets.uploader.profile']);
}
public function attachArtwork(GroupProject $project, Artwork $artwork, User $actor): GroupProject
{
if ((int) $artwork->group_id !== (int) $project->group_id) {
throw ValidationException::withMessages([
'artwork' => 'Only artworks published under this group can be attached to a group project.',
]);
}
GroupProjectArtwork::query()->updateOrCreate(
[
'group_project_id' => (int) $project->id,
'artwork_id' => (int) $artwork->id,
],
[
'sort_order' => (int) $project->artworkLinks()->count(),
]
);
$this->history->record(
$project->group,
$actor,
'project_artwork_attached',
sprintf('Attached artwork "%s" to project "%s".', $artwork->title, $project->title),
'group_project',
(int) $project->id,
null,
['artwork_id' => (int) $artwork->id]
);
return $project->fresh(['group', 'artworks.primaryAuthor.profile', 'creator.profile', 'lead.profile', 'memberLinks.user.profile', 'assets.uploader.profile']);
}
public function attachAsset(GroupProject $project, GroupAsset $asset, User $actor): GroupAsset
{
if ((int) $asset->group_id !== (int) $project->group_id) {
throw ValidationException::withMessages([
'asset' => 'Only assets belonging to this group can be attached to the project.',
]);
}
$asset->forceFill([
'linked_project_id' => (int) $project->id,
])->save();
$this->history->record(
$project->group,
$actor,
'project_asset_attached',
sprintf('Attached asset "%s" to project "%s".', $asset->title, $project->title),
'group_project',
(int) $project->id,
null,
['asset_id' => (int) $asset->id]
);
return $asset->fresh(['uploader.profile', 'approver.profile']);
}
public function createMilestone(GroupProject $project, User $actor, array $attributes): GroupProjectMilestone
{
$milestone = $project->milestones()->create([
'title' => trim((string) $attributes['title']),
'summary' => $this->nullableString($attributes['summary'] ?? null),
'status' => (string) ($attributes['status'] ?? GroupProjectMilestone::STATUS_PENDING),
'due_date' => $attributes['due_date'] ?? null,
'owner_user_id' => $this->normalizeLeadUserId($project->group, $attributes['owner_user_id'] ?? null),
'sort_order' => (int) $project->milestones()->count(),
'notes' => $this->nullableString($attributes['notes'] ?? null),
]);
$this->history->record(
$project->group,
$actor,
'project_milestone_created',
sprintf('Created milestone "%s" for project "%s".', $milestone->title, $project->title),
'group_project',
(int) $project->id,
null,
['milestone_id' => (int) $milestone->id, 'status' => $milestone->status]
);
if ($milestone->owner) {
$this->notifications->notifyGroupMilestoneAssigned(
$milestone->owner,
$actor,
$project->group,
'project',
$project->title,
$milestone->title,
route('studio.groups.projects.edit', ['group' => $project->group, 'project' => $project])
);
$this->notifyMilestoneDueSoonIfNeeded(
$milestone->owner,
$actor,
$project->group,
'project',
$project->title,
$milestone->title,
$milestone->due_date?->toDateString(),
route('studio.groups.projects.edit', ['group' => $project->group, 'project' => $project])
);
}
return $milestone->fresh('owner.profile');
}
public function updateMilestone(GroupProjectMilestone $milestone, User $actor, array $attributes): GroupProjectMilestone
{
$before = $milestone->only(['title', 'summary', 'status', 'due_date', 'owner_user_id', 'notes']);
$previousOwnerId = (int) ($milestone->owner_user_id ?? 0);
$milestone->fill([
'title' => trim((string) ($attributes['title'] ?? $milestone->title)),
'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $milestone->summary,
'status' => (string) ($attributes['status'] ?? $milestone->status),
'due_date' => $attributes['due_date'] ?? $milestone->due_date,
'owner_user_id' => array_key_exists('owner_user_id', $attributes) ? $this->normalizeLeadUserId($milestone->project->group, $attributes['owner_user_id']) : $milestone->owner_user_id,
'notes' => array_key_exists('notes', $attributes) ? $this->nullableString($attributes['notes']) : $milestone->notes,
])->save();
$this->history->record(
$milestone->project->group,
$actor,
'project_milestone_updated',
sprintf('Updated milestone "%s" for project "%s".', $milestone->title, $milestone->project->title),
'group_project',
(int) $milestone->project_id,
$before,
$milestone->only(['title', 'summary', 'status', 'due_date', 'owner_user_id', 'notes'])
);
if ((int) ($milestone->owner_user_id ?? 0) > 0 && (int) $milestone->owner_user_id !== $previousOwnerId && $milestone->owner) {
$this->notifications->notifyGroupMilestoneAssigned(
$milestone->owner,
$actor,
$milestone->project->group,
'project',
$milestone->project->title,
$milestone->title,
route('studio.groups.projects.edit', ['group' => $milestone->project->group, 'project' => $milestone->project])
);
}
if ($milestone->owner && $this->shouldNotifyDueSoon($before['due_date'] ?? null, $milestone->due_date, $before['status'] ?? null, $milestone->status, $previousOwnerId, (int) ($milestone->owner_user_id ?? 0))) {
$this->notifyMilestoneDueSoonIfNeeded(
$milestone->owner,
$actor,
$milestone->project->group,
'project',
$milestone->project->title,
$milestone->title,
$milestone->due_date?->toDateString(),
route('studio.groups.projects.edit', ['group' => $milestone->project->group, 'project' => $milestone->project])
);
}
return $milestone->fresh(['owner.profile', 'project']);
}
public function featuredProject(Group $group, ?User $viewer = null): ?array
{
$project = $this->visibleQuery($group, $viewer)
->with(['lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost'])
->whereIn('status', [GroupProject::STATUS_ACTIVE, GroupProject::STATUS_RELEASED, GroupProject::STATUS_REVIEW])
->orderByRaw("CASE status WHEN 'released' THEN 0 WHEN 'active' THEN 1 ELSE 2 END")
->latest('updated_at')
->first();
return $project ? $this->mapPublicProject($project, $viewer) : null;
}
public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->visibleQuery($group, $viewer)
->with(['lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost'])
->latest('updated_at')
->limit($limit)
->get()
->map(fn (GroupProject $project): array => $this->mapPublicProject($project, $viewer))
->values()
->all();
}
public function studioListing(Group $group, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'all');
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50);
$query = GroupProject::query()
->with(['lead.profile', 'linkedCollection', 'featuredArtwork', 'pinnedPost'])
->where('group_id', $group->id);
if ($bucket !== 'all') {
$query->where('status', $bucket);
}
$paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);
return [
'items' => collect($paginator->items())->map(fn (GroupProject $project): array => $this->mapStudioProject($project))->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'filters' => ['bucket' => $bucket],
'bucket_options' => [
['value' => 'all', 'label' => 'All'],
['value' => GroupProject::STATUS_PLANNED, 'label' => 'Planned'],
['value' => GroupProject::STATUS_ACTIVE, 'label' => 'Active'],
['value' => GroupProject::STATUS_REVIEW, 'label' => 'Review'],
['value' => GroupProject::STATUS_RELEASED, 'label' => 'Released'],
['value' => GroupProject::STATUS_ARCHIVED, 'label' => 'Archived'],
],
];
}
public function detailPayload(GroupProject $project, ?User $viewer = null): array
{
$project->loadMissing([
'group',
'creator.profile',
'lead.profile',
'linkedCollection',
'featuredArtwork.primaryAuthor.profile',
'pinnedPost.author.profile',
'artworks.primaryAuthor.profile',
'releases',
'assets.uploader.profile',
'milestones.owner.profile',
'memberLinks.user.profile',
]);
$payload = $this->mapPublicProject($project, $viewer);
$payload['description'] = $project->description;
$payload['artworks'] = $project->artworks->take(12)->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]),
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username,
])->values()->all();
$payload['assets'] = $project->assets
->filter(fn (GroupAsset $asset): bool => $asset->canBeViewedBy($viewer))
->take(12)
->map(fn (GroupAsset $asset): array => [
'id' => (int) $asset->id,
'title' => (string) $asset->title,
'category' => (string) $asset->category,
'visibility' => (string) $asset->visibility,
'download_url' => route('groups.assets.download', ['group' => $project->group, 'asset' => $asset]),
])->values()->all();
$payload['milestones'] = $project->milestones->map(fn (GroupProjectMilestone $milestone): array => [
'id' => (int) $milestone->id,
'title' => (string) $milestone->title,
'summary' => $milestone->summary,
'status' => (string) $milestone->status,
'due_date' => $milestone->due_date?->toDateString(),
'notes' => $milestone->notes,
'owner' => $milestone->owner ? [
'id' => (int) $milestone->owner->id,
'name' => $milestone->owner->name,
'username' => $milestone->owner->username,
] : null,
])->values()->all();
$payload['release_count'] = (int) $project->releases()->count();
$payload['team'] = $project->memberLinks->map(fn (GroupProjectMember $member): array => [
'id' => (int) $member->user_id,
'name' => $member->user?->name,
'username' => $member->user?->username,
'avatar_url' => $member->user?->profile?->avatar_url ?? null,
'role_label' => $member->role_label,
'is_lead' => (bool) $member->is_lead,
])->values()->all();
return $payload;
}
public function mapPublicProject(GroupProject $project, ?User $viewer = null): array
{
return [
'id' => (int) $project->id,
'title' => (string) $project->title,
'slug' => (string) $project->slug,
'summary' => $project->summary,
'status' => (string) $project->status,
'visibility' => (string) $project->visibility,
'cover_url' => $project->coverUrl(),
'start_date' => $project->start_date?->toDateString(),
'target_date' => $project->target_date?->toDateString(),
'released_at' => $project->released_at?->toISOString(),
'lead' => $project->lead ? [
'id' => (int) $project->lead->id,
'name' => $project->lead->name,
'username' => $project->lead->username,
] : null,
'linked_collection' => $project->linkedCollection ? [
'id' => (int) $project->linkedCollection->id,
'title' => $project->linkedCollection->title,
'url' => route('profile.collections.show', ['username' => strtolower((string) $project->linkedCollection->user?->username), 'slug' => $project->linkedCollection->slug]),
] : null,
'pinned_post' => $project->pinnedPost ? [
'id' => (int) $project->pinnedPost->id,
'title' => $project->pinnedPost->title,
'url' => route('groups.posts.show', ['group' => $project->group, 'post' => $project->pinnedPost]),
] : null,
'counts' => [
'artworks' => (int) $project->artworks()->count(),
'assets' => (int) $project->assets()->count(),
'team' => (int) $project->memberLinks()->count(),
'milestones' => (int) $project->milestones()->count(),
'releases' => (int) $project->releases()->count(),
],
'url' => route('groups.projects.show', ['group' => $project->group, 'project' => $project]),
];
}
public function mapStudioProject(GroupProject $project): array
{
return array_merge($this->mapPublicProject($project), [
'description' => $project->description,
'urls' => [
'public' => $project->visibility !== GroupProject::VISIBILITY_PRIVATE ? route('groups.projects.show', ['group' => $project->group, 'project' => $project]) : null,
'edit' => route('studio.groups.projects.edit', ['group' => $project->group, 'project' => $project]),
'status' => route('studio.groups.projects.status', ['group' => $project->group, 'project' => $project]),
'attach_artwork' => route('studio.groups.projects.attach-artwork', ['group' => $project->group, 'project' => $project]),
'attach_asset' => route('studio.groups.projects.attach-asset', ['group' => $project->group, 'project' => $project]),
],
]);
}
public function memberOptions(Group $group): array
{
return $group->members()
->with('user.profile')
->where('status', Group::STATUS_ACTIVE)
->get()
->map(fn ($member): array => [
'id' => (int) $member->user_id,
'name' => $member->user?->name,
'username' => $member->user?->username,
])
->prepend([
'id' => (int) $group->owner_user_id,
'name' => $group->owner?->name,
'username' => $group->owner?->username,
])
->unique('id')
->values()
->all();
}
private function visibleQuery(Group $group, ?User $viewer = null)
{
return GroupProject::query()
->where('group_id', $group->id)
->when(! ($viewer && $group->canViewStudio($viewer)), function ($query): void {
$query->where('visibility', GroupProject::VISIBILITY_PUBLIC)
->where('status', '!=', GroupProject::STATUS_ARCHIVED);
});
}
private function syncMembers(GroupProject $project, Group $group, array $memberUserIds): void
{
$allowedIds = $group->members()
->where('status', Group::STATUS_ACTIVE)
->pluck('user_id')
->push((int) $group->owner_user_id)
->map(fn ($id): int => (int) $id)
->unique()
->values();
$targetIds = collect($memberUserIds)
->map(fn ($id): int => (int) $id)
->filter(fn (int $id): bool => $id > 0 && $allowedIds->contains($id))
->unique()
->values();
GroupProjectMember::query()->where('group_project_id', $project->id)->whereNotIn('user_id', $targetIds->all())->delete();
foreach ($targetIds as $userId) {
GroupProjectMember::query()->updateOrCreate(
[
'group_project_id' => (int) $project->id,
'user_id' => $userId,
],
[
'role_label' => null,
'is_lead' => (int) ($project->lead_user_id ?? 0) === $userId,
]
);
}
}
private function makeUniqueSlug(string $source, ?int $ignoreProjectId = null): string
{
$base = Str::slug(Str::limit($source, 150, '')) ?: 'project';
$slug = $base;
$suffix = 2;
while (GroupProject::query()->where('slug', $slug)->when($ignoreProjectId !== null, fn ($query) => $query->where('id', '!=', $ignoreProjectId))->exists()) {
$slug = Str::limit($base, 180, '') . '-' . $suffix;
$suffix++;
}
return $slug;
}
private function normalizeLeadUserId(Group $group, mixed $leadUserId): ?int
{
$id = (int) $leadUserId;
if ($id <= 0) {
return null;
}
if ((int) $group->owner_user_id === $id) {
return $id;
}
$exists = $group->members()->where('user_id', $id)->where('status', Group::STATUS_ACTIVE)->exists();
return $exists ? $id : null;
}
private function normalizeCollectionId(Group $group, mixed $collectionId): ?int
{
$id = (int) $collectionId;
return $id > 0 && $group->collections()->where('id', $id)->exists() ? $id : null;
}
private function normalizeArtworkId(Group $group, mixed $artworkId): ?int
{
$id = (int) $artworkId;
return $id > 0 && $group->artworks()->where('id', $id)->whereNull('deleted_at')->exists() ? $id : null;
}
private function normalizePostId(Group $group, mixed $postId): ?int
{
$id = (int) $postId;
return $id > 0 && $group->posts()->where('id', $id)->exists() ? $id : null;
}
private function nullableString(mixed $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : null;
}
private function notifyMilestoneDueSoonIfNeeded(User $recipient, User $actor, Group $group, string $contextType, string $contextTitle, string $milestoneTitle, ?string $dueDate, string $url): void
{
if ($dueDate === null) {
return;
}
$date = now()->parse($dueDate);
if (! $date->betweenIncluded(now()->startOfDay(), now()->copy()->addDays(3)->endOfDay())) {
return;
}
$this->notifications->notifyGroupMilestoneDueSoon($recipient, $actor, $group, $contextType, $contextTitle, $milestoneTitle, $date->toDateString(), $url);
}
private function shouldNotifyDueSoon(mixed $beforeDueDate, mixed $afterDueDate, mixed $beforeStatus, string $afterStatus, int $previousOwnerId, int $currentOwnerId): bool
{
if ($currentOwnerId <= 0 || ! in_array($afterStatus, [GroupProjectMilestone::STATUS_PENDING, GroupProjectMilestone::STATUS_ACTIVE], true) || $afterDueDate === null) {
return false;
}
$beforeNormalized = $beforeDueDate ? now()->parse((string) $beforeDueDate)->toDateString() : null;
$afterNormalized = now()->parse((string) $afterDueDate)->toDateString();
return $previousOwnerId !== $currentOwnerId || $beforeNormalized !== $afterNormalized || (string) $beforeStatus !== $afterStatus;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupRecruitmentProfile;
use App\Models\User;
class GroupRecruitmentService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly NotificationService $notifications,
) {
}
public function upsert(Group $group, array $attributes, User $actor): GroupRecruitmentProfile
{
$profile = GroupRecruitmentProfile::query()->firstOrNew([
'group_id' => $group->id,
]);
$before = $profile->exists ? $profile->only([
'is_recruiting',
'headline',
'description',
'roles_json',
'skills_json',
'contact_mode',
'visibility',
]) : null;
$profile->fill([
'is_recruiting' => (bool) ($attributes['is_recruiting'] ?? false),
'headline' => $attributes['headline'] ?? null,
'description' => $attributes['description'] ?? null,
'roles_json' => $this->normalizeList($attributes['roles_json'] ?? []),
'skills_json' => $this->normalizeList($attributes['skills_json'] ?? []),
'contact_mode' => $attributes['contact_mode'] ?? null,
'visibility' => (string) ($attributes['visibility'] ?? 'public'),
])->save();
if ($profile->is_recruiting && $profile->visibility === 'public') {
foreach ($group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupRecruitmentUpdated($follow->user, $actor, $group, $profile);
}
}
}
$this->history->record(
$group,
$actor,
'recruitment_updated',
$profile->is_recruiting ? 'Enabled or updated recruitment profile.' : 'Disabled recruitment profile.',
'group_recruitment_profile',
(int) $profile->id,
$before,
$profile->only([
'is_recruiting',
'headline',
'description',
'roles_json',
'skills_json',
'contact_mode',
'visibility',
]),
);
return $profile->fresh();
}
public function payloadForGroup(Group $group): ?array
{
$profile = $group->relationLoaded('recruitmentProfile')
? $group->recruitmentProfile
: $group->recruitmentProfile()->first();
if (! $profile) {
return null;
}
return [
'id' => (int) $profile->id,
'is_recruiting' => (bool) $profile->is_recruiting,
'headline' => $profile->headline,
'description' => $profile->description,
'roles' => array_values(array_filter($profile->roles_json ?? [])),
'skills' => array_values(array_filter($profile->skills_json ?? [])),
'contact_mode' => $profile->contact_mode,
'visibility' => $profile->visibility,
'updated_at' => $profile->updated_at?->toISOString(),
'roles_options' => config('groups.recruitment.roles', []),
'skills_options' => config('groups.recruitment.skills', []),
];
}
private function normalizeList(array $items): array
{
return collect($items)
->map(fn ($item): string => trim((string) $item))
->filter()
->unique()
->values()
->all();
}
}

View File

@@ -0,0 +1,817 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupRelease;
use App\Models\GroupReleaseArtwork;
use App\Models\GroupReleaseContributor;
use App\Models\GroupReleaseMilestone;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GroupReleaseService
{
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupActivityService $activity,
private readonly GroupMediaService $media,
private readonly NotificationService $notifications,
private readonly GroupReputationService $reputation,
private readonly GroupDiscoveryService $discovery,
) {
}
public function create(Group $group, User $actor, array $attributes): GroupRelease
{
$coverPath = null;
try {
$release = DB::transaction(function () use ($group, $actor, $attributes, &$coverPath): GroupRelease {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->media->storeUploadedEntityImage($group, $attributes['cover_file'], 'releases');
}
return GroupRelease::query()->create([
'group_id' => (int) $group->id,
'title' => trim((string) $attributes['title']),
'slug' => $this->makeUniqueSlug((string) $attributes['title']),
'summary' => $this->nullableString($attributes['summary'] ?? null),
'description' => $this->nullableString($attributes['description'] ?? null),
'cover_path' => $coverPath ?: $this->nullableString($attributes['cover_path'] ?? null),
'status' => (string) ($attributes['status'] ?? GroupRelease::STATUS_PLANNED),
'current_stage' => (string) ($attributes['current_stage'] ?? GroupRelease::STAGE_CONCEPT),
'visibility' => (string) ($attributes['visibility'] ?? GroupRelease::VISIBILITY_PUBLIC),
'planned_release_at' => $attributes['planned_release_at'] ?? null,
'released_at' => null,
'lead_user_id' => $this->normalizeLeadUserId($group, $attributes['lead_user_id'] ?? null),
'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null),
'linked_collection_id' => $this->normalizeCollectionId($group, $attributes['linked_collection_id'] ?? null),
'featured_artwork_id' => $this->normalizeArtworkId($group, $attributes['featured_artwork_id'] ?? null),
'release_notes' => $this->nullableString($attributes['release_notes'] ?? null),
'created_by_user_id' => (int) $actor->id,
'published_at' => null,
'is_featured' => (bool) ($attributes['is_featured'] ?? false),
]);
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($coverPath);
throw $exception;
}
$this->history->record(
$group,
$actor,
'release_created',
sprintf('Created release "%s".', $release->title),
'group_release',
(int) $release->id,
null,
$release->only(['title', 'status', 'current_stage', 'visibility'])
);
$this->activity->record(
$group,
$actor,
'release_created',
'group_release',
(int) $release->id,
sprintf('%s opened a new release pipeline: %s', $actor->name ?: $actor->username ?: 'A member', $release->title),
$release->summary,
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
$this->notifyReleaseScheduledIfNeeded($release, $actor, null, null);
$this->reputation->refreshGroup($group);
$this->discovery->refresh($group);
return $release->fresh($this->detailRelations());
}
public function update(GroupRelease $release, User $actor, array $attributes): GroupRelease
{
$coverPath = null;
$oldCoverPath = $release->cover_path;
$before = $release->only(['title', 'summary', 'description', 'status', 'current_stage', 'visibility', 'planned_release_at', 'lead_user_id', 'linked_project_id', 'linked_collection_id', 'featured_artwork_id', 'release_notes', 'is_featured']);
try {
DB::transaction(function () use ($release, $attributes, &$coverPath): void {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->media->storeUploadedEntityImage($release->group, $attributes['cover_file'], 'releases');
}
$title = trim((string) ($attributes['title'] ?? $release->title));
$release->fill([
'title' => $title,
'slug' => $title !== $release->title ? $this->makeUniqueSlug($title, (int) $release->id) : $release->slug,
'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $release->summary,
'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $release->description,
'cover_path' => $coverPath ?: (array_key_exists('cover_path', $attributes) ? $this->nullableString($attributes['cover_path']) : $release->cover_path),
'status' => (string) ($attributes['status'] ?? $release->status),
'current_stage' => (string) ($attributes['current_stage'] ?? $release->current_stage),
'visibility' => (string) ($attributes['visibility'] ?? $release->visibility),
'planned_release_at' => $attributes['planned_release_at'] ?? $release->planned_release_at,
'lead_user_id' => array_key_exists('lead_user_id', $attributes) ? $this->normalizeLeadUserId($release->group, $attributes['lead_user_id']) : $release->lead_user_id,
'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($release->group, $attributes['linked_project_id']) : $release->linked_project_id,
'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($release->group, $attributes['linked_collection_id']) : $release->linked_collection_id,
'featured_artwork_id' => array_key_exists('featured_artwork_id', $attributes) ? $this->normalizeArtworkId($release->group, $attributes['featured_artwork_id']) : $release->featured_artwork_id,
'release_notes' => array_key_exists('release_notes', $attributes) ? $this->nullableString($attributes['release_notes']) : $release->release_notes,
'is_featured' => (bool) ($attributes['is_featured'] ?? $release->is_featured),
])->save();
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($coverPath);
throw $exception;
}
if ($coverPath !== null && $oldCoverPath !== $release->cover_path) {
$this->media->deleteIfManaged($oldCoverPath);
}
$this->history->record(
$release->group,
$actor,
'release_updated',
sprintf('Updated release "%s".', $release->title),
'group_release',
(int) $release->id,
$before,
$release->only(['title', 'summary', 'description', 'status', 'current_stage', 'visibility', 'planned_release_at', 'lead_user_id', 'linked_project_id', 'linked_collection_id', 'featured_artwork_id', 'release_notes', 'is_featured'])
);
$this->activity->record(
$release->group,
$actor,
'release_updated',
'group_release',
(int) $release->id,
sprintf('%s updated release %s', $actor->name ?: $actor->username ?: 'A member', $release->title),
$release->summary,
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
if (! (bool) ($before['is_featured'] ?? false) && $release->is_featured && $release->visibility === GroupRelease::VISIBILITY_PUBLIC) {
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyFeaturedReleasePromoted($follow->user, $actor, $release->group, $release);
}
}
}
$this->notifyReleaseScheduledIfNeeded(
$release,
$actor,
(string) ($before['status'] ?? null),
$before['planned_release_at'] ? (string) $before['planned_release_at'] : null,
);
$this->reputation->refreshGroup($release->group);
$this->discovery->refresh($release->group);
return $release->fresh($this->detailRelations());
}
public function updateStage(GroupRelease $release, User $actor, string $stage): GroupRelease
{
$before = $release->only(['current_stage', 'status']);
$status = $release->status;
if ($stage === GroupRelease::STAGE_RELEASED) {
$status = GroupRelease::STATUS_RELEASED;
} elseif ($stage === GroupRelease::STAGE_APPROVAL && $release->status === GroupRelease::STATUS_PLANNED) {
$status = GroupRelease::STATUS_INTERNAL_REVIEW;
} elseif ($release->status === GroupRelease::STATUS_PLANNED) {
$status = GroupRelease::STATUS_IN_PROGRESS;
}
$release->forceFill([
'current_stage' => $stage,
'status' => $status,
])->save();
$this->history->record(
$release->group,
$actor,
'release_stage_updated',
sprintf('Moved release "%s" to %s.', $release->title, $stage),
'group_release',
(int) $release->id,
$before,
['current_stage' => $release->current_stage, 'status' => $release->status]
);
$this->activity->record(
$release->group,
$actor,
'release_stage_updated',
'group_release',
(int) $release->id,
sprintf('%s moved release %s to %s', $actor->name ?: $actor->username ?: 'A member', $release->title, $stage),
$release->summary,
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
if ($release->visibility === GroupRelease::VISIBILITY_PUBLIC) {
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupReleaseStageChanged($follow->user, $actor, $release->group, $release);
}
}
}
$this->reputation->refreshGroup($release->group);
$this->discovery->refresh($release->group);
return $release->fresh($this->detailRelations());
}
public function publish(GroupRelease $release, User $actor): GroupRelease
{
$this->guardPublishable($release);
$before = $release->only(['status', 'current_stage', 'released_at', 'published_at']);
$release->forceFill([
'status' => GroupRelease::STATUS_RELEASED,
'current_stage' => GroupRelease::STAGE_RELEASED,
'released_at' => now(),
'published_at' => now(),
])->save();
$this->history->record(
$release->group,
$actor,
'release_published',
sprintf('Published release "%s".', $release->title),
'group_release',
(int) $release->id,
$before,
[
'status' => $release->status,
'current_stage' => $release->current_stage,
'released_at' => $release->released_at?->toISOString(),
'published_at' => $release->published_at?->toISOString(),
]
);
$this->activity->record(
$release->group,
$actor,
'release_published',
'group_release',
(int) $release->id,
sprintf('%s released %s', $release->group->name, $release->title),
$release->summary,
$release->visibility === GroupRelease::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
if ($release->visibility === GroupRelease::VISIBILITY_PUBLIC) {
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupReleasePublished($follow->user, $actor, $release->group, $release);
}
}
}
$this->reputation->refreshGroup($release->group);
$this->discovery->refresh($release->group);
return $release->fresh($this->detailRelations());
}
public function attachArtwork(GroupRelease $release, Artwork $artwork, User $actor): GroupRelease
{
if ((int) $artwork->group_id !== (int) $release->group_id) {
throw ValidationException::withMessages([
'artwork' => 'Only artworks published under this group can be attached to a group release.',
]);
}
GroupReleaseArtwork::query()->updateOrCreate(
[
'group_release_id' => (int) $release->id,
'artwork_id' => (int) $artwork->id,
],
[
'sort_order' => (int) $release->artworkLinks()->count(),
]
);
$this->history->record(
$release->group,
$actor,
'release_artwork_attached',
sprintf('Attached artwork "%s" to release "%s".', $artwork->title, $release->title),
'group_release',
(int) $release->id,
null,
['artwork_id' => (int) $artwork->id]
);
$this->reputation->refreshGroup($release->group);
return $release->fresh($this->detailRelations());
}
public function attachContributor(GroupRelease $release, User $contributor, User $actor, ?string $roleLabel = null): GroupRelease
{
if (! $release->group->hasActiveMember($contributor) && ! $release->group->isOwnedBy($contributor)) {
throw ValidationException::withMessages([
'user' => 'Only active group members can be attached as release contributors.',
]);
}
GroupReleaseContributor::query()->updateOrCreate(
[
'group_release_id' => (int) $release->id,
'user_id' => (int) $contributor->id,
],
[
'role_label' => $this->nullableString($roleLabel),
'sort_order' => (int) $release->contributorLinks()->count(),
]
);
$this->history->record(
$release->group,
$actor,
'release_contributor_attached',
sprintf('Attached %s as a contributor to release "%s".', $contributor->name ?: $contributor->username ?: 'a member', $release->title),
'group_release',
(int) $release->id,
null,
['user_id' => (int) $contributor->id, 'role_label' => $this->nullableString($roleLabel)]
);
$this->notifications->notifyGroupReleaseContributorAdded($contributor, $actor, $release->group, $release, $this->nullableString($roleLabel));
$this->reputation->refreshGroup($release->group);
return $release->fresh($this->detailRelations());
}
public function createMilestone(GroupRelease $release, User $actor, array $attributes): GroupReleaseMilestone
{
$milestone = $release->milestones()->create([
'title' => trim((string) $attributes['title']),
'summary' => $this->nullableString($attributes['summary'] ?? null),
'status' => (string) ($attributes['status'] ?? GroupReleaseMilestone::STATUS_PENDING),
'due_date' => $attributes['due_date'] ?? null,
'owner_user_id' => $this->normalizeLeadUserId($release->group, $attributes['owner_user_id'] ?? null),
'sort_order' => (int) $release->milestones()->count(),
'notes' => $this->nullableString($attributes['notes'] ?? null),
]);
$this->history->record(
$release->group,
$actor,
'release_milestone_created',
sprintf('Created milestone "%s" for release "%s".', $milestone->title, $release->title),
'group_release',
(int) $release->id,
null,
['milestone_id' => (int) $milestone->id, 'status' => $milestone->status]
);
if ($milestone->owner) {
$this->notifications->notifyGroupMilestoneAssigned(
$milestone->owner,
$actor,
$release->group,
'release',
$release->title,
$milestone->title,
route('studio.groups.releases.show', ['group' => $release->group, 'release' => $release])
);
$this->notifyMilestoneDueSoonIfNeeded(
$milestone->owner,
$actor,
$release->group,
'release',
$release->title,
$milestone->title,
$milestone->due_date?->toDateString(),
route('studio.groups.releases.show', ['group' => $release->group, 'release' => $release])
);
}
$this->reputation->refreshGroup($release->group);
return $milestone->fresh('owner.profile');
}
public function updateMilestone(GroupReleaseMilestone $milestone, User $actor, array $attributes): GroupReleaseMilestone
{
$before = $milestone->only(['title', 'summary', 'status', 'due_date', 'owner_user_id', 'notes']);
$previousOwnerId = (int) ($milestone->owner_user_id ?? 0);
$milestone->fill([
'title' => trim((string) ($attributes['title'] ?? $milestone->title)),
'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $milestone->summary,
'status' => (string) ($attributes['status'] ?? $milestone->status),
'due_date' => $attributes['due_date'] ?? $milestone->due_date,
'owner_user_id' => array_key_exists('owner_user_id', $attributes) ? $this->normalizeLeadUserId($milestone->release->group, $attributes['owner_user_id']) : $milestone->owner_user_id,
'notes' => array_key_exists('notes', $attributes) ? $this->nullableString($attributes['notes']) : $milestone->notes,
])->save();
$this->history->record(
$milestone->release->group,
$actor,
'release_milestone_updated',
sprintf('Updated milestone "%s" for release "%s".', $milestone->title, $milestone->release->title),
'group_release',
(int) $milestone->release_id,
$before,
$milestone->only(['title', 'summary', 'status', 'due_date', 'owner_user_id', 'notes'])
);
if ((int) ($milestone->owner_user_id ?? 0) > 0 && (int) $milestone->owner_user_id !== $previousOwnerId && $milestone->owner) {
$this->notifications->notifyGroupMilestoneAssigned(
$milestone->owner,
$actor,
$milestone->release->group,
'release',
$milestone->release->title,
$milestone->title,
route('studio.groups.releases.show', ['group' => $milestone->release->group, 'release' => $milestone->release])
);
}
if ($milestone->owner && $this->shouldNotifyDueSoon($before['due_date'] ?? null, $milestone->due_date, $before['status'] ?? null, $milestone->status, $previousOwnerId, (int) ($milestone->owner_user_id ?? 0))) {
$this->notifyMilestoneDueSoonIfNeeded(
$milestone->owner,
$actor,
$milestone->release->group,
'release',
$milestone->release->title,
$milestone->title,
$milestone->due_date?->toDateString(),
route('studio.groups.releases.show', ['group' => $milestone->release->group, 'release' => $milestone->release])
);
}
$this->reputation->refreshGroup($milestone->release->group);
return $milestone->fresh(['owner.profile', 'release']);
}
public function featuredRelease(Group $group, ?User $viewer = null): ?array
{
$release = $this->visibleQuery($group, $viewer)
->with(['lead.profile', 'linkedProject', 'linkedCollection', 'featuredArtwork', 'contributorLinks.user.profile'])
->where(function ($query): void {
$query->where('is_featured', true)
->orWhere('status', GroupRelease::STATUS_RELEASED);
})
->orderByDesc('is_featured')
->orderByDesc('released_at')
->latest('updated_at')
->first();
return $release ? $this->mapPublicRelease($release, $viewer) : null;
}
public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->visibleQuery($group, $viewer)
->with(['lead.profile', 'linkedProject', 'linkedCollection', 'featuredArtwork'])
->orderByDesc('released_at')
->latest('updated_at')
->limit($limit)
->get()
->map(fn (GroupRelease $release): array => $this->mapPublicRelease($release, $viewer))
->values()
->all();
}
public function studioListing(Group $group, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'all');
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50);
$query = GroupRelease::query()
->with(['lead.profile', 'linkedProject', 'linkedCollection', 'featuredArtwork'])
->where('group_id', $group->id);
if ($bucket !== 'all') {
$query->where('status', $bucket);
}
$paginator = $query->orderByDesc('released_at')->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);
return [
'items' => collect($paginator->items())->map(fn (GroupRelease $release): array => $this->mapStudioRelease($release))->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'filters' => ['bucket' => $bucket],
'bucket_options' => array_merge([
['value' => 'all', 'label' => 'All'],
], collect((array) config('groups.releases.statuses', []))
->map(fn (string $value): array => ['value' => $value, 'label' => str_replace('_', ' ', Str::headline($value))])
->values()
->all()),
];
}
public function detailPayload(GroupRelease $release, ?User $viewer = null): array
{
$release->loadMissing($this->detailRelations());
$payload = $this->mapPublicRelease($release, $viewer);
$payload['description'] = $release->description;
$payload['release_notes'] = $release->release_notes;
$payload['artworks'] = $release->artworks->take(18)->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]),
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username,
])->values()->all();
$payload['contributors'] = $release->contributorLinks->map(fn (GroupReleaseContributor $contributor): array => [
'id' => (int) $contributor->user_id,
'name' => $contributor->user?->name,
'username' => $contributor->user?->username,
'avatar_url' => $contributor->user ? AvatarUrl::forUser((int) $contributor->user->id, $contributor->user->profile?->avatar_hash, 72) : null,
'role_label' => $contributor->role_label,
])->values()->all();
$payload['milestones'] = $release->milestones->map(fn (GroupReleaseMilestone $milestone): array => $this->mapMilestone($milestone))->values()->all();
return $payload;
}
public function memberOptions(Group $group): array
{
return $group->members()
->with('user.profile')
->where('status', Group::STATUS_ACTIVE)
->get()
->map(fn ($member): array => [
'id' => (int) $member->user_id,
'name' => $member->user?->name,
'username' => $member->user?->username,
])
->prepend([
'id' => (int) $group->owner_user_id,
'name' => $group->owner?->name,
'username' => $group->owner?->username,
])
->unique('id')
->values()
->all();
}
public function mapPublicRelease(GroupRelease $release, ?User $viewer = null): array
{
return [
'id' => (int) $release->id,
'title' => (string) $release->title,
'slug' => (string) $release->slug,
'summary' => $release->summary,
'status' => (string) $release->status,
'current_stage' => (string) $release->current_stage,
'visibility' => (string) $release->visibility,
'cover_url' => $release->coverUrl(),
'planned_release_at' => $release->planned_release_at?->toISOString(),
'released_at' => $release->released_at?->toISOString(),
'published_at' => $release->published_at?->toISOString(),
'is_featured' => (bool) $release->is_featured,
'lead' => $release->lead ? [
'id' => (int) $release->lead->id,
'name' => $release->lead->name,
'username' => $release->lead->username,
] : null,
'linked_project' => $release->linkedProject ? [
'id' => (int) $release->linkedProject->id,
'title' => $release->linkedProject->title,
'url' => route('groups.projects.show', ['group' => $release->group, 'project' => $release->linkedProject]),
] : null,
'linked_collection' => $release->linkedCollection ? [
'id' => (int) $release->linkedCollection->id,
'title' => $release->linkedCollection->title,
'url' => route('profile.collections.show', ['username' => strtolower((string) $release->linkedCollection->user?->username), 'slug' => $release->linkedCollection->slug]),
] : null,
'featured_artwork' => $release->featuredArtwork ? [
'id' => (int) $release->featuredArtwork->id,
'title' => $release->featuredArtwork->title,
'thumb' => ThumbnailPresenter::present($release->featuredArtwork, 'md')['url'] ?? $release->featuredArtwork->thumbUrl('md'),
] : null,
'counts' => [
'artworks' => (int) $release->artworks()->count(),
'contributors' => (int) $release->contributorLinks()->count(),
'milestones' => (int) $release->milestones()->count(),
],
'url' => route('groups.releases.show', ['group' => $release->group, 'release' => $release]),
];
}
public function mapStudioRelease(GroupRelease $release): array
{
return array_merge($this->mapPublicRelease($release), [
'description' => $release->description,
'release_notes' => $release->release_notes,
'urls' => [
'public' => $release->visibility !== GroupRelease::VISIBILITY_PRIVATE ? route('groups.releases.show', ['group' => $release->group, 'release' => $release]) : null,
'edit' => route('studio.groups.releases.show', ['group' => $release->group, 'release' => $release]),
'stage' => route('studio.groups.releases.stage', ['group' => $release->group, 'release' => $release]),
'publish' => route('studio.groups.releases.publish', ['group' => $release->group, 'release' => $release]),
'attach_artwork' => route('studio.groups.releases.attach-artwork', ['group' => $release->group, 'release' => $release]),
'attach_contributor' => route('studio.groups.releases.attach-contributor', ['group' => $release->group, 'release' => $release]),
'store_milestone' => route('studio.groups.releases.milestones.store', ['group' => $release->group, 'release' => $release]),
'update_milestone_pattern' => route('studio.groups.releases.milestones.update', ['group' => $release->group, 'release' => $release, 'milestone' => '__MILESTONE__']),
],
]);
}
private function mapMilestone(GroupReleaseMilestone $milestone): array
{
return [
'id' => (int) $milestone->id,
'title' => (string) $milestone->title,
'summary' => $milestone->summary,
'status' => (string) $milestone->status,
'due_date' => $milestone->due_date?->toDateString(),
'notes' => $milestone->notes,
'owner' => $milestone->owner ? [
'id' => (int) $milestone->owner->id,
'name' => $milestone->owner->name,
'username' => $milestone->owner->username,
] : null,
];
}
private function visibleQuery(Group $group, ?User $viewer = null)
{
return GroupRelease::query()
->where('group_id', $group->id)
->when(! ($viewer && $group->canViewStudio($viewer)), function ($query): void {
$query->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
->whereNotIn('status', [GroupRelease::STATUS_ARCHIVED, GroupRelease::STATUS_CANCELLED]);
});
}
private function guardPublishable(GroupRelease $release): void
{
if ($release->group->status !== Group::LIFECYCLE_ACTIVE) {
throw ValidationException::withMessages([
'group' => 'Archived or suspended groups cannot publish new releases.',
]);
}
if ($release->visibility === GroupRelease::VISIBILITY_PRIVATE) {
throw ValidationException::withMessages([
'visibility' => 'Private releases cannot be published publicly.',
]);
}
foreach ($release->artworks as $artwork) {
if ((int) $artwork->group_id !== (int) $release->group_id || ! (bool) $artwork->is_public || ! (bool) $artwork->is_approved) {
throw ValidationException::withMessages([
'artworks' => 'All release artworks must belong to the group and be approved for public visibility.',
]);
}
}
if ($release->linkedProject && (int) $release->linkedProject->group_id !== (int) $release->group_id) {
throw ValidationException::withMessages([
'linked_project_id' => 'Linked project must belong to the same group.',
]);
}
if ($release->linkedCollection && (int) ($release->linkedCollection->group_id ?? 0) !== (int) $release->group_id) {
throw ValidationException::withMessages([
'linked_collection_id' => 'Linked collection must belong to the same group.',
]);
}
}
private function detailRelations(): array
{
return [
'group',
'creator.profile',
'lead.profile',
'linkedProject',
'linkedCollection.user.profile',
'featuredArtwork.primaryAuthor.profile',
'artworks.primaryAuthor.profile',
'contributorLinks.user.profile',
'milestones.owner.profile',
];
}
private function makeUniqueSlug(string $source, ?int $ignoreReleaseId = null): string
{
$base = Str::slug(Str::limit($source, 150, '')) ?: 'release';
$slug = $base;
$suffix = 2;
while (GroupRelease::query()->where('slug', $slug)->when($ignoreReleaseId !== null, fn ($query) => $query->where('id', '!=', $ignoreReleaseId))->exists()) {
$slug = Str::limit($base, 180, '') . '-' . $suffix;
$suffix++;
}
return $slug;
}
private function normalizeLeadUserId(Group $group, mixed $leadUserId): ?int
{
$id = (int) $leadUserId;
if ($id <= 0) {
return null;
}
if ((int) $group->owner_user_id === $id) {
return $id;
}
return $group->members()->where('user_id', $id)->where('status', Group::STATUS_ACTIVE)->exists() ? $id : null;
}
private function normalizeProjectId(Group $group, mixed $projectId): ?int
{
$id = (int) $projectId;
return $id > 0 && $group->projects()->where('id', $id)->exists() ? $id : null;
}
private function normalizeCollectionId(Group $group, mixed $collectionId): ?int
{
$id = (int) $collectionId;
return $id > 0 && $group->collections()->where('id', $id)->exists() ? $id : null;
}
private function normalizeArtworkId(Group $group, mixed $artworkId): ?int
{
$id = (int) $artworkId;
return $id > 0 && $group->artworks()->where('id', $id)->whereNull('deleted_at')->exists() ? $id : null;
}
private function nullableString(mixed $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : null;
}
private function notifyReleaseScheduledIfNeeded(GroupRelease $release, User $actor, ?string $previousStatus, ?string $previousPlannedReleaseAt): void
{
if ($release->visibility !== GroupRelease::VISIBILITY_PUBLIC || $release->status !== GroupRelease::STATUS_SCHEDULED || $release->planned_release_at === null) {
return;
}
$plannedReleaseAt = $release->planned_release_at->toISOString();
if ($previousStatus === GroupRelease::STATUS_SCHEDULED && $previousPlannedReleaseAt === $plannedReleaseAt) {
return;
}
foreach ($release->group->follows()->with('user.profile')->get() as $follow) {
if ($follow->user) {
$this->notifications->notifyGroupReleaseScheduled($follow->user, $actor, $release->group, $release);
}
}
}
private function notifyMilestoneDueSoonIfNeeded(User $recipient, User $actor, Group $group, string $contextType, string $contextTitle, string $milestoneTitle, ?string $dueDate, string $url): void
{
if ($dueDate === null) {
return;
}
$date = now()->parse($dueDate);
if (! $date->betweenIncluded(now()->startOfDay(), now()->copy()->addDays(3)->endOfDay())) {
return;
}
$this->notifications->notifyGroupMilestoneDueSoon($recipient, $actor, $group, $contextType, $contextTitle, $milestoneTitle, $date->toDateString(), $url);
}
private function shouldNotifyDueSoon(mixed $beforeDueDate, mixed $afterDueDate, mixed $beforeStatus, string $afterStatus, int $previousOwnerId, int $currentOwnerId): bool
{
if ($currentOwnerId <= 0 || ! in_array($afterStatus, [GroupReleaseMilestone::STATUS_PENDING, GroupReleaseMilestone::STATUS_ACTIVE], true) || $afterDueDate === null) {
return false;
}
$beforeNormalized = $beforeDueDate ? now()->parse((string) $beforeDueDate)->toDateString() : null;
$afterNormalized = now()->parse((string) $afterDueDate)->toDateString();
return $previousOwnerId !== $currentOwnerId || $beforeNormalized !== $afterNormalized || (string) $beforeStatus !== $afterStatus;
}
}

View File

@@ -0,0 +1,568 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupBadge;
use App\Models\GroupContributorStat;
use App\Models\GroupMember;
use App\Models\GroupMemberBadge;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\GroupReleaseContributor;
use App\Models\User;
use App\Support\AvatarUrl;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
class GroupReputationService
{
public function __construct(
private readonly NotificationService $notifications,
) {
}
public function refreshGroup(Group $group): void
{
$userIds = $this->contributorUserIds($group);
GroupContributorStat::query()
->where('group_id', $group->id)
->whereNotIn('user_id', $userIds)
->delete();
foreach ($userIds as $userId) {
GroupContributorStat::query()->updateOrCreate(
[
'group_id' => (int) $group->id,
'user_id' => $userId,
],
$this->statPayload($group, $userId)
);
}
$this->awardGroupBadges($group);
$this->awardMemberBadges($group);
}
public function topContributors(Group $group, int $limit = 6): array
{
return GroupContributorStat::query()
->with(['user.profile'])
->where('group_id', $group->id)
->orderByDesc('release_count')
->orderByDesc('credited_artworks_count')
->orderByDesc('review_actions_count')
->limit(max(1, min(24, $limit)))
->get()
->map(fn (GroupContributorStat $stat): array => $this->mapContributorStat($group, $stat))
->values()
->all();
}
public function summary(Group $group): array
{
$stats = GroupContributorStat::query()->where('group_id', $group->id);
return [
'top_contributors' => $this->topContributors($group, 8),
'counts' => [
'contributors' => (clone $stats)->count(),
'release_contributors' => (clone $stats)->where('release_count', '>', 0)->count(),
'reliable_reviewers' => (clone $stats)->where('review_actions_count', '>=', 5)->count(),
'trusted_contributors' => (clone $stats)->where('approved_submissions_count', '>=', 3)->count(),
'group_badges' => (int) $group->badges()->count(),
'member_badges' => (int) $group->memberBadges()->count(),
],
'recent_badges' => $this->groupBadges($group, 8),
'member_badge_unlocks' => $this->recentMemberBadges($group, 8),
];
}
public function trustSignals(Group $group): array
{
$releaseCount = (int) $group->releases()->where('status', GroupRelease::STATUS_RELEASED)->count();
$recentReleaseCount = (int) $group->releases()
->where('status', GroupRelease::STATUS_RELEASED)
->where('released_at', '>=', now()->subDays(45))
->count();
$activeMembers = (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count() + 1;
$approvedArtworks = (int) Artwork::query()
->where('group_id', $group->id)
->where('group_review_status', 'approved')
->count();
$signals = [];
if ($group->is_verified) {
$signals[] = [
'key' => 'verified',
'label' => 'Verified',
'tone' => 'sky',
'reason' => 'This group has a verified or official identity on Nova.',
];
}
if ($group->last_activity_at && $group->last_activity_at->greaterThanOrEqualTo(now()->subDays(14))) {
$signals[] = [
'key' => 'active',
'label' => 'Active',
'tone' => 'emerald',
'reason' => 'The group has posted or published work recently.',
];
}
if ($recentReleaseCount > 0) {
$signals[] = [
'key' => 'release_active',
'label' => 'Release Active',
'tone' => 'amber',
'reason' => 'The group has published a release in the last 45 days.',
];
}
if ($releaseCount >= 2 && $approvedArtworks >= 6) {
$signals[] = [
'key' => 'trusted',
'label' => 'Trusted',
'tone' => 'sky',
'reason' => 'Trust is earned through repeated releases and approved contributions.',
];
}
if ($activeMembers >= 4) {
$signals[] = [
'key' => 'collaborative',
'label' => 'Collaborative',
'tone' => 'violet',
'reason' => 'Several active members are contributing to this group.',
];
}
if (($group->recruitmentProfile?->is_recruiting ?? false) === true) {
$signals[] = [
'key' => 'recruiting',
'label' => 'Recruiting',
'tone' => 'emerald',
'reason' => 'The group is currently open to new collaborators.',
];
}
if ($signals === []) {
$signals[] = [
'key' => 'new_rising',
'label' => 'New & Rising',
'tone' => 'amber',
'reason' => 'This group is still early, but active enough to remain discoverable.',
];
}
return $signals;
}
public function groupBadges(Group $group, int $limit = 6): array
{
return $group->badges()
->latest('awarded_at')
->limit(max(1, min(24, $limit)))
->get()
->map(fn (GroupBadge $badge): array => [
'key' => (string) $badge->badge_key,
'label' => $this->badgeLabel('group', (string) $badge->badge_key),
'awarded_at' => $badge->awarded_at?->toISOString(),
'reason' => $badge->meta_json['reason'] ?? $this->badgeReason('group', (string) $badge->badge_key),
])
->values()
->all();
}
public function memberBadges(Group $group, User|int $user, int $limit = 4): array
{
$userId = $user instanceof User ? (int) $user->id : (int) $user;
return GroupMemberBadge::query()
->where('group_id', $group->id)
->where('user_id', $userId)
->latest('awarded_at')
->limit(max(1, min(12, $limit)))
->get()
->map(fn (GroupMemberBadge $badge): array => [
'key' => (string) $badge->badge_key,
'label' => $this->badgeLabel('member', (string) $badge->badge_key),
'awarded_at' => $badge->awarded_at?->toISOString(),
'reason' => $badge->meta_json['reason'] ?? $this->badgeReason('member', (string) $badge->badge_key),
])
->values()
->all();
}
private function recentMemberBadges(Group $group, int $limit): array
{
return GroupMemberBadge::query()
->with('user.profile')
->where('group_id', $group->id)
->latest('awarded_at')
->limit(max(1, min(24, $limit)))
->get()
->map(fn (GroupMemberBadge $badge): array => [
'user' => [
'id' => (int) $badge->user_id,
'name' => $badge->user?->name,
'username' => $badge->user?->username,
'avatar_url' => $badge->user ? AvatarUrl::forUser((int) $badge->user->id, $badge->user->profile?->avatar_hash, 72) : null,
],
'badge' => [
'key' => (string) $badge->badge_key,
'label' => $this->badgeLabel('member', (string) $badge->badge_key),
'awarded_at' => $badge->awarded_at?->toISOString(),
'reason' => $badge->meta_json['reason'] ?? $this->badgeReason('member', (string) $badge->badge_key),
],
])
->values()
->all();
}
private function contributorUserIds(Group $group): array
{
return collect([(int) $group->owner_user_id])
->merge($group->members()->where('status', Group::STATUS_ACTIVE)->pluck('user_id'))
->merge($group->releases()->pluck('lead_user_id'))
->merge($group->releases()->pluck('created_by_user_id'))
->merge(GroupReleaseContributor::query()
->whereIn('group_release_id', $group->releases()->pluck('id'))
->pluck('user_id'))
->merge($group->projects()->pluck('lead_user_id'))
->merge($group->projects()->pluck('created_by_user_id'))
->merge($group->projects()->with('memberLinks')->get()->flatMap(fn (GroupProject $project) => $project->memberLinks->pluck('user_id')))
->merge(Artwork::query()->where('group_id', $group->id)->pluck('primary_author_user_id'))
->merge(Artwork::query()->where('group_id', $group->id)->pluck('uploaded_by_user_id'))
->merge(Artwork::query()->where('group_id', $group->id)->pluck('group_reviewed_by_user_id'))
->filter(fn ($id): bool => (int) $id > 0)
->map(fn ($id): int => (int) $id)
->unique()
->values()
->all();
}
private function statPayload(Group $group, int $userId): array
{
$creditedArtworksCount = Artwork::query()
->where('group_id', $group->id)
->where(function ($query) use ($userId): void {
$query->where('primary_author_user_id', $userId)
->orWhere('uploaded_by_user_id', $userId)
->orWhereHas('contributors', fn ($contributorQuery) => $contributorQuery->where('user_id', $userId));
})
->count();
$releaseCount = GroupRelease::query()
->where('group_id', $group->id)
->where(function ($query) use ($userId): void {
$query->where('lead_user_id', $userId)
->orWhere('created_by_user_id', $userId)
->orWhereHas('contributorLinks', fn ($contributorQuery) => $contributorQuery->where('user_id', $userId));
})
->count();
$projectCount = GroupProject::query()
->where('group_id', $group->id)
->where(function ($query) use ($userId): void {
$query->where('lead_user_id', $userId)
->orWhere('created_by_user_id', $userId)
->orWhereHas('memberLinks', fn ($memberQuery) => $memberQuery->where('user_id', $userId));
})
->count();
$reviewActionsCount = Artwork::query()
->where('group_id', $group->id)
->where('group_reviewed_by_user_id', $userId)
->count();
$approvedSubmissionsCount = Artwork::query()
->where('group_id', $group->id)
->where('uploaded_by_user_id', $userId)
->where('group_review_status', 'approved')
->count();
return [
'credited_artworks_count' => $creditedArtworksCount,
'release_count' => $releaseCount,
'project_count' => $projectCount,
'review_actions_count' => $reviewActionsCount,
'approved_submissions_count' => $approvedSubmissionsCount,
'reputation_meta_json' => $this->reputationMeta($creditedArtworksCount, $releaseCount, $projectCount, $reviewActionsCount, $approvedSubmissionsCount),
];
}
private function reputationMeta(int $creditedArtworks, int $releaseCount, int $projectCount, int $reviewActions, int $approvedSubmissions): array
{
$creativeLevel = $this->levelLabel($creditedArtworks, [1 => 'Emerging', 5 => 'Established', 12 => 'Trusted']);
$collaborationLevel = $this->levelLabel($projectCount + $releaseCount, [1 => 'Active', 4 => 'Reliable', 8 => 'Core']);
$publishingLevel = $this->levelLabel($releaseCount + $approvedSubmissions, [1 => 'Contributing', 4 => 'Reliable', 8 => 'Trusted']);
$leadershipLevel = $this->levelLabel($reviewActions, [1 => 'Reviewing', 5 => 'Reliable Reviewer', 12 => 'Leadership']);
return [
'trusted_indicator' => $approvedSubmissions >= 3 || $releaseCount >= 2 || $reviewActions >= 5,
'summary' => trim(implode(' • ', array_filter([$creativeLevel, $collaborationLevel, $publishingLevel, $reviewActions > 0 ? $leadershipLevel : null]))),
'dimensions' => [
'creative_contribution' => [
'label' => $creativeLevel,
'value' => $creditedArtworks,
'reason' => 'Based on credited artworks and visible contributions in this group.',
],
'collaboration_reliability' => [
'label' => $collaborationLevel,
'value' => $projectCount + $releaseCount,
'reason' => 'Based on projects, releases, and consistent participation.',
],
'publishing_trust' => [
'label' => $publishingLevel,
'value' => $releaseCount + $approvedSubmissions,
'reason' => 'Based on published releases and approved submissions.',
],
'review_leadership_trust' => [
'label' => $reviewActions > 0 ? $leadershipLevel : 'Not enough review activity yet',
'value' => $reviewActions,
'reason' => 'Based on review actions and approval responsibility inside the group.',
],
],
];
}
private function mapContributorStat(Group $group, GroupContributorStat $stat): array
{
$meta = $stat->reputation_meta_json ?? [];
return [
'user' => [
'id' => (int) $stat->user_id,
'name' => $stat->user?->name,
'username' => $stat->user?->username,
'avatar_url' => $stat->user ? AvatarUrl::forUser((int) $stat->user->id, $stat->user->profile?->avatar_hash, 72) : null,
'profile_url' => $stat->user?->username ? route('profile.show', ['username' => strtolower((string) $stat->user->username)]) : null,
],
'joined_at' => $this->memberJoinedAt($group, $stat->user_id),
'counts' => [
'credited_artworks' => (int) $stat->credited_artworks_count,
'releases' => (int) $stat->release_count,
'projects' => (int) $stat->project_count,
'review_actions' => (int) $stat->review_actions_count,
'approved_submissions' => (int) $stat->approved_submissions_count,
],
'summary' => $meta['summary'] ?? null,
'trusted_indicator' => (bool) ($meta['trusted_indicator'] ?? false),
'dimensions' => $meta['dimensions'] ?? [],
'badges' => $this->memberBadges($group, (int) $stat->user_id),
'last_active_contribution_at' => $this->lastActiveContributionAt($group, (int) $stat->user_id),
];
}
private function awardGroupBadges(Group $group): void
{
$publicReleaseCount = (int) $group->releases()->where('status', GroupRelease::STATUS_RELEASED)->count();
$publishedArtworksCount = (int) Artwork::query()->where('group_id', $group->id)->where('artwork_status', 'published')->count();
$activeMembers = (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count() + 1;
$eventsCount = (int) $group->events()->where('status', 'published')->count();
$challengeCount = (int) $group->challenges()->whereIn('status', ['published', 'active'])->count();
$this->awardGroupBadge($group, 'first_release', $publicReleaseCount >= 1);
$this->awardGroupBadge($group, 'ten_releases', $publicReleaseCount >= 10);
$this->awardGroupBadge($group, 'hundred_published_artworks', $publishedArtworksCount >= 100);
$this->awardGroupBadge($group, 'community_favorite', (int) $group->followers_count >= 25);
$this->awardGroupBadge($group, 'consistent_activity', $group->last_activity_at?->greaterThanOrEqualTo(now()->subDays(30)) === true);
$this->awardGroupBadge($group, 'event_host', $eventsCount >= 3);
$this->awardGroupBadge($group, 'challenge_organizer', $challengeCount >= 2);
$this->awardGroupBadge($group, 'collaborative_group', $activeMembers >= 4 && $publicReleaseCount >= 1);
$this->awardGroupBadge($group, 'trusted_group', $publicReleaseCount >= 2 && $publishedArtworksCount >= 12);
}
private function awardMemberBadges(Group $group): void
{
$stats = GroupContributorStat::query()->where('group_id', $group->id)->get();
foreach ($stats as $stat) {
$this->awardMemberBadge($group, (int) $stat->user_id, 'first_group_contribution', (int) $stat->credited_artworks_count >= 1);
$this->awardMemberBadge($group, (int) $stat->user_id, 'ten_group_contributions', (int) $stat->credited_artworks_count >= 10);
$this->awardMemberBadge($group, (int) $stat->user_id, 'release_contributor', (int) $stat->release_count >= 1);
$this->awardMemberBadge($group, (int) $stat->user_id, 'project_lead', GroupProject::query()->where('group_id', $group->id)->where('lead_user_id', $stat->user_id)->exists());
$this->awardMemberBadge($group, (int) $stat->user_id, 'reliable_reviewer', (int) $stat->review_actions_count >= 5);
$this->awardMemberBadge($group, (int) $stat->user_id, 'long_term_collaborator', ((int) $stat->project_count + (int) $stat->release_count) >= 5);
$this->awardMemberBadge($group, (int) $stat->user_id, 'founding_member', $this->isFoundingMember($group, (int) $stat->user_id));
$this->awardMemberBadge($group, (int) $stat->user_id, 'asset_builder', $group->assets()->where('uploaded_by_user_id', $stat->user_id)->count() >= 3);
}
}
private function awardGroupBadge(Group $group, string $badgeKey, bool $shouldAward): void
{
if (! $shouldAward) {
return;
}
$badge = GroupBadge::query()->firstOrCreate(
[
'group_id' => (int) $group->id,
'badge_key' => $badgeKey,
],
[
'awarded_at' => now(),
'meta_json' => ['reason' => $this->badgeReason('group', $badgeKey)],
]
);
if ($badge->wasRecentlyCreated) {
$badgeLabel = $this->badgeLabel('group', $badgeKey);
$url = route('studio.groups.reputation', ['group' => $group]);
foreach ($this->badgeManagerRecipients($group) as $recipient) {
$this->notifications->notifyGroupBadgeEarned($recipient, $group, $badgeLabel, $url);
}
}
}
private function awardMemberBadge(Group $group, int $userId, string $badgeKey, bool $shouldAward): void
{
if (! $shouldAward) {
return;
}
$badge = GroupMemberBadge::query()->firstOrCreate(
[
'group_id' => (int) $group->id,
'user_id' => $userId,
'badge_key' => $badgeKey,
],
[
'awarded_at' => now(),
'meta_json' => ['reason' => $this->badgeReason('member', $badgeKey)],
]
);
if ($badge->wasRecentlyCreated) {
$recipient = User::query()->find($userId);
if ($recipient) {
$this->notifications->notifyGroupMemberBadgeEarned(
$recipient,
$group,
$this->badgeLabel('member', $badgeKey),
route('groups.show', ['group' => $group])
);
}
}
}
private function badgeManagerRecipients(Group $group): Collection
{
$owner = $group->relationLoaded('owner') ? $group->owner : $group->owner()->first();
$admins = $group->members()
->with('user.profile')
->where('status', Group::STATUS_ACTIVE)
->where('role', Group::ROLE_ADMIN)
->get()
->pluck('user');
return collect([$owner])
->merge($admins)
->filter(fn ($user): bool => $user instanceof User)
->unique(fn (User $user): int => (int) $user->id)
->values();
}
private function badgeLabel(string $scope, string $badgeKey): string
{
return (string) config(sprintf('groups.badges.%s.%s', $scope, $badgeKey), str_replace('_', ' ', $badgeKey));
}
private function badgeReason(string $scope, string $badgeKey): string
{
return match ($scope . ':' . $badgeKey) {
'group:first_release' => 'Earned by publishing a first release.',
'group:ten_releases' => 'Earned by publishing ten releases.',
'group:hundred_published_artworks' => 'Earned by publishing one hundred group artworks.',
'group:community_favorite' => 'Earned by sustained follower interest.',
'group:consistent_activity' => 'Earned by staying active over recent weeks.',
'group:event_host' => 'Earned by hosting multiple published events.',
'group:challenge_organizer' => 'Earned by running multiple challenges.',
'group:collaborative_group' => 'Earned by keeping several contributors active and releasing together.',
'group:trusted_group' => 'Earned through repeated public releases and approved work.',
'member:first_group_contribution' => 'Earned by making a first credited contribution to the group.',
'member:ten_group_contributions' => 'Earned by making ten credited group contributions.',
'member:release_contributor' => 'Earned by contributing to a group release.',
'member:project_lead' => 'Earned by leading a group project.',
'member:reliable_reviewer' => 'Earned through repeated group review actions.',
'member:long_term_collaborator' => 'Earned through consistent long-term collaboration.',
'member:founding_member' => 'Earned by helping the group from its early formation stage.',
'member:asset_builder' => 'Earned by supplying multiple shared group assets.',
default => 'Earned through visible group activity.',
};
}
private function memberJoinedAt(Group $group, int $userId): ?string
{
if ((int) $group->owner_user_id === $userId) {
return $group->created_at?->toISOString();
}
$member = GroupMember::query()
->where('group_id', $group->id)
->where('user_id', $userId)
->first();
return $member?->accepted_at?->toISOString() ?? $member?->created_at?->toISOString();
}
private function lastActiveContributionAt(Group $group, int $userId): ?string
{
$timestamps = collect([
Artwork::query()->where('group_id', $group->id)->where('uploaded_by_user_id', $userId)->max('updated_at'),
GroupProject::query()->where('group_id', $group->id)->where('updated_at', '!=', null)->where(function ($query) use ($userId): void {
$query->where('lead_user_id', $userId)
->orWhere('created_by_user_id', $userId)
->orWhereHas('memberLinks', fn ($memberQuery) => $memberQuery->where('user_id', $userId));
})->max('updated_at'),
GroupRelease::query()->where('group_id', $group->id)->where(function ($query) use ($userId): void {
$query->where('lead_user_id', $userId)
->orWhere('created_by_user_id', $userId)
->orWhereHas('contributorLinks', fn ($contributorQuery) => $contributorQuery->where('user_id', $userId));
})->max('updated_at'),
])->filter();
$latest = $timestamps->sortDesc()->first();
return $latest ? CarbonImmutable::parse((string) $latest)->toISOString() : null;
}
private function isFoundingMember(Group $group, int $userId): bool
{
if ((int) $group->owner_user_id === $userId) {
return true;
}
$member = GroupMember::query()
->where('group_id', $group->id)
->where('user_id', $userId)
->first();
if (! $member?->accepted_at || ! $group->created_at) {
return false;
}
return $member->accepted_at->lessThanOrEqualTo($group->created_at->copy()->addDays(30));
}
private function levelLabel(int $value, array $thresholds): string
{
$label = 'New';
foreach ($thresholds as $threshold => $candidate) {
if ($value >= $threshold) {
$label = $candidate;
}
}
return $label;
}
}

View File

@@ -0,0 +1,842 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\Group;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class GroupService
{
public function __construct(
private readonly GroupMembershipService $memberships,
private readonly GroupCardService $cards,
private readonly CollectionService $collections,
private readonly GroupMediaService $media,
private readonly GroupJoinRequestService $joinRequests,
private readonly GroupRecruitmentService $recruitment,
private readonly GroupPostService $posts,
private readonly GroupProjectService $projects,
private readonly GroupReleaseService $releases,
private readonly GroupChallengeService $challenges,
private readonly GroupEventService $events,
private readonly GroupAssetService $assets,
private readonly GroupActivityService $activity,
private readonly GroupHistoryService $history,
private readonly GroupReputationService $reputation,
private readonly ArtworkMaturityService $maturity,
) {
}
public function makeUniqueSlug(string $source, ?int $ignoreGroupId = null): string
{
$base = Str::slug(Str::limit($source, 90, ''));
$base = $base !== '' ? $base : 'group';
$slug = $base;
$suffix = 2;
while (Group::query()
->where('slug', $slug)
->when($ignoreGroupId !== null, fn ($query) => $query->where('id', '!=', $ignoreGroupId))
->exists()) {
$slug = Str::limit($base, 84, '') . '-' . $suffix;
$suffix++;
}
return $slug;
}
public function createGroup(User $owner, array $attributes): Group
{
$storedAvatarPath = null;
$storedBannerPath = null;
try {
return DB::transaction(function () use ($owner, $attributes, &$storedAvatarPath, &$storedBannerPath): Group {
$group = new Group();
$group->owner()->associate($owner);
$group->featured_artwork_id = null;
$group->is_verified = false;
$group->founded_at = $attributes['founded_at'] ?? null;
$group->name = (string) $attributes['name'];
$group->slug = $this->makeUniqueSlug((string) ($attributes['slug'] ?? $attributes['name']));
$group->headline = $attributes['headline'] ?? null;
$group->bio = $attributes['bio'] ?? null;
$group->type = $attributes['type'] ?? null;
$group->visibility = (string) ($attributes['visibility'] ?? Group::VISIBILITY_PUBLIC);
$group->status = Group::LIFECYCLE_ACTIVE;
$group->membership_policy = (string) ($attributes['membership_policy'] ?? Group::MEMBERSHIP_INVITE_ONLY);
$group->website_url = $attributes['website_url'] ?? null;
$group->links_json = $this->normalizeLinks($attributes['links_json'] ?? []);
$group->avatar_path = $this->normalizeMediaPath($attributes['avatar_path'] ?? null);
$group->banner_path = $this->normalizeMediaPath($attributes['banner_path'] ?? null);
$group->artworks_count = 0;
$group->collections_count = 0;
$group->followers_count = 0;
$group->last_activity_at = now();
$group->save();
if (($attributes['avatar_file'] ?? null) instanceof UploadedFile) {
$storedAvatarPath = $this->media->storeUploadedImage($group, $attributes['avatar_file'], 'avatar');
$group->avatar_path = $storedAvatarPath;
}
if (($attributes['banner_file'] ?? null) instanceof UploadedFile) {
$storedBannerPath = $this->media->storeUploadedImage($group, $attributes['banner_file'], 'banner');
$group->banner_path = $storedBannerPath;
}
if ($storedAvatarPath !== null || $storedBannerPath !== null) {
$group->save();
}
$this->memberships->ensureOwnerMembership($group);
return $group->fresh(['owner.profile']);
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($storedAvatarPath);
$this->media->deleteIfManaged($storedBannerPath);
throw $exception;
}
}
public function updateGroup(Group $group, array $attributes, User $actor): Group
{
$storedAvatarPath = null;
$storedBannerPath = null;
$obsoleteAvatarPath = null;
$obsoleteBannerPath = null;
try {
$updatedGroup = DB::transaction(function () use ($group, $attributes, $actor, &$storedAvatarPath, &$storedBannerPath, &$obsoleteAvatarPath, &$obsoleteBannerPath): Group {
$originalAvatarPath = $group->avatar_path;
$originalBannerPath = $group->banner_path;
$group->fill([
'name' => (string) ($attributes['name'] ?? $group->name),
'slug' => $this->makeUniqueSlug((string) ($attributes['slug'] ?? $attributes['name'] ?? $group->slug), (int) $group->id),
'headline' => $attributes['headline'] ?? null,
'bio' => $attributes['bio'] ?? null,
'type' => $attributes['type'] ?? $group->type,
'visibility' => (string) ($attributes['visibility'] ?? $group->visibility),
'membership_policy' => (string) ($attributes['membership_policy'] ?? $group->membership_policy ?? Group::MEMBERSHIP_INVITE_ONLY),
'founded_at' => $attributes['founded_at'] ?? $group->founded_at,
'website_url' => $attributes['website_url'] ?? null,
'links_json' => $this->normalizeLinks($attributes['links_json'] ?? $group->links_json ?? []),
'avatar_path' => array_key_exists('avatar_path', $attributes) ? $this->normalizeMediaPath($attributes['avatar_path']) : $group->avatar_path,
'banner_path' => array_key_exists('banner_path', $attributes) ? $this->normalizeMediaPath($attributes['banner_path']) : $group->banner_path,
'featured_artwork_id' => $this->normalizeFeaturedArtworkId($group, $attributes['featured_artwork_id'] ?? $group->featured_artwork_id),
'last_activity_at' => now(),
]);
$group->save();
if (($attributes['avatar_file'] ?? null) instanceof UploadedFile) {
$storedAvatarPath = $this->media->storeUploadedImage($group, $attributes['avatar_file'], 'avatar');
$group->avatar_path = $storedAvatarPath;
}
if (($attributes['banner_file'] ?? null) instanceof UploadedFile) {
$storedBannerPath = $this->media->storeUploadedImage($group, $attributes['banner_file'], 'banner');
$group->banner_path = $storedBannerPath;
}
if ($storedAvatarPath !== null || $storedBannerPath !== null) {
$group->save();
}
$this->memberships->ensureOwnerMembership($group);
$obsoleteAvatarPath = $originalAvatarPath !== $group->avatar_path ? $originalAvatarPath : null;
$obsoleteBannerPath = $originalBannerPath !== $group->banner_path ? $originalBannerPath : null;
return $group->fresh(['owner.profile']);
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($storedAvatarPath);
$this->media->deleteIfManaged($storedBannerPath);
throw $exception;
}
$this->media->deleteIfManaged($obsoleteAvatarPath);
$this->media->deleteIfManaged($obsoleteBannerPath);
return $updatedGroup;
}
public function syncArtworkCount(Group $group): void
{
$group->forceFill([
'artworks_count' => Artwork::query()
->where('group_id', $group->id)
->whereNull('deleted_at')
->count(),
'last_activity_at' => now(),
])->save();
}
public function syncCollectionCount(Group $group): void
{
$group->forceFill([
'collections_count' => Collection::query()
->where('group_id', $group->id)
->whereNull('deleted_at')
->count(),
'last_activity_at' => now(),
])->save();
}
public function studioOptionsForUser(User $user): array
{
$groups = Group::query()
->with(['owner.profile', 'members'])
->where('status', '!=', Group::LIFECYCLE_SUSPENDED)
->where(function ($query) use ($user): void {
$query->where('owner_user_id', $user->id)
->orWhereHas('members', function ($memberQuery) use ($user): void {
$memberQuery->where('user_id', $user->id)
->where('status', Group::STATUS_ACTIVE);
});
})
->orderBy('name')
->get();
return $groups->map(function (Group $group) use ($user): array {
$canPublishArtworks = $group->canPublishArtworks($user);
$canSubmitArtworkForReview = $group->canSubmitArtworkForReview($user);
$canManageReleases = $group->canManageReleases($user);
$canViewReputation = $group->canViewReputationDashboard($user);
return [
'id' => (int) $group->id,
'name' => (string) $group->name,
'slug' => (string) $group->slug,
'role' => $group->activeRoleFor($user),
'role_label' => Group::displayRole($group->activeRoleFor($user)),
'status' => (string) ($group->status ?? Group::LIFECYCLE_ACTIVE),
'avatar_url' => $group->avatarUrl(),
'artworks_count' => (int) $group->artworks_count,
'collections_count' => (int) $group->collections_count,
'followers_count' => (int) $group->followers_count,
'permissions' => [
'can_publish_artworks' => $canPublishArtworks,
'can_submit_artwork_for_review' => $canSubmitArtworkForReview,
],
'public_url' => $group->publicUrl(),
'studio_url' => route('studio.groups.show', ['group' => $group]),
'studio_artworks_url' => route('studio.groups.artworks', ['group' => $group]),
'studio_collections_url' => route('studio.groups.collections', ['group' => $group]),
'studio_members_url' => route('studio.groups.members', ['group' => $group]),
'studio_invitations_url' => route('studio.groups.invitations', ['group' => $group]),
'studio_join_requests_url' => route('studio.groups.join-requests', ['group' => $group]),
'studio_review_url' => route('studio.groups.review', ['group' => $group]),
'studio_recruitment_url' => route('studio.groups.recruitment', ['group' => $group]),
'studio_posts_url' => route('studio.groups.posts.index', ['group' => $group]),
'studio_settings_url' => route('studio.groups.settings', ['group' => $group]),
'studio_projects_url' => route('studio.groups.projects.index', ['group' => $group]),
'studio_releases_url' => $canManageReleases ? route('studio.groups.releases.index', ['group' => $group]) : null,
'studio_challenges_url' => route('studio.groups.challenges.index', ['group' => $group]),
'studio_events_url' => route('studio.groups.events.index', ['group' => $group]),
'studio_assets_url' => route('studio.groups.assets.index', ['group' => $group]),
'studio_reputation_url' => $canViewReputation ? route('studio.groups.reputation', ['group' => $group]) : null,
'studio_activity_url' => route('studio.groups.activity', ['group' => $group]),
'upload_url' => ($canPublishArtworks || $canSubmitArtworkForReview) ? route('upload', ['group' => $group->slug]) : null,
'collection_create_url' => route('settings.collections.create', ['group' => $group->slug]),
];
})->values()->all();
}
public function mapGroupCard(Group $group, ?User $viewer = null): array
{
return $this->cards->mapGroupCard($group, $viewer);
}
public function mapGroupDetail(Group $group, ?User $viewer = null): array
{
$recruitment = $this->recruitment->payloadForGroup($group);
return array_merge($this->mapGroupCard($group, $viewer), [
'website_url' => $group->website_url,
'bio' => $group->bio,
'links' => $this->normalizeLinks($group->links_json ?? []),
'avatar_path' => $group->avatar_path,
'banner_path' => $group->banner_path,
'featured_artwork_id' => $group->featured_artwork_id ? (int) $group->featured_artwork_id : null,
'founded_at' => $group->founded_at?->toISOString(),
'last_activity_at' => $group->last_activity_at?->toISOString(),
'created_at' => $group->created_at?->toISOString(),
'current_join_request' => $this->joinRequests->currentRequestFor($group, $viewer),
'recruitment' => $recruitment,
'pinned_post' => $this->posts->pinnedPost($group),
'featured_release' => $this->releases->featuredRelease($group, $viewer),
'featured_project' => $this->projects->featuredProject($group, $viewer),
'active_challenge' => $this->challenges->activeChallenge($group, $viewer),
'upcoming_event' => $this->events->upcomingEvent($group, $viewer),
'badge_showcase' => $this->reputation->groupBadges($group, 8),
'top_contributors' => $this->reputation->topContributors($group, 6),
'trust_signals' => $this->reputation->trustSignals($group),
]);
}
public function recentPostCards(Group $group, int $limit = 3): array
{
return $this->posts->recentPosts($group, $limit);
}
public function recentProjectCards(Group $group, ?User $viewer = null, int $limit = 3): array
{
return $this->projects->publicListing($group, $viewer, $limit);
}
public function recentReleaseCards(Group $group, ?User $viewer = null, int $limit = 3): array
{
return $this->releases->publicListing($group, $viewer, $limit);
}
public function recentChallengeCards(Group $group, ?User $viewer = null, int $limit = 3): array
{
return $this->challenges->publicListing($group, $viewer, $limit);
}
public function recentEventCards(Group $group, ?User $viewer = null, int $limit = 3): array
{
return $this->events->publicListing($group, $viewer, $limit);
}
public function publicProjectListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->projects->publicListing($group, $viewer, $limit);
}
public function publicReleaseListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->releases->publicListing($group, $viewer, $limit);
}
public function publicChallengeListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->challenges->publicListing($group, $viewer, $limit);
}
public function publicEventListing(Group $group, ?User $viewer = null, int $limit = 12): array
{
return $this->events->publicListing($group, $viewer, $limit);
}
public function publicAssetListing(Group $group, int $limit = 12): array
{
return $this->assets->publicListing($group, $limit);
}
public function publicActivityFeed(Group $group, int $limit = 8): array
{
return $this->activity->publicFeed($group, $limit);
}
public function studioActivityFeed(Group $group, User $viewer, int $limit = 20): array
{
return $this->activity->studioFeed($group, $viewer, $limit);
}
public function publicPostListing(Group $group, int $limit = 12): array
{
return $this->posts->publicPosts($group, $limit);
}
public function recruitmentPayload(Group $group): ?array
{
return $this->recruitment->payloadForGroup($group);
}
public function recentHistory(Group $group, int $limit = 8): array
{
return $this->history->recentFor($group, $limit);
}
public function featuredArtworkCards(Group $group, int $limit = 4): array
{
$query = Artwork::query()
->with(['user.profile', 'group', 'primaryAuthor.profile'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at');
$this->maturity->applyViewerFilter($query, request()->user());
if ((int) ($group->featured_artwork_id ?? 0) > 0) {
$featuredArtwork = (clone $query)
->where('id', (int) $group->featured_artwork_id)
->first();
$remaining = (clone $query)
->where('id', '!=', (int) $group->featured_artwork_id)
->latest('published_at')
->limit(max($limit - ($featuredArtwork ? 1 : 0), 0))
->get();
return collect([$featuredArtwork])
->filter()
->concat($remaining)
->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork))
->values()
->all();
}
return (clone $query)
->latest('published_at')
->limit($limit)
->get()
->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork))
->values()
->all();
}
public function featuredCollectionCards(Group $group, ?User $viewer = null, int $limit = 3): array
{
$collections = Collection::query()
->with(['user.profile', 'group', 'coverArtwork'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->where('is_featured', true)
->latest('featured_at')
->latest('updated_at')
->limit($limit)
->get()
->filter(fn (Collection $collection): bool => $collection->isPubliclyAccessible())
->values();
return $this->collections->mapCollectionCardPayloads($collections, false, $viewer);
}
public function mapLeadershipPreview(array $members, array $owner, int $limit = 4): array
{
$leadership = collect($members)
->filter(fn (array $member): bool => in_array((string) ($member['role'] ?? ''), [Group::ROLE_OWNER, Group::ROLE_ADMIN], true))
->map(function (array $member): array {
return [
'id' => (int) ($member['user']['id'] ?? 0),
'name' => $member['user']['name'] ?? null,
'username' => $member['user']['username'] ?? null,
'avatar_url' => $member['user']['avatar_url'] ?? null,
'profile_url' => $member['user']['profile_url'] ?? null,
'role' => (string) ($member['role'] ?? ''),
'role_label' => $member['role_label'] ?? Group::displayRole((string) ($member['role'] ?? '')),
];
})
->unique('id')
->values();
if ($leadership->isEmpty() && ! empty($owner['id'])) {
$leadership = collect([array_merge($owner, [
'role' => Group::ROLE_OWNER,
'role_label' => Group::displayRole(Group::ROLE_OWNER),
])]);
}
return $leadership->take($limit)->all();
}
public function archiveGroup(Group $group, User $actor): Group
{
if (! $group->canArchive($actor) && ! $actor->isAdmin()) {
abort(403);
}
$group->forceFill([
'status' => Group::LIFECYCLE_ARCHIVED,
'last_activity_at' => now(),
])->save();
return $group->fresh(['owner.profile', 'members']);
}
public function publicArtworkCards(Group $group, int $limit = 18): array
{
$query = Artwork::query()
->with(['user.profile', 'group', 'primaryAuthor.profile'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->latest('published_at');
$this->maturity->applyViewerFilter($query, request()->user());
return $query
->limit($limit)
->get()
->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork))
->values()
->all();
}
public function publicCollectionCards(Group $group, ?User $viewer = null, int $limit = 12): array
{
$collections = Collection::query()
->with(['user.profile', 'group', 'coverArtwork'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->latest('updated_at')
->limit($limit)
->get()
->filter(fn (Collection $collection): bool => $collection->isPubliclyAccessible())
->values();
return $this->collections->mapCollectionCardPayloads($collections, false, $viewer);
}
private function mapPublicArtworkCard(Artwork $artwork): array
{
return $this->maturity->decoratePayload([
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]),
'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
'thumb_srcset' => ThumbnailPresenter::srcsetForArtwork($artwork),
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'published_at' => $artwork->published_at?->toISOString(),
], $artwork, request()->user());
}
public function studioDashboardSummary(Group $group): array
{
$artworkQuery = Artwork::query()
->where('group_id', $group->id)
->whereNull('deleted_at');
$collectionQuery = Collection::query()
->where('group_id', $group->id)
->whereNull('deleted_at');
return [
'draft_artworks_count' => (clone $artworkQuery)->where('artwork_status', 'draft')->count(),
'scheduled_artworks_count' => (clone $artworkQuery)->where('artwork_status', 'scheduled')->count(),
'published_artworks_count' => (clone $artworkQuery)->where('artwork_status', 'published')->count(),
'pending_reviews_count' => (clone $artworkQuery)->where('group_review_status', 'submitted')->count(),
'draft_collections_count' => (clone $collectionQuery)
->where(function ($builder): void {
$builder->where('lifecycle_state', Collection::LIFECYCLE_DRAFT)
->orWhere('workflow_state', Collection::WORKFLOW_DRAFT)
->orWhere('workflow_state', Collection::WORKFLOW_IN_REVIEW);
})
->count(),
'active_members_count' => (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count(),
'pending_invites_count' => $this->memberships->pendingInviteCount($group),
'pending_join_requests_count' => $this->joinRequests->pendingCount($group),
'published_posts_count' => (int) $group->posts()->where('status', \App\Models\GroupPost::STATUS_PUBLISHED)->count(),
'is_recruiting' => (bool) ($this->recruitment->payloadForGroup($group)['is_recruiting'] ?? false),
'projects_count' => (int) $group->projects()->count(),
'releases_count' => (int) $group->releases()->count(),
'published_releases_count' => (int) $group->releases()->where('status', \App\Models\GroupRelease::STATUS_RELEASED)->count(),
'active_challenges_count' => (int) $group->challenges()->whereIn('status', ['published', 'active'])->count(),
'events_count' => (int) $group->events()->count(),
'assets_count' => (int) $group->assets()->count(),
'activity_count' => (int) $group->activityItems()->count(),
'group_badges_count' => (int) $group->badges()->count(),
'member_badges_count' => (int) $group->memberBadges()->count(),
'trust_score' => (float) ($group->discoveryMetric?->trust_score ?? 0),
];
}
public function studioArtworkPreviewItems(Group $group, string $bucket = 'all', int $limit = 6): array
{
$query = Artwork::query()
->with(['user.profile', 'primaryAuthor.profile', 'stats'])
->where('group_id', $group->id)
->whereNull('deleted_at');
if ($bucket === 'drafts') {
$query->where('artwork_status', 'draft');
} elseif ($bucket === 'scheduled') {
$query->where('artwork_status', 'scheduled');
} elseif ($bucket === 'published') {
$query->where('artwork_status', 'published');
}
return $query->latest('updated_at')
->limit($limit)
->get()
->map(fn (Artwork $artwork): array => $this->mapStudioArtworkItem($artwork))
->values()
->all();
}
public function studioFeaturedArtworkOptions(Group $group, int $limit = 24): array
{
return Artwork::query()
->with(['user.profile', 'primaryAuthor.profile'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->where('artwork_status', 'published')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->latest('published_at')
->limit($limit)
->get()
->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username,
'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
'published_at' => $artwork->published_at?->toISOString(),
])
->values()
->all();
}
public function studioCollectionPreviewItems(Group $group, int $limit = 6): array
{
return Collection::query()
->with(['user.profile', 'group', 'coverArtwork'])
->where('group_id', $group->id)
->whereNull('deleted_at')
->latest('updated_at')
->limit($limit)
->get()
->map(fn (Collection $collection): array => $this->mapStudioCollectionItem($collection))
->values()
->all();
}
public function studioArtworkListing(Group $group, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'all');
$search = trim((string) ($filters['q'] ?? ''));
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48);
$query = Artwork::query()
->with(['user.profile', 'primaryAuthor.profile'])
->where('group_id', $group->id)
->whereNull('deleted_at');
if ($bucket === 'drafts') {
$query->where('artwork_status', 'draft');
} elseif ($bucket === 'scheduled') {
$query->where('artwork_status', 'scheduled');
} elseif ($bucket === 'published') {
$query->where('artwork_status', 'published');
}
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$builder->where('title', 'like', '%' . $search . '%')
->orWhere('description', 'like', '%' . $search . '%');
});
}
$paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);
return $this->mapStudioListing(
$paginator,
fn (Artwork $artwork): array => $this->mapStudioArtworkItem($artwork),
[
['value' => 'all', 'label' => 'All'],
['value' => 'published', 'label' => 'Published'],
['value' => 'drafts', 'label' => 'Drafts'],
['value' => 'scheduled', 'label' => 'Scheduled'],
],
$bucket,
$search
);
}
public function studioCollectionListing(Group $group, array $filters = []): array
{
$bucket = (string) ($filters['bucket'] ?? 'all');
$search = trim((string) ($filters['q'] ?? ''));
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48);
$query = Collection::query()
->with(['user.profile', 'group', 'coverArtwork'])
->where('group_id', $group->id)
->whereNull('deleted_at');
if ($bucket === 'drafts') {
$query->where(function ($builder): void {
$builder->where('lifecycle_state', Collection::LIFECYCLE_DRAFT)
->orWhere('workflow_state', Collection::WORKFLOW_DRAFT)
->orWhere('workflow_state', Collection::WORKFLOW_IN_REVIEW);
});
} elseif ($bucket === 'scheduled') {
$query->where('lifecycle_state', Collection::LIFECYCLE_SCHEDULED);
} elseif ($bucket === 'published') {
$query->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED, Collection::LIFECYCLE_SCHEDULED]);
}
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$builder->where('title', 'like', '%' . $search . '%')
->orWhere('description', 'like', '%' . $search . '%');
});
}
$paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);
return $this->mapStudioListing(
$paginator,
fn (Collection $collection): array => $this->mapStudioCollectionItem($collection),
[
['value' => 'all', 'label' => 'All'],
['value' => 'published', 'label' => 'Published'],
['value' => 'drafts', 'label' => 'Drafts'],
['value' => 'scheduled', 'label' => 'Scheduled'],
],
$bucket,
$search
);
}
private function mapStudioListing(LengthAwarePaginator $paginator, callable $mapper, array $bucketOptions, string $bucket, string $search): array
{
return [
'items' => collect($paginator->items())->map($mapper)->values()->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'filters' => [
'bucket' => $bucket,
'q' => $search,
'sort' => 'updated_desc',
],
'module_options' => [],
'bucket_options' => $bucketOptions,
'sort_options' => [
['value' => 'updated_desc', 'label' => 'Recently updated'],
],
'advanced_filters' => [],
'default_view' => 'grid',
];
}
private function mapStudioArtworkItem(Artwork $artwork): array
{
$status = (string) ($artwork->artwork_status ?: ($artwork->published_at ? 'published' : 'draft'));
return [
'id' => 'artworks:' . (int) $artwork->id,
'numeric_id' => (int) $artwork->id,
'module' => 'artworks',
'module_label' => 'Artworks',
'module_icon' => 'fa-solid fa-images',
'title' => (string) $artwork->title,
'subtitle' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username,
'description' => $artwork->description,
'status' => $status,
'visibility' => (string) ($artwork->visibility ?: Artwork::VISIBILITY_PRIVATE),
'image_url' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
'preview_url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]),
'view_url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]),
'edit_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
'manage_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
'analytics_url' => route('studio.artworks.analytics', ['id' => $artwork->id]),
'created_at' => $artwork->created_at?->toISOString(),
'updated_at' => $artwork->updated_at?->toISOString(),
'published_at' => $artwork->published_at?->toISOString(),
'metrics' => [
'views' => (int) ($artwork->stats?->views ?? 0),
'appreciation' => (int) ($artwork->stats?->favorites ?? 0),
'comments' => (int) $artwork->comments()->count(),
],
'actions' => [],
];
}
private function mapStudioCollectionItem(Collection $collection): array
{
$mapped = $this->collections->mapCollectionCardPayloads([$collection->loadMissing(['user.profile', 'group', 'coverArtwork'])], true)[0];
$status = $mapped['lifecycle_state'] === Collection::LIFECYCLE_FEATURED ? 'published' : ($mapped['lifecycle_state'] ?? 'draft');
return [
'id' => 'collections:' . (int) $collection->id,
'numeric_id' => (int) $collection->id,
'module' => 'collections',
'module_label' => 'Collections',
'module_icon' => 'fa-solid fa-layer-group',
'title' => (string) $mapped['title'],
'subtitle' => $mapped['subtitle'] ?: ucfirst((string) ($mapped['type'] ?? 'collection')),
'description' => $mapped['summary'] ?: $mapped['description'],
'status' => $status,
'visibility' => (string) $mapped['visibility'],
'image_url' => $mapped['cover_image'],
'preview_url' => $mapped['url'],
'view_url' => $mapped['url'],
'edit_url' => $mapped['edit_url'] ?: $mapped['manage_url'],
'manage_url' => $mapped['manage_url'],
'analytics_url' => route('settings.collections.analytics', ['collection' => $collection->id]),
'created_at' => ($mapped['published_at'] ?? null) ?: ($mapped['updated_at'] ?? null),
'updated_at' => $mapped['updated_at'] ?? null,
'published_at' => $mapped['published_at'] ?? null,
'metrics' => [
'views' => (int) ($mapped['views_count'] ?? 0),
'appreciation' => (int) (($mapped['likes_count'] ?? 0) + ($mapped['followers_count'] ?? 0)),
'comments' => (int) ($mapped['comments_count'] ?? 0),
],
'actions' => [],
];
}
private function normalizeLinks(mixed $links): array
{
$items = is_array($links) ? $links : [];
return collect($items)
->filter(fn ($item): bool => is_array($item))
->map(function (array $item): array {
return [
'label' => trim((string) ($item['label'] ?? '')),
'url' => trim((string) ($item['url'] ?? '')),
];
})
->filter(fn (array $item): bool => $item['label'] !== '' && $item['url'] !== '')
->values()
->all();
}
private function normalizeMediaPath(mixed $path): ?string
{
$trimmed = trim((string) $path);
return $trimmed !== '' ? $trimmed : null;
}
private function normalizeFeaturedArtworkId(Group $group, mixed $featuredArtworkId): ?int
{
$id = (int) $featuredArtworkId;
if ($id <= 0) {
return null;
}
$exists = Artwork::query()
->where('id', $id)
->where('group_id', $group->id)
->whereNull('deleted_at')
->where('artwork_status', 'published')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->exists();
return $exists ? $id : null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace App\Services\Images;
use App\Models\Artwork;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\ThumbnailService;
use App\Services\Uploads\UploadDerivativesService;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use RuntimeException;
final class ArtworkSquareThumbnailBackfillService
{
public function __construct(
private readonly UploadDerivativesService $derivatives,
private readonly UploadStorageService $storage,
private readonly ArtworkFileRepository $artworkFiles,
private readonly ArtworkCdnPurgeService $cdnPurge,
) {
}
/**
* @return array<string, mixed>
*/
public function ensureSquareThumbnail(Artwork $artwork, bool $force = false, bool $dryRun = false): array
{
$hash = strtolower((string) ($artwork->hash ?? ''));
if ($hash === '') {
throw new RuntimeException('Artwork hash is required to generate a square thumbnail.');
}
$existing = DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->where('variant', 'sq')
->first(['path']);
if ($existing !== null && ! $force) {
return [
'status' => 'skipped',
'reason' => 'already_exists',
'artwork_id' => $artwork->id,
'path' => (string) ($existing->path ?? ''),
];
}
$resolved = $this->resolveBestSource($artwork);
if ($dryRun) {
return [
'status' => 'dry_run',
'artwork_id' => $artwork->id,
'source_variant' => $resolved['variant'],
'source_path' => $resolved['source_path'],
'object_path' => $this->storage->objectPathForVariant('sq', $hash, $hash . '.webp'),
];
}
try {
$asset = $this->derivatives->generateSquareDerivative($resolved['source_path'], $hash, [
'context' => ['artwork' => $artwork],
]);
$this->artworkFiles->upsert($artwork->id, 'sq', $asset['path'], $asset['mime'], $asset['size']);
$this->cdnPurge->purgeArtworkObjectPaths([$asset['path']], [
'artwork_id' => $artwork->id,
'reason' => 'square_thumbnail_regenerated',
]);
if (! is_string($artwork->thumb_ext) || trim($artwork->thumb_ext) === '') {
$artwork->forceFill(['thumb_ext' => 'webp'])->saveQuietly();
}
return [
'status' => 'generated',
'artwork_id' => $artwork->id,
'path' => $asset['path'],
'source_variant' => $resolved['variant'],
'crop_mode' => $asset['result']?->cropMode,
];
} finally {
if (($resolved['cleanup'] ?? false) === true) {
File::delete($resolved['source_path']);
}
}
}
/**
* @return array{variant: string, source_path: string, cleanup: bool}
*/
private function resolveBestSource(Artwork $artwork): array
{
$hash = strtolower((string) ($artwork->hash ?? ''));
$files = DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->pluck('path', 'variant')
->all();
$variants = ['orig_image', 'orig', 'xl', 'lg', 'md', 'sm', 'xs'];
foreach ($variants as $variant) {
$path = $files[$variant] ?? null;
if (! is_string($path) || trim($path) === '') {
continue;
}
if ($variant === 'orig_image' || $variant === 'orig') {
$filename = basename($path);
$localPath = $this->storage->localOriginalPath($hash, $filename);
if (is_file($localPath)) {
return [
'variant' => $variant,
'source_path' => $localPath,
'cleanup' => false,
];
}
}
$temporary = $this->downloadToTempFile($path, pathinfo($path, PATHINFO_EXTENSION) ?: 'webp');
if ($temporary !== null) {
return [
'variant' => $variant,
'source_path' => $temporary,
'cleanup' => true,
];
}
}
$directSource = $this->resolveArtworkFilePathSource($artwork);
if ($directSource !== null) {
return $directSource;
}
$canonicalDerivativeSource = $this->resolveCanonicalDerivativeSource($artwork);
if ($canonicalDerivativeSource !== null) {
return $canonicalDerivativeSource;
}
throw new RuntimeException(sprintf('No usable source image was found for artwork %d.', (int) $artwork->id));
}
/**
* @return array{variant: string, source_path: string, cleanup: bool}|null
*/
private function resolveArtworkFilePathSource(Artwork $artwork): ?array
{
$relativePath = trim((string) ($artwork->file_path ?? ''), '/');
if ($relativePath === '') {
return null;
}
foreach ($this->localFilePathCandidates($relativePath) as $candidate) {
if (is_file($candidate)) {
return [
'variant' => 'file_path',
'source_path' => $candidate,
'cleanup' => false,
];
}
}
$downloaded = $this->downloadUrlToTempFile($this->cdnUrlForPath($relativePath), pathinfo($relativePath, PATHINFO_EXTENSION));
if ($downloaded === null) {
return null;
}
return [
'variant' => 'file_path',
'source_path' => $downloaded,
'cleanup' => true,
];
}
/**
* @return array{variant: string, source_path: string, cleanup: bool}|null
*/
private function resolveCanonicalDerivativeSource(Artwork $artwork): ?array
{
$hash = strtolower((string) ($artwork->hash ?? ''));
$thumbExt = strtolower(ltrim((string) ($artwork->thumb_ext ?? ''), '.'));
if ($hash === '' || $thumbExt === '') {
return null;
}
foreach (['xl', 'lg', 'md', 'sm', 'xs'] as $variant) {
$url = ThumbnailService::fromHash($hash, $thumbExt, $variant);
if (! is_string($url) || $url === '') {
continue;
}
$downloaded = $this->downloadUrlToTempFile($url, $thumbExt);
if ($downloaded === null) {
continue;
}
return [
'variant' => $variant,
'source_path' => $downloaded,
'cleanup' => true,
];
}
return null;
}
/**
* @return array<int, string>
*/
private function localFilePathCandidates(string $relativePath): array
{
$normalizedPath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativePath);
return array_values(array_unique([
$normalizedPath,
base_path($normalizedPath),
public_path($normalizedPath),
storage_path('app/public' . DIRECTORY_SEPARATOR . $normalizedPath),
storage_path('app/private' . DIRECTORY_SEPARATOR . $normalizedPath),
]));
}
private function cdnUrlForPath(string $relativePath): string
{
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($relativePath, '/');
}
private function downloadUrlToTempFile(string $url, string $extension = ''): ?string
{
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 30,
'ignore_errors' => true,
'header' => implode("\r\n", [
'User-Agent: Skinbase Nova square-thumb backfill',
'Accept: image/*,*/*;q=0.8',
'Accept-Encoding: identity',
'Connection: close',
]) . "\r\n",
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$contents = @file_get_contents($url, false, $context);
$headers = $http_response_header ?? [];
if (! is_string($contents) || $contents === '' || ! $this->isSuccessfulHttpResponse($url, $headers)) {
return null;
}
if (! is_string($contents) || $contents === '') {
return null;
}
$resolvedExtension = trim($extension) !== ''
? trim($extension)
: $this->extensionFromContentType($this->contentTypeFromHeaders($headers));
return $this->writeTemporaryFile($contents, $resolvedExtension);
}
/**
* @param array<int, string> $headers
*/
private function isSuccessfulHttpResponse(string $url, array $headers): bool
{
if ($headers === [] && parse_url($url, PHP_URL_SCHEME) === 'file') {
return true;
}
$statusLine = $headers[0] ?? '';
if (! is_string($statusLine) || ! preg_match('/\s(\d{3})\s/', $statusLine, $matches)) {
return false;
}
$statusCode = (int) ($matches[1] ?? 0);
return $statusCode >= 200 && $statusCode < 300;
}
/**
* @param array<int, string> $headers
*/
private function contentTypeFromHeaders(array $headers): string
{
foreach ($headers as $header) {
if (! is_string($header) || stripos($header, 'Content-Type:') !== 0) {
continue;
}
return trim(substr($header, strlen('Content-Type:')));
}
return '';
}
private function writeTemporaryFile(string $contents, string $extension = ''): string
{
$temp = tempnam(sys_get_temp_dir(), 'sq-thumb-');
if ($temp === false) {
throw new RuntimeException('Unable to allocate a temporary file for square thumbnail generation.');
}
$normalizedExtension = trim((string) $extension);
$path = $normalizedExtension !== '' ? $temp . '.' . $normalizedExtension : $temp;
if ($normalizedExtension !== '') {
rename($temp, $path);
}
File::put($path, $contents);
return $path;
}
private function extensionFromContentType(string $contentType): string
{
$normalized = strtolower(trim(strtok($contentType, ';') ?: ''));
return match ($normalized) {
'image/jpeg', 'image/jpg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif',
default => '',
};
}
private function downloadToTempFile(string $objectPath, string $extension): ?string
{
$contents = $this->storage->readObject($objectPath);
if (! is_string($contents) || $contents === '') {
return null;
}
return $this->writeTemporaryFile($contents, $extension);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Services\Images\Detectors;
use App\Contracts\Images\SubjectDetectorInterface;
use App\Data\Images\SubjectDetectionResultData;
final class ChainedSubjectDetector implements SubjectDetectorInterface
{
/**
* @param iterable<int, SubjectDetectorInterface> $detectors
*/
public function __construct(private readonly iterable $detectors)
{
}
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
{
foreach ($this->detectors as $detector) {
$result = $detector->detect($sourcePath, $sourceWidth, $sourceHeight, $context);
if ($result !== null) {
return $result;
}
}
return null;
}
}

View File

@@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
namespace App\Services\Images\Detectors;
use App\Contracts\Images\SubjectDetectorInterface;
use App\Data\Images\CropBoxData;
use App\Data\Images\SubjectDetectionResultData;
final class HeuristicSubjectDetector implements SubjectDetectorInterface
{
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
{
if (! function_exists('imagecreatefromstring')) {
return null;
}
$binary = @file_get_contents($sourcePath);
if (! is_string($binary) || $binary === '') {
return null;
}
$source = @imagecreatefromstring($binary);
if ($source === false) {
return null;
}
try {
$sampleMax = max(24, (int) config('uploads.square_thumbnails.saliency.sample_max_dimension', 96));
$longest = max(1, max($sourceWidth, $sourceHeight));
$scale = min(1.0, $sampleMax / $longest);
$sampleWidth = max(8, (int) round($sourceWidth * $scale));
$sampleHeight = max(8, (int) round($sourceHeight * $scale));
$sample = imagecreatetruecolor($sampleWidth, $sampleHeight);
if ($sample === false) {
return null;
}
try {
imagecopyresampled($sample, $source, 0, 0, 0, 0, $sampleWidth, $sampleHeight, $sourceWidth, $sourceHeight);
$gray = $this->grayscaleMatrix($sample, $sampleWidth, $sampleHeight);
$rarity = $this->colorRarityMatrix($sample, $sampleWidth, $sampleHeight);
$vegetation = $this->vegetationMaskMatrix($sample, $sampleWidth, $sampleHeight);
} finally {
imagedestroy($sample);
}
$energy = $this->energyMatrix($gray, $sampleWidth, $sampleHeight);
$saliency = $this->combineSaliency($energy, $rarity, $sampleWidth, $sampleHeight);
$prefix = $this->prefixMatrix($saliency, $sampleWidth, $sampleHeight);
$vegetationPrefix = $this->prefixMatrix($vegetation, $sampleWidth, $sampleHeight);
$totalEnergy = $prefix[$sampleHeight][$sampleWidth] ?? 0.0;
if ($totalEnergy < (float) config('uploads.square_thumbnails.saliency.min_total_energy', 2400.0)) {
return null;
}
$candidate = $this->bestCandidate($prefix, $vegetationPrefix, $sampleWidth, $sampleHeight, $totalEnergy);
$rareSubjectCandidate = $this->rareSubjectCandidate($rarity, $vegetation, $sampleWidth, $sampleHeight);
if ($rareSubjectCandidate !== null && ($candidate === null || $rareSubjectCandidate['score'] > ($candidate['score'] * 0.72))) {
$candidate = $rareSubjectCandidate;
}
if ($candidate === null) {
return null;
}
$scaleX = $sourceWidth / max(1, $sampleWidth);
$scaleY = $sourceHeight / max(1, $sampleHeight);
$sideScale = max($scaleX, $scaleY);
$cropBox = new CropBoxData(
x: (int) floor($candidate['x'] * $scaleX),
y: (int) floor($candidate['y'] * $scaleY),
width: max(1, (int) round($candidate['side'] * $sideScale)),
height: max(1, (int) round($candidate['side'] * $sideScale)),
);
$averageDensity = $totalEnergy / max(1, $sampleWidth * $sampleHeight);
$confidence = min(1.0, max(0.15, ($candidate['density'] / max(1.0, $averageDensity)) / 4.0));
return new SubjectDetectionResultData(
cropBox: $cropBox->clampToImage($sourceWidth, $sourceHeight),
strategy: 'saliency',
reason: 'heuristic_saliency',
confidence: $confidence,
meta: [
'sample_width' => $sampleWidth,
'sample_height' => $sampleHeight,
'score' => $candidate['score'],
],
);
} finally {
imagedestroy($source);
}
}
/**
* @return array<int, array<int, int>>
*/
private function grayscaleMatrix($sample, int $width, int $height): array
{
$gray = [];
for ($y = 0; $y < $height; $y++) {
$gray[$y] = [];
for ($x = 0; $x < $width; $x++) {
$rgb = imagecolorat($sample, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$gray[$y][$x] = (int) round($r * 0.299 + $g * 0.587 + $b * 0.114);
}
}
return $gray;
}
/**
* @param array<int, array<int, int>> $gray
* @return array<int, array<int, float>>
*/
private function energyMatrix(array $gray, int $width, int $height): array
{
$energy = [];
for ($y = 0; $y < $height; $y++) {
$energy[$y] = [];
for ($x = 0; $x < $width; $x++) {
$center = $gray[$y][$x] ?? 0;
$right = $gray[$y][$x + 1] ?? $center;
$down = $gray[$y + 1][$x] ?? $center;
$diag = $gray[$y + 1][$x + 1] ?? $center;
$energy[$y][$x] = abs($center - $right)
+ abs($center - $down)
+ (abs($center - $diag) * 0.5);
}
}
return $energy;
}
/**
* Build a map that highlights globally uncommon colors, which helps distinguish
* a main subject from repetitive foliage or sky textures.
*
* @return array<int, array<int, float>>
*/
private function colorRarityMatrix($sample, int $width, int $height): array
{
$counts = [];
$pixels = [];
$totalPixels = max(1, $width * $height);
for ($y = 0; $y < $height; $y++) {
$pixels[$y] = [];
for ($x = 0; $x < $width; $x++) {
$rgb = imagecolorat($sample, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$bucket = (($r >> 5) << 6) | (($g >> 5) << 3) | ($b >> 5);
$counts[$bucket] = ($counts[$bucket] ?? 0) + 1;
$pixels[$y][$x] = [$r, $g, $b, $bucket];
}
}
$rarity = [];
for ($y = 0; $y < $height; $y++) {
$rarity[$y] = [];
for ($x = 0; $x < $width; $x++) {
[$r, $g, $b, $bucket] = $pixels[$y][$x];
$bucketCount = max(1, (int) ($counts[$bucket] ?? 1));
$baseRarity = log(($totalPixels + 1) / $bucketCount);
$maxChannel = max($r, $g, $b);
$minChannel = min($r, $g, $b);
$saturation = $maxChannel - $minChannel;
$luma = ($r * 0.299) + ($g * 0.587) + ($b * 0.114);
$neutralLightBoost = ($luma >= 135 && $saturation <= 95) ? 1.0 : 0.0;
$warmBoost = ($r >= 96 && $r >= $b + 10) ? 1.0 : 0.0;
$vegetationPenalty = ($g >= 72 && $g >= $r * 1.12 && $g >= $b * 1.08) ? 1.0 : 0.0;
$rarity[$y][$x] = max(0.0,
($baseRarity * 32.0)
+ ($saturation * 0.10)
+ ($neutralLightBoost * 28.0)
+ ($warmBoost * 18.0)
- ($vegetationPenalty * 18.0)
);
}
}
return $rarity;
}
/**
* @return array<int, array<int, float>>
*/
private function vegetationMaskMatrix($sample, int $width, int $height): array
{
$mask = [];
for ($y = 0; $y < $height; $y++) {
$mask[$y] = [];
for ($x = 0; $x < $width; $x++) {
$rgb = imagecolorat($sample, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$mask[$y][$x] = ($g >= 72 && $g >= $r * 1.12 && $g >= $b * 1.08) ? 1.0 : 0.0;
}
}
return $mask;
}
/**
* @param array<int, array<int, float>> $energy
* @param array<int, array<int, float>> $rarity
* @return array<int, array<int, float>>
*/
private function combineSaliency(array $energy, array $rarity, int $width, int $height): array
{
$combined = [];
for ($y = 0; $y < $height; $y++) {
$combined[$y] = [];
for ($x = 0; $x < $width; $x++) {
$combined[$y][$x] = ($energy[$y][$x] ?? 0.0) + (($rarity[$y][$x] ?? 0.0) * 1.45);
}
}
return $combined;
}
/**
* @param array<int, array<int, float>> $matrix
* @return array<int, array<int, float>>
*/
private function prefixMatrix(array $matrix, int $width, int $height): array
{
$prefix = array_fill(0, $height + 1, array_fill(0, $width + 1, 0.0));
for ($y = 1; $y <= $height; $y++) {
for ($x = 1; $x <= $width; $x++) {
$prefix[$y][$x] = $matrix[$y - 1][$x - 1]
+ $prefix[$y - 1][$x]
+ $prefix[$y][$x - 1]
- $prefix[$y - 1][$x - 1];
}
}
return $prefix;
}
/**
* @param array<int, array<int, float>> $prefix
* @return array{x: int, y: int, side: int, density: float, score: float}|null
*/
private function bestCandidate(array $prefix, array $vegetationPrefix, int $sampleWidth, int $sampleHeight, float $totalEnergy): ?array
{
$minDimension = min($sampleWidth, $sampleHeight);
$ratios = (array) config('uploads.square_thumbnails.saliency.window_ratios', [0.55, 0.7, 0.82, 1.0]);
$best = null;
foreach ($ratios as $ratio) {
$side = max(8, min($minDimension, (int) round($minDimension * (float) $ratio)));
$step = max(1, (int) floor($side / 5));
for ($y = 0; $y <= max(0, $sampleHeight - $side); $y += $step) {
for ($x = 0; $x <= max(0, $sampleWidth - $side); $x += $step) {
$sum = $this->sumRegion($prefix, $x, $y, $side, $side);
$density = $sum / max(1, $side * $side);
$centerX = ($x + ($side / 2)) / max(1, $sampleWidth);
$centerY = ($y + ($side / 2)) / max(1, $sampleHeight);
$centerBias = 1.0 - min(1.0, abs($centerX - 0.5) * 1.2 + abs($centerY - 0.42) * 0.9);
$coverage = $side / max(1, $minDimension);
$coverageFit = 1.0 - min(1.0, abs($coverage - 0.72) / 0.45);
$vegetationRatio = $this->sumRegion($vegetationPrefix, $x, $y, $side, $side) / max(1, $side * $side);
$score = $density * (1.0 + max(0.0, $centerBias) * 0.18)
+ (($sum / max(1.0, $totalEnergy)) * 4.0)
+ (max(0.0, $coverageFit) * 2.5)
- ($vegetationRatio * 68.0);
if ($best === null || $score > $best['score']) {
$best = [
'x' => $x,
'y' => $y,
'side' => $side,
'density' => $density,
'score' => $score,
];
}
}
}
}
return $best;
}
/**
* Build a second candidate from rare, non-foliage pixels so a smooth subject can
* still win even when repetitive textured leaves dominate edge energy.
*
* @param array<int, array<int, float>> $rarity
* @param array<int, array<int, float>> $vegetation
* @return array{x: int, y: int, side: int, density: float, score: float}|null
*/
private function rareSubjectCandidate(array $rarity, array $vegetation, int $sampleWidth, int $sampleHeight): ?array
{
$values = [];
for ($y = 0; $y < $sampleHeight; $y++) {
for ($x = 0; $x < $sampleWidth; $x++) {
if (($vegetation[$y][$x] ?? 0.0) >= 0.5) {
continue;
}
$values[] = (float) ($rarity[$y][$x] ?? 0.0);
}
}
if (count($values) < 24) {
return null;
}
sort($values);
$thresholdIndex = max(0, (int) floor((count($values) - 1) * 0.88));
$threshold = max(48.0, (float) ($values[$thresholdIndex] ?? 0.0));
$weightSum = 0.0;
$weightedX = 0.0;
$weightedY = 0.0;
$minX = $sampleWidth;
$minY = $sampleHeight;
$maxX = 0;
$maxY = 0;
$count = 0;
for ($y = 0; $y < $sampleHeight; $y++) {
for ($x = 0; $x < $sampleWidth; $x++) {
if (($vegetation[$y][$x] ?? 0.0) >= 0.5) {
continue;
}
$weight = (float) ($rarity[$y][$x] ?? 0.0);
if ($weight < $threshold) {
continue;
}
$weightSum += $weight;
$weightedX += ($x + 0.5) * $weight;
$weightedY += ($y + 0.5) * $weight;
$minX = min($minX, $x);
$minY = min($minY, $y);
$maxX = max($maxX, $x);
$maxY = max($maxY, $y);
$count++;
}
}
if ($count < 12 || $weightSum <= 0.0) {
return null;
}
$meanX = $weightedX / $weightSum;
$meanY = $weightedY / $weightSum;
$boxWidth = max(8, ($maxX - $minX) + 1);
$boxHeight = max(8, ($maxY - $minY) + 1);
$minDimension = min($sampleWidth, $sampleHeight);
$side = max($boxWidth, $boxHeight);
$side = max($side, (int) round($minDimension * 0.42));
$side = min($minDimension, (int) round($side * 1.18));
return [
'x' => (int) round($meanX - ($side / 2)),
'y' => (int) round($meanY - ($side / 2)),
'side' => max(8, $side),
'density' => $weightSum / max(1, $count),
'score' => ($weightSum / max(1, $count)) + ($count * 0.35),
];
}
/**
* @param array<int, array<int, float>> $prefix
*/
private function sumRegion(array $prefix, int $x, int $y, int $width, int $height): float
{
$x2 = $x + $width;
$y2 = $y + $height;
return ($prefix[$y2][$x2] ?? 0.0)
- ($prefix[$y][$x2] ?? 0.0)
- ($prefix[$y2][$x] ?? 0.0)
+ ($prefix[$y][$x] ?? 0.0);
}
}

Some files were not shown because too many files have changed in this diff Show More