optimizations

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

View File

@@ -10,6 +10,7 @@ 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;
@@ -89,6 +90,12 @@ class AchievementService
$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;
}

View File

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

View File

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

View File

@@ -11,9 +11,9 @@ use Illuminate\Support\Str;
final class ArtworkDraftService
{
public function createDraft(int $userId, string $title, ?string $description, ?int $categoryId = null): ArtworkDraftResult
public function createDraft(int $userId, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false): ArtworkDraftResult
{
return DB::transaction(function () use ($userId, $title, $description, $categoryId) {
return DB::transaction(function () use ($userId, $title, $description, $categoryId, $isMature) {
$slug = $this->uniqueSlug($title);
$artwork = Artwork::create([
@@ -28,8 +28,11 @@ final class ArtworkDraftService
'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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -273,7 +273,7 @@ class ContentSanitizer
$toUnwrap[] = $child;
continue;
}
$child->setAttribute('rel', 'noopener noreferrer nofollow ugc');
$child->setAttribute('rel', 'noopener noreferrer nofollow');
$child->setAttribute('target', '_blank');
}

View File

@@ -7,6 +7,7 @@ namespace App\Services\EarlyGrowth;
use App\Models\Artwork;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* SpotlightEngine
@@ -68,6 +69,7 @@ final class SpotlightEngine implements SpotlightEngineInterface
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()
@@ -82,7 +84,7 @@ final class SpotlightEngine implements SpotlightEngineInterface
->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 + RAND({$seed}) * 0.4 DESC")
->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)
@@ -96,6 +98,7 @@ final class SpotlightEngine implements SpotlightEngineInterface
private function selectCurated(int $limit, int $olderThanDays): Collection
{
$seed = (int) now()->format('Ymd');
$randomExpr = $this->dailyRandomExpression('artworks.id', $seed);
return Artwork::query()
->public()
@@ -108,9 +111,18 @@ final class SpotlightEngine implements SpotlightEngineInterface
->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 + RAND({$seed}) * 0.3 DESC")
->orderByRaw("COALESCE(_ast2.ranking_score, 0) * 0.7 + {$randomExpr} * 0.3 DESC")
->limit($limit)
->get()
->values();
}
private function dailyRandomExpression(string $idColumn, int $seed): string
{
if (DB::connection()->getDriverName() === 'sqlite') {
return "(ABS((({$idColumn} * 1103515245) + {$seed}) % 2147483647) / 2147483647.0)";
}
return "RAND({$seed} + {$idColumn})";
}
}

View File

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

View File

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

View File

@@ -3,8 +3,11 @@
namespace App\Services;
use App\Models\User;
use App\Notifications\UserFollowedNotification;
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;
/**
@@ -19,7 +22,12 @@ use Illuminate\Support\Facades\DB;
*/
final class FollowService
{
public function __construct(private readonly XPService $xp) {}
public function __construct(
private readonly XPService $xp,
private readonly NotificationService $notifications,
private readonly FollowAnalyticsService $analytics,
) {
}
/**
* Follow $targetId on behalf of $actorId.
@@ -66,12 +74,17 @@ final class FollowService
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logFollow($actorId, $targetId);
} catch (\Throwable) {}
$targetUser = User::query()->find($targetId);
$actorUser = User::query()->find($actorId);
if ($targetUser && $actorUser) {
$targetUser->notify(new UserFollowedNotification($actorUser));
$this->notifications->notifyUserFollowed($targetUser->loadMissing('profile'), $actorUser->loadMissing('profile'));
}
$this->analytics->recordFollow($actorId, $targetId);
$this->xp->awardFollowerReceived($targetId, $actorId);
event(new AchievementCheckRequested($targetId));
}
@@ -108,6 +121,10 @@ final class FollowService
$this->decrementCounter($targetId, 'followers_count');
});
if ($deleted) {
$this->analytics->recordUnfollow($actorId, $targetId);
}
return $deleted;
}
@@ -143,6 +160,60 @@ final class FollowService
->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
@@ -167,4 +238,89 @@ final class FollowService
'updated_at' => now(),
]);
}
private function buildFollowerOverlapSummary(int $viewerId, int $targetId): ?array
{
$preview = DB::table('user_followers as viewer_following')
->join('user_followers as target_followers', 'target_followers.follower_id', '=', 'viewer_following.user_id')
->join('users as u', 'u.id', '=', 'viewer_following.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('viewer_following.follower_id', $viewerId)
->where('target_followers.user_id', $targetId)
->whereNull('u.deleted_at')
->where('u.is_active', true)
->orderByDesc('target_followers.created_at')
->limit(3)
->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash'])
->get();
if ($preview->isEmpty()) {
return null;
}
$count = DB::table('user_followers as viewer_following')
->join('user_followers as target_followers', 'target_followers.follower_id', '=', 'viewer_following.user_id')
->where('viewer_following.follower_id', $viewerId)
->where('target_followers.user_id', $targetId)
->count();
$lead = $preview->first();
$label = $count > 1
? sprintf('Followed by %s and %d other%s', $lead->username ?? $lead->name ?? 'someone', $count - 1, $count - 1 === 1 ? '' : 's')
: sprintf('Followed by %s', $lead->username ?? $lead->name ?? 'someone');
return [
'count' => (int) $count,
'label' => $label,
'users' => $this->mapUsers($preview),
];
}
private function buildSharedFollowingSummary(int $viewerId, int $targetId): ?array
{
$preview = DB::table('user_followers as viewer_following')
->join('user_followers as target_following', 'target_following.user_id', '=', 'viewer_following.user_id')
->join('users as u', 'u.id', '=', 'viewer_following.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('viewer_following.follower_id', $viewerId)
->where('target_following.follower_id', $targetId)
->whereNull('u.deleted_at')
->where('u.is_active', true)
->orderByDesc('viewer_following.created_at')
->limit(3)
->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash'])
->get();
if ($preview->isEmpty()) {
return null;
}
$count = DB::table('user_followers as viewer_following')
->join('user_followers as target_following', 'target_following.user_id', '=', 'viewer_following.user_id')
->where('viewer_following.follower_id', $viewerId)
->where('target_following.follower_id', $targetId)
->count();
$lead = $preview->first();
$label = $count > 1
? sprintf('You both follow %s and %d other%s', $lead->username ?? $lead->name ?? 'someone', $count - 1, $count - 1 === 1 ? '' : 's')
: sprintf('You both follow %s', $lead->username ?? $lead->name ?? 'someone');
return [
'count' => (int) $count,
'label' => $label,
'users' => $this->mapUsers($preview),
];
}
private function mapUsers(Collection $rows): array
{
return $rows->map(fn ($row) => [
'id' => (int) $row->id,
'username' => (string) ($row->username ?? ''),
'name' => (string) ($row->name ?? $row->username ?? ''),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash ?? null, 48),
])->values()->all();
}
}

View File

@@ -9,9 +9,11 @@ use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\EarlyGrowth;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Recommendation\RecommendationService;
use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserPreferenceService;
use App\Support\AvatarUrl;
use App\Models\Collection as CollectionModel;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -27,13 +29,22 @@ use Illuminate\Database\QueryException;
final class HomepageService
{
private const CACHE_TTL = 300; // 5 minutes
private const ARTWORK_SERIALIZATION_RELATIONS = [
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,sort_order',
'categories.contentType:id,name,slug',
];
public function __construct(
private readonly ArtworkService $artworks,
private readonly ArtworkSearchService $search,
private readonly UserPreferenceService $prefs,
private readonly RecommendationService $reco,
private readonly RecommendationFeedResolver $feedResolver,
private readonly GridFiller $gridFiller,
private readonly CollectionDiscoveryService $collectionDiscovery,
private readonly CollectionService $collectionService,
private readonly CollectionSurfaceService $collectionSurfaces,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -50,6 +61,10 @@ final class HomepageService
'rising' => $this->getRising(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'collections_featured' => $this->getFeaturedCollections(),
'collections_trending' => $this->getTrendingCollections(),
'collections_editorial' => $this->getEditorialCollections(),
'collections_community' => $this->getCommunityCollections(),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
@@ -61,12 +76,12 @@ final class HomepageService
*
* Sections:
* 1. user_data welcome row counts (messages, notifications, new followers)
* 2. from_following artworks from creators you follow
* 3. trending same trending feed as guests
* 4. by_tags artworks matching user's top tags (Trending For You)
* 5. by_categories fresh uploads in user's favourite categories
* 6. suggested_creators creators the user might want to follow
* 7. tags / creators / news shared with guest homepage
* 2. from_following artworks from creators you follow
* 3. for_you personalized recommendation preview
* 4. trending same trending feed as guests
* 5. by_categories fresh uploads in user's favourite categories
* 6. suggested_creators creators the user might want to follow
* 7. tags / creators / news shared with guest homepage
*/
public function allForUser(\App\Models\User $user): array
{
@@ -81,6 +96,11 @@ final class HomepageService
'rising' => $this->getRising(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'collections_featured' => $this->getFeaturedCollections(),
'collections_recent' => $this->getRecentCollections(),
'collections_trending' => $this->getTrendingCollections(),
'collections_editorial' => $this->getEditorialCollections(),
'collections_community' => $this->getCommunityCollections(),
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
'suggested_creators' => $this->getSuggestedCreators($user, $prefs),
@@ -95,21 +115,127 @@ final class HomepageService
}
/**
* "For You" homepage preview: first 12 results from the Phase 1 personalised feed.
*
* Uses RecommendationService which handles Meilisearch retrieval, PHP reranking,
* diversity controls, and its own Redis cache layer.
* "For You" homepage preview backed by the personalized feed engine.
*/
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
{
try {
return $this->reco->forYouPreview($user, $limit);
$feed = $this->feedResolver->getFeed((int) $user->id, $limit);
$algoVersion = (string) ($feed['meta']['algo_version'] ?? '');
$discoveryEndpoint = route('api.discovery.events.store');
$hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork');
$dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag');
return collect($feed['data'] ?? [])->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array {
$reason = (string) ($item['reason'] ?? 'Picked for you');
return [
'id' => (int) ($item['id'] ?? 0),
'title' => (string) ($item['title'] ?? 'Untitled'),
'name' => (string) ($item['title'] ?? 'Untitled'),
'slug' => (string) ($item['slug'] ?? ''),
'author' => (string) ($item['author'] ?? 'Artist'),
'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null,
'author_username' => (string) ($item['username'] ?? ''),
'author_avatar' => $item['avatar_url'] ?? null,
'avatar_url' => $item['avatar_url'] ?? null,
'thumb' => $item['thumbnail_url'] ?? null,
'thumb_url' => $item['thumbnail_url'] ?? null,
'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
'category_name' => (string) ($item['category_name'] ?? ''),
'category_slug' => (string) ($item['category_slug'] ?? ''),
'content_type_name' => (string) ($item['content_type_name'] ?? ''),
'content_type_slug' => (string) ($item['content_type_slug'] ?? ''),
'url' => (string) ($item['url'] ?? ('/art/' . ((int) ($item['id'] ?? 0)) . '/' . ($item['slug'] ?? ''))),
'width' => isset($item['width']) ? (int) $item['width'] : null,
'height' => isset($item['height']) ? (int) $item['height'] : null,
'published_at' => $item['published_at'] ?? null,
'primary_tag' => $item['primary_tag'] ?? null,
'tags' => is_array($item['tags'] ?? null) ? $item['tags'] : [],
'recommendation_source' => (string) ($item['source'] ?? 'mixed'),
'recommendation_reason' => $reason,
'recommendation_score' => isset($item['score']) ? round((float) $item['score'], 4) : null,
'recommendation_algo_version' => (string) ($item['algo_version'] ?? $algoVersion),
'recommendation_surface' => 'homepage-for-you',
'discovery_endpoint' => $discoveryEndpoint,
'hide_artwork_endpoint' => $hideArtworkEndpoint,
'dislike_tag_endpoint' => $dislikeTagEndpoint,
'metric_badge' => [
'label' => $reason,
'className' => 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
],
];
})->values()->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
return [];
}
}
public function getTrendingCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.trending_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicTrendingCollections($limit),
false
);
}
public function getEditorialCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.editorial_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_EDITORIAL, $limit),
false
);
}
public function getCommunityCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.community_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_COMMUNITY, $limit),
false
);
}
public function getFeaturedCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.featured_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicFeaturedCollections($limit),
false,
);
}
public function getRecentCollections(int $limit = 6): array
{
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicRecentCollections($limit),
false,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Sections
// ─────────────────────────────────────────────────────────────────────────
@@ -133,6 +259,10 @@ final class HomepageService
$artwork = null;
}
if ($artwork instanceof Artwork) {
$artwork->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
}
return $artwork ? $this->serializeArtwork($artwork, 'lg') : null;
});
}
@@ -156,13 +286,13 @@ final class HomepageService
])
->paginate($limit, 'page', 1);
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
if ($results->isEmpty()) {
if ($items->isEmpty()) {
return $this->getRisingFromDb($limit);
}
return $results->getCollection()
return $items
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -183,7 +313,7 @@ final class HomepageService
{
return Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', now()->subDays(30))
@@ -216,13 +346,13 @@ final class HomepageService
])
->paginate($limit, 'page', 1);
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
if ($results->isEmpty()) {
if ($items->isEmpty()) {
return $this->getTrendingFromDb($limit);
}
return $results->getCollection()
return $items
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -244,7 +374,7 @@ final class HomepageService
{
return Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', now()->subDays(30))
@@ -270,7 +400,7 @@ final class HomepageService
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($limit): array {
$artworks = Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->orderByDesc('published_at')
->limit($limit)
->get();
@@ -523,7 +653,7 @@ final class HomepageService
function () use ($followingIds): array {
$artworks = Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->whereIn('user_id', $followingIds)
->orderByDesc('published_at')
->limit(10)
@@ -535,7 +665,7 @@ final class HomepageService
}
/**
* Artworks matching the user's top tags (max 12).
* Fresh artworks matching the user's favourite tags (max 12).
* Powered by Meilisearch.
*/
public function getByTags(array $tagSlugs): array
@@ -546,8 +676,9 @@ final class HomepageService
try {
$results = $this->search->discoverByTags($tagSlugs, 12);
$items = $this->searchResultCollection($results);
return $results->getCollection()
return $items
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -569,8 +700,9 @@ final class HomepageService
try {
$results = $this->search->discoverByCategories($categorySlugs, 12);
$items = $this->searchResultCollection($results);
return $results->getCollection()
return $items
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
@@ -584,6 +716,42 @@ final class HomepageService
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* @return Collection<int, Artwork>
*/
private function searchResultCollection(mixed $results): Collection
{
if ($results instanceof Collection) {
return $results;
}
if (is_object($results) && method_exists($results, 'getCollection')) {
$collection = $results->getCollection();
if ($collection instanceof Collection) {
return $collection;
}
}
return collect();
}
/**
* Ensure serialized artwork payloads do not trigger lazy-loading per item.
*
* @param Collection<int, Artwork> $artworks
* @return Collection<int, Artwork>
*/
private function prepareArtworksForSerialization(Collection $artworks): Collection
{
if ($artworks->isEmpty()) {
return $artworks;
}
$artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
return $artworks;
}
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
{
$thumbMd = $artwork->thumbUrl('md');

View File

@@ -25,13 +25,21 @@ class SendMessageAction
{
$body = trim((string) ($payload['body'] ?? ''));
$files = $payload['attachments'] ?? [];
$clientTempId = $this->normalizedClientTempId($payload['client_temp_id'] ?? null);
$created = false;
/** @var Message $message */
$message = DB::transaction(function () use ($conversation, $sender, $payload, $body, $files) {
$message = DB::transaction(function () use ($conversation, $sender, $payload, $body, $files, $clientTempId, &$created) {
$existing = $this->findExistingMessage($conversation, $sender, $clientTempId);
if ($existing) {
return $existing;
}
$message = Message::query()->create([
'conversation_id' => $conversation->id,
'sender_id' => $sender->id,
'client_temp_id' => $payload['client_temp_id'] ?? null,
'client_temp_id' => $clientTempId,
'message_type' => empty($files) ? 'text' : ($body === '' ? 'attachment' : 'text'),
'body' => $body,
'reply_to_message_id' => $payload['reply_to_message_id'] ?? null,
@@ -48,9 +56,15 @@ class SendMessageAction
'last_message_at' => $message->created_at,
])->save();
$created = true;
return $message;
});
if (! $created) {
return $message->fresh(['sender:id,username,name', 'attachments', 'reactions']);
}
$participantIds = $this->conversationState->activeParticipantIds($conversation);
$this->conversationState->touchConversationCachesForUsers($participantIds);
@@ -68,6 +82,27 @@ class SendMessageAction
return $message->fresh(['sender:id,username,name', 'attachments', 'reactions']);
}
private function findExistingMessage(Conversation $conversation, User $sender, ?string $clientTempId): ?Message
{
if ($clientTempId === null) {
return null;
}
return Message::query()
->where('conversation_id', $conversation->id)
->where('sender_id', $sender->id)
->where('client_temp_id', $clientTempId)
->whereNull('deleted_at')
->first();
}
private function normalizedClientTempId(mixed $value): ?string
{
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
}
private function storeAttachment(UploadedFile $file, Message $message, int $userId): void
{
$mime = (string) $file->getMimeType();

View File

@@ -11,6 +11,121 @@ use Illuminate\Support\Collection;
final class NotificationService
{
public function notifyUserFollowed(User $recipient, User $actor): ?Notification
{
if ($recipient->id === $actor->id) {
return null;
}
if ($recipient->profile && $recipient->profile->follower_notifications === false) {
return null;
}
$label = $actor->name ?: $actor->username ?: 'Someone';
return Notification::query()->create([
'user_id' => (int) $recipient->id,
'type' => 'user_followed',
'data' => [
'type' => 'user_followed',
'actor_id' => (int) $actor->id,
'actor_name' => $actor->name,
'actor_username' => $actor->username,
'message' => $label . ' started following you',
'url' => $actor->username ? '/@' . $actor->username : null,
],
]);
}
public function notifyCollectionInvite(User $recipient, User $actor, \App\Models\Collection $collection, string $role): ?Notification
{
if ($recipient->id === $actor->id) {
return null;
}
return Notification::query()->create([
'user_id' => (int) $recipient->id,
'type' => 'collection_invite',
'data' => [
'type' => 'collection_invite',
'actor_id' => (int) $actor->id,
'actor_name' => $actor->name,
'actor_username' => $actor->username,
'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' invited you to collaborate on ' . $collection->title,
'url' => route('settings.collections.show', ['collection' => $collection->id]),
'collection_id' => (int) $collection->id,
'collection_title' => (string) $collection->title,
'role' => $role,
],
]);
}
public function notifyCollectionSubmission(User $recipient, User $actor, \App\Models\Collection $collection, \App\Models\Artwork $artwork): ?Notification
{
if ($recipient->id === $actor->id) {
return null;
}
return Notification::query()->create([
'user_id' => (int) $recipient->id,
'type' => 'collection_submission',
'data' => [
'type' => 'collection_submission',
'actor_id' => (int) $actor->id,
'actor_name' => $actor->name,
'actor_username' => $actor->username,
'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' submitted "' . $artwork->title . '" to ' . $collection->title,
'url' => route('settings.collections.show', ['collection' => $collection->id]),
'collection_id' => (int) $collection->id,
'artwork_id' => (int) $artwork->id,
],
]);
}
public function notifyCollectionComment(User $recipient, User $actor, \App\Models\Collection $collection, \App\Models\CollectionComment $comment): ?Notification
{
if ($recipient->id === $actor->id) {
return null;
}
return Notification::query()->create([
'user_id' => (int) $recipient->id,
'type' => 'collection_comment',
'data' => [
'type' => 'collection_comment',
'actor_id' => (int) $actor->id,
'actor_name' => $actor->name,
'actor_username' => $actor->username,
'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' commented on ' . $collection->title,
'url' => route('profile.collections.show', ['username' => strtolower((string) $collection->user->username), 'slug' => $collection->slug]),
'collection_id' => (int) $collection->id,
'comment_id' => (int) $comment->id,
],
]);
}
public function notifyNovaCardComment(User $recipient, User $actor, \App\Models\NovaCard $card, \App\Models\NovaCardComment $comment): ?Notification
{
if ($recipient->id === $actor->id) {
return null;
}
return Notification::query()->create([
'user_id' => (int) $recipient->id,
'type' => 'nova_card_comment',
'data' => [
'type' => 'nova_card_comment',
'actor_id' => (int) $actor->id,
'actor_name' => $actor->name,
'actor_username' => $actor->username,
'message' => ($actor->name ?: $actor->username ?: 'Someone') . ' commented on ' . $card->title,
'url' => $card->publicUrl() . '#comment-' . $comment->id,
'card_id' => (int) $card->id,
'comment_id' => (int) $comment->id,
],
]);
}
public function listForUser(User $user, int $page = 1, int $perPage = 20): array
{
$resolvedPage = max(1, $page);
@@ -87,4 +202,4 @@ final class NotificationService
] : null,
];
}
}
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use Illuminate\Support\Arr;
/**
* AI-assist hooks for Nova Cards v3.
*
* This service provides assistive suggestions it does NOT auto-publish
* or override the creator. All suggestions are optional and editable.
*
* Integration strategy: each suggest* method returns a plain array of
* suggestions. If an AI/ML backend is configured (via config/nova_cards.php),
* this service dispatches to it; otherwise it uses deterministic rule-based
* fallbacks. This keeps the system functional without a live AI dependency.
*/
class NovaCardAiAssistService
{
public function suggestTags(NovaCard $card): array
{
$text = implode(' ', array_filter([
$card->quote_text,
$card->quote_author,
$card->title,
$card->description,
]));
// Rule-based: extract meaningful words as tag suggestions.
// In production, replace/extend with an actual NLP/AI call.
$words = preg_split('/[\s\-_,\.]+/u', strtolower($text));
$stopWords = ['the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'of', 'and', 'or', 'is', 'it', 'be', 'by', 'that', 'this', 'with', 'you', 'your', 'we', 'our', 'not'];
$filtered = array_values(array_filter(
array_unique($words ?? []),
fn ($w) => is_string($w) && mb_strlen($w) >= 4 && ! in_array($w, $stopWords, true),
));
return array_slice($filtered, 0, 6);
}
public function suggestMood(NovaCard $card): array
{
$text = strtolower((string) $card->quote_text . ' ' . (string) $card->title);
$moods = [];
$moodMap = [
'love|heart|romance|kiss|tender' => 'romantic',
'dark|shadow|night|alone|silence|void|lost' => 'dark-poetry',
'inspire|hope|dream|believe|courage|strength|rise' => 'inspirational',
'morning|sunrise|calm|peace|gentle|soft|breeze' => 'soft-morning',
'minimal|simple|quiet|still|breath|moment' => 'minimal',
'power|bold|fierce|fire|warrior|fight|hustle' => 'motivational',
'cyber|neon|digital|code|matrix|tech|signal' => 'cyber-mood',
];
foreach ($moodMap as $pattern => $mood) {
if (preg_match('/(' . $pattern . ')/i', $text)) {
$moods[] = $mood;
}
}
return array_slice(array_unique($moods), 0, 3);
}
public function suggestLayout(NovaCard $card): array
{
$project = is_array($card->project_json) ? $card->project_json : [];
$quoteLength = mb_strlen((string) $card->quote_text);
$hasAuthor = ! empty($card->quote_author);
// Heuristic layout suggestions based on text content.
$suggestions = [];
if ($quoteLength < 80) {
$suggestions[] = [
'layout' => 'quote_centered',
'reason' => 'Short quotes work well centered with generous padding.',
];
} elseif ($quoteLength < 200) {
$suggestions[] = [
'layout' => 'quote_heavy',
'reason' => 'Medium quotes benefit from a focused heavy layout.',
];
} else {
$suggestions[] = [
'layout' => 'editorial',
'reason' => 'Longer quotes fit editorial multi-column layouts.',
];
}
if ($hasAuthor) {
$suggestions[] = [
'layout' => 'byline_bottom',
'reason' => 'With an author, a bottom byline anchors attribution cleanly.',
];
}
return $suggestions;
}
public function suggestBackground(NovaCard $card): array
{
$moods = $this->suggestMood($card);
$suggestions = [];
$moodGradientMap = [
'romantic' => ['gradient_preset' => 'rose-poetry', 'reason' => 'Warm rose tones suit romantic content.'],
'dark-poetry' => ['gradient_preset' => 'midnight-nova', 'reason' => 'Deep dark gradients amplify dark poetry vibes.'],
'inspirational' => ['gradient_preset' => 'golden-hour', 'reason' => 'Warm golden tones elevate inspirational messages.'],
'soft-morning' => ['gradient_preset' => 'soft-dawn', 'reason' => 'Gentle pastels suit morning or calm content.'],
'minimal' => ['gradient_preset' => 'carbon-minimal', 'reason' => 'Clean dark or light neutrals suit minimal style.'],
'motivational' => ['gradient_preset' => 'bold-fire', 'reason' => 'Bold warm gradients energise motivational content.'],
'cyber-mood' => ['gradient_preset' => 'cyber-pulse', 'reason' => 'Electric neon gradients suit cyber aesthetic.'],
];
foreach ($moods as $mood) {
if (isset($moodGradientMap[$mood])) {
$suggestions[] = $moodGradientMap[$mood];
}
}
// Default if no match.
if (empty($suggestions)) {
$suggestions[] = ['gradient_preset' => 'midnight-nova', 'reason' => 'A versatile dark gradient that works for most content.'];
}
return $suggestions;
}
public function suggestFontPairing(NovaCard $card): array
{
$moods = $this->suggestMood($card);
$moodFontMap = [
'romantic' => ['font_preset' => 'romantic-serif', 'reason' => 'Elegant serif pairs beautifully with romantic content.'],
'dark-poetry' => ['font_preset' => 'dark-poetic', 'reason' => 'Strong contrast serif pairs with dark poetry style.'],
'inspirational' => ['font_preset' => 'modern-sans', 'reason' => 'Clean modern sans feels energising and clear.'],
'soft-morning' => ['font_preset' => 'soft-rounded', 'reason' => 'Rounded type has warmth and approachability.'],
'minimal' => ['font_preset' => 'minimal-mono', 'reason' => 'Monospaced type enforces a minimalist aesthetic.'],
'cyber-mood' => ['font_preset' => 'cyber-display', 'reason' => 'Tech display fonts suit cyber and digital themes.'],
];
foreach ($moods as $mood) {
if (isset($moodFontMap[$mood])) {
return [$moodFontMap[$mood]];
}
}
return [['font_preset' => 'modern-sans', 'reason' => 'A clean, versatile sans-serif for most content.']];
}
public function suggestReadabilityFixes(NovaCard $card): array
{
$issues = [];
$project = is_array($card->project_json) ? $card->project_json : [];
$textColor = Arr::get($project, 'typography.text_color', '#ffffff');
$bgType = Arr::get($project, 'background.type', 'gradient');
$overlayStyle = Arr::get($project, 'background.overlay_style', 'dark-soft');
$quoteSize = (int) Arr::get($project, 'typography.quote_size', 72);
$lineHeight = (float) Arr::get($project, 'typography.line_height', 1.2);
// Detect light text without overlay on upload background.
if ($bgType === 'upload' && in_array($overlayStyle, ['none', 'minimal'], true)) {
$issues[] = [
'field' => 'background.overlay_style',
'message' => 'Adding a dark overlay improves text legibility on photo backgrounds.',
'suggestion' => 'dark-soft',
];
}
// Detect very small quote text.
if ($quoteSize < 40) {
$issues[] = [
'field' => 'typography.quote_size',
'message' => 'Quote text may be too small to read on mobile.',
'suggestion' => 52,
];
}
// Detect very tight line height on long text.
if ($lineHeight < 1.1 && mb_strlen((string) $card->quote_text) > 100) {
$issues[] = [
'field' => 'typography.line_height',
'message' => 'Increasing line height improves readability for longer quotes.',
'suggestion' => 1.3,
];
}
return $issues;
}
/**
* Return all suggestions in one call, for the AI assist panel.
*/
public function allSuggestions(NovaCard $card): array
{
return [
'tags' => $this->suggestTags($card),
'moods' => $this->suggestMood($card),
'layouts' => $this->suggestLayout($card),
'backgrounds' => $this->suggestBackground($card),
'font_pairings' => $this->suggestFontPairing($card),
'readability_fixes' => $this->suggestReadabilityFixes($card),
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCardBackground;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
class NovaCardBackgroundService
{
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
} catch (\Throwable) {
$this->manager = null;
}
}
public function storeUploadedBackground(User $user, UploadedFile $file): NovaCardBackground
{
if ($this->manager === null) {
throw new RuntimeException('Nova card background processing requires Intervention Image.');
}
$uuid = (string) Str::uuid();
$extension = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$originalDisk = Storage::disk((string) config('nova_cards.storage.private_disk', 'local'));
$processedDisk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
$originalPath = trim((string) config('nova_cards.storage.background_original_prefix', 'cards/backgrounds/original'), '/')
. '/' . $user->id . '/' . $uuid . '.' . $extension;
$processedPath = trim((string) config('nova_cards.storage.background_processed_prefix', 'cards/backgrounds/processed'), '/')
. '/' . $user->id . '/' . $uuid . '.webp';
$originalDisk->put($originalPath, file_get_contents($file->getRealPath()) ?: '');
$image = $this->manager->read($file->getRealPath())->scaleDown(width: 2200, height: 2200);
$encoded = (string) $image->encode(new WebpEncoder(88));
$processedDisk->put($processedPath, $encoded);
return NovaCardBackground::query()->create([
'user_id' => $user->id,
'original_path' => $originalPath,
'processed_path' => $processedPath,
'width' => $image->width(),
'height' => $image->height(),
'mime_type' => (string) ($file->getMimeType() ?: 'image/jpeg'),
'file_size' => (int) $file->getSize(),
'sha256' => hash_file('sha256', $file->getRealPath()) ?: null,
'visibility' => 'card-only',
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardChallengeEntry;
use App\Models\User;
class NovaCardChallengeService
{
public function submit(User $user, NovaCardChallenge $challenge, NovaCard $card, ?string $note = null): NovaCardChallengeEntry
{
$entry = NovaCardChallengeEntry::query()->updateOrCreate([
'challenge_id' => $challenge->id,
'card_id' => $card->id,
], [
'user_id' => $user->id,
'status' => NovaCardChallengeEntry::STATUS_ACTIVE,
'note' => $note,
]);
$challenge->forceFill([
'entries_count' => NovaCardChallengeEntry::query()->where('challenge_id', $challenge->id)->count(),
])->save();
$card->forceFill([
'challenge_entries_count' => NovaCardChallengeEntry::query()->where('card_id', $card->id)->count(),
'last_engaged_at' => now(),
])->save();
return $entry;
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Jobs\UpdateNovaCardStatsJob;
use App\Models\NovaCard;
use App\Models\NovaCardCollection;
use App\Models\NovaCardCollectionItem;
use App\Models\User;
use Illuminate\Support\Str;
class NovaCardCollectionService
{
public function createCollection(User $user, array $attributes): NovaCardCollection
{
$name = trim((string) ($attributes['name'] ?? 'Saved Cards'));
$slug = $this->uniqueSlug($user, Str::slug($attributes['slug'] ?? $name) ?: 'saved-cards');
return NovaCardCollection::query()->create([
'user_id' => $user->id,
'slug' => $slug,
'name' => $name,
'description' => $attributes['description'] ?? null,
'visibility' => $attributes['visibility'] ?? NovaCardCollection::VISIBILITY_PRIVATE,
'official' => false,
]);
}
public function createManagedCollection(array $attributes): NovaCardCollection
{
$owner = User::query()->findOrFail((int) $attributes['user_id']);
$name = trim((string) ($attributes['name'] ?? 'Untitled Collection'));
$slug = $this->uniqueSlug($owner, Str::slug($attributes['slug'] ?? $name) ?: 'nova-collection');
return NovaCardCollection::query()->create([
'user_id' => $owner->id,
'slug' => $slug,
'name' => $name,
'description' => $attributes['description'] ?? null,
'visibility' => $attributes['visibility'] ?? NovaCardCollection::VISIBILITY_PUBLIC,
'official' => (bool) ($attributes['official'] ?? false),
'featured' => (bool) ($attributes['featured'] ?? false),
]);
}
public function listCollections(User $user): array
{
return NovaCardCollection::query()
->withCount('items')
->where('user_id', $user->id)
->orderByDesc('updated_at')
->get()
->map(fn (NovaCardCollection $collection): array => [
'id' => (int) $collection->id,
'slug' => (string) $collection->slug,
'name' => (string) $collection->name,
'description' => $collection->description,
'visibility' => (string) $collection->visibility,
'featured' => (bool) $collection->featured,
'cards_count' => (int) $collection->cards_count,
'items_count' => (int) $collection->items_count,
])
->values()
->all();
}
public function saveCard(User $user, NovaCard $card, ?int $collectionId = null, ?string $note = null): NovaCardCollection
{
$collection = $collectionId
? NovaCardCollection::query()->where('user_id', $user->id)->findOrFail($collectionId)
: $this->defaultCollection($user);
$this->addCardToCollection($collection, $card, $note);
return $collection->refresh();
}
public function addCardToCollection(NovaCardCollection $collection, NovaCard $card, ?string $note = null, ?int $sortOrder = null): NovaCardCollectionItem
{
$sortOrder ??= (int) NovaCardCollectionItem::query()->where('collection_id', $collection->id)->max('sort_order') + 1;
$item = NovaCardCollectionItem::query()->updateOrCreate([
'collection_id' => $collection->id,
'card_id' => $card->id,
], [
'note' => $note,
'sort_order' => $sortOrder,
]);
$this->refreshCounts($collection, $card);
return $item;
}
public function unsaveCard(User $user, NovaCard $card, ?int $collectionId = null): void
{
$query = NovaCardCollectionItem::query()
->where('card_id', $card->id)
->whereHas('collection', fn ($builder) => $builder->where('user_id', $user->id));
if ($collectionId !== null) {
$query->where('collection_id', $collectionId);
}
$collectionIds = $query->pluck('collection_id')->unique()->all();
$query->delete();
foreach ($collectionIds as $id) {
$collection = NovaCardCollection::query()->find($id);
if ($collection) {
$this->refreshCounts($collection, $card);
}
}
}
public function removeCardFromCollection(NovaCardCollection $collection, NovaCard $card): void
{
NovaCardCollectionItem::query()
->where('collection_id', $collection->id)
->where('card_id', $card->id)
->delete();
$this->refreshCounts($collection, $card);
}
public function defaultCollection(User $user): NovaCardCollection
{
return NovaCardCollection::query()->firstOrCreate([
'user_id' => $user->id,
'slug' => 'saved-cards',
], [
'name' => 'Saved Cards',
'description' => 'Private library of Nova Cards saved for remixing, referencing, and future collections.',
'visibility' => NovaCardCollection::VISIBILITY_PRIVATE,
'official' => false,
]);
}
private function refreshCounts(NovaCardCollection $collection, NovaCard $card): void
{
$collection->forceFill([
'cards_count' => NovaCardCollectionItem::query()->where('collection_id', $collection->id)->count(),
])->save();
UpdateNovaCardStatsJob::dispatch($card->id);
}
private function uniqueSlug(User $user, string $base): string
{
$slug = $base;
$suffix = 2;
while (NovaCardCollection::query()->where('user_id', $user->id)->where('slug', $slug)->exists()) {
$slug = $base . '-' . $suffix;
$suffix++;
}
return $slug;
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Jobs\UpdateNovaCardStatsJob;
use App\Models\NovaCard;
use App\Models\NovaCardComment;
use App\Models\User;
use App\Services\NotificationService;
use App\Support\AvatarUrl;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class NovaCardCommentService
{
public function __construct(
private readonly NotificationService $notifications,
) {
}
public function create(NovaCard $card, User $actor, string $body, ?NovaCardComment $parent = null): NovaCardComment
{
if (! $card->canReceiveCommentsFrom($actor)) {
throw ValidationException::withMessages([
'card' => 'Comments are unavailable for this card.',
]);
}
$comment = NovaCardComment::query()->create([
'card_id' => $card->id,
'user_id' => $actor->id,
'parent_id' => $parent?->id,
'body' => trim($body),
'rendered_body' => nl2br(e(trim($body))),
'status' => 'visible',
]);
if (! $card->isOwnedBy($actor)) {
$this->notifications->notifyNovaCardComment($card->user, $actor, $card, $comment);
}
UpdateNovaCardStatsJob::dispatch($card->id);
return $comment->fresh(['user.profile', 'replies.user.profile', 'card.user']);
}
public function delete(NovaCardComment $comment, User $actor): void
{
if ((int) $comment->user_id !== (int) $actor->id && ! $comment->card->isOwnedBy($actor) && ! $this->isModerator($actor)) {
throw ValidationException::withMessages([
'comment' => 'You are not allowed to remove this comment.',
]);
}
if ($comment->trashed()) {
return;
}
$comment->delete();
UpdateNovaCardStatsJob::dispatch($comment->card_id);
}
public function mapComments(NovaCard $card, ?User $viewer = null): array
{
$comments = $card->comments()
->whereNull('parent_id')
->where('status', 'visible')
->with(['user.profile', 'replies.user.profile', 'card.user'])
->latest()
->limit(30)
->get();
return $comments->map(fn (NovaCardComment $comment) => $this->mapComment($comment, $viewer))->all();
}
private function mapComment(NovaCardComment $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->card->isOwnedBy($viewer) || $this->isModerator($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,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64),
'profile_url' => '/@' . Str::lower((string) $user->username),
],
'replies' => $comment->replies
->where('status', 'visible')
->map(fn (NovaCardComment $reply) => $this->mapComment($reply, $viewer))
->values()
->all(),
];
}
private function isModerator(User $user): bool
{
if (method_exists($user, 'isModerator')) {
return (bool) $user->isModerator();
}
if (method_exists($user, 'hasRole')) {
return (bool) $user->hasRole('moderator') || (bool) $user->hasRole('admin');
}
return false;
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use App\Models\NovaCardCreatorPreset;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class NovaCardCreatorPresetService
{
/** Maximum presets per user across all types. */
public const MAX_TOTAL = 30;
/** Maximum presets per type per user. */
public const MAX_PER_TYPE = 8;
public function listForUser(User $user, ?string $presetType = null): Collection
{
return NovaCardCreatorPreset::query()
->where('user_id', $user->id)
->when($presetType !== null, fn ($q) => $q->where('preset_type', $presetType))
->orderByDesc('is_default')
->orderBy('name')
->get();
}
public function create(User $user, array $data): NovaCardCreatorPreset
{
$type = (string) Arr::get($data, 'preset_type', NovaCardCreatorPreset::TYPE_STYLE);
// Enforce per-type limit.
$typeCount = NovaCardCreatorPreset::query()
->where('user_id', $user->id)
->where('preset_type', $type)
->count();
if ($typeCount >= self::MAX_PER_TYPE) {
abort(422, 'Maximum number of ' . $type . ' presets reached (' . self::MAX_PER_TYPE . ').');
}
// Enforce total limit.
$totalCount = NovaCardCreatorPreset::query()->where('user_id', $user->id)->count();
if ($totalCount >= self::MAX_TOTAL) {
abort(422, 'Maximum total presets reached (' . self::MAX_TOTAL . ').');
}
$preset = NovaCardCreatorPreset::query()->create([
'user_id' => $user->id,
'name' => (string) Arr::get($data, 'name', 'My preset'),
'preset_type' => $type,
'config_json' => $this->sanitizeConfig((array) Arr::get($data, 'config_json', []), $type),
'is_default' => false,
]);
if ((bool) Arr::get($data, 'is_default', false)) {
$this->setDefault($user, $preset);
}
return $preset->refresh();
}
public function update(User $user, NovaCardCreatorPreset $preset, array $data): NovaCardCreatorPreset
{
$this->authorizeOwner($user, $preset);
$preset->fill([
'name' => (string) Arr::get($data, 'name', $preset->name),
'config_json' => $this->sanitizeConfig(
(array) Arr::get($data, 'config_json', $preset->config_json ?? []),
$preset->preset_type,
),
]);
$preset->save();
if (array_key_exists('is_default', $data) && (bool) $data['is_default']) {
$this->setDefault($user, $preset);
}
return $preset->refresh();
}
public function delete(User $user, NovaCardCreatorPreset $preset): void
{
$this->authorizeOwner($user, $preset);
$preset->delete();
}
public function setDefault(User $user, NovaCardCreatorPreset|int $preset): void
{
if (is_int($preset)) {
$preset = NovaCardCreatorPreset::query()->findOrFail($preset);
}
$this->authorizeOwner($user, $preset);
// Clear existing defaults of the same type before setting new one.
NovaCardCreatorPreset::query()
->where('user_id', $user->id)
->where('preset_type', $preset->preset_type)
->where('id', '!=', $preset->id)
->update(['is_default' => false]);
$preset->update(['is_default' => true]);
}
/**
* Capture a preset from a published or saved card project JSON.
* Extracts only the fields relevant to the given preset type.
*/
public function captureFromCard(User $user, NovaCard $card, string $presetName, string $presetType): NovaCardCreatorPreset
{
$project = is_array($card->project_json) ? $card->project_json : [];
$config = $this->extractFromProject($project, $presetType);
return $this->create($user, [
'name' => $presetName,
'preset_type' => $presetType,
'config_json' => $config,
]);
}
/**
* Apply a preset config on top of the given project patch array.
* Returns the merged patch to be passed into the draft autosave flow.
*/
public function applyToProjectPatch(NovaCardCreatorPreset $preset, array|NovaCard $currentProject): array
{
if ($currentProject instanceof NovaCard) {
$currentProject = is_array($currentProject->project_json) ? $currentProject->project_json : [];
}
$config = $preset->config_json ?? [];
return match ($preset->preset_type) {
NovaCardCreatorPreset::TYPE_TYPOGRAPHY => [
'typography' => array_merge(
(array) Arr::get($currentProject, 'typography', []),
(array) (Arr::get($config, 'typography', $config)),
),
],
NovaCardCreatorPreset::TYPE_LAYOUT => [
'layout' => array_merge(
(array) Arr::get($currentProject, 'layout', []),
(array) (Arr::get($config, 'layout', $config)),
),
],
NovaCardCreatorPreset::TYPE_BACKGROUND => [
'background' => array_merge(
(array) Arr::get($currentProject, 'background', []),
(array) (Arr::get($config, 'background', $config)),
),
],
NovaCardCreatorPreset::TYPE_STYLE => array_filter([
'typography' => isset($config['typography'])
? array_merge((array) Arr::get($currentProject, 'typography', []), (array) $config['typography'])
: null,
'background' => isset($config['background'])
? array_merge((array) Arr::get($currentProject, 'background', []), (array) $config['background'])
: null,
'effects' => isset($config['effects'])
? array_merge((array) Arr::get($currentProject, 'effects', []), (array) $config['effects'])
: null,
]),
NovaCardCreatorPreset::TYPE_STARTER => $config,
default => [],
};
}
private function extractFromProject(array $project, string $presetType): array
{
return match ($presetType) {
NovaCardCreatorPreset::TYPE_TYPOGRAPHY => array_intersect_key(
(array) Arr::get($project, 'typography', []),
array_flip(['font_preset', 'text_color', 'accent_color', 'quote_size', 'author_size', 'letter_spacing', 'line_height', 'shadow_preset', 'quote_mark_preset', 'text_panel_style', 'text_glow', 'text_stroke']),
),
NovaCardCreatorPreset::TYPE_LAYOUT => array_intersect_key(
(array) Arr::get($project, 'layout', []),
array_flip(['layout', 'position', 'alignment', 'padding', 'max_width']),
),
NovaCardCreatorPreset::TYPE_BACKGROUND => array_intersect_key(
(array) Arr::get($project, 'background', []),
array_flip(['type', 'gradient_preset', 'gradient_colors', 'solid_color', 'overlay_style', 'blur_level', 'opacity', 'brightness', 'contrast', 'texture_overlay', 'gradient_direction']),
),
NovaCardCreatorPreset::TYPE_STYLE => [
'typography' => array_intersect_key(
(array) Arr::get($project, 'typography', []),
array_flip(['font_preset', 'text_color', 'accent_color', 'shadow_preset', 'quote_mark_preset', 'text_panel_style']),
),
'background' => array_intersect_key(
(array) Arr::get($project, 'background', []),
array_flip(['gradient_preset', 'gradient_colors', 'overlay_style']),
),
'effects' => (array) Arr::get($project, 'effects', []),
],
NovaCardCreatorPreset::TYPE_STARTER => array_intersect_key($project, array_flip([
'template', 'layout', 'typography', 'background', 'decorations', 'effects', 'frame',
])),
default => [],
};
}
private function sanitizeConfig(array $config, string $type): array
{
// Strip deeply nested unknowns and limit total size.
$encoded = json_encode($config);
if ($encoded === false || mb_strlen($encoded) > 32_768) {
abort(422, 'Preset config is too large.');
}
return $config;
}
private function authorizeOwner(User $user, NovaCardCreatorPreset $preset): void
{
if ((int) $preset->user_id !== (int) $user->id) {
abort(403);
}
}
public function toArray(NovaCardCreatorPreset $preset): array
{
return [
'id' => (int) $preset->id,
'name' => (string) $preset->name,
'preset_type' => (string) $preset->preset_type,
'config_json' => $preset->config_json ?? [],
'is_default' => (bool) $preset->is_default,
'created_at' => optional($preset->created_at)?->toISOString(),
];
}
}

View File

@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Events\NovaCards\NovaCardAutosaved;
use App\Events\NovaCards\NovaCardCreated;
use App\Events\NovaCards\NovaCardTemplateSelected;
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardTemplate;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class NovaCardDraftService
{
public function __construct(
private readonly NovaCardTagService $tagService,
private readonly NovaCardProjectNormalizer $normalizer,
private readonly NovaCardVersionService $versions,
) {
}
public function createDraft(User $user, array $attributes = []): NovaCard
{
$template = $this->resolveTemplate(Arr::get($attributes, 'template_id'));
$category = $this->resolveCategory(Arr::get($attributes, 'category_id'));
$title = trim((string) Arr::get($attributes, 'title', 'Untitled card'));
$quote = trim((string) Arr::get($attributes, 'quote_text', 'Your next quote starts here.'));
$project = $this->normalizer->upgradeToV2(null, $template, $attributes);
$topLevel = $this->normalizer->syncTopLevelAttributes($project);
$originalCardId = Arr::get($attributes, 'original_card_id');
$rootCardId = Arr::get($attributes, 'root_card_id', $originalCardId);
$card = NovaCard::query()->create([
'user_id' => $user->id,
'category_id' => $category?->id,
'template_id' => $template?->id,
'title' => Str::limit($topLevel['title'] ?: $title, (int) config('nova_cards.validation.title_max', 120), ''),
'slug' => $this->generateUniqueSlug($title),
'quote_text' => Str::limit($topLevel['quote_text'] ?: $quote, (int) config('nova_cards.validation.quote_max', 420), ''),
'quote_author' => $topLevel['quote_author'],
'quote_source' => $topLevel['quote_source'],
'description' => Arr::get($attributes, 'description'),
'format' => $this->resolveFormat((string) Arr::get($attributes, 'format', NovaCard::FORMAT_SQUARE)),
'project_json' => $project,
'schema_version' => (int) $topLevel['schema_version'],
'visibility' => NovaCard::VISIBILITY_PRIVATE,
'status' => NovaCard::STATUS_DRAFT,
'moderation_status' => NovaCard::MOD_PENDING,
'background_type' => $topLevel['background_type'],
'background_image_id' => $topLevel['background_image_id'],
'allow_download' => true,
'allow_remix' => (bool) Arr::get($attributes, 'allow_remix', true),
'allow_background_reuse' => (bool) Arr::get($attributes, 'allow_background_reuse', false),
'allow_export' => (bool) Arr::get($attributes, 'allow_export', true),
'style_family' => Arr::get($attributes, 'style_family'),
'palette_family' => Arr::get($attributes, 'palette_family'),
'original_card_id' => $originalCardId,
'root_card_id' => $rootCardId,
]);
$this->tagService->syncTags($card, Arr::wrap(Arr::get($attributes, 'tags', [])));
$this->versions->snapshot($card->fresh(['template']), $user, $originalCardId ? 'Remix draft created' : 'Initial draft', true);
event(new NovaCardCreated($card->fresh()->load(['category', 'template', 'tags'])));
if ($card->template_id !== null) {
event(new NovaCardTemplateSelected($card->fresh()->load(['category', 'template', 'tags']), null, (int) $card->template_id));
}
return $card->load(['category', 'template', 'tags']);
}
public function autosave(NovaCard $card, array $payload): NovaCard
{
$currentProject = $this->normalizer->upgradeToV2(is_array($card->project_json) ? $card->project_json : [], $card->template, [], $card);
$template = $this->resolveTemplateId($payload, $card)
? NovaCardTemplate::query()->find($this->resolveTemplateId($payload, $card))
: $card->template;
$projectPatch = is_array(Arr::get($payload, 'project_json')) ? Arr::get($payload, 'project_json') : [];
$normalizedProject = $this->normalizer->upgradeToV2(
array_replace_recursive($currentProject, $projectPatch),
$template,
array_merge($payload, [
'title' => Arr::get($payload, 'title', $card->title),
'quote_text' => Arr::get($payload, 'quote_text', $card->quote_text),
'quote_author' => Arr::get($payload, 'quote_author', $card->quote_author),
'quote_source' => Arr::get($payload, 'quote_source', $card->quote_source),
'background_type' => Arr::get($payload, 'background_type', $card->background_type),
'background_image_id' => Arr::get($payload, 'background_image_id', $card->background_image_id),
'original_card_id' => $card->original_card_id,
'root_card_id' => $card->root_card_id,
]),
$card,
);
$topLevel = $this->normalizer->syncTopLevelAttributes($normalizedProject);
if (array_key_exists('title', $payload)) {
$topLevel['title'] = trim((string) $payload['title']);
}
if (array_key_exists('quote_text', $payload)) {
$topLevel['quote_text'] = trim((string) $payload['quote_text']);
}
if (array_key_exists('quote_author', $payload)) {
$topLevel['quote_author'] = (($value = trim((string) $payload['quote_author'])) !== '') ? $value : null;
}
if (array_key_exists('quote_source', $payload)) {
$topLevel['quote_source'] = (($value = trim((string) $payload['quote_source'])) !== '') ? $value : null;
}
$previousTemplateId = $card->template_id ? (int) $card->template_id : null;
$card->fill([
'title' => Str::limit($topLevel['title'], (int) config('nova_cards.validation.title_max', 120), ''),
'quote_text' => Str::limit($topLevel['quote_text'], (int) config('nova_cards.validation.quote_max', 420), ''),
'quote_author' => $topLevel['quote_author'],
'quote_source' => $topLevel['quote_source'],
'description' => Arr::get($payload, 'description', $card->description),
'format' => $this->resolveFormat((string) Arr::get($payload, 'format', $card->format)),
'project_json' => $normalizedProject,
'schema_version' => (int) $topLevel['schema_version'],
'background_type' => $topLevel['background_type'],
'background_image_id' => $topLevel['background_image_id'],
'template_id' => $this->resolveTemplateId($payload, $card),
'category_id' => $this->resolveCategoryId($payload, $card),
'visibility' => Arr::get($payload, 'visibility', $card->visibility),
'allow_download' => (bool) Arr::get($payload, 'allow_download', $card->allow_download),
'allow_remix' => (bool) Arr::get($payload, 'allow_remix', $card->allow_remix),
'allow_background_reuse' => (bool) Arr::get($payload, 'allow_background_reuse', $card->allow_background_reuse ?? false),
'allow_export' => (bool) Arr::get($payload, 'allow_export', $card->allow_export ?? true),
'style_family' => Arr::get($payload, 'style_family', $card->style_family),
'palette_family' => Arr::get($payload, 'palette_family', $card->palette_family),
'editor_mode_last_used' => Arr::get($payload, 'editor_mode_last_used', $card->editor_mode_last_used),
]);
if ($card->isDirty('title')) {
$card->slug = $this->generateUniqueSlug($card->title, $card->id);
}
$card->save();
$changes = $card->getChanges();
if (array_key_exists('tags', $payload)) {
$this->tagService->syncTags($card, Arr::wrap(Arr::get($payload, 'tags', [])));
$changes['tags'] = true;
}
if ($changes !== []) {
$fresh = $card->fresh()->load(['category', 'template', 'tags']);
$this->versions->snapshot($fresh->loadMissing('template'), $fresh->user, Arr::get($payload, 'version_label'));
event(new NovaCardAutosaved($fresh, array_keys($changes)));
$currentTemplateId = $fresh->template_id ? (int) $fresh->template_id : null;
if ($currentTemplateId !== $previousTemplateId && $currentTemplateId !== null) {
event(new NovaCardTemplateSelected($fresh, $previousTemplateId, $currentTemplateId));
}
}
return $card->refresh()->load(['category', 'template', 'tags']);
}
public function createRemix(User $user, NovaCard $source, array $attributes = []): NovaCard
{
$baseProject = $this->normalizer->normalizeForCard($source->loadMissing('template'));
$payload = array_merge([
'title' => 'Remix of ' . $source->title,
'quote_text' => $source->quote_text,
'quote_author' => $source->quote_author,
'quote_source' => $source->quote_source,
'description' => $source->description,
'format' => $source->format,
'background_type' => $source->background_type,
'background_image_id' => $source->background_image_id,
'template_id' => $source->template_id,
'category_id' => $source->category_id,
'tags' => $source->tags->pluck('name')->all(),
'project_json' => $baseProject,
'original_card_id' => $source->id,
'root_card_id' => $source->root_card_id ?: $source->id,
], $attributes);
return $this->createDraft($user, $payload);
}
public function createDuplicate(User $user, NovaCard $source, array $attributes = []): NovaCard
{
abort_unless($source->isOwnedBy($user), 403);
$baseProject = $this->normalizer->normalizeForCard($source->loadMissing('template'));
$payload = array_merge([
'title' => 'Copy of ' . $source->title,
'quote_text' => $source->quote_text,
'quote_author' => $source->quote_author,
'quote_source' => $source->quote_source,
'description' => $source->description,
'format' => $source->format,
'background_type' => $source->background_type,
'background_image_id' => $source->background_image_id,
'template_id' => $source->template_id,
'category_id' => $source->category_id,
'tags' => $source->tags->pluck('name')->all(),
'project_json' => $baseProject,
'allow_remix' => $source->allow_remix,
], $attributes);
return $this->createDraft($user, $payload);
}
private function buildProjectPayload(?NovaCardTemplate $template, array $attributes): array
{
return $this->normalizer->normalize(null, $template, $attributes);
}
private function generateUniqueSlug(string $title, ?int $ignoreId = null): string
{
$base = Str::slug($title);
if ($base === '') {
$base = 'nova-card';
}
$slug = $base;
$suffix = 2;
while (NovaCard::query()
->when($ignoreId !== null, fn ($query) => $query->where('id', '!=', $ignoreId))
->where('slug', $slug)
->exists()) {
$slug = $base . '-' . $suffix;
$suffix++;
}
return $slug;
}
private function resolveTemplate(mixed $templateId): ?NovaCardTemplate
{
if ($templateId) {
return NovaCardTemplate::query()->find($templateId);
}
return NovaCardTemplate::query()->where('active', true)->orderBy('order_num')->first();
}
private function resolveCategory(mixed $categoryId): ?NovaCardCategory
{
if ($categoryId) {
return NovaCardCategory::query()->find($categoryId);
}
return NovaCardCategory::query()->where('active', true)->orderBy('order_num')->first();
}
private function resolveFormat(string $format): string
{
$formats = array_keys((array) config('nova_cards.formats', []));
return in_array($format, $formats, true) ? $format : NovaCard::FORMAT_SQUARE;
}
private function resolveTemplateId(array $payload, NovaCard $card): ?int
{
if (! array_key_exists('template_id', $payload)) {
return $card->template_id;
}
return $this->resolveTemplate(Arr::get($payload, 'template_id'))?->id;
}
private function resolveCategoryId(array $payload, NovaCard $card): ?int
{
if (! array_key_exists('category_id', $payload)) {
return $card->category_id;
}
return $this->resolveCategory(Arr::get($payload, 'category_id'))?->id;
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Jobs\NovaCards\GenerateNovaCardExportJob;
use App\Models\NovaCard;
use App\Models\NovaCardExport;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;
/**
* Handles export request creation, status checking, and file resolution.
* Actual rendering is delegated to GenerateNovaCardExportJob (queued).
*/
class NovaCardExportService
{
/** How long (in minutes) a generated export file is available. */
private const EXPORT_TTL_MINUTES = 60;
/** Allowed export types and their canvas dimensions. */
public const EXPORT_SPECS = [
NovaCardExport::TYPE_PREVIEW => ['width' => 1080, 'height' => 1080, 'format' => 'webp'],
NovaCardExport::TYPE_HIRES => ['width' => 2160, 'height' => 2160, 'format' => 'webp'],
NovaCardExport::TYPE_SQUARE => ['width' => 1080, 'height' => 1080, 'format' => 'webp'],
NovaCardExport::TYPE_STORY => ['width' => 1080, 'height' => 1920, 'format' => 'webp'],
NovaCardExport::TYPE_WALLPAPER => ['width' => 2560, 'height' => 1440, 'format' => 'webp'],
NovaCardExport::TYPE_OG => ['width' => 1200, 'height' => 630, 'format' => 'jpg'],
];
public function requestExport(User $user, NovaCard $card, string $exportType, array $options = []): NovaCardExport
{
if (! $card->allow_export && ! $card->isOwnedBy($user)) {
abort(403, 'This card does not allow exports.');
}
$spec = self::EXPORT_SPECS[$exportType] ?? self::EXPORT_SPECS[NovaCardExport::TYPE_PREVIEW];
// Reuse a recent pending/ready non-expired export for the same type.
$existing = NovaCardExport::query()
->where('card_id', $card->id)
->where('user_id', $user->id)
->where('export_type', $exportType)
->whereIn('status', [NovaCardExport::STATUS_READY, NovaCardExport::STATUS_PENDING, NovaCardExport::STATUS_PROCESSING])
->where('expires_at', '>', now())
->orderByDesc('created_at')
->first();
if ($existing !== null) {
return $existing;
}
$export = NovaCardExport::query()->create([
'card_id' => $card->id,
'user_id' => $user->id,
'export_type' => $exportType,
'status' => NovaCardExport::STATUS_PENDING,
'width' => $spec['width'],
'height' => $spec['height'],
'format' => $spec['format'],
'options_json' => $options,
'expires_at' => now()->addMinutes(self::EXPORT_TTL_MINUTES),
]);
GenerateNovaCardExportJob::dispatch($export->id)
->onQueue((string) config('nova_cards.render.queue', 'default'));
return $export->refresh();
}
public function getStatus(NovaCardExport $export): array
{
return [
'id' => (int) $export->id,
'export_type' => (string) $export->export_type,
'status' => (string) $export->status,
'output_url' => $export->isReady() && ! $export->isExpired() ? $export->outputUrl() : null,
'width' => $export->width,
'height' => $export->height,
'format' => (string) $export->format,
'ready_at' => optional($export->ready_at)?->toISOString(),
'expires_at' => optional($export->expires_at)?->toISOString(),
];
}
public function cleanupExpired(): int
{
$exports = NovaCardExport::query()
->where('expires_at', '<', now())
->whereNotNull('output_path')
->get();
$count = 0;
foreach ($exports as $export) {
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
if ($export->output_path && $disk->exists($export->output_path)) {
$disk->delete($export->output_path);
}
$export->delete();
$count++;
}
return $count;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use App\Models\User;
class NovaCardLineageService
{
public function __construct(
private readonly NovaCardPresenter $presenter,
) {
}
public function resolve(NovaCard $card, ?User $viewer = null): array
{
$trailCards = [];
$cursor = $card;
$visited = [];
while ($cursor && ! in_array($cursor->id, $visited, true)) {
$visited[] = $cursor->id;
$trailCards[] = $cursor;
$cursor = $cursor->originalCard?->loadMissing(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard.user', 'rootCard.user']);
}
$trailCards = array_reverse($trailCards);
$rootCard = $card->rootCard ?? ($trailCards[0] ?? $card);
$family = NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard', 'rootCard'])
->where(function ($query) use ($rootCard): void {
$query->where('id', $rootCard->id)
->orWhere('root_card_id', $rootCard->id)
->orWhere('original_card_id', $rootCard->id);
})
->published()
->orderByDesc('published_at')
->get();
return [
'card' => $this->presenter->card($card, false, $viewer),
'trail' => $this->presenter->cards($trailCards, false, $viewer),
'root_card' => $this->presenter->card($rootCard, false, $viewer),
'family_cards' => $this->presenter->cards($family, false, $viewer),
];
}
}

View File

@@ -0,0 +1,355 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use App\Models\NovaCardAssetPack;
use App\Models\NovaCardCategory;
use App\Models\NovaCardCollection;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardCreatorPreset;
use App\Models\NovaCardReaction;
use App\Models\NovaCardTemplate;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class NovaCardPresenter
{
public function __construct(
private readonly NovaCardProjectNormalizer $normalizer,
private readonly NovaCardPublishModerationService $moderation,
) {
}
public function options(): array
{
$assetPacks = collect((array) config('nova_cards.asset_packs', []))
->map(fn (array $pack): array => ['id' => null, ...$pack])
->values();
$templatePacks = collect((array) config('nova_cards.template_packs', []))
->map(fn (array $pack): array => ['id' => null, ...$pack])
->values();
$databasePacks = NovaCardAssetPack::query()
->where('active', true)
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (NovaCardAssetPack $pack): array => [
'id' => (int) $pack->id,
'slug' => (string) $pack->slug,
'name' => (string) $pack->name,
'description' => $pack->description,
'type' => (string) $pack->type,
'preview_image' => $pack->preview_image,
'manifest_json' => $pack->manifest_json,
'official' => (bool) $pack->official,
'active' => (bool) $pack->active,
]);
return [
'formats' => collect((array) config('nova_cards.formats', []))
->map(fn (array $format, string $key): array => [
'key' => $key,
'label' => (string) ($format['label'] ?? ucfirst($key)),
'width' => (int) ($format['width'] ?? 1080),
'height' => (int) ($format['height'] ?? 1080),
])
->values()
->all(),
'font_presets' => collect((array) config('nova_cards.font_presets', []))
->map(fn (array $font, string $key): array => ['key' => $key, ...$font])
->values()
->all(),
'gradient_presets' => collect((array) config('nova_cards.gradient_presets', []))
->map(fn (array $gradient, string $key): array => ['key' => $key, ...$gradient])
->values()
->all(),
'decor_presets' => array_values((array) config('nova_cards.decor_presets', [])),
'background_modes' => array_values((array) config('nova_cards.background_modes', [])),
'layout_presets' => array_values((array) config('nova_cards.layout_presets', [])),
'alignment_presets' => array_values((array) config('nova_cards.alignment_presets', [])),
'position_presets' => array_values((array) config('nova_cards.position_presets', [])),
'padding_presets' => array_values((array) config('nova_cards.padding_presets', [])),
'max_width_presets' => array_values((array) config('nova_cards.max_width_presets', [])),
'line_height_presets' => array_values((array) config('nova_cards.line_height_presets', [])),
'shadow_presets' => array_values((array) config('nova_cards.shadow_presets', [])),
'focal_positions' => array_values((array) config('nova_cards.focal_positions', [])),
'categories' => NovaCardCategory::query()
->where('active', true)
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (NovaCardCategory $category): array => [
'id' => (int) $category->id,
'slug' => (string) $category->slug,
'name' => (string) $category->name,
'description' => $category->description,
])
->values()
->all(),
'templates' => NovaCardTemplate::query()
->where('active', true)
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (NovaCardTemplate $template): array => [
'id' => (int) $template->id,
'slug' => (string) $template->slug,
'name' => (string) $template->name,
'description' => $template->description,
'preview_image' => $template->preview_image,
'supported_formats' => $template->supported_formats,
'config_json' => $template->config_json,
'official' => (bool) $template->official,
])
->values()
->all(),
'asset_packs' => $assetPacks
->concat($databasePacks->where('type', NovaCardAssetPack::TYPE_ASSET)->values())
->values()
->all(),
'template_packs' => $templatePacks
->concat($databasePacks->where('type', NovaCardAssetPack::TYPE_TEMPLATE)->values())
->values()
->all(),
'challenge_feed' => NovaCardChallenge::query()
->whereIn('status', [NovaCardChallenge::STATUS_ACTIVE, NovaCardChallenge::STATUS_COMPLETED])
->orderByDesc('featured')
->orderBy('starts_at')
->limit(8)
->get()
->map(fn (NovaCardChallenge $challenge): array => [
'id' => (int) $challenge->id,
'slug' => (string) $challenge->slug,
'title' => (string) $challenge->title,
'description' => $challenge->description,
'prompt' => $challenge->prompt,
'status' => (string) $challenge->status,
'official' => (bool) $challenge->official,
'featured' => (bool) $challenge->featured,
'entries_count' => (int) $challenge->entries_count,
'starts_at' => optional($challenge->starts_at)?->toISOString(),
'ends_at' => optional($challenge->ends_at)?->toISOString(),
])
->values()
->all(),
'validation' => (array) config('nova_cards.validation', []),
// v3 additions
'quote_mark_presets' => array_values((array) config('nova_cards.quote_mark_presets', [])),
'text_panel_styles' => array_values((array) config('nova_cards.text_panel_styles', [])),
'frame_presets' => array_values((array) config('nova_cards.frame_presets', [])),
'color_grade_presets' => array_values((array) config('nova_cards.color_grade_presets', [])),
'effect_presets' => array_values((array) config('nova_cards.effect_presets', [])),
'style_families' => array_values((array) config('nova_cards.style_families', [])),
'export_formats' => collect((array) config('nova_cards.export_formats', []))
->map(fn ($fmt, $key) => array_merge(['key' => $key], (array) $fmt))
->values()
->all(),
];
}
public function optionsWithPresets(array $options, User $user): array
{
$presets = NovaCardCreatorPreset::query()
->where('user_id', $user->id)
->orderByDesc('is_default')
->orderBy('name')
->get()
->groupBy('preset_type')
->map(fn ($group) => $group->map(fn ($p) => [
'id' => (int) $p->id,
'name' => (string) $p->name,
'preset_type' => (string) $p->preset_type,
'config_json' => $p->config_json ?? [],
'is_default' => (bool) $p->is_default,
])->values()->all())
->all();
return array_merge($options, ['creator_presets' => $presets]);
}
public function card(NovaCard $card, bool $withProject = false, ?User $viewer = null): array
{
$card->loadMissing(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard', 'rootCard']);
$project = $this->normalizer->normalizeForCard($card);
$viewerCollections = $viewer
? NovaCardCollection::query()
->where('user_id', $viewer->id)
->whereHas('cards', fn ($query) => $query->where('nova_cards.id', $card->id))
->pluck('id')
->map(fn ($id): int => (int) $id)
->values()
->all()
: [];
$viewerReactions = $viewer
? NovaCardReaction::query()
->where('user_id', $viewer->id)
->where('card_id', $card->id)
->pluck('type')
->all()
: [];
$moderationReasons = $this->moderation->storedReasons($card);
$moderationOverride = $this->moderation->latestOverride($card);
return [
'id' => (int) $card->id,
'uuid' => (string) $card->uuid,
'title' => (string) $card->title,
'slug' => (string) $card->slug,
'quote_text' => (string) $card->quote_text,
'quote_author' => $card->quote_author,
'quote_source' => $card->quote_source,
'description' => $card->description,
'format' => (string) $card->format,
'visibility' => (string) $card->visibility,
'status' => (string) $card->status,
'moderation_status' => (string) $card->moderation_status,
'moderation_reasons' => $moderationReasons,
'moderation_reason_labels' => $this->moderation->labelsFor($moderationReasons),
'moderation_source' => $this->moderation->storedSource($card),
'moderation_override' => $moderationOverride,
'moderation_override_history' => $this->moderation->overrideHistory($card),
'featured' => (bool) $card->featured,
'allow_download' => (bool) $card->allow_download,
'background_type' => (string) $card->background_type,
'background_image_id' => $card->background_image_id ? (int) $card->background_image_id : null,
'template_id' => $card->template_id ? (int) $card->template_id : null,
'category_id' => $card->category_id ? (int) $card->category_id : null,
'preview_url' => $card->previewUrl(),
'og_preview_url' => $card->ogPreviewUrl(),
'public_url' => $card->publicUrl(),
'published_at' => optional($card->published_at)?->toISOString(),
'render_version' => (int) $card->render_version,
'schema_version' => (int) $card->schema_version,
'views_count' => (int) $card->views_count,
'shares_count' => (int) $card->shares_count,
'downloads_count' => (int) $card->downloads_count,
'likes_count' => (int) $card->likes_count,
'favorites_count' => (int) $card->favorites_count,
'saves_count' => (int) $card->saves_count,
'remixes_count' => (int) $card->remixes_count,
'comments_count' => (int) $card->comments_count,
'challenge_entries_count' => (int) $card->challenge_entries_count,
'allow_remix' => (bool) $card->allow_remix,
'allow_background_reuse' => (bool) $card->allow_background_reuse,
'allow_export' => (bool) $card->allow_export,
'style_family' => $card->style_family,
'palette_family' => $card->palette_family,
'editor_mode_last_used' => $card->editor_mode_last_used,
'featured_score' => $card->featured_score !== null ? (float) $card->featured_score : null,
'last_engaged_at' => optional($card->last_engaged_at)?->toISOString(),
'last_ranked_at' => optional($card->last_ranked_at)?->toISOString(),
'creator' => [
'id' => (int) $card->user->id,
'username' => (string) $card->user->username,
'name' => $card->user->name,
],
'category' => $card->category ? [
'id' => (int) $card->category->id,
'slug' => (string) $card->category->slug,
'name' => (string) $card->category->name,
] : null,
'template' => $card->template ? [
'id' => (int) $card->template->id,
'slug' => (string) $card->template->slug,
'name' => (string) $card->template->name,
'description' => $card->template->description,
'config_json' => $card->template->config_json,
'supported_formats' => $card->template->supported_formats,
] : null,
'background_image' => $card->backgroundImage ? [
'id' => (int) $card->backgroundImage->id,
'processed_url' => $card->backgroundImage->processedUrl(),
'width' => (int) $card->backgroundImage->width,
'height' => (int) $card->backgroundImage->height,
] : null,
'tags' => $card->tags->map(fn ($tag): array => [
'id' => (int) $tag->id,
'slug' => (string) $tag->slug,
'name' => (string) $tag->name,
])->values()->all(),
'lineage' => [
'original_card_id' => $card->original_card_id ? (int) $card->original_card_id : null,
'root_card_id' => $card->root_card_id ? (int) $card->root_card_id : null,
'original_card' => $card->originalCard ? [
'id' => (int) $card->originalCard->id,
'title' => (string) $card->originalCard->title,
'slug' => (string) $card->originalCard->slug,
] : null,
'root_card' => $card->rootCard ? [
'id' => (int) $card->rootCard->id,
'title' => (string) $card->rootCard->title,
'slug' => (string) $card->rootCard->slug,
] : null,
],
'version_count' => $card->relationLoaded('versions') ? $card->versions->count() : $card->versions()->count(),
'can_edit' => $viewer ? $card->isOwnedBy($viewer) : false,
'viewer_state' => [
'liked' => in_array(NovaCardReaction::TYPE_LIKE, $viewerReactions, true),
'favorited' => in_array(NovaCardReaction::TYPE_FAVORITE, $viewerReactions, true),
'saved_collection_ids' => $viewerCollections,
],
'project_json' => $withProject ? $project : null,
];
}
public function cards(iterable $cards, bool $withProject = false, ?User $viewer = null): array
{
return collect($cards)
->map(fn (NovaCard $card): array => $this->card($card, $withProject, $viewer))
->values()
->all();
}
public function collection(NovaCardCollection $collection, ?User $viewer = null, bool $withCards = false): array
{
$collection->loadMissing(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']);
$items = $collection->items
->filter(fn ($item): bool => $item->card !== null && $item->card->canBeViewedBy($viewer))
->sortBy('sort_order')
->values();
return [
'id' => (int) $collection->id,
'slug' => (string) $collection->slug,
'name' => (string) $collection->name,
'description' => $collection->description,
'visibility' => (string) $collection->visibility,
'official' => (bool) $collection->official,
'featured' => (bool) $collection->featured,
'cards_count' => (int) $collection->cards_count,
'public_url' => $collection->publicUrl(),
'owner' => [
'id' => (int) $collection->user->id,
'username' => (string) $collection->user->username,
'name' => $collection->user->name,
],
'cover_card' => $items->isNotEmpty() ? $this->card($items->first()->card, false, $viewer) : null,
'items' => $withCards ? $items->map(fn ($item): array => [
'id' => (int) $item->id,
'note' => $item->note,
'sort_order' => (int) $item->sort_order,
'card' => $this->card($item->card, false, $viewer),
])->values()->all() : [],
];
}
public function paginator(LengthAwarePaginator $paginator, bool $withProject = false, ?User $viewer = null): array
{
return [
'data' => $this->cards($paginator->items(), $withProject, $viewer),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'from' => $paginator->firstItem(),
'to' => $paginator->lastItem(),
],
];
}
}

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use App\Models\NovaCardTemplate;
use Illuminate\Support\Arr;
class NovaCardProjectNormalizer
{
public function normalize(?array $project, ?NovaCardTemplate $template = null, array $attributes = [], ?NovaCard $card = null): array
{
return $this->upgradeToV3($project, $template, $attributes, $card);
}
public function isLegacyProject(?array $project): bool
{
return (int) Arr::get($project, 'schema_version', 1) < 3;
}
public function upgradeToV3(?array $project, ?NovaCardTemplate $template = null, array $attributes = [], ?NovaCard $card = null): array
{
// First normalize to v2 as the base, then layer on v3 additions.
$v2 = $this->upgradeToV2($project, $template, $attributes, $card);
// v3: enrich background with v3-specific fields.
$v3Background = array_merge($v2['background'], [
'brightness' => (int) Arr::get($project, 'background.brightness', Arr::get($attributes, 'brightness', 0)),
'contrast' => (int) Arr::get($project, 'background.contrast', Arr::get($attributes, 'contrast', 0)),
'texture_overlay' => (string) Arr::get($project, 'background.texture_overlay', Arr::get($attributes, 'texture_overlay', '')),
'gradient_direction' => (string) Arr::get($project, 'background.gradient_direction', Arr::get($attributes, 'gradient_direction', 'to-bottom')),
]);
// v3: enrich typography with v3-specific fields.
$v3Typography = array_merge($v2['typography'], [
'quote_mark_preset' => (string) Arr::get($project, 'typography.quote_mark_preset', Arr::get($attributes, 'quote_mark_preset', 'none')),
'text_panel_style' => (string) Arr::get($project, 'typography.text_panel_style', Arr::get($attributes, 'text_panel_style', 'none')),
'text_glow' => (bool) Arr::get($project, 'typography.text_glow', Arr::get($attributes, 'text_glow', false)),
'text_stroke' => (bool) Arr::get($project, 'typography.text_stroke', Arr::get($attributes, 'text_stroke', false)),
]);
// v3: canvas safe zones and layout anchors.
$v3Canvas = [
'snap_guides' => (bool) Arr::get($project, 'canvas.snap_guides', true),
'safe_zones' => (bool) Arr::get($project, 'canvas.safe_zones', true),
];
// v3: frame pack reference.
$v3Frame = [
'frame_preset' => (string) Arr::get($project, 'frame.frame_preset', Arr::get($attributes, 'frame_preset', 'none')),
'frame_color' => (string) Arr::get($project, 'frame.frame_color', Arr::get($attributes, 'frame_color', '')),
];
// v3: effects layer (glow overlays, vignette, etc.)
$v3Effects = [
'vignette' => (bool) Arr::get($project, 'effects.vignette', Arr::get($attributes, 'vignette', false)),
'vignette_strength' => (string) Arr::get($project, 'effects.vignette_strength', 'soft'),
'color_grade' => (string) Arr::get($project, 'effects.color_grade', Arr::get($attributes, 'color_grade', 'none')),
'glow_overlay' => (bool) Arr::get($project, 'effects.glow_overlay', Arr::get($attributes, 'glow_overlay', false)),
];
// v3: export preferences (what the creator last chose).
$v3ExportPrefs = [
'preferred_format' => (string) Arr::get($project, 'export_preferences.preferred_format', Arr::get($attributes, 'export_preferred_format', 'preview')),
'include_watermark' => (bool) Arr::get($project, 'export_preferences.include_watermark', true),
];
// v3: source/attribution context.
$v3SourceContext = [
'original_card_id' => Arr::get($v2, 'meta.remix.original_card_id'),
'root_card_id' => Arr::get($v2, 'meta.remix.root_card_id'),
'original_creator_id' => Arr::get($attributes, 'original_creator_id', $card?->original_creator_id),
'preset_id' => Arr::get($attributes, 'preset_id', Arr::get($project, 'source_context.preset_id')),
];
return array_merge($v2, [
'schema_version' => 3,
'meta' => array_merge($v2['meta'], [
'editor' => 'nova-cards-v3',
]),
'canvas' => $v3Canvas,
'background' => $v3Background,
'typography' => $v3Typography,
'frame' => $v3Frame,
'effects' => $v3Effects,
'export_preferences' => $v3ExportPrefs,
'source_context' => $v3SourceContext,
]);
}
public function upgradeToV2(?array $project, ?NovaCardTemplate $template = null, array $attributes = [], ?NovaCard $card = null): array
{
$project = is_array($project) ? $project : [];
$templateConfig = is_array($template?->config_json) ? $template->config_json : [];
$existingContent = is_array(Arr::get($project, 'content')) ? Arr::get($project, 'content') : [];
$title = trim((string) Arr::get($attributes, 'title', Arr::get($existingContent, 'title', $card?->title ?? 'Untitled card')));
$quoteText = trim((string) Arr::get($attributes, 'quote_text', Arr::get($existingContent, 'quote_text', $card?->quote_text ?? 'Your next quote starts here.')));
$quoteAuthor = trim((string) Arr::get($attributes, 'quote_author', Arr::get($existingContent, 'quote_author', $card?->quote_author ?? '')));
$quoteSource = trim((string) Arr::get($attributes, 'quote_source', Arr::get($existingContent, 'quote_source', $card?->quote_source ?? '')));
$textBlocks = $this->normalizeTextBlocks(Arr::wrap(Arr::get($project, 'text_blocks', [])), [
'title' => $title,
'quote_text' => $quoteText,
'quote_author' => $quoteAuthor,
'quote_source' => $quoteSource,
]);
[$syncedTitle, $syncedQuote, $syncedAuthor, $syncedSource] = $this->syncLegacyContent($textBlocks, [
'title' => $title,
'quote_text' => $quoteText,
'quote_author' => $quoteAuthor,
'quote_source' => $quoteSource,
]);
if (array_key_exists('title', $attributes)) {
$syncedTitle = $title;
}
if (array_key_exists('quote_text', $attributes)) {
$syncedQuote = $quoteText;
}
if (array_key_exists('quote_author', $attributes)) {
$syncedAuthor = $quoteAuthor;
}
if (array_key_exists('quote_source', $attributes)) {
$syncedSource = $quoteSource;
}
$gradientKey = (string) Arr::get($project, 'background.gradient_preset', Arr::get($attributes, 'gradient_preset', Arr::get($templateConfig, 'gradient_preset', 'midnight-nova')));
$fontKey = (string) Arr::get($project, 'typography.font_preset', Arr::get($attributes, 'font_preset', Arr::get($templateConfig, 'font_preset', 'modern-sans')));
$sourceLineEnabled = collect($textBlocks)->contains(fn (array $block): bool => ($block['type'] ?? null) === 'source' && (bool) ($block['enabled'] ?? true) && trim((string) ($block['text'] ?? '')) !== '');
$authorLineEnabled = collect($textBlocks)->contains(fn (array $block): bool => ($block['type'] ?? null) === 'author' && (bool) ($block['enabled'] ?? true) && trim((string) ($block['text'] ?? '')) !== '');
return [
'schema_version' => 2,
'template' => [
'id' => $template?->id ?? Arr::get($project, 'template.id'),
'slug' => $template?->slug ?? Arr::get($project, 'template.slug'),
],
'meta' => [
'editor' => 'nova-cards-v2',
'remix' => [
'original_card_id' => Arr::get($attributes, 'original_card_id', $card?->original_card_id),
'root_card_id' => Arr::get($attributes, 'root_card_id', $card?->root_card_id),
],
],
'content' => [
'title' => $syncedTitle,
'quote_text' => $syncedQuote,
'quote_author' => $syncedAuthor,
'quote_source' => $syncedSource,
],
'text_blocks' => $textBlocks,
'layout' => [
'layout' => (string) Arr::get($project, 'layout.layout', Arr::get($attributes, 'layout', Arr::get($templateConfig, 'layout', 'quote_heavy'))),
'position' => (string) Arr::get($project, 'layout.position', Arr::get($attributes, 'position', 'center')),
'alignment' => (string) Arr::get($project, 'layout.alignment', Arr::get($attributes, 'alignment', Arr::get($templateConfig, 'text_align', 'center'))),
'padding' => (string) Arr::get($project, 'layout.padding', Arr::get($attributes, 'padding', 'comfortable')),
'max_width' => (string) Arr::get($project, 'layout.max_width', Arr::get($attributes, 'max_width', 'balanced')),
],
'typography' => [
'font_preset' => $fontKey,
'text_color' => (string) Arr::get($project, 'typography.text_color', Arr::get($attributes, 'text_color', Arr::get($templateConfig, 'text_color', '#ffffff'))),
'accent_color' => (string) Arr::get($project, 'typography.accent_color', Arr::get($attributes, 'accent_color', '#e0f2fe')),
'quote_size' => (int) Arr::get($project, 'typography.quote_size', Arr::get($attributes, 'quote_size', 72)),
'author_size' => (int) Arr::get($project, 'typography.author_size', Arr::get($attributes, 'author_size', 28)),
'letter_spacing' => (int) Arr::get($project, 'typography.letter_spacing', Arr::get($attributes, 'letter_spacing', 0)),
'line_height' => (float) Arr::get($project, 'typography.line_height', Arr::get($attributes, 'line_height', 1.2)),
'shadow_preset' => (string) Arr::get($project, 'typography.shadow_preset', Arr::get($attributes, 'shadow_preset', 'soft')),
'author_line_enabled' => $authorLineEnabled,
'source_line_enabled' => $sourceLineEnabled,
],
'background' => [
'type' => (string) Arr::get($project, 'background.type', Arr::get($attributes, 'background_type', $card?->background_type ?? 'gradient')),
'gradient_preset' => $gradientKey,
'gradient_colors' => array_values(Arr::wrap(Arr::get($project, 'background.gradient_colors', Arr::get($attributes, 'gradient_colors', Arr::get(config('nova_cards.gradient_presets'), $gradientKey . '.colors', ['#0f172a', '#1d4ed8']))))),
'solid_color' => (string) Arr::get($project, 'background.solid_color', Arr::get($attributes, 'solid_color', '#111827')),
'background_image_id' => Arr::get($attributes, 'background_image_id', Arr::get($project, 'background.background_image_id', $card?->background_image_id)),
'overlay_style' => (string) Arr::get($project, 'background.overlay_style', Arr::get($attributes, 'overlay_style', Arr::get($templateConfig, 'overlay_style', 'dark-soft'))),
'focal_position' => (string) Arr::get($project, 'background.focal_position', Arr::get($attributes, 'focal_position', 'center')),
'blur_level' => (int) Arr::get($project, 'background.blur_level', Arr::get($attributes, 'blur_level', 0)),
'opacity' => (int) Arr::get($project, 'background.opacity', Arr::get($attributes, 'opacity', 50)),
],
'decorations' => array_values(Arr::wrap(Arr::get($project, 'decorations', Arr::get($attributes, 'decorations', [])))),
'assets' => [
'pack_ids' => array_values(array_filter(
array_map('intval', Arr::wrap(Arr::get($project, 'assets.pack_ids', Arr::get($attributes, 'asset_pack_ids', []))))
)),
'template_pack_ids' => array_values(array_filter(
array_map('intval', Arr::wrap(Arr::get($project, 'assets.template_pack_ids', Arr::get($attributes, 'template_pack_ids', []))))
)),
'items' => array_values(Arr::wrap(Arr::get($project, 'assets.items', []))),
],
];
}
public function normalizeForCard(NovaCard $card): array
{
return $this->normalize($card->project_json, $card->template, [
'title' => $card->title,
'quote_text' => $card->quote_text,
'quote_author' => $card->quote_author,
'quote_source' => $card->quote_source,
'background_type' => $card->background_type,
'background_image_id' => $card->background_image_id,
'original_card_id' => $card->original_card_id,
'root_card_id' => $card->root_card_id,
], $card);
}
public function syncTopLevelAttributes(array $project): array
{
[$title, $quoteText, $quoteAuthor, $quoteSource] = $this->syncLegacyContent(Arr::wrap(Arr::get($project, 'text_blocks', [])), Arr::get($project, 'content', []));
return [
'schema_version' => (int) Arr::get($project, 'schema_version', 3),
'title' => $title,
'quote_text' => $quoteText,
'quote_author' => $quoteAuthor !== '' ? $quoteAuthor : null,
'quote_source' => $quoteSource !== '' ? $quoteSource : null,
'background_type' => (string) Arr::get($project, 'background.type', 'gradient'),
'background_image_id' => Arr::get($project, 'background.background_image_id'),
];
}
private function normalizeTextBlocks(array $blocks, array $fallback): array
{
$normalized = collect($blocks)
->map(function ($block, int $index): array {
$block = is_array($block) ? $block : [];
return [
'key' => (string) Arr::get($block, 'key', 'block-' . ($index + 1)),
'type' => (string) Arr::get($block, 'type', 'body'),
'text' => (string) Arr::get($block, 'text', ''),
'enabled' => ! array_key_exists('enabled', $block) || (bool) Arr::get($block, 'enabled', true),
'style' => is_array(Arr::get($block, 'style')) ? Arr::get($block, 'style') : [],
];
})
->filter(fn (array $block): bool => $block['type'] !== '' && $block['key'] !== '')
->values();
if ($normalized->isEmpty()) {
$normalized = collect([
['key' => 'title', 'type' => 'title', 'text' => (string) ($fallback['title'] ?? ''), 'enabled' => true, 'style' => ['role' => 'eyebrow']],
['key' => 'quote', 'type' => 'quote', 'text' => (string) ($fallback['quote_text'] ?? ''), 'enabled' => true, 'style' => ['role' => 'headline']],
['key' => 'author', 'type' => 'author', 'text' => (string) ($fallback['quote_author'] ?? ''), 'enabled' => (string) ($fallback['quote_author'] ?? '') !== '', 'style' => ['role' => 'byline']],
['key' => 'source', 'type' => 'source', 'text' => (string) ($fallback['quote_source'] ?? ''), 'enabled' => (string) ($fallback['quote_source'] ?? '') !== '', 'style' => ['role' => 'caption']],
]);
}
return $normalized->take((int) config('nova_cards.validation.max_text_blocks', 8))->values()->all();
}
private function syncLegacyContent(array $blocks, array $fallback): array
{
$title = trim((string) ($fallback['title'] ?? 'Untitled card'));
$quoteText = trim((string) ($fallback['quote_text'] ?? 'Your next quote starts here.'));
$quoteAuthor = trim((string) ($fallback['quote_author'] ?? ''));
$quoteSource = trim((string) ($fallback['quote_source'] ?? ''));
foreach ($blocks as $block) {
if (! is_array($block) || ! ($block['enabled'] ?? true)) {
continue;
}
$text = trim((string) ($block['text'] ?? ''));
if ($text === '') {
continue;
}
$type = (string) ($block['type'] ?? '');
if ($type === 'title') {
$title = $text;
} elseif ($type === 'quote') {
$quoteText = $text;
} elseif ($type === 'author') {
$quoteAuthor = $text;
} elseif ($type === 'source') {
$quoteSource = $text;
}
}
return [$title !== '' ? $title : 'Untitled card', $quoteText !== '' ? $quoteText : 'Your next quote starts here.', $quoteAuthor, $quoteSource];
}
}

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use App\Models\User;
class NovaCardPublishModerationService
{
private const REASON_LABELS = [
'duplicate_content' => 'Duplicate content',
'self_remix_loop' => 'Self-remix loop',
];
public const DISPOSITION_LABELS = [
'cleared_after_review' => 'Cleared after review',
'approved_with_watch' => 'Approved with watch',
'escalated_for_review' => 'Escalated for review',
'rights_review_required' => 'Rights review required',
'rejected_after_review' => 'Rejected after review',
'returned_to_pending' => 'Returned to pending',
];
public function evaluate(NovaCard $card): array
{
$card->loadMissing(['originalCard.user', 'rootCard.user']);
$reasons = [];
if ($this->hasDuplicateContent($card)) {
$reasons[] = 'duplicate_content';
}
if ($this->hasSelfRemixLoop($card)) {
$reasons[] = 'self_remix_loop';
}
return [
'flagged' => $reasons !== [],
'reasons' => $reasons,
];
}
public function moderationStatus(NovaCard $card): string
{
return $this->evaluate($card)['flagged'] ? NovaCard::MOD_FLAGGED : NovaCard::MOD_APPROVED;
}
public function applyPublishOutcome(NovaCard $card, array $evaluation): NovaCard
{
$project = (array) ($card->project_json ?? []);
$project['moderation'] = [
'source' => 'publish_heuristics',
'flagged' => (bool) ($evaluation['flagged'] ?? false),
'reasons' => $this->normalizeReasons($evaluation['reasons'] ?? []),
'updated_at' => now()->toISOString(),
];
$card->forceFill([
'project_json' => $project,
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => (bool) ($evaluation['flagged'] ?? false) ? NovaCard::MOD_FLAGGED : NovaCard::MOD_APPROVED,
])->save();
return $card->refresh();
}
public function storedReasons(NovaCard $card): array
{
return $this->normalizeReasons(((array) (($card->project_json ?? [])['moderation'] ?? []))['reasons'] ?? []);
}
public function storedReasonLabels(NovaCard $card): array
{
return $this->labelsFor($this->storedReasons($card));
}
public function storedSource(NovaCard $card): ?string
{
$source = ((array) (($card->project_json ?? [])['moderation'] ?? []))['source'] ?? null;
return is_string($source) && $source !== '' ? $source : null;
}
public function latestOverride(NovaCard $card): ?array
{
$override = ((array) (($card->project_json ?? [])['moderation'] ?? []))['override'] ?? null;
return is_array($override) && $override !== [] ? $override : null;
}
public function dispositionOptions(?string $moderationStatus = null): array
{
$keys = match ($moderationStatus) {
NovaCard::MOD_APPROVED => ['cleared_after_review', 'approved_with_watch'],
NovaCard::MOD_FLAGGED => ['escalated_for_review', 'rights_review_required'],
NovaCard::MOD_REJECTED => ['rejected_after_review'],
NovaCard::MOD_PENDING => ['returned_to_pending'],
default => array_keys(self::DISPOSITION_LABELS),
};
return array_values(array_map(fn (string $key): array => [
'value' => $key,
'label' => self::DISPOSITION_LABELS[$key] ?? ucwords(str_replace('_', ' ', $key)),
], $keys));
}
public function overrideHistory(NovaCard $card): array
{
$history = ((array) (($card->project_json ?? [])['moderation'] ?? []))['override_history'] ?? [];
return array_values(array_filter($history, fn ($entry): bool => is_array($entry) && $entry !== []));
}
public function recordStaffOverride(
NovaCard $card,
string $moderationStatus,
?User $actor,
string $source,
array $context = [],
): NovaCard {
$project = (array) ($card->project_json ?? []);
$moderation = (array) ($project['moderation'] ?? []);
$disposition = $this->normalizeDisposition(
$context['disposition'] ?? $this->defaultDispositionForStatus($moderationStatus),
$moderationStatus,
);
$override = array_filter([
'moderation_status' => $moderationStatus,
'previous_status' => (string) $card->moderation_status,
'disposition' => $disposition,
'disposition_label' => self::DISPOSITION_LABELS[$disposition] ?? ucwords(str_replace('_', ' ', $disposition)),
'source' => $source,
'actor_user_id' => $actor?->id,
'actor_username' => $actor?->username,
'note' => isset($context['note']) && is_string($context['note']) && trim($context['note']) !== '' ? trim($context['note']) : null,
'report_id' => isset($context['report_id']) ? (int) $context['report_id'] : null,
'updated_at' => now()->toISOString(),
], fn ($value): bool => $value !== null);
$history = $this->overrideHistory($card);
array_unshift($history, $override);
$moderation['override'] = $override;
$moderation['override_history'] = array_slice($history, 0, 10);
$project['moderation'] = $moderation;
$card->forceFill([
'moderation_status' => $moderationStatus,
'project_json' => $project,
])->save();
return $card->refresh();
}
public function labelsFor(array $reasons): array
{
return array_values(array_map(
fn (string $reason): string => self::REASON_LABELS[$reason] ?? ucwords(str_replace('_', ' ', $reason)),
$this->normalizeReasons($reasons),
));
}
private function normalizeReasons(array $reasons): array
{
return array_values(array_unique(array_filter(array_map(
fn ($reason): string => is_string($reason) ? trim($reason) : '',
$reasons,
))));
}
private function normalizeDisposition(mixed $disposition, string $moderationStatus): string
{
$value = is_string($disposition) ? trim($disposition) : '';
return $value !== '' && array_key_exists($value, self::DISPOSITION_LABELS)
? $value
: $this->defaultDispositionForStatus($moderationStatus);
}
private function defaultDispositionForStatus(string $moderationStatus): string
{
return match ($moderationStatus) {
NovaCard::MOD_APPROVED => 'cleared_after_review',
NovaCard::MOD_FLAGGED => 'escalated_for_review',
NovaCard::MOD_REJECTED => 'rejected_after_review',
default => 'returned_to_pending',
};
}
private function hasDuplicateContent(NovaCard $card): bool
{
$title = mb_strtolower(trim((string) $card->title));
$quote = mb_strtolower(trim((string) $card->quote_text));
if ($title === '' || $quote === '') {
return false;
}
return NovaCard::query()
->where('id', '!=', $card->id)
->where('status', NovaCard::STATUS_PUBLISHED)
->whereNotIn('moderation_status', [NovaCard::MOD_FLAGGED, NovaCard::MOD_REJECTED])
->whereRaw('LOWER(title) = ?', [$title])
->whereRaw('LOWER(quote_text) = ?', [$quote])
->exists();
}
private function hasSelfRemixLoop(NovaCard $card): bool
{
if (! $card->originalCard || ! $card->rootCard) {
return false;
}
$depth = 0;
$cursor = $card;
$visited = [];
while ($cursor->originalCard && ! in_array($cursor->id, $visited, true)) {
$visited[] = $cursor->id;
$cursor = $cursor->originalCard;
$depth++;
}
return $depth >= 3
&& (int) $card->user_id === (int) ($card->originalCard->user_id ?? 0)
&& (int) $card->user_id === (int) ($card->rootCard->user_id ?? 0);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Jobs\NovaCards\RenderNovaCardPreviewJob;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardPublishModerationService;
use Illuminate\Support\Carbon;
class NovaCardPublishService
{
public function __construct(
private readonly NovaCardRenderService $renderService,
private readonly NovaCardVersionService $versions,
private readonly NovaCardPublishModerationService $moderation,
) {
}
public function queuePublish(NovaCard $card): NovaCard
{
$card->forceFill([
'status' => NovaCard::STATUS_PROCESSING,
'moderation_status' => NovaCard::MOD_PENDING,
'published_at' => $card->published_at ?? Carbon::now(),
'render_version' => (int) $card->render_version + 1,
])->save();
RenderNovaCardPreviewJob::dispatch($card->id)
->onQueue((string) config('nova_cards.render.queue', 'default'));
return $card->refresh();
}
public function publishNow(NovaCard $card): NovaCard
{
$card->forceFill([
'status' => NovaCard::STATUS_PROCESSING,
'moderation_status' => NovaCard::MOD_PENDING,
'published_at' => $card->published_at ?? Carbon::now(),
'render_version' => (int) $card->render_version + 1,
])->save();
$this->renderService->render($card->refresh());
$evaluation = $this->moderation->evaluate($card->fresh()->loadMissing(['originalCard.user', 'rootCard.user']));
$card = $this->moderation->applyPublishOutcome($card->fresh(), $evaluation);
$this->versions->snapshot($card->refresh()->loadMissing('template'), $card->user, 'Published version', true);
return $card->refresh()->load(['category', 'template', 'tags', 'backgroundImage']);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Jobs\UpdateNovaCardStatsJob;
use App\Models\NovaCard;
use App\Models\NovaCardReaction;
use App\Models\User;
class NovaCardReactionService
{
public function setReaction(User $user, NovaCard $card, string $type, bool $active): array
{
$existing = NovaCardReaction::query()
->where('user_id', $user->id)
->where('card_id', $card->id)
->where('type', $type)
->first();
if ($active && ! $existing) {
NovaCardReaction::query()->create([
'user_id' => $user->id,
'card_id' => $card->id,
'type' => $type,
]);
}
if (! $active && $existing) {
$existing->delete();
}
UpdateNovaCardStatsJob::dispatch($card->id);
$card->refresh();
return [
'liked' => NovaCardReaction::query()->where('user_id', $user->id)->where('card_id', $card->id)->where('type', NovaCardReaction::TYPE_LIKE)->exists(),
'favorited' => NovaCardReaction::query()->where('user_id', $user->id)->where('card_id', $card->id)->where('type', NovaCardReaction::TYPE_FAVORITE)->exists(),
'likes_count' => (int) $card->fresh()->likes_count,
'favorites_count' => (int) $card->fresh()->favorites_count,
];
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* Computes and returns related cards for a given card using multiple
* similarity signals: template family, category, mood tags, format,
* style family, palette family, and creator.
*/
class NovaCardRelatedCardsService
{
private const CACHE_TTL = 600;
private const LIMIT = 8;
public function related(NovaCard $card, int $limit = self::LIMIT, bool $cached = true): Collection
{
if ($cached) {
return Cache::remember(
'nova_cards.related.' . $card->id . '.' . $limit,
self::CACHE_TTL,
fn () => $this->compute($card, $limit),
);
}
return $this->compute($card, $limit);
}
public function invalidateForCard(NovaCard $card): void
{
foreach ([4, 6, 8, 12] as $limit) {
Cache::forget('nova_cards.related.' . $card->id . '.' . $limit);
}
}
private function compute(NovaCard $card, int $limit): Collection
{
$card->loadMissing(['tags', 'category', 'template']);
$tagIds = $card->tags->pluck('id')->all();
$templateId = $card->template_id;
$categoryId = $card->category_id;
$format = $card->format;
$styleFamily = $card->style_family;
$paletteFamily = $card->palette_family;
$creatorId = $card->user_id;
$query = NovaCard::query()
->publiclyVisible()
->where('nova_cards.id', '!=', $card->id)
->select(['nova_cards.*'])
->selectRaw('0 AS relevance_score');
// We build a union-ranked set via scored sub-queries, then re-aggregate
// in PHP (simpler than scoring in MySQL without a full-text index).
$candidates = NovaCard::query()
->publiclyVisible()
->where('nova_cards.id', '!=', $card->id)
->where(function ($q) use ($tagIds, $templateId, $categoryId, $format, $styleFamily, $paletteFamily, $creatorId): void {
$q->whereHas('tags', fn ($tq) => $tq->whereIn('nova_card_tags.id', $tagIds))
->orWhere('template_id', $templateId)
->orWhere('category_id', $categoryId)
->orWhere('format', $format)
->orWhere('style_family', $styleFamily)
->orWhere('palette_family', $paletteFamily)
->orWhere('user_id', $creatorId);
})
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
->limit(80)
->get();
// Score in PHP — lightweight for this candidate set size.
$scored = $candidates->map(function (NovaCard $c) use ($tagIds, $templateId, $categoryId, $format, $styleFamily, $paletteFamily, $creatorId): array {
$score = 0;
// Tag overlap: up to 10 points
$overlap = count(array_intersect($c->tags->pluck('id')->all(), $tagIds));
$score += min($overlap * 2, 10);
// Same template: 5 pts
if ($templateId && $c->template_id === $templateId) {
$score += 5;
}
// Same category: 3 pts
if ($categoryId && $c->category_id === $categoryId) {
$score += 3;
}
// Same format: 2 pts
if ($c->format === $format) {
$score += 2;
}
// Same style family: 4 pts
if ($styleFamily && $c->style_family === $styleFamily) {
$score += 4;
}
// Same palette: 3 pts
if ($paletteFamily && $c->palette_family === $paletteFamily) {
$score += 3;
}
// Same creator (more cards by creator): 1 pt
if ($c->user_id === $creatorId) {
$score += 1;
}
// Engagement quality boost (saves + remixes weighted)
$engagementBoost = min(($c->saves_count + $c->remixes_count * 2) * 0.1, 3.0);
$score += $engagementBoost;
return ['card' => $c, 'score' => $score];
});
return $scored
->sortByDesc('score')
->take($limit)
->pluck('card')
->values();
}
}

View File

@@ -0,0 +1,350 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
class NovaCardRenderService
{
public function render(NovaCard $card): array
{
if (! function_exists('imagecreatetruecolor')) {
throw new RuntimeException('Nova card rendering requires the GD extension.');
}
$format = Arr::get(config('nova_cards.formats'), $card->format, config('nova_cards.formats.square'));
$width = (int) Arr::get($format, 'width', 1080);
$height = (int) Arr::get($format, 'height', 1080);
$project = is_array($card->project_json) ? $card->project_json : [];
$image = imagecreatetruecolor($width, $height);
imagealphablending($image, true);
imagesavealpha($image, true);
$this->paintBackground($image, $card, $width, $height, $project);
$this->paintOverlay($image, $project, $width, $height);
$this->paintText($image, $card, $project, $width, $height);
$this->paintDecorations($image, $project, $width, $height);
$this->paintAssets($image, $project, $width, $height);
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
$basePath = trim((string) config('nova_cards.storage.preview_prefix', 'cards/previews'), '/') . '/' . $card->user_id;
$previewPath = $basePath . '/' . $card->uuid . '.webp';
$ogPath = $basePath . '/' . $card->uuid . '-og.jpg';
ob_start();
imagewebp($image, null, (int) config('nova_cards.render.preview_quality', 86));
$webpBinary = (string) ob_get_clean();
ob_start();
imagejpeg($image, null, (int) config('nova_cards.render.og_quality', 88));
$jpgBinary = (string) ob_get_clean();
imagedestroy($image);
$disk->put($previewPath, $webpBinary);
$disk->put($ogPath, $jpgBinary);
$card->forceFill([
'preview_path' => $previewPath,
'preview_width' => $width,
'preview_height' => $height,
])->save();
return [
'preview_path' => $previewPath,
'og_path' => $ogPath,
'width' => $width,
'height' => $height,
];
}
private function paintBackground($image, NovaCard $card, int $width, int $height, array $project): void
{
$background = Arr::get($project, 'background', []);
$type = (string) Arr::get($background, 'type', $card->background_type ?: 'gradient');
if ($type === 'solid') {
$color = $this->allocateHex($image, (string) Arr::get($background, 'solid_color', '#111827'));
imagefilledrectangle($image, 0, 0, $width, $height, $color);
return;
}
if ($type === 'upload' && $card->backgroundImage?->processed_path) {
$this->paintImageBackground($image, $card->backgroundImage->processed_path, $width, $height);
} else {
$colors = Arr::wrap(Arr::get($background, 'gradient_colors', ['#0f172a', '#1d4ed8']));
$from = (string) Arr::get($colors, 0, '#0f172a');
$to = (string) Arr::get($colors, 1, '#1d4ed8');
$this->paintVerticalGradient($image, $width, $height, $from, $to);
}
}
private function paintImageBackground($image, string $path, int $width, int $height): void
{
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
if (! $disk->exists($path)) {
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
return;
}
$blob = $disk->get($path);
$background = @imagecreatefromstring($blob);
if ($background === false) {
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
return;
}
$focalPosition = (string) Arr::get($card->project_json, 'background.focal_position', 'center');
[$srcX, $srcY] = $this->resolveFocalSourceOrigin($focalPosition, imagesx($background), imagesy($background));
imagecopyresampled(
$image,
$background,
0,
0,
$srcX,
$srcY,
$width,
$height,
max(1, imagesx($background) - $srcX),
max(1, imagesy($background) - $srcY)
);
$blurLevel = (int) Arr::get($card->project_json, 'background.blur_level', 0);
for ($index = 0; $index < (int) floor($blurLevel / 4); $index++) {
imagefilter($image, IMG_FILTER_GAUSSIAN_BLUR);
}
imagedestroy($background);
}
private function paintOverlay($image, array $project, int $width, int $height): void
{
$style = (string) Arr::get($project, 'background.overlay_style', 'dark-soft');
$alpha = match ($style) {
'dark-strong' => 72,
'dark-soft' => 92,
'light-soft' => 108,
default => null,
};
if ($alpha === null) {
return;
}
$rgb = $style === 'light-soft' ? [255, 255, 255] : [0, 0, 0];
$overlay = imagecolorallocatealpha($image, $rgb[0], $rgb[1], $rgb[2], $alpha);
imagefilledrectangle($image, 0, 0, $width, $height, $overlay);
}
private function paintText($image, NovaCard $card, array $project, int $width, int $height): void
{
$textColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.text_color', '#ffffff'));
$authorColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', Arr::get($project, 'typography.text_color', '#ffffff')));
$alignment = (string) Arr::get($project, 'layout.alignment', 'center');
$lineHeightMultiplier = (float) Arr::get($project, 'typography.line_height', 1.2);
$shadowPreset = (string) Arr::get($project, 'typography.shadow_preset', 'soft');
$paddingRatio = match ((string) Arr::get($project, 'layout.padding', 'comfortable')) {
'tight' => 0.08,
'airy' => 0.15,
default => 0.11,
};
$xPadding = (int) round($width * $paddingRatio);
$maxLineWidth = match ((string) Arr::get($project, 'layout.max_width', 'balanced')) {
'compact' => (int) round($width * 0.5),
'wide' => (int) round($width * 0.78),
default => (int) round($width * 0.64),
};
$textBlocks = $this->resolveTextBlocks($card, $project);
$charWidth = imagefontwidth(5);
$lineHeight = max(imagefontheight(5) + 4, (int) round((imagefontheight(5) + 2) * $lineHeightMultiplier));
$charsPerLine = max(14, (int) floor($maxLineWidth / max(1, $charWidth)));
$textBlockHeight = 0;
foreach ($textBlocks as $block) {
$font = $this->fontForBlockType((string) ($block['type'] ?? 'body'));
$wrapped = preg_split('/\r\n|\r|\n/', wordwrap((string) ($block['text'] ?? ''), max(10, $charsPerLine - ($font === 3 ? 4 : 0)), "\n", true)) ?: [(string) ($block['text'] ?? '')];
$textBlockHeight += count($wrapped) * max(imagefontheight($font) + 4, (int) round((imagefontheight($font) + 2) * $lineHeightMultiplier));
$textBlockHeight += 18;
}
$position = (string) Arr::get($project, 'layout.position', 'center');
$startY = match ($position) {
'top' => (int) round($height * 0.14),
'upper-middle' => (int) round($height * 0.26),
'lower-middle' => (int) round($height * 0.58),
'bottom' => max($xPadding, $height - $textBlockHeight - (int) round($height * 0.12)),
default => (int) round(($height - $textBlockHeight) / 2),
};
foreach ($textBlocks as $block) {
$type = (string) ($block['type'] ?? 'body');
$font = $this->fontForBlockType($type);
$color = in_array($type, ['author', 'source', 'title'], true) ? $authorColor : $textColor;
$prefix = $type === 'author' ? '— ' : '';
$value = $prefix . (string) ($block['text'] ?? '');
$wrapped = preg_split('/\r\n|\r|\n/', wordwrap($type === 'title' ? strtoupper($value) : $value, max(10, $charsPerLine - ($font === 3 ? 4 : 0)), "\n", true)) ?: [$value];
$blockLineHeight = max(imagefontheight($font) + 4, (int) round((imagefontheight($font) + 2) * $lineHeightMultiplier));
foreach ($wrapped as $line) {
$lineWidth = imagefontwidth($font) * strlen($line);
$x = $this->resolveAlignedX($alignment, $width, $xPadding, $lineWidth);
$this->drawText($image, $font, $x, $startY, $line, $color, $shadowPreset);
$startY += $blockLineHeight;
}
$startY += 18;
}
}
private function paintDecorations($image, array $project, int $width, int $height): void
{
$decorations = Arr::wrap(Arr::get($project, 'decorations', []));
$accent = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', '#ffffff'));
foreach (array_slice($decorations, 0, (int) config('nova_cards.validation.max_decorations', 6)) as $index => $decoration) {
$x = (int) Arr::get($decoration, 'x', ($index % 2 === 0 ? 0.12 : 0.82) * $width);
$y = (int) Arr::get($decoration, 'y', (0.14 + ($index * 0.1)) * $height);
$size = max(2, (int) Arr::get($decoration, 'size', 6));
imagefilledellipse($image, $x, $y, $size, $size, $accent);
}
}
private function paintAssets($image, array $project, int $width, int $height): void
{
$items = Arr::wrap(Arr::get($project, 'assets.items', []));
if ($items === []) {
return;
}
$accent = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', '#ffffff'));
foreach (array_slice($items, 0, 6) as $index => $item) {
$type = (string) Arr::get($item, 'type', 'glyph');
if ($type === 'glyph') {
$glyph = (string) Arr::get($item, 'glyph', Arr::get($item, 'label', '✦'));
imagestring($image, 5, (int) round($width * (0.08 + (($index % 3) * 0.28))), (int) round($height * (0.08 + (intdiv($index, 3) * 0.74))), $glyph, $accent);
continue;
}
if ($type === 'frame') {
$y = $index % 2 === 0 ? (int) round($height * 0.08) : (int) round($height * 0.92);
imageline($image, (int) round($width * 0.12), $y, (int) round($width * 0.88), $y, $accent);
}
}
}
private function resolveTextBlocks(NovaCard $card, array $project): array
{
$blocks = collect(Arr::wrap(Arr::get($project, 'text_blocks', [])))
->filter(fn ($block): bool => is_array($block) && (bool) Arr::get($block, 'enabled', true) && trim((string) Arr::get($block, 'text', '')) !== '')
->values();
if ($blocks->isNotEmpty()) {
return $blocks->all();
}
return [
['type' => 'title', 'text' => trim((string) $card->title)],
['type' => 'quote', 'text' => trim((string) $card->quote_text)],
['type' => 'author', 'text' => trim((string) $card->quote_author)],
['type' => 'source', 'text' => trim((string) $card->quote_source)],
];
}
private function fontForBlockType(string $type): int
{
return match ($type) {
'title', 'source' => 3,
'author', 'body' => 4,
'caption' => 2,
default => 5,
};
}
private function paintVerticalGradient($image, int $width, int $height, string $fromHex, string $toHex): void
{
[$r1, $g1, $b1] = $this->hexToRgb($fromHex);
[$r2, $g2, $b2] = $this->hexToRgb($toHex);
for ($y = 0; $y < $height; $y++) {
$ratio = $height > 1 ? $y / ($height - 1) : 0;
$red = (int) round($r1 + (($r2 - $r1) * $ratio));
$green = (int) round($g1 + (($g2 - $g1) * $ratio));
$blue = (int) round($b1 + (($b2 - $b1) * $ratio));
$color = imagecolorallocate($image, $red, $green, $blue);
imageline($image, 0, $y, $width, $y, $color);
}
}
private function allocateHex($image, string $hex)
{
[$r, $g, $b] = $this->hexToRgb($hex);
return imagecolorallocate($image, $r, $g, $b);
}
private function hexToRgb(string $hex): array
{
$normalized = ltrim($hex, '#');
if (strlen($normalized) === 3) {
$normalized = preg_replace('/(.)/', '$1$1', $normalized) ?: 'ffffff';
}
if (strlen($normalized) !== 6) {
$normalized = 'ffffff';
}
return [
hexdec(substr($normalized, 0, 2)),
hexdec(substr($normalized, 2, 2)),
hexdec(substr($normalized, 4, 2)),
];
}
private function resolveAlignedX(string $alignment, int $width, int $padding, int $lineWidth): int
{
return match ($alignment) {
'left' => $padding,
'right' => max($padding, $width - $padding - $lineWidth),
default => max($padding, (int) round(($width - $lineWidth) / 2)),
};
}
private function resolveFocalSourceOrigin(string $focalPosition, int $sourceWidth, int $sourceHeight): array
{
$x = match ($focalPosition) {
'left', 'top-left', 'bottom-left' => 0,
'right', 'top-right', 'bottom-right' => max(0, (int) round($sourceWidth * 0.18)),
default => max(0, (int) round($sourceWidth * 0.09)),
};
$y = match ($focalPosition) {
'top', 'top-left', 'top-right' => 0,
'bottom', 'bottom-left', 'bottom-right' => max(0, (int) round($sourceHeight * 0.18)),
default => max(0, (int) round($sourceHeight * 0.09)),
};
return [$x, $y];
}
private function drawText($image, int $font, int $x, int $y, string $text, int $color, string $shadowPreset): void
{
if ($shadowPreset !== 'none') {
$offset = $shadowPreset === 'strong' ? 3 : 1;
$shadow = imagecolorallocatealpha($image, 2, 6, 23, $shadowPreset === 'strong' ? 46 : 78);
imagestring($image, $font, $x + $offset, $y + $offset, $text, $shadow);
}
imagestring($image, $font, $x, $y, $text, $color);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Surfaces "rising" cards recently published cards that are gaining
* engagement faster than average, weighted to balance novelty creators.
*/
class NovaCardRisingService
{
/** Number of hours a card is eligible for "rising" feed. */
private const WINDOW_HOURS = 96;
/** Cache TTL in seconds. */
private const CACHE_TTL = 300;
public function risingCards(int $limit = 18, bool $cached = true): Collection
{
if ($cached) {
return Cache::remember(
'nova_cards.rising.' . $limit,
self::CACHE_TTL,
fn () => $this->queryRising($limit),
);
}
return $this->queryRising($limit);
}
public function invalidateCache(): void
{
foreach ([6, 18, 24, 36] as $limit) {
Cache::forget('nova_cards.rising.' . $limit);
}
}
private function queryRising(int $limit): Collection
{
$cutoff = Carbon::now()->subHours(self::WINDOW_HOURS);
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
$ageHoursExpression = $isSqlite
? "CASE WHEN ((julianday('now') - julianday(published_at)) * 24.0) < 1 THEN 1 ELSE ((julianday('now') - julianday(published_at)) * 24.0) END"
: 'GREATEST(1, TIMESTAMPDIFF(HOUR, published_at, NOW()))';
$decayExpression = $isSqlite
? $ageHoursExpression
: 'POWER(' . $ageHoursExpression . ', 0.7)';
$risingMomentumExpression = '(
(saves_count * 5.0 + remixes_count * 6.0 + likes_count * 4.0 + favorites_count * 2.5 + comments_count * 2.0 + challenge_entries_count * 4.0)
/ ' . $decayExpression . '
) AS rising_momentum';
return NovaCard::query()
->publiclyVisible()
->where('published_at', '>=', $cutoff)
// Must have at least one meaningful engagement signal.
->where(function (Builder $q): void {
$q->where('saves_count', '>', 0)
->orWhere('remixes_count', '>', 0)
->orWhere('likes_count', '>', 1);
})
->select([
'nova_cards.*',
// Rising score: weight recent engagement, penalise by sqrt(age hours) to let novelty show
DB::raw($risingMomentumExpression),
])
->orderByDesc('rising_momentum')
->orderByDesc('published_at')
->limit($limit)
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
->get();
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use App\Models\NovaCardTag;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class NovaCardTagService
{
public function syncTags(NovaCard $card, array $tags): Collection
{
$limit = (int) config('nova_cards.validation.max_tags', 8);
$normalized = collect($tags)
->map(static fn ($tag) => trim((string) $tag))
->filter(static fn (string $tag): bool => $tag !== '')
->map(static fn (string $tag): array => [
'name' => Str::headline(Str::lower($tag)),
'slug' => Str::slug($tag),
])
->filter(static fn (array $tag): bool => $tag['slug'] !== '')
->unique('slug')
->take($limit)
->values();
$tagIds = $normalized->map(function (array $tag): int {
$model = NovaCardTag::query()->firstOrCreate(
['slug' => $tag['slug']],
['name' => $tag['name']]
);
return (int) $model->id;
})->all();
$card->tags()->sync($tagIds);
return NovaCardTag::query()->whereIn('id', $tagIds)->get();
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use App\Models\NovaCardChallengeEntry;
use App\Models\NovaCardComment;
use App\Models\NovaCardCollectionItem;
use App\Models\NovaCardReaction;
use Carbon\Carbon;
use Carbon\CarbonInterface;
class NovaCardTrendingService
{
public function refreshCard(NovaCard $card): NovaCard
{
$likes = NovaCardReaction::query()->where('card_id', $card->id)->where('type', NovaCardReaction::TYPE_LIKE)->count();
$favorites = NovaCardReaction::query()->where('card_id', $card->id)->where('type', NovaCardReaction::TYPE_FAVORITE)->count();
$saves = NovaCardCollectionItem::query()->where('card_id', $card->id)->count();
$remixes = NovaCard::query()->where('original_card_id', $card->id)->count();
$comments = NovaCardComment::query()->where('card_id', $card->id)->where('status', 'visible')->count();
$challengeEntries = NovaCardChallengeEntry::query()->where('card_id', $card->id)->count();
$lastEngagedAt = $this->lastEngagedAt($card);
$card->forceFill([
'likes_count' => $likes,
'favorites_count' => $favorites,
'saves_count' => $saves,
'remixes_count' => $remixes,
'comments_count' => $comments,
'challenge_entries_count' => $challengeEntries,
'last_engaged_at' => $lastEngagedAt,
'trending_score' => $this->score($card, $likes, $favorites, $saves, $remixes, $comments, $challengeEntries, $lastEngagedAt),
])->save();
return $card->refresh();
}
public function rebuildAll(): void
{
NovaCard::query()->select('id')->orderBy('id')->chunkById(100, function ($cards): void {
foreach ($cards as $card) {
$this->refreshCard(NovaCard::query()->findOrFail($card->id));
}
});
}
private function score(NovaCard $card, int $likes, int $favorites, int $saves, int $remixes, int $comments, int $challengeEntries, ?CarbonInterface $lastEngagedAt): float
{
$base = ($likes * 4.0)
+ ($favorites * 2.5)
+ ($saves * 5.0)
+ ($remixes * 6.0)
+ ($comments * 2.0)
+ ($challengeEntries * 4.0)
+ ($card->shares_count * 3.0)
+ ($card->downloads_count * 2.0)
+ ($card->views_count * 0.25);
$engagedAt = $lastEngagedAt ?? $card->published_at ?? now();
$ageHours = max(1.0, (float) $engagedAt->diffInHours(now()));
$decay = max(0.2, 1 / (1 + ($ageHours / 72)));
return round($base * $decay, 4);
}
private function lastEngagedAt(NovaCard $card): ?CarbonInterface
{
$timestamps = array_filter([
NovaCardReaction::query()->where('card_id', $card->id)->max('created_at'),
NovaCardCollectionItem::query()->where('card_id', $card->id)->max('created_at'),
NovaCard::query()->where('original_card_id', $card->id)->max('created_at'),
NovaCardComment::query()->where('card_id', $card->id)->max('created_at'),
NovaCardChallengeEntry::query()->where('card_id', $card->id)->max('created_at'),
$card->updated_at?->toDateTimeString(),
$card->published_at?->toDateTimeString(),
]);
if ($timestamps === []) {
return null;
}
return collect($timestamps)
->map(fn ($timestamp) => Carbon::parse($timestamp))
->sortDesc()
->first();
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use App\Models\NovaCardVersion;
use App\Models\User;
class NovaCardVersionService
{
public function __construct(
private readonly NovaCardProjectNormalizer $normalizer,
) {
}
public function snapshot(NovaCard $card, ?User $actor = null, ?string $label = null, bool $force = false): NovaCardVersion
{
$project = $this->normalizer->normalizeForCard($card->fresh(['template']));
$hash = hash('sha256', json_encode($project, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$latest = $card->versions()->latest('version_number')->first();
if (! $force && $latest && $latest->snapshot_hash === $hash) {
return $latest;
}
return $card->versions()->create([
'user_id' => $actor?->id,
'version_number' => (int) ($latest?->version_number ?? 0) + 1,
'label' => $label,
'snapshot_hash' => $hash,
'snapshot_json' => $project,
]);
}
public function restore(NovaCard $card, NovaCardVersion $version, ?User $actor = null): NovaCard
{
$project = $this->normalizer->normalize($version->snapshot_json, $card->template, [], $card);
$topLevel = $this->normalizer->syncTopLevelAttributes($project);
$card->forceFill([
'project_json' => $project,
'schema_version' => $topLevel['schema_version'],
'title' => $topLevel['title'],
'quote_text' => $topLevel['quote_text'],
'quote_author' => $topLevel['quote_author'],
'quote_source' => $topLevel['quote_source'],
'background_type' => $topLevel['background_type'],
'background_image_id' => $topLevel['background_image_id'],
'render_version' => (int) $card->render_version + 1,
])->save();
$this->snapshot($card->refresh(['template']), $actor, 'Restored from version ' . $version->version_number, true);
return $card->refresh()->load(['category', 'template', 'tags', 'backgroundImage', 'versions']);
}
}

View File

@@ -8,6 +8,7 @@ use App\Jobs\RegenerateUserRecommendationCacheJob;
use App\Models\Artwork;
use App\Models\UserInterestProfile;
use App\Models\UserRecommendationCache;
use App\Support\AvatarUrl;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
@@ -382,7 +383,13 @@ final class PersonalizedFeedService
/** @var Collection<int, Artwork> $artworks */
$artworks = Artwork::query()
->with(['user:id,name'])
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,name,slug',
'tags:id,name,slug',
])
->whereIn('id', $ids)
->public()
->published()
@@ -397,14 +404,51 @@ final class PersonalizedFeedService
continue;
}
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$primaryTag = $artwork->tags->sortBy('name')->first();
$source = (string) ($item['source'] ?? 'mixed');
$responseItems[] = [
'id' => $artwork->id,
'slug' => $artwork->slug,
'title' => $artwork->title,
'thumbnail_url' => $artwork->thumb_url,
'thumbnail_srcset' => $artwork->thumb_srcset,
'author' => $artwork->user?->name,
'username' => $artwork->user?->username,
'author_id' => $artwork->user?->id,
'avatar_url' => AvatarUrl::forUser(
(int) ($artwork->user?->id ?? 0),
$artwork->user?->profile?->avatar_hash,
64
),
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
'category_name' => $primaryCategory?->name ?? '',
'category_slug' => $primaryCategory?->slug ?? '',
'width' => $artwork->width,
'height' => $artwork->height,
'published_at' => $artwork->published_at?->toIso8601String(),
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
'primary_tag' => $primaryTag !== null ? [
'id' => (int) $primaryTag->id,
'name' => (string) $primaryTag->name,
'slug' => (string) $primaryTag->slug,
] : null,
'tags' => $artwork->tags
->sortBy('name')
->take(3)
->map(static fn ($tag): array => [
'id' => (int) $tag->id,
'name' => (string) $tag->name,
'slug' => (string) $tag->slug,
])
->values()
->all(),
'score' => (float) ($item['score'] ?? 0.0),
'source' => (string) ($item['source'] ?? 'mixed'),
'source' => $source,
'reason' => $this->buildRecommendationReason($artwork, $source),
'algo_version' => $algoVersion,
];
}
@@ -422,10 +466,30 @@ final class PersonalizedFeedService
'cache_status' => $cacheStatus,
'generated_at' => $generatedAt,
'total_candidates' => count($items),
'engine' => 'v1',
],
];
}
private function buildRecommendationReason(Artwork $artwork, string $source): string
{
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$categoryName = trim((string) ($primaryCategory?->name ?? ''));
return match ($source) {
'personalized' => $categoryName !== ''
? 'Matched to your interest in ' . $categoryName
: 'Matched to your recent interests',
'cold_start' => $categoryName !== ''
? 'Popular in ' . $categoryName . ' right now'
: 'Popular with the community right now',
'fallback' => $categoryName !== ''
? 'Trending in ' . $categoryName
: 'Trending across Skinbase',
default => 'Picked for you',
};
}
private function resolveAlgoVersion(?string $algoVersion = null, ?int $userId = null): string
{
if ($algoVersion !== null && $algoVersion !== '') {

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
final class RecommendationFeedResolver
{
public function __construct(
private readonly PersonalizedFeedService $personalizedFeed,
private readonly \App\Services\Recommendations\RecommendationServiceV2 $v2Feed,
) {
}
public function getFeed(int $userId, int $limit = 24, ?string $cursor = null, ?string $algoVersion = null): array
{
if ($this->shouldUseV2($userId, $algoVersion)) {
return $this->v2Feed->getFeed($userId, $limit, $cursor, $algoVersion);
}
return $this->personalizedFeed->getFeed($userId, $limit, $cursor, $algoVersion);
}
public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void
{
if ($this->shouldUseV2($userId, $algoVersion)) {
$this->v2Feed->regenerateCacheForUser($userId, $algoVersion);
return;
}
$this->personalizedFeed->regenerateCacheForUser($userId, $algoVersion);
}
/**
* @return array<string, mixed>
*/
public function inspectDecision(int $userId, ?string $algoVersion = null): array
{
$requested = trim((string) ($algoVersion ?? ''));
$v2AlgoVersion = trim((string) config('discovery.v2.algo_version', 'clip-cosine-v2-adaptive'));
$v2Enabled = (bool) config('discovery.v2.enabled', false);
$rollout = max(0, min(100, (int) config('discovery.v2.rollout_percentage', 0)));
$bucket = abs((int) crc32((string) $userId)) % 100;
$forcedByAlgoVersion = $requested !== '' && $requested === $v2AlgoVersion;
$usesV2 = $forcedByAlgoVersion
|| ($v2Enabled && ($rollout >= 100 || ($rollout > 0 && $bucket < $rollout)));
$reason = match (true) {
$forcedByAlgoVersion => 'explicit_algo_override',
! $v2Enabled => 'v2_disabled',
$rollout >= 100 => 'full_rollout',
$rollout <= 0 => 'rollout_zero',
$bucket < $rollout => 'bucket_in_rollout',
default => 'bucket_outside_rollout',
};
return [
'user_id' => $userId,
'requested_algo_version' => $requested !== '' ? $requested : null,
'v2_algo_version' => $v2AlgoVersion,
'v2_enabled' => $v2Enabled,
'rollout_percentage' => $rollout,
'bucket' => $bucket,
'bucket_in_rollout' => $bucket < $rollout,
'forced_by_algo_version' => $forcedByAlgoVersion,
'uses_v2' => $usesV2,
'selected_engine' => $usesV2 ? 'v2' : 'v1',
'reason' => $reason,
];
}
private function shouldUseV2(int $userId, ?string $algoVersion = null): bool
{
return (bool) ($this->inspectDecision($userId, $algoVersion)['uses_v2'] ?? false);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Models\UserInterestProfile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
final class SessionRecoService
{
/**
* @param array<string, mixed> $meta
*/
public function applyEvent(
int $userId,
string $eventType,
int $artworkId,
?int $categoryId,
string $occurredAt,
array $meta = []
): void {
if ($userId <= 0 || $artworkId <= 0) {
return;
}
$state = $this->readState($userId);
$weights = (array) config('discovery.v2.session.event_weights', []);
$fallbackWeights = (array) config('discovery.weights', []);
$eventWeight = (float) ($weights[$eventType] ?? $fallbackWeights[$eventType] ?? 1.0);
$timestamp = strtotime($occurredAt) ?: time();
$this->upsertSignal($state['signals'], 'artwork:' . $artworkId, $eventWeight, $timestamp);
if ($categoryId !== null && $categoryId > 0) {
$this->upsertSignal($state['signals'], 'category:' . $categoryId, $eventWeight, $timestamp);
}
foreach ($this->tagSlugsForArtwork($artworkId) as $tagSlug) {
$this->upsertSignal($state['signals'], 'tag:' . $tagSlug, $eventWeight, $timestamp);
}
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if ($creatorId > 0) {
$this->upsertSignal($state['signals'], 'creator:' . $creatorId, $eventWeight, $timestamp);
}
$state['recent_artwork_ids'] = $this->prependUnique($state['recent_artwork_ids'], $artworkId);
if ($creatorId > 0) {
$state['recent_creator_ids'] = $this->prependUnique($state['recent_creator_ids'], $creatorId);
}
foreach ($this->tagSlugsForArtwork($artworkId) as $tagSlug) {
$state['recent_tag_slugs'] = $this->prependUnique($state['recent_tag_slugs'], $tagSlug);
}
if (in_array($eventType, ['view', 'click', 'favorite', 'download', 'dwell', 'scroll'], true)) {
$state['seen_artwork_ids'] = $this->prependUnique($state['seen_artwork_ids'], $artworkId, 200);
}
$state['updated_at'] = $timestamp;
$this->writeState($userId, $state);
}
/**
* @return array{
* merged_scores: array<string, float>,
* session_scores: array<string, float>,
* long_term_scores: array<string, float>,
* recent_artwork_ids: array<int, int>,
* recent_creator_ids: array<int, int>,
* recent_tag_slugs: array<int, string>,
* seen_artwork_ids: array<int, int>
* }
*/
public function mergedProfile(int $userId, string $algoVersion): array
{
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
$profile = UserInterestProfile::query()
->where('user_id', $userId)
->where('profile_version', $profileVersion)
->where('algo_version', $algoVersion)
->first();
$longTermScores = $this->normalizeScores((array) ($profile?->normalized_scores_json ?? []));
$state = $this->readState($userId);
$sessionScores = $this->materializeSessionScores($state['signals']);
$multiplier = max(0.0, (float) config('discovery.v2.session.merge_multiplier', 1.35));
$merged = $longTermScores;
foreach ($sessionScores as $key => $score) {
$merged[$key] = (float) ($merged[$key] ?? 0.0) + ($score * $multiplier);
}
return [
'merged_scores' => $this->normalizeScores($merged),
'session_scores' => $sessionScores,
'long_term_scores' => $longTermScores,
'recent_artwork_ids' => array_values(array_map('intval', $state['recent_artwork_ids'])),
'recent_creator_ids' => array_values(array_map('intval', $state['recent_creator_ids'])),
'recent_tag_slugs' => array_values(array_map('strval', $state['recent_tag_slugs'])),
'seen_artwork_ids' => array_values(array_map('intval', $state['seen_artwork_ids'])),
];
}
/**
* @return array<int, int>
*/
public function seenArtworkIds(int $userId): array
{
$state = $this->readState($userId);
return array_values(array_map('intval', $state['seen_artwork_ids']));
}
/**
* @return array<string, mixed>
*/
private function readState(int $userId): array
{
$key = $this->redisKey($userId);
try {
$raw = Redis::get($key);
} catch (\Throwable $e) {
Log::warning('SessionRecoService read failed', ['user_id' => $userId, 'error' => $e->getMessage()]);
return $this->emptyState();
}
if (! is_string($raw) || $raw === '') {
return $this->emptyState();
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? array_merge($this->emptyState(), $decoded) : $this->emptyState();
}
/**
* @param array<string, mixed> $state
*/
private function writeState(int $userId, array $state): void
{
$key = $this->redisKey($userId);
$ttlSeconds = max(60, (int) config('discovery.v2.session.ttl_seconds', 14400));
try {
Redis::setex($key, $ttlSeconds, (string) json_encode($state, JSON_UNESCAPED_SLASHES));
} catch (\Throwable $e) {
Log::warning('SessionRecoService write failed', ['user_id' => $userId, 'error' => $e->getMessage()]);
}
}
/**
* @param array<string, mixed> $signals
* @return array<string, float>
*/
private function materializeSessionScores(array $signals): array
{
$halfLifeHours = max(0.1, (float) config('discovery.v2.session.half_life_hours', 8));
$now = time();
$scores = [];
foreach ($signals as $key => $signal) {
if (! is_array($signal)) {
continue;
}
$score = (float) Arr::get($signal, 'score', 0.0);
$updatedAt = (int) Arr::get($signal, 'updated_at', $now);
$hoursElapsed = max(0.0, ($now - $updatedAt) / 3600);
$decay = exp(-log(2) * ($hoursElapsed / $halfLifeHours));
$decayedScore = $score * $decay;
if ($decayedScore > 0.000001) {
$scores[(string) $key] = $decayedScore;
}
}
return $this->normalizeScores($scores);
}
/**
* @param array<string, mixed> $signals
*/
private function upsertSignal(array &$signals, string $key, float $weight, int $timestamp): void
{
$maxItems = max(20, (int) config('discovery.v2.session.max_items', 120));
$current = (array) ($signals[$key] ?? []);
$signals[$key] = [
'score' => (float) ($current['score'] ?? 0.0) + $weight,
'updated_at' => $timestamp,
];
if (count($signals) <= $maxItems) {
return;
}
uasort($signals, static fn (array $left, array $right): int => ((int) ($right['updated_at'] ?? 0)) <=> ((int) ($left['updated_at'] ?? 0)));
$signals = array_slice($signals, 0, $maxItems, true);
}
/**
* @param array<int, int|string> $items
* @return array<int, int|string>
*/
private function prependUnique(array $items, int|string $value, int $maxItems = 40): array
{
$items = array_values(array_filter($items, static fn (mixed $item): bool => (string) $item !== (string) $value));
array_unshift($items, $value);
return array_slice($items, 0, $maxItems);
}
/**
* @return array<string, mixed>
*/
private function emptyState(): array
{
return [
'signals' => [],
'recent_artwork_ids' => [],
'recent_creator_ids' => [],
'recent_tag_slugs' => [],
'seen_artwork_ids' => [],
'updated_at' => null,
];
}
private function redisKey(int $userId): string
{
return 'session_reco:' . $userId;
}
/**
* @param array<string, mixed> $scores
* @return array<string, float>
*/
private function normalizeScores(array $scores): array
{
$typed = [];
foreach ($scores as $key => $score) {
if (is_numeric($score) && (float) $score > 0.0) {
$typed[(string) $key] = (float) $score;
}
}
$sum = array_sum($typed);
if ($sum <= 0.0) {
return [];
}
foreach ($typed as $key => $score) {
$typed[$key] = $score / $sum;
}
return $typed;
}
/**
* @return array<int, string>
*/
private function tagSlugsForArtwork(int $artworkId): array
{
return DB::table('artwork_tag')
->join('tags', 'tags.id', '=', 'artwork_tag.tag_id')
->where('artwork_tag.artwork_id', $artworkId)
->pluck('tags.slug')
->map(static fn (mixed $slug): string => (string) $slug)
->values()
->all();
}
}

View File

@@ -0,0 +1,481 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class SmartCollectionService
{
public function sanitizeRules(?array $input): ?array
{
if ($input === null) {
return null;
}
$match = strtolower((string) ($input['match'] ?? 'all'));
$sort = strtolower((string) ($input['sort'] ?? Collection::SORT_NEWEST));
$rules = array_values(array_filter((array) ($input['rules'] ?? []), static fn ($rule) => is_array($rule)));
$allowedMatch = config('collections.smart_rules.allowed_match', ['all', 'any']);
$allowedSort = config('collections.smart_rules.allowed_sort', ['newest', 'oldest', 'popular']);
$allowedFields = config('collections.smart_rules.allowed_fields', []);
$maxRules = (int) config('collections.smart_rules.max_rules', 8);
if (! in_array($match, $allowedMatch, true)) {
throw ValidationException::withMessages([
'smart_rules_json.match' => 'Smart collections must use either all or any matching.',
]);
}
if (! in_array($sort, $allowedSort, true)) {
throw ValidationException::withMessages([
'smart_rules_json.sort' => 'Choose a supported sort for the smart collection.',
]);
}
if ($rules === []) {
throw ValidationException::withMessages([
'smart_rules_json.rules' => 'Add at least one smart collection rule.',
]);
}
if (count($rules) > $maxRules) {
throw ValidationException::withMessages([
'smart_rules_json.rules' => 'Too many smart collection rules were submitted.',
]);
}
$sanitized = [];
foreach ($rules as $index => $rule) {
$field = strtolower(trim((string) ($rule['field'] ?? '')));
$operator = strtolower(trim((string) ($rule['operator'] ?? '')));
$value = $rule['value'] ?? null;
if (! in_array($field, $allowedFields, true)) {
throw ValidationException::withMessages([
"smart_rules_json.rules.$index.field" => 'This smart collection field is not supported.',
]);
}
$sanitized[] = [
'field' => $field,
'operator' => $this->validateOperator($field, $operator, $index),
'value' => $this->sanitizeValue($field, $value, $index),
];
}
return [
'match' => $match,
'sort' => $sort,
'rules' => $sanitized,
];
}
public function resolveArtworks(Collection $collection, bool $ownerView, int $perPage = 24): LengthAwarePaginator
{
$query = $this->queryForCollection($collection, $ownerView)
->with($this->artworkRelations())
->select('artworks.*');
return $query->paginate($perPage)->withQueryString();
}
public function preview(User $owner, array $rules, bool $ownerView = true, int $perPage = 12): LengthAwarePaginator
{
$query = $this->queryForOwner($owner, $rules, $ownerView)
->with($this->artworkRelations())
->select('artworks.*');
return $query->paginate($perPage)->withQueryString();
}
public function countMatching(Collection|User $subject, ?array $rules = null, bool $ownerView = true): int
{
$query = $subject instanceof Collection
? $this->queryForCollection($subject, $ownerView)
: $this->queryForOwner($subject, $rules, $ownerView);
return (int) $query->toBase()->getCountForPagination();
}
public function firstArtwork(Collection $collection, bool $ownerView): ?Artwork
{
return $this->queryForCollection($collection, $ownerView)
->with($this->artworkRelations())
->select('artworks.*')
->first();
}
public function smartSummary(?array $rules): ?string
{
if (! is_array($rules) || empty($rules['rules'])) {
return null;
}
$glue = ($rules['match'] ?? 'all') === 'any' ? ' or ' : ' and ';
$parts = [];
foreach ((array) $rules['rules'] as $rule) {
$field = $rule['field'] ?? null;
$value = $rule['value'] ?? null;
if ($field === 'created_at' && is_array($value)) {
$from = $value['from'] ?? null;
$to = $value['to'] ?? null;
if ($from && $to) {
$parts[] = sprintf('created between %s and %s', $from, $to);
}
continue;
}
if ($field === 'is_featured') {
$parts[] = ((bool) $value) ? 'marked as featured artworks' : 'not marked as featured artworks';
continue;
}
if ($field === 'is_mature') {
$parts[] = ((bool) $value) ? 'marked as mature artworks' : 'not marked as mature artworks';
continue;
}
if (is_string($value) && $value !== '') {
$label = match ($field) {
'tags' => 'tagged ' . $value,
'category' => 'in category ' . $value,
'subcategory' => 'in subcategory ' . $value,
'medium' => 'in medium ' . $value,
'style' => 'matching style ' . $value,
'color' => 'using color palette ' . $value,
'ai_tag' => 'matching AI tag ' . $value,
default => $value,
};
$parts[] = $label;
}
}
if ($parts === []) {
return null;
}
return 'Includes artworks ' . implode($glue, $parts) . '.';
}
public function ruleOptionsForOwner(User $owner): array
{
$tagOptions = DB::table('artwork_tag as at')
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
->join('tags as t', 't.id', '=', 'at.tag_id')
->where('a.user_id', $owner->id)
->whereNull('a.deleted_at')
->where('t.is_active', true)
->groupBy('t.slug', 't.name')
->orderByRaw('COUNT(*) DESC')
->limit(20)
->get(['t.slug', 't.name'])
->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name])
->all();
$rootCategories = DB::table('artwork_category as ac')
->join('artworks as a', 'a.id', '=', 'ac.artwork_id')
->join('categories as c', 'c.id', '=', 'ac.category_id')
->where('a.user_id', $owner->id)
->whereNull('a.deleted_at')
->where('c.is_active', true)
->whereNull('c.parent_id')
->groupBy('c.slug', 'c.name')
->orderByRaw('COUNT(*) DESC')
->limit(20)
->get(['c.slug', 'c.name'])
->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name])
->all();
$subcategories = DB::table('artwork_category as ac')
->join('artworks as a', 'a.id', '=', 'ac.artwork_id')
->join('categories as c', 'c.id', '=', 'ac.category_id')
->where('a.user_id', $owner->id)
->whereNull('a.deleted_at')
->where('c.is_active', true)
->whereNotNull('c.parent_id')
->groupBy('c.slug', 'c.name')
->orderByRaw('COUNT(*) DESC')
->limit(20)
->get(['c.slug', 'c.name'])
->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name])
->all();
$mediumOptions = DB::table('artwork_category as ac')
->join('artworks as a', 'a.id', '=', 'ac.artwork_id')
->join('categories as c', 'c.id', '=', 'ac.category_id')
->join('content_types as ct', 'ct.id', '=', 'c.content_type_id')
->where('a.user_id', $owner->id)
->whereNull('a.deleted_at')
->groupBy('ct.slug', 'ct.name')
->orderBy('ct.name')
->get(['ct.slug', 'ct.name'])
->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name])
->all();
$styleOptions = $this->aiTagOptionsForOwner($owner, (array) config('collections.smart_rules.style_terms', []));
$colorOptions = $this->aiTagOptionsForOwner($owner, (array) config('collections.smart_rules.color_terms', []));
return [
'fields' => [
['value' => 'tags', 'label' => 'Tag'],
['value' => 'category', 'label' => 'Category'],
['value' => 'subcategory', 'label' => 'Subcategory'],
['value' => 'medium', 'label' => 'Medium'],
['value' => 'style', 'label' => 'Style'],
['value' => 'color', 'label' => 'Color palette'],
['value' => 'ai_tag', 'label' => 'AI tag'],
['value' => 'created_at', 'label' => 'Created date'],
['value' => 'is_featured', 'label' => 'Featured artwork'],
['value' => 'is_mature', 'label' => 'Mature artwork'],
],
'tag_options' => $tagOptions,
'category_options' => $rootCategories,
'subcategory_options' => $subcategories,
'medium_options' => $mediumOptions,
'style_options' => $styleOptions,
'color_options' => $colorOptions,
'sort_options' => [
['value' => 'newest', 'label' => 'Newest first'],
['value' => 'oldest', 'label' => 'Oldest first'],
['value' => 'popular', 'label' => 'Most viewed'],
],
];
}
public function queryForCollection(Collection $collection, bool $ownerView): Builder
{
return $this->queryForOwner($collection->user, $collection->smart_rules_json, $ownerView);
}
public function queryForOwner(User $owner, ?array $rules, bool $ownerView): Builder
{
if ($rules === null) {
throw ValidationException::withMessages([
'smart_rules_json' => 'Smart collections require at least one valid rule.',
]);
}
$sanitized = $this->sanitizeRules($rules);
$query = Artwork::query()
->where('artworks.user_id', $owner->id)
->whereNull('artworks.deleted_at');
if (! $ownerView) {
$query->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->where('artworks.published_at', '<=', now());
}
$method = ($sanitized['match'] ?? 'all') === 'any' ? 'orWhere' : 'where';
$query->where(function (Builder $outer) use ($sanitized, $method): void {
foreach ((array) ($sanitized['rules'] ?? []) as $index => $rule) {
$callback = function (Builder $builder) use ($rule): void {
$this->applyRule($builder, $rule);
};
if ($index === 0 || $method === 'where') {
$outer->where($callback);
} else {
$outer->orWhere($callback);
}
}
});
return $this->applySort($query, (string) ($sanitized['sort'] ?? Collection::SORT_NEWEST));
}
/**
* @return array<int, string|array>
*/
private function artworkRelations(): array
{
return [
'user:id,name,username',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']);
},
];
}
/**
* @param array{field:string,operator:string,value:mixed} $rule
*/
private function applyRule(Builder $query, array $rule): void
{
$field = $rule['field'];
$value = $rule['value'];
match ($field) {
'tags' => $query->whereHas('tags', function (Builder $builder) use ($value): void {
$builder->where('tags.slug', (string) $value)
->orWhere('tags.name', 'like', '%' . (string) $value . '%');
}),
'category' => $query->whereHas('categories', function (Builder $builder) use ($value): void {
$builder->where('categories.slug', (string) $value)
->whereNull('categories.parent_id');
}),
'subcategory' => $query->whereHas('categories', function (Builder $builder) use ($value): void {
$builder->where('categories.slug', (string) $value)
->whereNotNull('categories.parent_id');
}),
'medium' => $query->whereHas('categories.contentType', function (Builder $builder) use ($value): void {
$builder->where('content_types.slug', (string) $value);
}),
'style' => $this->applyAiPivotTagRule($query, (string) $value),
'color' => $this->applyAiPivotTagRule($query, (string) $value),
'ai_tag' => $query->where(function (Builder $builder) use ($value): void {
$builder->where('artworks.blip_caption', 'like', '%' . (string) $value . '%')
->orWhere('artworks.clip_tags_json', 'like', '%' . (string) $value . '%')
->orWhere('artworks.yolo_objects_json', 'like', '%' . (string) $value . '%');
}),
'created_at' => $query->whereBetween('artworks.created_at', [
Carbon::parse((string) ($value['from'] ?? now()->subYear()->toDateString()))->startOfDay(),
Carbon::parse((string) ($value['to'] ?? now()->toDateString()))->endOfDay(),
]),
'is_featured' => $this->applyFeaturedRule($query, (bool) $value),
'is_mature' => $query->where('artworks.is_mature', (bool) $value),
default => null,
};
}
private function applyFeaturedRule(Builder $query, bool $value): void
{
if ($value) {
$query->whereExists(function ($sub): void {
$sub->selectRaw('1')
->from('artwork_features as af')
->whereColumn('af.artwork_id', 'artworks.id')
->where('af.is_active', true)
->whereNull('af.deleted_at');
});
return;
}
$query->whereNotExists(function ($sub): void {
$sub->selectRaw('1')
->from('artwork_features as af')
->whereColumn('af.artwork_id', 'artworks.id')
->where('af.is_active', true)
->whereNull('af.deleted_at');
});
}
private function applySort(Builder $query, string $sort): Builder
{
return match ($sort) {
Collection::SORT_OLDEST => $query->orderBy('artworks.published_at')->orderBy('artworks.id'),
Collection::SORT_POPULAR => $query->orderByDesc('artworks.view_count')->orderByDesc('artworks.id'),
default => $query->orderByDesc('artworks.published_at')->orderByDesc('artworks.id'),
};
}
private function validateOperator(string $field, string $operator, int $index): string
{
$allowed = match ($field) {
'created_at' => ['between'],
'is_featured', 'is_mature' => ['equals'],
default => ['contains', 'equals'],
};
if (! in_array($operator, $allowed, true)) {
throw ValidationException::withMessages([
"smart_rules_json.rules.$index.operator" => 'This operator is not supported for the selected field.',
]);
}
return $operator;
}
private function sanitizeValue(string $field, mixed $value, int $index): mixed
{
if ($field === 'created_at') {
if (! is_array($value) || empty($value['from']) || empty($value['to'])) {
throw ValidationException::withMessages([
"smart_rules_json.rules.$index.value" => 'Provide a valid start and end date for this rule.',
]);
}
return [
'from' => Carbon::parse((string) $value['from'])->toDateString(),
'to' => Carbon::parse((string) $value['to'])->toDateString(),
];
}
if ($field === 'is_featured' || $field === 'is_mature') {
return (bool) $value;
}
$stringValue = trim((string) $value);
if ($stringValue === '' || mb_strlen($stringValue) > 80) {
throw ValidationException::withMessages([
"smart_rules_json.rules.$index.value" => 'Provide a shorter value for this smart rule.',
]);
}
return $stringValue;
}
private function applyAiPivotTagRule(Builder $query, string $value): void
{
$query->whereHas('tags', function (Builder $builder) use ($value): void {
$builder->where('artwork_tag.source', 'ai')
->where(function (Builder $match) use ($value): void {
$match->where('tags.slug', str($value)->slug()->value())
->orWhere('tags.name', 'like', '%' . $value . '%');
});
});
}
/**
* @param array<int, string> $allowedTerms
* @return array<int, array{value:string,label:string}>
*/
private function aiTagOptionsForOwner(User $owner, array $allowedTerms): array
{
if ($allowedTerms === []) {
return [];
}
$allowedSlugs = collect($allowedTerms)
->map(static fn (string $term) => str($term)->slug()->value())
->filter()
->unique()
->values();
if ($allowedSlugs->isEmpty()) {
return [];
}
return DB::table('artwork_tag as at')
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
->join('tags as t', 't.id', '=', 'at.tag_id')
->where('a.user_id', $owner->id)
->whereNull('a.deleted_at')
->where('at.source', 'ai')
->whereIn('t.slug', $allowedSlugs->all())
->groupBy('t.slug', 't.name')
->orderByRaw('COUNT(*) DESC')
->orderBy('t.name')
->get(['t.slug', 't.name'])
->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name])
->all();
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Artwork;
use App\Models\ArtworkAiAssist;
use App\Models\ArtworkAiAssistEvent;
final class StudioAiAssistEventService
{
/**
* @param array<string, mixed> $meta
*/
public function record(Artwork $artwork, string $eventType, array $meta = [], ?ArtworkAiAssist $assist = null): ArtworkAiAssistEvent
{
$assist ??= $artwork->artworkAiAssist;
return ArtworkAiAssistEvent::query()->create([
'artwork_ai_assist_id' => $assist?->id,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'event_type' => $eventType,
'meta' => $meta,
]);
}
}

View File

@@ -0,0 +1,514 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Models\Artwork;
use App\Models\ArtworkAiAssist;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\TagNormalizer;
use App\Services\TagService;
use App\Services\Vision\AiArtworkVectorSearchService;
use App\Services\Vision\VisionService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class StudioAiAssistService
{
public function __construct(
private readonly VisionService $vision,
private readonly StudioAiSuggestionBuilder $builder,
private readonly StudioAiCategoryMapper $categoryMapper,
private readonly AiArtworkVectorSearchService $similarity,
private readonly TagService $tagService,
private readonly TagNormalizer $tagNormalizer,
private readonly StudioAiAssistEventService $eventService,
) {
}
public function queueAnalysis(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
{
$assist = $this->assistRecord($artwork);
$mode = $assist->mode ?: $this->builder->detectMode($artwork->loadMissing(['tags', 'categories.contentType']), []);
$assist->forceFill([
'status' => ArtworkAiAssist::STATUS_QUEUED,
'mode' => $mode,
'error_message' => null,
])->save();
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_QUEUED])->saveQuietly();
$meta = ['force' => $force, 'direct' => false, 'intent' => $intent];
$this->appendAction($assist, 'analysis_requested', $meta);
$this->eventService->record($artwork, 'analysis_requested', $meta, $assist);
AnalyzeArtworkAiAssistJob::dispatch($artwork->id, $force)->afterCommit();
return $assist->fresh();
}
public function analyzeDirect(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
{
$assist = $this->assistRecord($artwork);
$meta = ['force' => $force, 'direct' => true, 'intent' => $intent];
$this->appendAction($assist, 'analysis_requested', $meta);
$this->eventService->record($artwork, 'analysis_requested', $meta, $assist);
return $this->analyze($artwork, $force, $intent);
}
public function analyze(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
{
$artwork->loadMissing(['tags', 'categories.contentType', 'user']);
$assist = $this->assistRecord($artwork);
$assist->forceFill([
'status' => ArtworkAiAssist::STATUS_PROCESSING,
'error_message' => null,
])->save();
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_PROCESSING])->saveQuietly();
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
if ($hash === '') {
return $this->failAssist($assist, $artwork, 'Artwork hash is missing, so AI analysis could not start.');
}
if (! $this->vision->isEnabled()) {
return $this->failAssist($assist, $artwork, 'Vision analysis is disabled in the current environment.');
}
try {
$visionResult = $this->vision->analyzeArtworkDetailed($artwork, $hash);
$analysis = (array) ($visionResult['analysis'] ?? []);
$visionDebug = (array) ($visionResult['debug'] ?? []);
$this->vision->persistVisionMetadata(
$artwork,
(array) ($analysis['clip_tags'] ?? []),
isset($analysis['blip_caption']) ? (string) $analysis['blip_caption'] : null,
(array) ($analysis['yolo_objects'] ?? [])
);
$mode = $this->builder->detectMode($artwork, $analysis);
$signals = $this->builder->buildSignals($artwork, $analysis);
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$categorySuggestions = $this->categoryMapper->map($signals, $primaryCategory instanceof Category ? $primaryCategory : null);
$titleSuggestions = $this->builder->buildTitleSuggestions($artwork, $analysis, $mode);
$descriptionSuggestions = $this->builder->buildDescriptionSuggestions($artwork, $analysis, $mode);
$tagSuggestions = $this->builder->buildTagSuggestions($artwork, $analysis, $mode);
$similarCandidates = $this->buildSimilarCandidates($artwork);
$assist->forceFill([
'status' => ArtworkAiAssist::STATUS_READY,
'mode' => $mode,
'title_suggestions_json' => $titleSuggestions,
'description_suggestions_json' => $descriptionSuggestions,
'tag_suggestions_json' => $tagSuggestions,
'category_suggestions_json' => $categorySuggestions,
'similar_candidates_json' => $similarCandidates,
'raw_response_json' => [
'request' => [
'artwork_id' => (int) $artwork->id,
'hash' => $hash,
'intent' => $intent,
'force' => $force,
'current_title' => (string) ($artwork->title ?? ''),
'current_description' => (string) ($artwork->description ?? ''),
'current_tags' => $artwork->tags->pluck('slug')->values()->all(),
],
'vision_debug' => $visionDebug,
'analysis' => $analysis,
'generated_at' => \now()->toIso8601String(),
'force' => $force,
],
'error_message' => null,
'processed_at' => \now(),
])->save();
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_READY])->saveQuietly();
$meta = [
'force' => $force,
'mode' => $mode,
'intent' => $intent,
'title_suggestion_count' => count($titleSuggestions),
'description_suggestion_count' => count($descriptionSuggestions),
'tag_suggestion_count' => count($tagSuggestions),
'similar_candidate_count' => count($similarCandidates),
];
$this->appendAction($assist, 'analysis_completed', $meta);
$this->eventService->record($artwork, 'analysis_completed', $meta, $assist);
return $assist->fresh();
} catch (\Throwable $exception) {
return $this->failAssist($assist, $artwork, $exception->getMessage());
}
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function applySuggestions(Artwork $artwork, array $payload): array
{
$artwork->loadMissing(['tags', 'categories.contentType']);
$assist = $this->assistRecord($artwork);
$updated = false;
$applied = [];
DB::transaction(function () use ($artwork, $payload, &$updated, &$applied): void {
if (\filled($payload['title'] ?? null)) {
$mode = (string) ($payload['title_mode'] ?? 'replace');
$incoming = trim((string) $payload['title']);
$artwork->title = $mode === 'insert' && $artwork->title
? trim($artwork->title . ' ' . $incoming)
: $incoming;
$artwork->title_source = 'ai_applied';
$updated = true;
$applied[] = 'title';
}
if (\filled($payload['description'] ?? null)) {
$mode = (string) ($payload['description_mode'] ?? 'replace');
$incoming = trim((string) $payload['description']);
$artwork->description = $mode === 'append' && \filled($artwork->description)
? trim((string) $artwork->description . "\n\n" . $incoming)
: $incoming;
$artwork->description_source = 'ai_applied';
$updated = true;
$applied[] = 'description';
}
if (array_key_exists('tags', $payload) && is_array($payload['tags'])) {
$tagMode = (string) ($payload['tag_mode'] ?? 'add');
$tags = array_values(array_filter(array_map(fn (mixed $tag): string => $this->tagNormalizer->normalize((string) $tag), $payload['tags'])));
if ($tagMode === 'replace') {
$currentTags = $artwork->tags->pluck('slug')->all();
if ($currentTags !== []) {
$this->tagService->detachTags($artwork, $currentTags);
}
$this->tagService->attachAiTags($artwork, array_map(fn (string $tag): array => ['tag' => $tag], $tags));
} elseif ($tagMode === 'remove') {
$this->tagService->detachTags($artwork, $tags);
} else {
$this->tagService->attachAiTags($artwork, array_map(fn (string $tag): array => ['tag' => $tag], $tags));
}
$artwork->tags_source = 'ai_applied';
$updated = true;
$applied[] = 'tags';
}
$categoryId = $this->resolveCategoryId($payload);
if ($categoryId !== null) {
$artwork->categories()->sync([$categoryId]);
$artwork->category_source = 'ai_applied';
$updated = true;
$applied[] = 'category';
if (isset($payload['content_type_id']) && $payload['content_type_id'] !== null) {
$applied[] = 'content_type';
}
}
if ($updated) {
$artwork->save();
$artwork->load(['tags', 'categories.contentType']);
}
});
if (! empty($payload['similar_actions']) && is_array($payload['similar_actions'])) {
$this->applySimilarActions($assist, $payload['similar_actions']);
$applied[] = 'similar_candidates';
$this->eventService->record($artwork, 'similar_candidates_updated', [
'count' => count($payload['similar_actions']),
'states' => array_values(array_unique(array_map(
static fn (array $action): string => (string) ($action['state'] ?? 'unknown'),
array_filter($payload['similar_actions'], 'is_array')
))),
], $assist);
foreach (array_filter($payload['similar_actions'], 'is_array') as $action) {
$state = (string) ($action['state'] ?? 'unknown');
$candidateId = (int) ($action['artwork_id'] ?? 0);
if ($candidateId <= 0) {
continue;
}
$eventType = match ($state) {
'ignored' => 'duplicate_candidate_ignored',
'reviewed' => 'duplicate_candidate_reviewed',
default => 'duplicate_candidate_updated',
};
$this->eventService->record($artwork, $eventType, [
'candidate_artwork_id' => $candidateId,
'state' => $state,
], $assist);
}
}
if ($applied !== []) {
$fields = array_values(array_unique($applied));
$meta = ['fields' => $fields];
$this->appendAction($assist, 'suggestions_applied', $meta);
$this->eventService->record($artwork, 'suggestions_applied', $meta, $assist);
foreach ($fields as $field) {
$eventType = match ($field) {
'title' => 'title_suggestion_applied',
'description' => 'description_suggestion_applied',
'tags' => 'tags_suggestion_applied',
'content_type' => 'content_type_suggestion_applied',
'category' => 'category_suggestion_applied',
default => null,
};
if ($eventType === null) {
continue;
}
$this->eventService->record($artwork, $eventType, [
'fields' => $fields,
], $assist);
}
}
return $this->payloadFor($artwork->fresh(['tags', 'categories.contentType', 'artworkAiAssist']));
}
/**
* @return array<string, mixed>
*/
public function payloadFor(Artwork $artwork): array
{
$artwork->loadMissing(['artworkAiAssist', 'tags', 'categories.contentType']);
$assist = $artwork->artworkAiAssist;
$primaryCategory = $artwork->categories->first();
if (! $assist) {
return [
'status' => 'not_analyzed',
'mode' => null,
'title_suggestions' => [],
'description_suggestions' => [],
'tag_suggestions' => [],
'content_type' => null,
'category' => null,
'similar_candidates' => [],
'processed_at' => null,
'error_message' => null,
'current' => $this->currentPayload($artwork, $primaryCategory),
];
}
$categorySuggestions = is_array($assist->category_suggestions_json) ? $assist->category_suggestions_json : [];
return [
'status' => (string) $assist->status,
'mode' => $assist->mode,
'title_suggestions' => array_values((array) ($assist->title_suggestions_json ?? [])),
'description_suggestions' => array_values((array) ($assist->description_suggestions_json ?? [])),
'tag_suggestions' => array_values((array) ($assist->tag_suggestions_json ?? [])),
'content_type' => $categorySuggestions['content_type'] ?? null,
'category' => $categorySuggestions['category'] ?? null,
'similar_candidates' => array_values((array) ($assist->similar_candidates_json ?? [])),
'processed_at' => optional($assist->processed_at)?->toIso8601String(),
'error_message' => $assist->error_message,
'current' => $this->currentPayload($artwork, $primaryCategory),
'debug' => is_array($assist->raw_response_json) ? [
'request' => $assist->raw_response_json['request'] ?? null,
'vision_debug' => $assist->raw_response_json['vision_debug'] ?? null,
'analysis' => $assist->raw_response_json['analysis'] ?? null,
'generated_at' => $assist->raw_response_json['generated_at'] ?? null,
] : null,
];
}
private function assistRecord(Artwork $artwork): ArtworkAiAssist
{
return ArtworkAiAssist::query()->firstOrCreate(
['artwork_id' => (int) $artwork->id],
['status' => ArtworkAiAssist::STATUS_PENDING]
);
}
private function failAssist(ArtworkAiAssist $assist, Artwork $artwork, string $message): ArtworkAiAssist
{
$assist->forceFill([
'status' => ArtworkAiAssist::STATUS_FAILED,
'error_message' => Str::limit($message, 1500, ''),
])->save();
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_FAILED])->saveQuietly();
$meta = ['message' => Str::limit($message, 240, '')];
$this->appendAction($assist, 'analysis_failed', $meta);
$this->eventService->record($artwork, 'analysis_failed', $meta, $assist);
return $assist->fresh();
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildSimilarCandidates(Artwork $artwork): array
{
$exactMatches = Artwork::query()
->with('user:id,name')
->where('id', '!=', $artwork->id)
->whereNotNull('hash')
->where('hash', $artwork->hash)
->latest('id')
->limit(5)
->get()
->map(fn (Artwork $candidate): array => [
'artwork_id' => (int) $candidate->id,
'title' => (string) $candidate->title,
'thumbnail_url' => $candidate->thumbUrl('md'),
'match_type' => 'exact_hash',
'score' => 1.0,
'owner' => $candidate->user?->name,
'url' => '/art/' . $candidate->id . '/' . $candidate->slug,
'review_state' => null,
])
->values()
->all();
$vectorMatches = [];
if ($this->similarity->isConfigured()) {
try {
foreach ($this->similarity->similarToArtwork($artwork, 5) as $candidate) {
$vectorMatches[] = [
'artwork_id' => (int) ($candidate['id'] ?? 0),
'title' => (string) ($candidate['title'] ?? ''),
'thumbnail_url' => $candidate['thumb'] ?? null,
'match_type' => (string) ($candidate['source'] ?? 'vector_gateway'),
'score' => (float) ($candidate['score'] ?? 0.0),
'owner' => $candidate['author'] ?? null,
'url' => $candidate['url'] ?? null,
'review_state' => null,
];
}
} catch (\Throwable $exception) {
Log::warning('Studio AI assist similar lookup failed', [
'artwork_id' => (int) $artwork->id,
'error' => $exception->getMessage(),
]);
}
}
return collect($exactMatches)
->merge($vectorMatches)
->unique('artwork_id')
->take(5)
->values()
->all();
}
/**
* @param array<int, array<string, mixed>> $similarActions
*/
private function applySimilarActions(ArtworkAiAssist $assist, array $similarActions): void
{
$current = collect((array) ($assist->similar_candidates_json ?? []));
if ($current->isEmpty()) {
return;
}
$indexedActions = collect($similarActions)
->filter(fn (mixed $item): bool => is_array($item) && isset($item['artwork_id'], $item['state']))
->keyBy(fn (array $item): int => (int) $item['artwork_id']);
$updated = $current->map(function (array $candidate) use ($indexedActions): array {
$action = $indexedActions->get((int) ($candidate['artwork_id'] ?? 0));
if (! $action) {
return $candidate;
}
$candidate['review_state'] = (string) $action['state'];
return $candidate;
})->values()->all();
$assist->forceFill(['similar_candidates_json' => $updated])->save();
}
/**
* @param array<string, mixed> $meta
*/
private function appendAction(ArtworkAiAssist $assist, string $type, array $meta = []): void
{
$log = collect((array) ($assist->action_log_json ?? []))
->take(-24)
->push([
'type' => $type,
'meta' => $meta,
'created_at' => \now()->toIso8601String(),
])
->values()
->all();
$assist->forceFill(['action_log_json' => $log])->save();
}
/**
* @return array<string, mixed>
*/
private function currentPayload(Artwork $artwork, mixed $primaryCategory): array
{
return [
'title' => (string) $artwork->title,
'description' => (string) ($artwork->description ?? ''),
'tags' => $artwork->tags->pluck('slug')->values()->all(),
'category_id' => $primaryCategory?->id,
'content_type_id' => $primaryCategory?->contentType?->id,
'sources' => [
'title' => $artwork->title_source ?: 'manual',
'description' => $artwork->description_source ?: 'manual',
'tags' => $artwork->tags_source ?: 'manual',
'category' => $artwork->category_source ?: 'manual',
],
];
}
/**
* @param array<string, mixed> $payload
*/
private function resolveCategoryId(array $payload): ?int
{
if (isset($payload['category_id']) && $payload['category_id'] !== null) {
return (int) $payload['category_id'];
}
if (! isset($payload['content_type_id']) || $payload['content_type_id'] === null) {
return null;
}
$contentType = ContentType::query()->find((int) $payload['content_type_id']);
if (! $contentType) {
return null;
}
$category = $contentType->rootCategories()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->first();
if (! $category) {
$category = Category::query()
->where('content_type_id', $contentType->id)
->where('is_active', true)
->orderByRaw('CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END')
->orderBy('sort_order')
->orderBy('name')
->first();
}
return $category?->id;
}
}

View File

@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Category;
use App\Models\ContentType;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class StudioAiCategoryMapper
{
/**
* @param array<int, string> $signals
* @return array{content_type: array<string, mixed>|null, category: array<string, mixed>|null}
*/
public function map(array $signals, ?Category $currentCategory = null): array
{
$tokens = $this->tokenize($signals);
$haystack = ' ' . implode(' ', $tokens) . ' ';
$contentTypes = ContentType::query()->with(['rootCategories.children'])->get();
$contentTypeScores = $contentTypes
->map(fn (ContentType $contentType): array => $this->scoreContentType($contentType, $tokens, $haystack))
->filter(fn (array $row): bool => $row['score'] > 0)
->sortByDesc('score')
->values();
$selectedContentTypeRow = $contentTypeScores->first();
$selectedContentType = is_array($selectedContentTypeRow) ? ($selectedContentTypeRow['model'] ?? null) : null;
if (! $selectedContentType) {
$selectedContentType = $currentCategory?->contentType;
}
$categoryScores = $this->scoreCategories($contentTypes, $tokens, $haystack, $selectedContentType?->id);
$selectedCategoryRow = $categoryScores->first();
$selectedCategory = is_array($selectedCategoryRow) ? ($selectedCategoryRow['model'] ?? null) : null;
if (! $selectedCategory) {
$selectedCategory = $currentCategory;
}
return [
'content_type' => $selectedContentType ? $this->serializeContentType(
$selectedContentType,
$this->confidenceForModel($contentTypeScores, $selectedContentType->id)
) : null,
'category' => $selectedCategory ? $this->serializeCategory(
$selectedCategory,
$this->confidenceForModel($categoryScores, $selectedCategory->id),
$categoryScores
->reject(fn (array $row): bool => (int) $row['model']->id === (int) $selectedCategory->id)
->take(3)
->map(fn (array $row): array => $this->serializeCategory($row['model'], $row['confidence']))
->all()
) : null,
];
}
/**
* @param array<int, string> $tokens
* @return array<string, mixed>
*/
private function scoreContentType(ContentType $contentType, array $tokens, string $haystack): array
{
$keywords = array_merge([$contentType->slug, $contentType->name], $this->keywordsForContentType($contentType->slug));
$score = $this->keywordScore($keywords, $tokens, $haystack);
return [
'model' => $contentType,
'score' => $score,
'confidence' => $this->normalizeConfidence($score),
];
}
/**
* @return Collection<int, array{model: Category, score: int, confidence: float}>
*/
private function scoreCategories(Collection $contentTypes, array $tokens, string $haystack, ?int $contentTypeId = null): Collection
{
return $contentTypes
->filter(fn (ContentType $contentType): bool => $contentTypeId === null || (int) $contentType->id === (int) $contentTypeId)
->flatMap(function (ContentType $contentType) use ($tokens, $haystack): array {
$categories = [];
foreach ($contentType->rootCategories as $rootCategory) {
$categories[] = $rootCategory;
foreach ($rootCategory->children as $childCategory) {
$categories[] = $childCategory;
}
}
return array_map(function (Category $category) use ($tokens, $haystack): array {
$keywords = array_filter([
$category->slug,
$category->name,
$category->parent?->slug,
$category->parent?->name,
]);
$score = $this->keywordScore($keywords, $tokens, $haystack);
return [
'model' => $category,
'score' => $score,
'confidence' => $this->normalizeConfidence($score),
];
}, $categories);
})
->filter(fn (array $row): bool => $row['score'] > 0)
->sortByDesc('score')
->values();
}
/**
* @param array<int, string> $signals
* @return array<int, string>
*/
private function tokenize(array $signals): array
{
return Collection::make($signals)
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->flatMap(function (string $value): array {
$normalized = Str::of($value)
->lower()
->replaceMatches('/[^a-z0-9\s\-]+/', ' ')
->replace('-', ' ')
->squish()
->value();
return $normalized === '' ? [] : explode(' ', $normalized);
})
->filter(fn (string $value): bool => $value !== '' && strlen($value) >= 3)
->unique()
->values()
->all();
}
/**
* @param array<int, string> $keywords
* @param array<int, string> $tokens
*/
private function keywordScore(array $keywords, array $tokens, string $haystack): int
{
$score = 0;
$tokenVariants = Collection::make($tokens)
->flatMap(fn (string $token): array => array_unique([$token, $this->singularize($token), $this->pluralize($token)]))
->filter(fn (string $token): bool => $token !== '')
->values()
->all();
foreach ($keywords as $keyword) {
$normalized = Str::of((string) $keyword)
->lower()
->replaceMatches('/[^a-z0-9\s\-]+/', ' ')
->replace('-', ' ')
->squish()
->value();
if ($normalized === '') {
continue;
}
if (str_contains($haystack, ' ' . $normalized . ' ')) {
$score += str_contains($normalized, ' ') ? 4 : 3;
continue;
}
foreach (explode(' ', $normalized) as $part) {
if ($part !== '' && in_array($part, $tokenVariants, true)) {
$score += 1;
}
}
}
return $score;
}
/**
* @return array<int, string>
*/
private function keywordsForContentType(string $slug): array
{
return match ($slug) {
'skins' => ['skin', 'winamp', 'theme', 'interface skin'],
'wallpapers' => ['wallpaper', 'background', 'desktop', 'lockscreen'],
'photography' => ['photo', 'photograph', 'photography', 'portrait', 'macro', 'nature', 'camera'],
'members' => ['profile', 'avatar', 'member'],
default => ['artwork', 'illustration', 'digital art', 'painting', 'concept art', 'screenshot', 'ui', 'game'],
};
}
private function normalizeConfidence(int $score): float
{
if ($score <= 0) {
return 0.0;
}
return min(0.99, round(0.45 + ($score * 0.08), 2));
}
private function singularize(string $value): string
{
return str_ends_with($value, 's') ? rtrim($value, 's') : $value;
}
private function pluralize(string $value): string
{
return str_ends_with($value, 's') ? $value : $value . 's';
}
private function confidenceForModel(Collection $scores, int $modelId): float
{
$row = $scores->first(fn (array $item): bool => (int) $item['model']->id === $modelId);
return (float) ($row['confidence'] ?? 0.55);
}
/**
* @return array<string, mixed>
*/
private function serializeContentType(ContentType $contentType, float $confidence): array
{
return [
'id' => (int) $contentType->id,
'value' => (string) $contentType->slug,
'label' => (string) $contentType->name,
'confidence' => $confidence,
];
}
/**
* @param array<int, array<string, mixed>> $alternatives
* @return array<string, mixed>
*/
private function serializeCategory(Category $category, float $confidence, array $alternatives = []): array
{
$rootCategory = $category->parent ?: $category;
return [
'id' => (int) $category->id,
'value' => (string) $category->slug,
'label' => (string) $category->name,
'confidence' => $confidence,
'content_type_id' => (int) $category->content_type_id,
'root_category_id' => (int) $rootCategory->id,
'sub_category_id' => $category->parent_id ? (int) $category->id : null,
'alternatives' => array_values($alternatives),
];
}
}

View File

@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Artwork;
use App\Services\TagNormalizer;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class StudioAiSuggestionBuilder
{
private const GENERIC_TAGS = [
'image', 'picture', 'artwork', 'art', 'design', 'visual', 'graphic', 'photo of', 'image of',
];
public function __construct(
private readonly TagNormalizer $normalizer,
) {
}
/**
* @param array<string, mixed> $analysis
*/
public function detectMode(Artwork $artwork, array $analysis): string
{
$signals = Collection::make([
$artwork->title,
$artwork->description,
$artwork->file_name,
$analysis['blip_caption'] ?? null,
...Collection::make((array) ($analysis['clip_tags'] ?? []))->pluck('tag')->all(),
...Collection::make((array) ($analysis['yolo_objects'] ?? []))->pluck('tag')->all(),
])->filter()->implode(' ');
return preg_match('/\b(screenshot|screen|ui|interface|menu|hud|dashboard|settings|launcher|app|game)\b/i', $signals) === 1
? 'screenshot'
: 'artwork';
}
/**
* @param array<string, mixed> $analysis
* @return array<int, array{text: string, confidence: float}>
*/
public function buildTitleSuggestions(Artwork $artwork, array $analysis, string $mode): array
{
$caption = $this->cleanCaption((string) ($analysis['blip_caption'] ?? ''));
$topTerms = $this->topTerms($analysis, 4);
$titleSeeds = Collection::make([
$this->titleCase($caption),
$this->titleCase($this->limitWords($caption, 6)),
$mode === 'screenshot'
? $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Screen'))
: $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 3)))),
$mode === 'screenshot'
? $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Interface'))
: $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Study')),
$mode === 'screenshot'
? $this->titleCase(trim(($topTerms[0] ?? 'Interface') . ' View'))
: $this->titleCase(trim(($topTerms[0] ?? 'Artwork') . ' Composition')),
])
->filter(fn (?string $value): bool => is_string($value) && trim($value) !== '')
->map(fn (string $value): string => Str::limit(trim($value), 80, ''))
->unique()
->take(5)
->values();
return $titleSeeds->map(fn (string $text, int $index): array => [
'text' => $text,
'confidence' => round(max(0.55, 0.92 - ($index * 0.07)), 2),
])->all();
}
/**
* @param array<string, mixed> $analysis
* @return array<int, array{variant: string, text: string, confidence: float}>
*/
public function buildDescriptionSuggestions(Artwork $artwork, array $analysis, string $mode): array
{
$caption = $this->cleanCaption((string) ($analysis['blip_caption'] ?? ''));
$terms = $this->topTerms($analysis, 5);
$termSentence = $terms !== [] ? implode(', ', array_slice($terms, 0, 3)) : null;
$short = $caption !== ''
? Str::ucfirst(Str::finish($caption, '.'))
: ($mode === 'screenshot'
? 'A clear screenshot with interface-focused visual details.'
: 'A visually focused artwork with clear subject and style cues.');
$normal = $short;
if ($termSentence) {
$normal .= ' It highlights ' . $termSentence . ' without overclaiming details.';
}
$seo = $artwork->title !== ''
? $artwork->title . ' is presented with ' . ($termSentence ?: ($mode === 'screenshot' ? 'useful interface context' : 'strong visual detail')) . ' for discovery on Skinbase.'
: $normal;
return [
['variant' => 'short', 'text' => Str::limit($short, 180, ''), 'confidence' => 0.89],
['variant' => 'normal', 'text' => Str::limit($normal, 280, ''), 'confidence' => 0.85],
['variant' => 'seo', 'text' => Str::limit($seo, 220, ''), 'confidence' => 0.8],
];
}
/**
* @param array<string, mixed> $analysis
* @return array<int, array{tag: string, confidence: float|null}>
*/
public function buildTagSuggestions(Artwork $artwork, array $analysis, string $mode): array
{
$rawTags = Collection::make()
->merge((array) ($analysis['clip_tags'] ?? []))
->merge((array) ($analysis['yolo_objects'] ?? []))
->map(function (mixed $item): array {
if (is_string($item)) {
return ['tag' => $item, 'confidence' => null];
}
return [
'tag' => (string) ($item['tag'] ?? ''),
'confidence' => isset($item['confidence']) && is_numeric($item['confidence']) ? (float) $item['confidence'] : null,
];
});
foreach ($this->extractCaptionTags((string) ($analysis['blip_caption'] ?? '')) as $captionTag) {
$rawTags->push(['tag' => $captionTag, 'confidence' => 0.62]);
}
if ($mode === 'screenshot') {
foreach (['screenshot', 'ui'] as $fallbackTag) {
$rawTags->push(['tag' => $fallbackTag, 'confidence' => 0.58]);
}
}
$suggestions = $rawTags
->map(function (array $row): ?array {
$tag = $this->normalizer->normalize((string) ($row['tag'] ?? ''));
if ($tag === '' || in_array($tag, self::GENERIC_TAGS, true)) {
return null;
}
return [
'tag' => $tag,
'confidence' => isset($row['confidence']) && is_numeric($row['confidence']) ? round((float) $row['confidence'], 2) : null,
];
})
->filter()
->unique('tag')
->sortByDesc(fn (array $row): float => (float) ($row['confidence'] ?? 0.0))
->take(15)
->values();
return $suggestions->all();
}
/**
* @param array<string, mixed> $analysis
* @return array<int, string>
*/
public function buildSignals(Artwork $artwork, array $analysis): array
{
return Collection::make([
$artwork->title,
$artwork->description,
$artwork->file_name,
$analysis['blip_caption'] ?? null,
...Collection::make((array) ($analysis['clip_tags'] ?? []))->pluck('tag')->all(),
...Collection::make((array) ($analysis['yolo_objects'] ?? []))->pluck('tag')->all(),
...$artwork->tags->pluck('slug')->all(),
])
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->values()
->all();
}
/**
* @param array<string, mixed> $analysis
* @return array<int, string>
*/
private function topTerms(array $analysis, int $limit): array
{
return Collection::make()
->merge((array) ($analysis['clip_tags'] ?? []))
->merge((array) ($analysis['yolo_objects'] ?? []))
->map(fn (mixed $item): string => trim((string) (is_array($item) ? ($item['tag'] ?? '') : $item)))
->filter()
->flatMap(fn (string $term): array => preg_split('/\s+/', Str::of($term)->replace('-', ' ')->value()) ?: [])
->filter(fn (string $term): bool => strlen($term) >= 3)
->map(fn (string $term): string => Str::title($term))
->unique()
->take($limit)
->values()
->all();
}
/**
* @return array<int, string>
*/
private function extractCaptionTags(string $caption): array
{
$clean = Str::of($caption)
->lower()
->replaceMatches('/[^a-z0-9\s\-]+/', ' ')
->replace('-', ' ')
->squish()
->value();
if ($clean === '') {
return [];
}
$tokens = Collection::make(explode(' ', $clean))
->filter(fn (string $value): bool => strlen($value) >= 3)
->reject(fn (string $value): bool => in_array($value, ['with', 'from', 'into', 'over', 'under', 'image', 'picture', 'artwork'], true))
->values();
$bigrams = [];
for ($index = 0; $index < $tokens->count() - 1; $index++) {
$bigrams[] = $tokens[$index] . ' ' . $tokens[$index + 1];
}
return $tokens->merge($bigrams)->unique()->take(10)->all();
}
private function cleanCaption(string $caption): string
{
return Str::of($caption)
->replaceMatches('/^(a|an|the)\s+/i', '')
->replaceMatches('/^(image|photo|screenshot) of\s+/i', '')
->squish()
->value();
}
private function titleCase(string $value): string
{
return Str::title(trim($value));
}
private function limitWords(string $value, int $maxWords): string
{
$words = preg_split('/\s+/', trim($value)) ?: [];
return implode(' ', array_slice($words, 0, $maxWords));
}
}

View File

@@ -252,6 +252,45 @@ final class TagService
$this->queueReindex($artwork);
}
/**
* Sync the exact visible tag set from Studio editing.
*
* For manual/mixed saves, the submitted list becomes authoritative:
* removed tags are detached regardless of prior source, and remaining
* tags are upgraded to `user` source to reflect manual curation.
*
* For ai_applied saves, the submitted list is stored as AI tags.
*
* @param array<int, string> $tags
*/
public function syncStudioTags(Artwork $artwork, array $tags, string $source = 'manual'): void
{
if ($source === 'ai_applied') {
$currentTags = $artwork->tags()->pluck('tags.slug')->all();
if ($currentTags !== []) {
$this->detachTags($artwork, $currentTags);
}
$normalizedAi = $this->normalizeUserTags($tags);
$this->attachAiTags(
$artwork,
array_map(static fn (string $tag): array => ['tag' => $tag], $normalizedAi)
);
return;
}
$normalized = $this->normalizeUserTags($tags);
$currentAll = $artwork->tags()->pluck('tags.slug')->all();
$toDetach = array_values(array_diff($currentAll, $normalized));
if ($toDetach !== []) {
$this->detachTags($artwork, $toDetach);
}
$this->syncTags($artwork, $normalized);
}
public function updateUsageCount(Tag $tag): void
{
$this->syncUsageCount($tag);

View File

@@ -14,15 +14,14 @@ use Illuminate\Support\Facades\Log;
* Calculates and persists deterministic trending scores for artworks.
*
* Formula (Phase 1):
* score = (award_score * 5)
* + (favorites_count * 3)
* + (reactions_count * 2)
* + (downloads_count * 1)
* + (views * 2)
* score = (favorites_velocity * wf)
* + (comments_velocity * wc)
* + (shares_velocity * ws)
* + (downloads_velocity * wd)
* + (views_velocity * wv)
* - (hours_since_published * 0.1)
*
* The score is stored in artworks.trending_score_24h (artworks 7 days old)
* and artworks.trending_score_7d (artworks 30 days old).
* The score is stored in artworks.trending_score_1h / 24h / 7d.
*
* Both columns are updated every run; use `--period` to limit computation.
*/
@@ -39,14 +38,16 @@ final class TrendingService
/**
* Recalculate trending scores for artworks published within the look-back window.
*
* @param string $period '24h' targets trending_score_24h (7-day window)
* '7d' targets trending_score_7d (30-day window)
* @param string $period '1h' targets trending_score_1h (3-day window)
* '24h' targets trending_score_24h (7-day window)
* '7d' targets trending_score_7d (30-day window)
* @param int $chunkSize Number of IDs per DB UPDATE batch
* @return int Number of artworks updated
*/
public function recalculate(string $period = '7d', int $chunkSize = 1000): int
{
[$column, $windowDays] = match ($period) {
'1h' => ['trending_score_1h', 3],
'24h' => ['trending_score_24h', 7],
default => ['trending_score_7d', 30],
};
@@ -54,10 +55,23 @@ final class TrendingService
// Use the windowed counters: views_24h/views_7d and downloads_24h/downloads_7d
// instead of all-time totals so trending reflects recent activity.
[$viewCol, $dlCol] = match ($period) {
'1h' => ['views_1h', 'downloads_1h'],
'24h' => ['views_24h', 'downloads_24h'],
default => ['views_7d', 'downloads_7d'],
};
[$favCol, $commentCol, $shareCol] = match ($period) {
'1h' => ['favourites_1h', 'comments_1h', 'shares_1h'],
'24h' => ['favourites_24h', 'comments_24h', 'shares_24h'],
default => ['favorites', 'comments_count', 'shares_count'],
};
$weights = (array) config('discovery.v2.trending.velocity_weights', []);
$wView = (float) ($weights['views'] ?? self::W_VIEW);
$wFavorite = (float) ($weights['favorites'] ?? self::W_FAVORITE);
$wComment = (float) ($weights['comments'] ?? self::W_REACTION);
$wShare = (float) ($weights['shares'] ?? self::W_DOWNLOAD);
$cutoff = now()->subDays($windowDays)->toDateTimeString();
$updated = 0;
@@ -69,7 +83,7 @@ final class TrendingService
->whereNotNull('published_at')
->where('published_at', '>=', $cutoff)
->orderBy('id')
->chunkById($chunkSize, function ($artworks) use ($column, $viewCol, $dlCol, &$updated): void {
->chunkById($chunkSize, function ($artworks) use ($column, $viewCol, $dlCol, $favCol, $commentCol, $shareCol, $wFavorite, $wComment, $wShare, $wView, &$updated): void {
$ids = $artworks->pluck('id')->toArray();
$inClause = implode(',', array_fill(0, count($ids), '?'));
@@ -81,17 +95,17 @@ final class TrendingService
"UPDATE artworks
SET
{$column} = GREATEST(
COALESCE((SELECT score_total FROM artwork_award_stats WHERE artwork_award_stats.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT favorites FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT COUNT(*) FROM artwork_reactions WHERE artwork_reactions.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT {$dlCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT {$viewCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
COALESCE((SELECT {$favCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT {$commentCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT {$shareCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT {$dlCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
+ COALESCE((SELECT {$viewCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
- (TIMESTAMPDIFF(HOUR, artworks.published_at, NOW()) * ?)
, 0),
last_trending_calculated_at = NOW()
WHERE id IN ({$inClause})",
array_merge(
[self::W_AWARD, self::W_FAVORITE, self::W_REACTION, self::W_DOWNLOAD, self::W_VIEW, self::DECAY_RATE],
[$wFavorite, $wComment, $wShare, self::W_DOWNLOAD, $wView, self::DECAY_RATE],
$ids
)
);
@@ -114,7 +128,11 @@ final class TrendingService
*/
public function syncToSearchIndex(string $period = '7d', int $chunkSize = 500): void
{
$windowDays = $period === '24h' ? 7 : 30;
$windowDays = match ($period) {
'1h' => 3,
'24h' => 7,
default => 30,
};
$cutoff = now()->subDays($windowDays)->toDateTimeString();
Artwork::query()

View File

@@ -174,7 +174,11 @@ final class UploadPipelineService
public function originalHashExists(string $hash): bool
{
return $this->storage->originalHashExists($hash);
return Artwork::query()
->where('hash', $hash)
->where('artwork_status', 'published')
->whereNotNull('published_at')
->exists();
}
private function quarantine(UploadSessionData $session, string $reason): void

View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use App\Services\Recommendation\UserPreferenceBuilder;
use App\Support\AvatarUrl;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
final class UserSuggestionService
{
public function __construct(
private readonly UserPreferenceBuilder $preferenceBuilder,
private readonly FollowService $followService,
) {
}
public function suggestFor(User $viewer, int $limit = 8): array
{
$resolvedLimit = max(1, min(24, $limit));
$cacheKey = sprintf('user_suggestions:v2:%d:%d', (int) $viewer->id, $resolvedLimit);
return Cache::remember($cacheKey, now()->addMinutes(15), function () use ($viewer, $resolvedLimit): array {
try {
return $this->buildSuggestions($viewer, $resolvedLimit);
} catch (\Throwable $e) {
Log::warning('UserSuggestionService failed', [
'viewer_id' => (int) $viewer->id,
'error' => $e->getMessage(),
]);
return [];
}
});
}
private function buildSuggestions(User $viewer, int $limit): array
{
$profile = $this->preferenceBuilder->build($viewer);
$followingIds = DB::table('user_followers')
->where('follower_id', $viewer->id)
->pluck('user_id')
->map(fn ($id) => (int) $id)
->values()
->all();
$excludedIds = array_values(array_unique(array_merge($followingIds, [(int) $viewer->id])));
$topTagSlugs = array_slice($profile->topTagSlugs ?? [], 0, 10);
$topCategoryIds = $this->topCategoryIdsForViewer((int) $viewer->id);
$candidates = [];
foreach ($this->mutualFollowCandidates($viewer, $followingIds) as $candidate) {
$candidates[$candidate['id']] = $candidate;
}
foreach ($this->sharedInterestCandidates($viewer, $topTagSlugs, $topCategoryIds) as $candidate) {
if (isset($candidates[$candidate['id']])) {
$candidates[$candidate['id']]['score'] += $candidate['score'];
$candidates[$candidate['id']]['reason'] = $candidates[$candidate['id']]['reason'] . ' · ' . $candidate['reason'];
continue;
}
$candidates[$candidate['id']] = $candidate;
}
foreach ($this->trendingCreatorCandidates($excludedIds) as $candidate) {
if (! isset($candidates[$candidate['id']])) {
$candidates[$candidate['id']] = $candidate;
}
}
foreach ($this->newActiveCreatorCandidates($excludedIds) as $candidate) {
if (! isset($candidates[$candidate['id']])) {
$candidates[$candidate['id']] = $candidate;
}
}
$ranked = array_values(array_filter(
$candidates,
fn (array $candidate): bool => ! in_array((int) $candidate['id'], $excludedIds, true)
));
usort($ranked, fn (array $left, array $right): int => $right['score'] <=> $left['score']);
return array_map(function (array $candidate) use ($viewer): array {
$context = $this->followService->relationshipContext((int) $viewer->id, (int) $candidate['id']);
return [
'id' => (int) $candidate['id'],
'username' => (string) $candidate['username'],
'name' => (string) ($candidate['name'] ?? $candidate['username']),
'profile_url' => '/@' . strtolower((string) $candidate['username']),
'avatar_url' => AvatarUrl::forUser((int) $candidate['id'], $candidate['avatar_hash'] ?? null, 64),
'followers_count' => (int) ($candidate['followers_count'] ?? 0),
'following_count' => (int) ($candidate['following_count'] ?? 0),
'reason' => (string) ($candidate['reason'] ?? 'Recommended creator'),
'context' => $context,
];
}, array_slice($ranked, 0, $limit));
}
private function mutualFollowCandidates(User $viewer, array $followingIds): array
{
if ($followingIds === []) {
return [];
}
return DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->whereIn('uf.follower_id', $followingIds)
->where('uf.user_id', '!=', $viewer->id)
->where('u.is_active', true)
->whereNull('u.deleted_at')
->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(*) as overlap_count')
->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count')
->orderByDesc('overlap_count')
->limit(20)
->get()
->map(fn ($row) => [
'id' => (int) $row->id,
'username' => $row->username,
'name' => $row->name,
'avatar_hash' => $row->avatar_hash,
'followers_count' => (int) $row->followers_count,
'following_count' => (int) $row->following_count,
'score' => (float) $row->overlap_count * 3.0,
'reason' => 'Popular in your network',
])
->all();
}
private function sharedInterestCandidates(User $viewer, array $topTagSlugs, array $topCategoryIds): array
{
if ($topTagSlugs === [] && $topCategoryIds === []) {
return [];
}
$query = DB::table('users as u')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->join('artworks as a', 'a.user_id', '=', 'u.id')
->leftJoin('artwork_tag as at', 'at.artwork_id', '=', 'a.id')
->leftJoin('tags as t', 't.id', '=', 'at.tag_id')
->leftJoin('artwork_category as ac', 'ac.artwork_id', '=', 'a.id')
->where('u.id', '!=', $viewer->id)
->where('u.is_active', true)
->whereNull('u.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNull('a.deleted_at')
->whereNotNull('a.published_at');
$query->where(function ($builder) use ($topTagSlugs, $topCategoryIds): void {
if ($topTagSlugs !== []) {
$builder->orWhereIn('t.slug', $topTagSlugs);
}
if ($topCategoryIds !== []) {
$builder->orWhereIn('ac.category_id', $topCategoryIds);
}
});
return $query
->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(DISTINCT t.id) as matched_tags, COUNT(DISTINCT ac.category_id) as matched_categories')
->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count')
->orderByDesc(DB::raw('COUNT(DISTINCT t.id) + COUNT(DISTINCT ac.category_id)'))
->limit(20)
->get()
->map(fn ($row) => [
'id' => (int) $row->id,
'username' => $row->username,
'name' => $row->name,
'avatar_hash' => $row->avatar_hash,
'followers_count' => (int) $row->followers_count,
'following_count' => (int) $row->following_count,
'score' => ((float) $row->matched_tags * 2.0) + (float) $row->matched_categories,
'reason' => 'Shared tags and categories',
])
->all();
}
private function trendingCreatorCandidates(array $excludedIds): array
{
return DB::table('users as u')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->join('artworks as a', 'a.user_id', '=', 'u.id')
->whereNotIn('u.id', $excludedIds)
->where('u.is_active', true)
->whereNull('u.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNull('a.deleted_at')
->where('a.published_at', '>=', now()->subDays(30))
->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(a.id) as recent_artworks')
->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count')
->orderByDesc('followers_count')
->orderByDesc('recent_artworks')
->limit(10)
->get()
->map(fn ($row) => [
'id' => (int) $row->id,
'username' => $row->username,
'name' => $row->name,
'avatar_hash' => $row->avatar_hash,
'followers_count' => (int) $row->followers_count,
'following_count' => (int) $row->following_count,
'score' => ((float) $row->followers_count * 0.1) + (float) $row->recent_artworks,
'reason' => 'Trending creator',
])
->all();
}
private function newActiveCreatorCandidates(array $excludedIds): array
{
return DB::table('users as u')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->join('artworks as a', 'a.user_id', '=', 'u.id')
->whereNotIn('u.id', $excludedIds)
->where('u.is_active', true)
->whereNull('u.deleted_at')
->where('u.created_at', '>=', now()->subDays(60))
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNull('a.deleted_at')
->where('a.published_at', '>=', now()->subDays(14))
->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(a.id) as recent_artworks')
->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count')
->orderByDesc('recent_artworks')
->orderByDesc('followers_count')
->limit(10)
->get()
->map(fn ($row) => [
'id' => (int) $row->id,
'username' => $row->username,
'name' => $row->name,
'avatar_hash' => $row->avatar_hash,
'followers_count' => (int) $row->followers_count,
'following_count' => (int) $row->following_count,
'score' => ((float) $row->recent_artworks * 2.0) + ((float) $row->followers_count * 0.05),
'reason' => 'New active creator',
])
->all();
}
private function topCategoryIdsForViewer(int $viewerId): array
{
return DB::table('artwork_category as ac')
->join('artworks as a', 'a.id', '=', 'ac.artwork_id')
->leftJoin('artwork_favourites as af', 'af.artwork_id', '=', 'a.id')
->where(function ($query) use ($viewerId): void {
$query
->where('a.user_id', $viewerId)
->orWhere('af.user_id', $viewerId);
})
->selectRaw('ac.category_id, COUNT(*) as weight')
->groupBy('ac.category_id')
->orderByDesc('weight')
->limit(6)
->pluck('category_id')
->map(fn ($id) => (int) $id)
->values()
->all();
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Services\Vision;
use App\Models\Artwork;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
final class AiArtworkVectorSearchService
{
public function __construct(
private readonly VectorGatewayClient $client,
private readonly ArtworkVisionImageUrl $imageUrl,
) {
}
public function isConfigured(): bool
{
return $this->client->isConfigured();
}
/**
* @return list<array<string, mixed>>
*/
public function similarToArtwork(Artwork $artwork, int $limit = 12): array
{
$safeLimit = max(1, min(24, $limit));
$cacheKey = sprintf('rec:artwork:%d:similar-ai:%d', $artwork->id, $safeLimit);
$ttl = max(60, (int) config('recommendations.ttl.similar_artworks', 30 * 60));
return Cache::remember($cacheKey, $ttl, function () use ($artwork, $safeLimit): array {
$url = $this->imageUrl->fromArtwork($artwork);
if ($url === null) {
return [];
}
$matches = $this->client->searchByUrl($url, $safeLimit + 1);
return $this->resolveMatches($matches, $safeLimit, $artwork->id);
});
}
/**
* @return list<array<string, mixed>>
*/
public function searchByUploadedImage(UploadedFile $file, int $limit = 12): array
{
$safeLimit = max(1, min(24, $limit));
$path = $file->store('ai-search/tmp', 'public');
if (! is_string($path) || $path === '') {
throw new RuntimeException('Unable to persist uploaded image for vector search.');
}
$publicBaseUrl = rtrim((string) config('filesystems.disks.public.url', ''), '/');
if ($publicBaseUrl === '') {
Storage::disk('public')->delete($path);
throw new RuntimeException('Public disk URL is not configured for vector search uploads.');
}
$url = $publicBaseUrl . '/' . ltrim($path, '/');
try {
$matches = $this->client->searchByUrl($url, $safeLimit);
return $this->resolveMatches($matches, $safeLimit);
} finally {
Storage::disk('public')->delete($path);
}
}
/**
* @param list<array{id: int|string, score: float, metadata: array<string, mixed>}> $matches
* @return list<array<string, mixed>>
*/
private function resolveMatches(array $matches, int $limit, ?int $excludeArtworkId = null): array
{
$orderedIds = [];
$scores = [];
foreach ($matches as $match) {
$artworkId = (int) ($match['id'] ?? 0);
if ($artworkId <= 0) {
continue;
}
if ($excludeArtworkId !== null && $artworkId === $excludeArtworkId) {
continue;
}
if (isset($scores[$artworkId])) {
continue;
}
$orderedIds[] = $artworkId;
$scores[$artworkId] = (float) ($match['score'] ?? 0.0);
}
if ($orderedIds === []) {
return [];
}
$artworks = Artwork::query()
->whereIn('id', $orderedIds)
->public()
->published()
->with(['user:id,name', 'user.profile:user_id,avatar_hash'])
->get()
->keyBy('id');
$items = [];
foreach ($orderedIds as $artworkId) {
/** @var Artwork|null $artwork */
$artwork = $artworks->get($artworkId);
if ($artwork === null) {
continue;
}
$items[] = [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb' => $artwork->thumbUrl('md'),
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
'author' => $artwork->user?->name ?? 'Artist',
'author_avatar' => $artwork->user?->profile?->avatar_url,
'author_id' => $artwork->user_id,
'score' => round((float) ($scores[$artworkId] ?? 0.0), 5),
'source' => 'vector_gateway',
];
if (count($items) >= $limit) {
break;
}
}
return $items;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Services\Vision;
use App\Models\Artwork;
use RuntimeException;
final class ArtworkVectorIndexService
{
public function __construct(
private readonly VectorGatewayClient $client,
private readonly ArtworkVisionImageUrl $imageUrl,
private readonly ArtworkVectorMetadataService $metadata,
) {
}
public function isConfigured(): bool
{
return $this->client->isConfigured();
}
/**
* @return array{url: string, metadata: array{content_type: string, category: string, user_id: string}}
*/
public function payloadForArtwork(Artwork $artwork): array
{
$url = $this->imageUrl->fromArtwork($artwork);
if ($url === null || $url === '') {
throw new RuntimeException('No vision image URL could be generated for artwork ' . (int) $artwork->id . '.');
}
return [
'url' => $url,
'metadata' => $this->metadata->forArtwork($artwork),
];
}
/**
* @return array{url: string, metadata: array{content_type: string, category: string, user_id: string}}
*/
public function upsertArtwork(Artwork $artwork): array
{
$payload = $this->payloadForArtwork($artwork);
$this->client->upsertByUrl($payload['url'], (int) $artwork->id, $payload['metadata']);
$artwork->forceFill([
'last_vector_indexed_at' => now(),
])->save();
return $payload;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Vision;
use App\Models\Artwork;
use App\Models\Category;
final class ArtworkVectorMetadataService
{
/**
* @return array{content_type: string, category: string, user_id: string, tags: list<string>}
*/
public function forArtwork(Artwork $artwork): array
{
$artwork->loadMissing([
'categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name'),
'tags:id,slug',
]);
$category = $this->primaryCategory($artwork);
return [
'content_type' => (string) ($category?->contentType?->name ?? ''),
'category' => (string) ($category?->name ?? ''),
'user_id' => (string) ($artwork->user_id ?? ''),
'tags' => $artwork->tags
->pluck('slug')
->map(static fn (mixed $slug): string => trim((string) $slug))
->filter(static fn (string $slug): bool => $slug !== '')
->unique()
->values()
->all(),
];
}
private function primaryCategory(Artwork $artwork): ?Category
{
/** @var Category|null $category */
$category = $artwork->categories->sortBy('sort_order')->first();
return $category;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Services\Vision;
use App\Models\Artwork;
use Illuminate\Http\UploadedFile;
final class VectorService
{
public function __construct(
private readonly AiArtworkVectorSearchService $searchService,
private readonly ArtworkVectorIndexService $indexService,
) {
}
public function isConfigured(): bool
{
return $this->searchService->isConfigured();
}
/**
* @return list<array<string, mixed>>
*/
public function similarToArtwork(Artwork $artwork, int $limit = 12): array
{
return $this->searchService->similarToArtwork($artwork, $limit);
}
/**
* @return list<array<string, mixed>>
*/
public function searchByUploadedImage(UploadedFile $file, int $limit = 12): array
{
return $this->searchService->searchByUploadedImage($file, $limit);
}
/**
* @return array{url: string, metadata: array{content_type: string, category: string, user_id: string}}
*/
public function payloadForArtwork(Artwork $artwork): array
{
return $this->indexService->payloadForArtwork($artwork);
}
/**
* @return array{url: string, metadata: array{content_type: string, category: string, user_id: string}}
*/
public function upsertArtwork(Artwork $artwork): array
{
return $this->indexService->upsertArtwork($artwork);
}
}

View File

@@ -0,0 +1,774 @@
<?php
declare(strict_types=1);
namespace App\Services\Vision;
use App\Models\Artwork;
use App\Services\ThumbnailService;
use App\Services\TagNormalizer;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class VisionService
{
public function isEnabled(): bool
{
return (bool) config('vision.enabled', true);
}
public function buildImageUrl(string $hash): ?string
{
$variant = (string) config('vision.image_variant', 'md');
$variant = $variant !== '' ? $variant : 'md';
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
return ThumbnailService::fromHash($clean, 'webp', $variant);
}
/**
* @return array{clip_tags: array<int, array{tag: string, confidence?: float|int|null}>, yolo_objects: array<int, array{tag: string, confidence?: float|int|null}>, blip_caption: ?string}|array{}
*/
public function analyzeArtwork(Artwork $artwork, string $hash): array
{
return $this->analyzeArtworkDetailed($artwork, $hash)['analysis'];
}
/**
* @return array{analysis: array{clip_tags: array<int, array{tag: string, confidence?: float|int|null}>, yolo_objects: array<int, array{tag: string, confidence?: float|int|null}>, blip_caption: ?string}|array{}, debug: array<string, mixed>}
*/
public function analyzeArtworkDetailed(Artwork $artwork, string $hash): array
{
$imageUrl = $this->buildImageUrl($hash);
if ($imageUrl === null) {
return [
'analysis' => [],
'debug' => [
'image_url' => null,
'hash' => $hash,
'reason' => 'image_url_unavailable',
'calls' => [],
],
];
}
$ref = (string) Str::uuid();
$debug = [
'ref' => $ref,
'artwork_id' => (int) $artwork->id,
'hash' => $hash,
'image_url' => $imageUrl,
'calls' => [],
];
$gatewayCall = $this->callGatewayAllDetailed($imageUrl, (int) $artwork->id, $hash, 8, $ref);
$debug['calls'][] = $gatewayCall['debug'];
$gatewayAnalysis = $gatewayCall['analysis'];
$clipTags = $gatewayAnalysis['clip_tags'] ?? [];
if ($clipTags === []) {
$clipCall = $this->callClipDetailed($imageUrl, (int) $artwork->id, $hash, $ref);
$debug['calls'][] = $clipCall['debug'];
$clipTags = $clipCall['tags'];
}
$yoloTags = $gatewayAnalysis['yolo_objects'] ?? [];
if ($yoloTags === [] && $this->shouldRunYolo($artwork)) {
$yoloCall = $this->callYoloDetailed($imageUrl, (int) $artwork->id, $hash, $ref);
$debug['calls'][] = $yoloCall['debug'];
$yoloTags = $yoloCall['tags'];
}
return [
'analysis' => [
'clip_tags' => $clipTags,
'yolo_objects' => $yoloTags,
'blip_caption' => $gatewayAnalysis['blip_caption'] ?? null,
],
'debug' => $debug,
];
}
/**
* @return array{tags: array<int, array{name: string, slug: string, confidence: float|null, source: string, is_ai: true}>, vision_enabled: bool, source?: string, reason?: string}
*/
public function suggestTags(Artwork $artwork, TagNormalizer $normalizer, int $limit = 10): array
{
if (! $this->isEnabled()) {
return ['tags' => [], 'vision_enabled' => false];
}
$imageUrl = $this->buildImageUrl((string) $artwork->hash);
if ($imageUrl === null) {
return [
'tags' => [],
'vision_enabled' => true,
'reason' => 'image_url_unavailable',
];
}
$gatewayBase = trim((string) config('vision.gateway.base_url', config('vision.clip.base_url', '')));
if ($gatewayBase === '') {
return [
'tags' => [],
'vision_enabled' => true,
'reason' => 'gateway_not_configured',
];
}
$safeLimit = min(20, max(5, $limit));
$url = rtrim($gatewayBase, '/') . '/analyze/all';
$timeout = (int) config('vision.gateway.timeout_seconds', 10);
$connectTimeout = (int) config('vision.gateway.connect_timeout_seconds', 3);
$ref = (string) Str::uuid();
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = $this->requestWithVisionAuth('gateway', $ref)
->connectTimeout(max(1, $connectTimeout))
->timeout(max(1, $timeout))
->withHeaders(['X-Request-ID' => $ref])
->post($url, [
'url' => $imageUrl,
'limit' => $safeLimit,
]);
if (! $response->ok()) {
Log::warning('vision-suggest: non-ok response', [
'ref' => $ref,
'artwork_id' => (int) $artwork->id,
'status' => $response->status(),
'body' => Str::limit($response->body(), 400),
]);
return [
'tags' => [],
'vision_enabled' => true,
'reason' => 'gateway_error_' . $response->status(),
];
}
return [
'tags' => $this->parseGatewaySuggestions($response->json(), $normalizer),
'vision_enabled' => true,
'source' => 'gateway_sync',
];
} catch (\Throwable $e) {
Log::warning('vision-suggest: request failed', [
'ref' => $ref,
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
return [
'tags' => [],
'vision_enabled' => true,
'reason' => 'gateway_exception',
];
}
}
/**
* @param array<int, array{tag: string, confidence?: float|int|null}> $clipTags
* @param array<int, array{tag: string, confidence?: float|int|null}> $yoloTags
*/
public function persistVisionMetadata(Artwork $artwork, array $clipTags, ?string $blipCaption, array $yoloTags): void
{
$artwork->forceFill([
'clip_tags_json' => $clipTags === [] ? null : array_values($clipTags),
'blip_caption' => $blipCaption,
'yolo_objects_json' => $yoloTags === [] ? null : array_values($yoloTags),
'vision_metadata_updated_at' => now(),
])->saveQuietly();
}
/**
* @param array<int, array{tag: string, confidence?: float|int|null}> $a
* @param array<int, array{tag: string, confidence?: float|int|null}> $b
* @return array<int, array{tag: string, confidence?: float|int|null}>
*/
public function mergeTags(array $a, array $b): array
{
$byTag = [];
foreach (array_merge($a, $b) as $row) {
$tag = (string) ($row['tag'] ?? '');
if ($tag === '') {
continue;
}
$conf = $row['confidence'] ?? null;
$conf = is_numeric($conf) ? (float) $conf : null;
if (! isset($byTag[$tag])) {
$byTag[$tag] = ['tag' => $tag, 'confidence' => $conf];
continue;
}
$existing = $byTag[$tag]['confidence'];
if ($existing === null || ($conf !== null && $conf > (float) $existing)) {
$byTag[$tag]['confidence'] = $conf;
}
}
return array_values($byTag);
}
/**
* @param mixed $json
* @return array{clip_tags: array<int, array{tag: string, confidence?: float|int|null}>, yolo_objects: array<int, array{tag: string, confidence?: float|int|null}>, blip_caption: ?string}|array{}
*/
private function parseGatewayAnalysis(mixed $json): array
{
if (! is_array($json)) {
return [];
}
return [
'clip_tags' => $this->extractTagRowsFromMixed($json['clip'] ?? []),
'yolo_objects' => $this->extractTagRowsFromMixed($json['yolo'] ?? ($json['objects'] ?? [])),
'blip_caption' => $this->extractCaption($json['blip'] ?? ($json['captions'] ?? ($json['caption'] ?? null))),
];
}
/**
* @param mixed $value
* @return array<int, array{tag: string, confidence?: float|int|null}>
*/
private function extractTagRowsFromMixed(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$rows = [];
foreach ($value as $item) {
if (is_string($item)) {
$rows[] = ['tag' => $item, 'confidence' => null];
continue;
}
if (! is_array($item)) {
continue;
}
$tag = (string) ($item['tag'] ?? $item['label'] ?? $item['name'] ?? '');
if ($tag === '') {
continue;
}
$rows[] = ['tag' => $tag, 'confidence' => $item['confidence'] ?? null];
}
return $rows;
}
/**
* @param mixed $value
*/
private function extractCaption(mixed $value): ?string
{
if (is_string($value)) {
$caption = trim($value);
return $caption !== '' ? $caption : null;
}
if (! is_array($value)) {
return null;
}
foreach ($value as $item) {
if (is_string($item) && trim($item) !== '') {
return trim($item);
}
if (is_array($item)) {
$caption = trim((string) ($item['caption'] ?? $item['text'] ?? ''));
if ($caption !== '') {
return $caption;
}
}
}
return null;
}
/**
* @param mixed $json
* @return array<int, array{name: string, slug: string, confidence: float|null, source: string, is_ai: true}>
*/
private function parseGatewaySuggestions(mixed $json, TagNormalizer $normalizer): array
{
$raw = [];
if (! is_array($json)) {
return [];
}
if (isset($json['clip']) && is_array($json['clip'])) {
foreach ($json['clip'] as $item) {
$raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'clip'];
}
}
if (isset($json['yolo']) && is_array($json['yolo'])) {
foreach ($json['yolo'] as $item) {
$raw[] = ['tag' => $item['tag'] ?? $item['label'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'yolo'];
}
}
if ($raw === []) {
$list = $json['tags'] ?? $json['data'] ?? $json;
if (is_array($list)) {
foreach ($list as $item) {
if (is_array($item)) {
$raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? $item['label'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'vision'];
} elseif (is_string($item)) {
$raw[] = ['tag' => $item, 'confidence' => null, 'source' => 'vision'];
}
}
}
}
$bySlug = [];
foreach ($raw as $row) {
$slug = $normalizer->normalize((string) ($row['tag'] ?? ''));
if ($slug === '') {
continue;
}
$conf = isset($row['confidence']) && is_numeric($row['confidence']) ? (float) $row['confidence'] : null;
if (! isset($bySlug[$slug]) || ($conf !== null && $conf > (float) ($bySlug[$slug]['confidence'] ?? 0))) {
$bySlug[$slug] = [
'name' => ucwords(str_replace(['-', '_'], ' ', $slug)),
'slug' => $slug,
'confidence' => $conf,
'source' => $row['source'] ?? 'vision',
'is_ai' => true,
];
}
}
$sorted = array_values($bySlug);
usort($sorted, static fn (array $a, array $b): int => ($b['confidence'] ?? 0) <=> ($a['confidence'] ?? 0));
return $sorted;
}
/**
* @return array{clip_tags: array<int, array{tag: string, confidence?: float|int|null}>, yolo_objects: array<int, array{tag: string, confidence?: float|int|null}>, blip_caption: ?string}|array{}
*/
private function callGatewayAll(string $imageUrl, int $artworkId, string $hash, int $limit, string $ref): array
{
return $this->callGatewayAllDetailed($imageUrl, $artworkId, $hash, $limit, $ref)['analysis'];
}
/**
* @return array{analysis: array{clip_tags: array<int, array{tag: string, confidence?: float|int|null}>, yolo_objects: array<int, array{tag: string, confidence?: float|int|null}>, blip_caption: ?string}|array{}, debug: array<string, mixed>}
*/
private function callGatewayAllDetailed(string $imageUrl, int $artworkId, string $hash, int $limit, string $ref): array
{
$base = trim((string) config('vision.gateway.base_url', ''));
if ($base === '') {
return [
'analysis' => [],
'debug' => [
'service' => 'gateway_all',
'enabled' => false,
'reason' => 'base_url_missing',
],
];
}
$url = rtrim($base, '/') . '/analyze/all';
$timeout = (int) config('vision.gateway.timeout_seconds', 10);
$connectTimeout = (int) config('vision.gateway.connect_timeout_seconds', 3);
$requestPayload = [
'url' => $imageUrl,
'image_url' => $imageUrl,
'limit' => $limit,
'artwork_id' => $artworkId,
'hash' => $hash,
];
$debug = [
'service' => 'gateway_all',
'endpoint' => $url,
'request' => $requestPayload,
];
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = $this->requestWithVisionAuth('gateway', $ref)
->connectTimeout(max(1, $connectTimeout))
->timeout(max(1, $timeout))
->post($url, $requestPayload);
$debug['status'] = $response->status();
$debug['auth_header_sent'] = $this->visionApiKey('gateway') !== '';
$debug['response'] = $response->json() ?? $this->safeBody($response->body());
} catch (\Throwable $e) {
Log::warning('Vision gateway analyze/all request failed', [
'ref' => $ref,
'artwork_id' => $artworkId,
'error' => $e->getMessage(),
]);
$debug['error'] = $e->getMessage();
return ['analysis' => [], 'debug' => $debug];
}
if (! $response->ok()) {
Log::warning('Vision gateway analyze/all non-ok response', [
'ref' => $ref,
'status' => $response->status(),
'body' => $this->safeBody($response->body()),
]);
return ['analysis' => [], 'debug' => $debug];
}
return ['analysis' => $this->parseGatewayAnalysis($response->json()), 'debug' => $debug];
}
/**
* @return array<int, array{tag: string, confidence?: float|int|null}>
*/
private function callClip(string $imageUrl, int $artworkId, string $hash, string $ref): array
{
return $this->callClipDetailed($imageUrl, $artworkId, $hash, $ref)['tags'];
}
/**
* @return array{tags: array<int, array{tag: string, confidence?: float|int|null}>, debug: array<string, mixed>}
*/
private function callClipDetailed(string $imageUrl, int $artworkId, string $hash, string $ref): array
{
$base = trim((string) config('vision.clip.base_url', ''));
if ($base === '') {
return [
'tags' => [],
'debug' => [
'service' => 'clip',
'enabled' => false,
'reason' => 'base_url_missing',
],
];
}
$endpoint = (string) config('vision.clip.endpoint', '/analyze');
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
$timeout = (int) config('vision.clip.timeout_seconds', 8);
$connectTimeout = (int) config('vision.clip.connect_timeout_seconds', 2);
$retries = (int) config('vision.clip.retries', 1);
$delay = (int) config('vision.clip.retry_delay_ms', 200);
$requestPayload = [
'url' => $imageUrl,
'image_url' => $imageUrl,
'limit' => 8,
'artwork_id' => $artworkId,
'hash' => $hash,
];
$debug = [
'service' => 'clip',
'endpoint' => $url,
'request' => $requestPayload,
];
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = $this->requestWithVisionAuth('clip', $ref)
->connectTimeout(max(1, $connectTimeout))
->timeout(max(1, $timeout))
->retry(max(0, $retries), max(0, $delay), throw: false)
->post($url, $requestPayload);
$debug['status'] = $response->status();
$debug['auth_header_sent'] = $this->visionApiKey('clip') !== '';
$debug['response'] = $response->json() ?? $this->safeBody($response->body());
} catch (\Throwable $e) {
Log::warning('CLIP analyze request failed', [
'ref' => $ref,
'artwork_id' => $artworkId,
'error' => $e->getMessage(),
]);
$debug['error'] = $e->getMessage();
throw new \RuntimeException(json_encode($debug, JSON_UNESCAPED_SLASHES) ?: $e->getMessage(), previous: $e);
}
if ($response->serverError()) {
Log::warning('CLIP analyze server error', [
'ref' => $ref,
'status' => $response->status(),
'body' => $this->safeBody($response->body()),
]);
throw new \RuntimeException(json_encode($debug, JSON_UNESCAPED_SLASHES) ?: ('CLIP server error: ' . $response->status()));
}
if (! $response->ok()) {
Log::warning('CLIP analyze non-ok response', [
'ref' => $ref,
'status' => $response->status(),
'body' => $this->safeBody($response->body()),
]);
try {
$variant = (string) config('vision.image_variant', 'md');
$row = DB::table('artwork_files')
->where('artwork_id', $artworkId)
->where('variant', $variant)
->first();
if ($row && ! empty($row->path)) {
$storageRoot = rtrim((string) config('uploads.storage_root', ''), DIRECTORY_SEPARATOR);
$absolute = $storageRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $row->path);
if (is_file($absolute) && is_readable($absolute)) {
$uploadUrl = rtrim($base, '/') . '/analyze/all/file';
try {
$attach = file_get_contents($absolute);
if ($attach !== false) {
/** @var \Illuminate\Http\Client\Response $uploadResp */
$uploadResp = $this->requestWithVisionAuth('clip', $ref)
->attach('file', $attach, basename($absolute))
->post($uploadUrl, ['limit' => 5]);
if ($uploadResp->ok()) {
$debug['fallback_upload'] = [
'endpoint' => $uploadUrl,
'status' => $uploadResp->status(),
'response' => $uploadResp->json() ?? $this->safeBody($uploadResp->body()),
];
return ['tags' => $this->extractTagList($uploadResp->json()), 'debug' => $debug];
}
Log::warning('CLIP upload fallback non-ok', [
'ref' => $ref,
'status' => $uploadResp->status(),
'body' => $this->safeBody($uploadResp->body()),
]);
}
} catch (\Throwable $e) {
Log::warning('CLIP upload fallback failed', ['ref' => $ref, 'error' => $e->getMessage()]);
}
}
}
} catch (\Throwable $e) {
Log::warning('CLIP fallback check failed', ['ref' => $ref, 'error' => $e->getMessage()]);
}
return ['tags' => [], 'debug' => $debug];
}
return ['tags' => $this->extractTagList($response->json()), 'debug' => $debug];
}
/**
* @return array<int, array{tag: string, confidence?: float|int|null}>
*/
private function callYolo(string $imageUrl, int $artworkId, string $hash, string $ref): array
{
return $this->callYoloDetailed($imageUrl, $artworkId, $hash, $ref)['tags'];
}
/**
* @return array{tags: array<int, array{tag: string, confidence?: float|int|null}>, debug: array<string, mixed>}
*/
private function callYoloDetailed(string $imageUrl, int $artworkId, string $hash, string $ref): array
{
if (! (bool) config('vision.yolo.enabled', true)) {
return [
'tags' => [],
'debug' => [
'service' => 'yolo',
'enabled' => false,
'reason' => 'disabled',
],
];
}
$base = trim((string) config('vision.yolo.base_url', ''));
if ($base === '') {
return [
'tags' => [],
'debug' => [
'service' => 'yolo',
'enabled' => false,
'reason' => 'base_url_missing',
],
];
}
$endpoint = (string) config('vision.yolo.endpoint', '/analyze');
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
$timeout = (int) config('vision.yolo.timeout_seconds', 8);
$connectTimeout = (int) config('vision.yolo.connect_timeout_seconds', 2);
$retries = (int) config('vision.yolo.retries', 1);
$delay = (int) config('vision.yolo.retry_delay_ms', 200);
$requestPayload = [
'url' => $imageUrl,
'image_url' => $imageUrl,
'conf' => 0.25,
'artwork_id' => $artworkId,
'hash' => $hash,
];
$debug = [
'service' => 'yolo',
'endpoint' => $url,
'request' => $requestPayload,
];
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = $this->requestWithVisionAuth('yolo', $ref)
->connectTimeout(max(1, $connectTimeout))
->timeout(max(1, $timeout))
->retry(max(0, $retries), max(0, $delay), throw: false)
->post($url, $requestPayload);
$debug['status'] = $response->status();
$debug['auth_header_sent'] = $this->visionApiKey('yolo') !== '';
$debug['response'] = $response->json() ?? $this->safeBody($response->body());
} catch (\Throwable $e) {
Log::warning('YOLO analyze request failed', [
'ref' => $ref,
'artwork_id' => $artworkId,
'error' => $e->getMessage(),
]);
$debug['error'] = $e->getMessage();
throw new \RuntimeException(json_encode($debug, JSON_UNESCAPED_SLASHES) ?: $e->getMessage(), previous: $e);
}
if ($response->serverError()) {
Log::warning('YOLO analyze server error', [
'ref' => $ref,
'status' => $response->status(),
'body' => $this->safeBody($response->body()),
]);
throw new \RuntimeException(json_encode($debug, JSON_UNESCAPED_SLASHES) ?: ('YOLO server error: ' . $response->status()));
}
if (! $response->ok()) {
Log::warning('YOLO analyze non-ok response', [
'ref' => $ref,
'status' => $response->status(),
'body' => $this->safeBody($response->body()),
]);
return ['tags' => [], 'debug' => $debug];
}
return ['tags' => $this->extractTagList($response->json()), 'debug' => $debug];
}
private function shouldRunYolo(Artwork $artwork): bool
{
if (! (bool) config('vision.yolo.enabled', true)) {
return false;
}
if (! (bool) config('vision.yolo.photography_only', true)) {
return true;
}
foreach ($artwork->categories as $category) {
$slug = strtolower((string) ($category->contentType?->slug ?? ''));
if ($slug === 'photography') {
return true;
}
}
return false;
}
private function requestWithVisionAuth(string $service, ?string $requestId = null): PendingRequest
{
$headers = [];
$apiKey = $this->visionApiKey($service);
if ($apiKey !== '') {
$headers['X-API-Key'] = $apiKey;
}
if ($requestId) {
$headers['X-Request-ID'] = $requestId;
}
return Http::acceptJson()->withHeaders($headers);
}
private function visionApiKey(string $service): string
{
return match ($service) {
'gateway' => trim((string) config('vision.gateway.api_key', '')),
'clip' => trim((string) config('vision.clip.api_key', '')),
'yolo' => trim((string) config('vision.yolo.api_key', '')),
default => '',
};
}
/**
* @param mixed $json
* @return array<int, array{tag: string, confidence?: float|int|null}>
*/
private function extractTagList(mixed $json): array
{
if (is_array($json) && $this->isListOfTags($json)) {
return $json;
}
if (is_array($json) && isset($json['tags']) && is_array($json['tags']) && $this->isListOfTags($json['tags'])) {
return $json['tags'];
}
if (is_array($json) && isset($json['data']) && is_array($json['data']) && $this->isListOfTags($json['data'])) {
return $json['data'];
}
if (is_array($json) && isset($json['objects']) && is_array($json['objects'])) {
$out = [];
foreach ($json['objects'] as $obj) {
if (! is_array($obj)) {
continue;
}
$label = (string) ($obj['label'] ?? $obj['tag'] ?? '');
if ($label === '') {
continue;
}
$out[] = ['tag' => $label, 'confidence' => $obj['confidence'] ?? null];
}
return $out;
}
return [];
}
/**
* @param array<mixed> $arr
*/
private function isListOfTags(array $arr): bool
{
if ($arr === []) {
return true;
}
foreach ($arr as $row) {
if (! is_array($row)) {
return false;
}
if (! array_key_exists('tag', $row)) {
return false;
}
}
return true;
}
private function safeBody(string $body): string
{
$body = trim($body);
if ($body === '') {
return '';
}
return Str::limit($body, 800);
}
}