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