optimizations
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
558
app/Services/Activity/UserActivityService.php
Normal file
558
app/Services/Activity/UserActivityService.php
Normal file
@@ -0,0 +1,558 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Activity;
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\ForumPost;
|
||||
use App\Models\ForumThread;
|
||||
use App\Models\User;
|
||||
use App\Models\UserActivity;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class UserActivityService
|
||||
{
|
||||
public const DEFAULT_PER_PAGE = 20;
|
||||
private const FEED_SCHEMA_VERSION = 2;
|
||||
|
||||
private const FILTER_ALL = 'all';
|
||||
private const FILTER_UPLOADS = 'uploads';
|
||||
private const FILTER_COMMENTS = 'comments';
|
||||
private const FILTER_LIKES = 'likes';
|
||||
private const FILTER_FORUM = 'forum';
|
||||
private const FILTER_FOLLOWING = 'following';
|
||||
|
||||
public function logUpload(int $userId, int $artworkId, array $meta = []): ?UserActivity
|
||||
{
|
||||
return $this->log($userId, UserActivity::TYPE_UPLOAD, UserActivity::ENTITY_ARTWORK, $artworkId, $meta);
|
||||
}
|
||||
|
||||
public function logComment(int $userId, int $commentId, bool $isReply = false, array $meta = []): ?UserActivity
|
||||
{
|
||||
return $this->log(
|
||||
$userId,
|
||||
$isReply ? UserActivity::TYPE_REPLY : UserActivity::TYPE_COMMENT,
|
||||
UserActivity::ENTITY_ARTWORK_COMMENT,
|
||||
$commentId,
|
||||
$meta,
|
||||
);
|
||||
}
|
||||
|
||||
public function logLike(int $userId, int $artworkId, array $meta = []): ?UserActivity
|
||||
{
|
||||
return $this->log($userId, UserActivity::TYPE_LIKE, UserActivity::ENTITY_ARTWORK, $artworkId, $meta);
|
||||
}
|
||||
|
||||
public function logFavourite(int $userId, int $artworkId, array $meta = []): ?UserActivity
|
||||
{
|
||||
return $this->log($userId, UserActivity::TYPE_FAVOURITE, UserActivity::ENTITY_ARTWORK, $artworkId, $meta);
|
||||
}
|
||||
|
||||
public function logFollow(int $userId, int $targetUserId, array $meta = []): ?UserActivity
|
||||
{
|
||||
return $this->log($userId, UserActivity::TYPE_FOLLOW, UserActivity::ENTITY_USER, $targetUserId, $meta);
|
||||
}
|
||||
|
||||
public function logAchievement(int $userId, int $achievementId, array $meta = []): ?UserActivity
|
||||
{
|
||||
return $this->log($userId, UserActivity::TYPE_ACHIEVEMENT, UserActivity::ENTITY_ACHIEVEMENT, $achievementId, $meta);
|
||||
}
|
||||
|
||||
public function logForumPost(int $userId, int $threadId, array $meta = []): ?UserActivity
|
||||
{
|
||||
return $this->log($userId, UserActivity::TYPE_FORUM_POST, UserActivity::ENTITY_FORUM_THREAD, $threadId, $meta);
|
||||
}
|
||||
|
||||
public function logForumReply(int $userId, int $postId, array $meta = []): ?UserActivity
|
||||
{
|
||||
return $this->log($userId, UserActivity::TYPE_FORUM_REPLY, UserActivity::ENTITY_FORUM_POST, $postId, $meta);
|
||||
}
|
||||
|
||||
public function feedForUser(User $user, string $filter = self::FILTER_ALL, int $page = 1, int $perPage = self::DEFAULT_PER_PAGE): array
|
||||
{
|
||||
$normalizedFilter = $this->normalizeFilter($filter);
|
||||
$resolvedPage = max(1, $page);
|
||||
$resolvedPerPage = max(1, min(50, $perPage));
|
||||
$version = $this->cacheVersion((int) $user->id);
|
||||
|
||||
return Cache::remember(
|
||||
sprintf('user_activity_feed:%d:%d:%s:%d:%d', (int) $user->id, $version, $normalizedFilter, $resolvedPage, $resolvedPerPage),
|
||||
now()->addSeconds(30),
|
||||
function () use ($user, $normalizedFilter, $resolvedPage, $resolvedPerPage): array {
|
||||
return $this->buildFeed($user, $normalizedFilter, $resolvedPage, $resolvedPerPage);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function normalizeFilter(string $filter): string
|
||||
{
|
||||
return match (strtolower(trim($filter))) {
|
||||
self::FILTER_UPLOADS => self::FILTER_UPLOADS,
|
||||
self::FILTER_COMMENTS => self::FILTER_COMMENTS,
|
||||
self::FILTER_LIKES => self::FILTER_LIKES,
|
||||
self::FILTER_FORUM => self::FILTER_FORUM,
|
||||
self::FILTER_FOLLOWING => self::FILTER_FOLLOWING,
|
||||
default => self::FILTER_ALL,
|
||||
};
|
||||
}
|
||||
|
||||
public function invalidateUserFeed(int $userId): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->bumpCacheVersion($userId);
|
||||
}
|
||||
|
||||
private function log(int $userId, string $type, string $entityType, int $entityId, array $meta = []): ?UserActivity
|
||||
{
|
||||
if ($userId <= 0 || $entityId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$activity = UserActivity::query()->create([
|
||||
'user_id' => $userId,
|
||||
'type' => $type,
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'meta' => $meta ?: null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->bumpCacheVersion($userId);
|
||||
|
||||
return $activity;
|
||||
}
|
||||
|
||||
private function buildFeed(User $user, string $filter, int $page, int $perPage): array
|
||||
{
|
||||
$query = UserActivity::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->whereNull('hidden_at')
|
||||
->whereIn('type', $this->typesForFilter($filter))
|
||||
->latest('created_at')
|
||||
->latest('id');
|
||||
|
||||
$total = (clone $query)->count();
|
||||
|
||||
/** @var Collection<int, UserActivity> $rows */
|
||||
$rows = $query
|
||||
->forPage($page, $perPage)
|
||||
->get(['id', 'user_id', 'type', 'entity_type', 'entity_id', 'meta', 'created_at']);
|
||||
|
||||
$actor = $user->loadMissing('profile')->loadCount('artworks');
|
||||
$related = $this->loadRelated($rows);
|
||||
|
||||
$data = $rows
|
||||
->map(fn (UserActivity $activity): ?array => $this->formatActivity($activity, $actor, $related))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => (int) max(1, ceil($total / $perPage)),
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'has_more' => ($page * $perPage) < $total,
|
||||
],
|
||||
'filter' => $filter,
|
||||
];
|
||||
}
|
||||
|
||||
private function loadRelated(Collection $rows): array
|
||||
{
|
||||
$artworkIds = $rows
|
||||
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_ARTWORK)
|
||||
->pluck('entity_id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$commentIds = $rows
|
||||
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_ARTWORK_COMMENT)
|
||||
->pluck('entity_id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$userIds = $rows
|
||||
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_USER)
|
||||
->pluck('entity_id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$achievementIds = $rows
|
||||
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_ACHIEVEMENT)
|
||||
->pluck('entity_id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$forumThreadIds = $rows
|
||||
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_FORUM_THREAD)
|
||||
->pluck('entity_id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$forumPostIds = $rows
|
||||
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_FORUM_POST)
|
||||
->pluck('entity_id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'artworks' => empty($artworkIds)
|
||||
? collect()
|
||||
: Artwork::query()
|
||||
->with(['stats'])
|
||||
->whereIn('id', $artworkIds)
|
||||
->public()
|
||||
->published()
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('id'),
|
||||
'comments' => empty($commentIds)
|
||||
? collect()
|
||||
: ArtworkComment::query()
|
||||
->with(['artwork.stats'])
|
||||
->whereIn('id', $commentIds)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('artwork', fn ($query) => $query->public()->published()->whereNull('deleted_at'))
|
||||
->get()
|
||||
->keyBy('id'),
|
||||
'users' => empty($userIds)
|
||||
? collect()
|
||||
: User::query()
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->withCount('artworks')
|
||||
->whereIn('id', $userIds)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('id'),
|
||||
'achievements' => empty($achievementIds)
|
||||
? collect()
|
||||
: Achievement::query()
|
||||
->whereIn('id', $achievementIds)
|
||||
->get()
|
||||
->keyBy('id'),
|
||||
'forum_threads' => empty($forumThreadIds)
|
||||
? collect()
|
||||
: ForumThread::query()
|
||||
->with('category:id,name,slug')
|
||||
->whereIn('id', $forumThreadIds)
|
||||
->where('visibility', 'public')
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('id'),
|
||||
'forum_posts' => empty($forumPostIds)
|
||||
? collect()
|
||||
: ForumPost::query()
|
||||
->with(['thread.category:id,name,slug'])
|
||||
->whereIn('id', $forumPostIds)
|
||||
->whereNull('deleted_at')
|
||||
->where('flagged', false)
|
||||
->whereHas('thread', fn ($query) => $query->where('visibility', 'public')->whereNull('deleted_at'))
|
||||
->get()
|
||||
->keyBy('id'),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatActivity(UserActivity $activity, User $actor, array $related): ?array
|
||||
{
|
||||
$base = [
|
||||
'id' => (int) $activity->id,
|
||||
'type' => (string) $activity->type,
|
||||
'entity_type' => (string) $activity->entity_type,
|
||||
'created_at' => $activity->created_at?->toIso8601String(),
|
||||
'time_ago' => $activity->created_at?->diffForHumans(),
|
||||
'actor' => $this->buildUserPayload($actor),
|
||||
'meta' => is_array($activity->meta) ? $activity->meta : [],
|
||||
];
|
||||
|
||||
return match ($activity->type) {
|
||||
UserActivity::TYPE_UPLOAD,
|
||||
UserActivity::TYPE_LIKE,
|
||||
UserActivity::TYPE_FAVOURITE => $this->formatArtworkActivity($base, $activity, $related),
|
||||
UserActivity::TYPE_COMMENT,
|
||||
UserActivity::TYPE_REPLY => $this->formatCommentActivity($base, $activity, $related),
|
||||
UserActivity::TYPE_FOLLOW => $this->formatFollowActivity($base, $activity, $related),
|
||||
UserActivity::TYPE_ACHIEVEMENT => $this->formatAchievementActivity($base, $activity, $related),
|
||||
UserActivity::TYPE_FORUM_POST,
|
||||
UserActivity::TYPE_FORUM_REPLY => $this->formatForumActivity($base, $activity, $related),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function formatArtworkActivity(array $base, UserActivity $activity, array $related): ?array
|
||||
{
|
||||
/** @var Artwork|null $artwork */
|
||||
$artwork = $related['artworks']->get((int) $activity->entity_id);
|
||||
if (! $artwork) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
...$base,
|
||||
'artwork' => $this->buildArtworkPayload($artwork),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatCommentActivity(array $base, UserActivity $activity, array $related): ?array
|
||||
{
|
||||
/** @var ArtworkComment|null $comment */
|
||||
$comment = $related['comments']->get((int) $activity->entity_id);
|
||||
if (! $comment || ! $comment->artwork) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
...$base,
|
||||
'artwork' => $this->buildArtworkPayload($comment->artwork),
|
||||
'comment' => [
|
||||
'id' => (int) $comment->id,
|
||||
'parent_id' => $comment->parent_id ? (int) $comment->parent_id : null,
|
||||
'body' => $this->plainTextExcerpt((string) ($comment->raw_content ?? $comment->content ?? '')),
|
||||
'url' => route('art.show', ['id' => (int) $comment->artwork_id, 'slug' => Str::slug((string) $comment->artwork->slug ?: (string) $comment->artwork->title)]) . '#comment-' . $comment->id,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function formatFollowActivity(array $base, UserActivity $activity, array $related): ?array
|
||||
{
|
||||
/** @var User|null $target */
|
||||
$target = $related['users']->get((int) $activity->entity_id);
|
||||
if (! $target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
...$base,
|
||||
'target_user' => $this->buildUserPayload($target),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatAchievementActivity(array $base, UserActivity $activity, array $related): ?array
|
||||
{
|
||||
/** @var Achievement|null $achievement */
|
||||
$achievement = $related['achievements']->get((int) $activity->entity_id);
|
||||
if (! $achievement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
...$base,
|
||||
'achievement' => [
|
||||
'id' => (int) $achievement->id,
|
||||
'name' => (string) $achievement->name,
|
||||
'slug' => (string) $achievement->slug,
|
||||
'description' => (string) ($achievement->description ?? ''),
|
||||
'icon' => (string) ($achievement->icon ?? 'fa-solid fa-trophy'),
|
||||
'xp_reward' => (int) ($achievement->xp_reward ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function formatForumActivity(array $base, UserActivity $activity, array $related): ?array
|
||||
{
|
||||
if ($activity->type === UserActivity::TYPE_FORUM_POST) {
|
||||
/** @var ForumThread|null $thread */
|
||||
$thread = $related['forum_threads']->get((int) $activity->entity_id);
|
||||
if (! $thread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
...$base,
|
||||
'forum' => [
|
||||
'thread' => $this->buildForumThreadPayload($thread),
|
||||
'post' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @var ForumPost|null $post */
|
||||
$post = $related['forum_posts']->get((int) $activity->entity_id);
|
||||
if (! $post || ! $post->thread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
...$base,
|
||||
'forum' => [
|
||||
'thread' => $this->buildForumThreadPayload($post->thread),
|
||||
'post' => [
|
||||
'id' => (int) $post->id,
|
||||
'excerpt' => $this->plainTextExcerpt((string) $post->content),
|
||||
'url' => $this->forumThreadUrl($post->thread) . '#post-' . $post->id,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildArtworkPayload(Artwork $artwork): array
|
||||
{
|
||||
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
|
||||
if ($slug === '') {
|
||||
$slug = (string) $artwork->id;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => html_entity_decode((string) ($artwork->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
|
||||
'thumb' => $artwork->thumbUrl('md') ?? $artwork->thumbnail_url,
|
||||
'stats' => [
|
||||
'views' => (int) ($artwork->stats?->views ?? 0),
|
||||
'likes' => (int) ($artwork->stats?->favorites ?? 0),
|
||||
'comments' => (int) ($artwork->stats?->comments_count ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildUserPayload(User $user): array
|
||||
{
|
||||
$username = (string) ($user->username ?? '');
|
||||
|
||||
return [
|
||||
'id' => (int) $user->id,
|
||||
'name' => html_entity_decode((string) ($user->name ?? $username ?: 'User'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'username' => $username,
|
||||
'profile_url' => $username !== '' ? route('profile.show', ['username' => strtolower($username)]) : null,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64),
|
||||
'badge' => $this->resolveBadge($user),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildForumThreadPayload(ForumThread $thread): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $thread->id,
|
||||
'title' => $this->plainText((string) $thread->title),
|
||||
'url' => $this->forumThreadUrl($thread),
|
||||
'category_name' => $this->plainText((string) ($thread->category?->name ?? 'Forum')),
|
||||
'category_slug' => (string) ($thread->category?->slug ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function plainTextExcerpt(string $content, int $limit = 220): string
|
||||
{
|
||||
$text = $this->plainText($content);
|
||||
|
||||
return Str::limit($text, $limit, '...');
|
||||
}
|
||||
|
||||
private function plainText(string $value): string
|
||||
{
|
||||
return trim((string) (preg_replace('/\s+/', ' ', strip_tags($this->decodeHtml($value))) ?? ''));
|
||||
}
|
||||
|
||||
private function decodeHtml(string $value): string
|
||||
{
|
||||
$decoded = $value;
|
||||
|
||||
for ($pass = 0; $pass < 5; $pass++) {
|
||||
$next = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
if ($next === $decoded) {
|
||||
break;
|
||||
}
|
||||
|
||||
$decoded = $next;
|
||||
}
|
||||
|
||||
return str_replace(['´', '´'], ["'", "'"], $decoded);
|
||||
}
|
||||
|
||||
private function forumThreadUrl(ForumThread $thread): string
|
||||
{
|
||||
$topic = (string) ($thread->slug ?: $thread->id);
|
||||
|
||||
if (Route::has('forum.topic.show')) {
|
||||
return (string) route('forum.topic.show', ['topic' => $topic]);
|
||||
}
|
||||
|
||||
if (Route::has('forum.thread.show')) {
|
||||
return (string) route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug ?: $thread->id]);
|
||||
}
|
||||
|
||||
return '/forum/topic/' . $topic;
|
||||
}
|
||||
|
||||
private function resolveBadge(User $user): ?array
|
||||
{
|
||||
if ($user->hasRole('admin')) {
|
||||
return ['label' => 'Admin', 'tone' => 'rose'];
|
||||
}
|
||||
|
||||
if ($user->hasRole('moderator')) {
|
||||
return ['label' => 'Moderator', 'tone' => 'amber'];
|
||||
}
|
||||
|
||||
if ((int) ($user->artworks_count ?? 0) > 0) {
|
||||
return ['label' => 'Creator', 'tone' => 'sky'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function typesForFilter(string $filter): array
|
||||
{
|
||||
return match ($filter) {
|
||||
self::FILTER_UPLOADS => [UserActivity::TYPE_UPLOAD],
|
||||
self::FILTER_COMMENTS => [UserActivity::TYPE_COMMENT, UserActivity::TYPE_REPLY],
|
||||
self::FILTER_LIKES => [UserActivity::TYPE_LIKE, UserActivity::TYPE_FAVOURITE],
|
||||
self::FILTER_FORUM => [UserActivity::TYPE_FORUM_POST, UserActivity::TYPE_FORUM_REPLY],
|
||||
self::FILTER_FOLLOWING => [UserActivity::TYPE_FOLLOW],
|
||||
default => [
|
||||
UserActivity::TYPE_UPLOAD,
|
||||
UserActivity::TYPE_COMMENT,
|
||||
UserActivity::TYPE_REPLY,
|
||||
UserActivity::TYPE_LIKE,
|
||||
UserActivity::TYPE_FAVOURITE,
|
||||
UserActivity::TYPE_FOLLOW,
|
||||
UserActivity::TYPE_ACHIEVEMENT,
|
||||
UserActivity::TYPE_FORUM_POST,
|
||||
UserActivity::TYPE_FORUM_REPLY,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private function cacheVersion(int $userId): int
|
||||
{
|
||||
return (int) Cache::get($this->versionKey($userId), self::FEED_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
private function bumpCacheVersion(int $userId): void
|
||||
{
|
||||
$key = $this->versionKey($userId);
|
||||
Cache::add($key, self::FEED_SCHEMA_VERSION, now()->addDays(7));
|
||||
Cache::increment($key);
|
||||
}
|
||||
|
||||
private function versionKey(int $userId): string
|
||||
{
|
||||
return 'user_activity_feed_version:v' . self::FEED_SCHEMA_VERSION . ':' . $userId;
|
||||
}
|
||||
}
|
||||
872
app/Services/Analytics/DiscoveryFeedbackReportService.php
Normal file
872
app/Services/Analytics/DiscoveryFeedbackReportService.php
Normal 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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
788
app/Services/CollectionAiCurationService.php
Normal file
788
app/Services/CollectionAiCurationService.php
Normal 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 collection’s current themes and categories.'
|
||||
: 'No closely related unassigned artworks were found in your gallery yet.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
public function suggestTags(Collection $collection, array $draft = []): array
|
||||
{
|
||||
$context = $this->buildContext($collection, $draft);
|
||||
$artworks = $this->candidateArtworks($collection, $draft, 24);
|
||||
$tags = collect([
|
||||
$context['primary_theme'],
|
||||
$context['top_category'],
|
||||
$context['event_label'] !== '' ? Str::slug($context['event_label']) : null,
|
||||
$collection->type === Collection::TYPE_EDITORIAL ? 'staff-picks' : null,
|
||||
$collection->type === Collection::TYPE_COMMUNITY ? 'community-curation' : null,
|
||||
])
|
||||
->filter()
|
||||
->merge(
|
||||
$artworks->flatMap(fn (Artwork $artwork) => $artwork->tags->pluck('slug'))
|
||||
->filter()
|
||||
->countBy()
|
||||
->sortDesc()
|
||||
->keys()
|
||||
->take(4)
|
||||
)
|
||||
->map(fn ($value) => Str::of((string) $value)->replace('-', ' ')->trim()->lower()->value())
|
||||
->filter()
|
||||
->unique()
|
||||
->take(6)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'tags' => $tags,
|
||||
'rationale' => 'Suggested from recurring artwork themes, categories, and collection type signals.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
public function suggestSeoDescription(Collection $collection, array $draft = []): array
|
||||
{
|
||||
$summary = $this->suggestSummary($collection, $draft);
|
||||
$title = $this->draftString($collection, $draft, 'title') ?: $collection->title;
|
||||
$label = match ($collection->type) {
|
||||
Collection::TYPE_EDITORIAL => 'Staff Pick',
|
||||
Collection::TYPE_COMMUNITY => 'Community Collection',
|
||||
default => 'Collection',
|
||||
};
|
||||
|
||||
return [
|
||||
'description' => Str::limit(sprintf('%s: %s. %s', $label, $title, $summary['seo_description']), 155, ''),
|
||||
'rationale' => 'Optimised for social previews and search snippets using the title, type, and strongest collection theme.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
public function detectWeakMetadata(Collection $collection, array $draft = []): array
|
||||
{
|
||||
$context = $this->buildContext($collection, $draft);
|
||||
$issues = [];
|
||||
$title = $this->draftString($collection, $draft, 'title') ?: (string) $collection->title;
|
||||
$summary = $this->draftString($collection, $draft, 'summary');
|
||||
$description = $this->draftString($collection, $draft, 'description');
|
||||
$metadataScore = (float) ($collection->metadata_completeness_score ?? 0);
|
||||
$coverArtworkId = $draft['cover_artwork_id'] ?? $collection->cover_artwork_id;
|
||||
|
||||
if ($title === '' || Str::length($title) < 12 || preg_match('/^(untitled|new collection|my collection)/i', $title) === 1) {
|
||||
$issues[] = $this->metadataIssue(
|
||||
'title',
|
||||
'high',
|
||||
'Title needs more specificity',
|
||||
'Use a more descriptive title that signals the theme, series, or purpose of the collection.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($summary === null || Str::length($summary) < 60) {
|
||||
$issues[] = $this->metadataIssue(
|
||||
'summary',
|
||||
'high',
|
||||
'Summary is missing or too short',
|
||||
'Add a concise summary that explains what ties these artworks together and why the collection matters.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($description === null || Str::length(strip_tags($description)) < 140) {
|
||||
$issues[] = $this->metadataIssue(
|
||||
'description',
|
||||
'medium',
|
||||
'Description lacks depth',
|
||||
'Expand the description with context, mood, creator intent, or campaign framing so the collection reads as deliberate curation.'
|
||||
);
|
||||
}
|
||||
|
||||
if (! $coverArtworkId) {
|
||||
$issues[] = $this->metadataIssue(
|
||||
'cover',
|
||||
'medium',
|
||||
'No explicit cover artwork is set',
|
||||
'Choose a cover artwork so the collection has a stronger visual anchor across profile, saved-library, and programming surfaces.'
|
||||
);
|
||||
}
|
||||
|
||||
if (($context['primary_theme'] ?? null) === null && ($context['top_category'] ?? null) === null) {
|
||||
$issues[] = $this->metadataIssue(
|
||||
'theme',
|
||||
'medium',
|
||||
'Theme signals are weak',
|
||||
'Add or retag a few representative artworks so the collection has a clearer theme for discovery and recommendations.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($metadataScore > 0 && $metadataScore < 65) {
|
||||
$issues[] = $this->metadataIssue(
|
||||
'metadata_score',
|
||||
'medium',
|
||||
'Metadata completeness is below the recommended threshold',
|
||||
sprintf('The current metadata completeness score is %.1f. Tightening title, summary, description, and cover selection should improve it.', $metadataScore)
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $issues === [] ? 'healthy' : 'needs_work',
|
||||
'issues' => $issues,
|
||||
'rationale' => $issues === []
|
||||
? 'The collection metadata is strong enough for creator-facing surfaces and AI assistance did not find obvious weak spots.'
|
||||
: 'Detected from title specificity, metadata coverage, theme clarity, and cover readiness heuristics.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
public function suggestStaleRefresh(Collection $collection, array $draft = []): array
|
||||
{
|
||||
$context = $this->buildContext($collection, $draft);
|
||||
$referenceAt = $this->staleReferenceAt($collection);
|
||||
$daysSinceRefresh = $referenceAt?->diffInDays(now()) ?? null;
|
||||
$isStale = $daysSinceRefresh !== null && ($daysSinceRefresh >= 45 || (float) ($collection->freshness_score ?? 0) < 40);
|
||||
$actions = [];
|
||||
|
||||
if ($isStale) {
|
||||
$actions[] = [
|
||||
'key' => 'refresh_summary',
|
||||
'label' => 'Refresh the summary and description',
|
||||
'reason' => 'A quick metadata pass helps older collections feel current again when they resurface in search or recommendations.',
|
||||
];
|
||||
|
||||
if (! $collection->cover_artwork_id) {
|
||||
$actions[] = [
|
||||
'key' => 'set_cover',
|
||||
'label' => 'Choose a stronger cover artwork',
|
||||
'reason' => 'A defined cover is the fastest way to make a stale collection feel newly curated.',
|
||||
];
|
||||
}
|
||||
|
||||
if (($context['artworks_count'] ?? 0) < 6) {
|
||||
$actions[] = [
|
||||
'key' => 'add_recent_artworks',
|
||||
'label' => 'Add newer artworks to the set',
|
||||
'reason' => 'The collection is still relatively small, so a few fresh pieces would meaningfully improve recency and depth.',
|
||||
];
|
||||
} else {
|
||||
$actions[] = [
|
||||
'key' => 'resequence_highlights',
|
||||
'label' => 'Resequence the leading artworks',
|
||||
'reason' => 'Reordering the strongest pieces can refresh the collection without changing its core theme.',
|
||||
];
|
||||
}
|
||||
|
||||
if (($context['primary_theme'] ?? null) === null) {
|
||||
$actions[] = [
|
||||
'key' => 'tighten_theme',
|
||||
'label' => 'Clarify the collection theme',
|
||||
'reason' => 'The current artwork set does not emit a strong recurring theme, so a tighter selection would improve discovery quality.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'stale' => $isStale,
|
||||
'days_since_refresh' => $daysSinceRefresh,
|
||||
'last_active_at' => $referenceAt?->toIso8601String(),
|
||||
'actions' => $actions,
|
||||
'rationale' => $isStale
|
||||
? 'Detected from freshness, recent activity, and current collection depth.'
|
||||
: 'The collection has recent enough activity that a refresh is not urgent right now.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
public function suggestCampaignFit(Collection $collection, array $draft = []): array
|
||||
{
|
||||
$context = $this->buildContext($collection, $draft);
|
||||
$campaignSummary = $this->campaigns->campaignSummary($collection);
|
||||
$seasonKey = $this->draftString($collection, $draft, 'season_key') ?: (string) ($collection->season_key ?? '');
|
||||
$eventKey = $this->draftString($collection, $draft, 'event_key') ?: (string) ($collection->event_key ?? '');
|
||||
$eventLabel = $this->draftString($collection, $draft, 'event_label') ?: (string) ($collection->event_label ?? '');
|
||||
|
||||
$candidates = Collection::query()
|
||||
->public()
|
||||
->where('id', '!=', $collection->id)
|
||||
->whereNotNull('campaign_key')
|
||||
->with(['user:id,username,name'])
|
||||
->orderByDesc('ranking_score')
|
||||
->orderByDesc('saves_count')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(36)
|
||||
->get()
|
||||
->map(function (Collection $candidate) use ($collection, $context, $seasonKey, $eventKey): array {
|
||||
$score = 0;
|
||||
$reasons = [];
|
||||
|
||||
if ((string) $candidate->type === (string) $collection->type) {
|
||||
$score += 4;
|
||||
$reasons[] = 'Matches the same collection type.';
|
||||
}
|
||||
|
||||
if ($seasonKey !== '' && $candidate->season_key === $seasonKey) {
|
||||
$score += 5;
|
||||
$reasons[] = 'Shares the same seasonal window.';
|
||||
}
|
||||
|
||||
if ($eventKey !== '' && $candidate->event_key === $eventKey) {
|
||||
$score += 6;
|
||||
$reasons[] = 'Shares the same event context.';
|
||||
}
|
||||
|
||||
if ((int) $candidate->user_id === (int) $collection->user_id) {
|
||||
$score += 2;
|
||||
$reasons[] = 'Comes from the same curator account.';
|
||||
}
|
||||
|
||||
if ($context['top_category'] !== null && filled($candidate->theme_token) && Str::contains(mb_strtolower((string) $candidate->theme_token), mb_strtolower((string) $context['top_category']))) {
|
||||
$score += 2;
|
||||
$reasons[] = 'Theme token overlaps with the collection category.';
|
||||
}
|
||||
|
||||
if ((float) ($candidate->ranking_score ?? 0) >= 70) {
|
||||
$score += 1;
|
||||
$reasons[] = 'Campaign is represented by a strong-performing collection.';
|
||||
}
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'candidate' => $candidate,
|
||||
'reasons' => $reasons,
|
||||
];
|
||||
})
|
||||
->filter(fn (array $item): bool => $item['score'] > 0)
|
||||
->sortByDesc(fn (array $item): string => sprintf('%08d-%s', $item['score'], optional($item['candidate']->updated_at)?->timestamp ?? 0))
|
||||
->groupBy(fn (array $item): string => (string) $item['candidate']->campaign_key)
|
||||
->map(function ($items, string $campaignKey): array {
|
||||
$top = collect($items)->sortByDesc('score')->first();
|
||||
$candidate = $top['candidate'];
|
||||
|
||||
return [
|
||||
'campaign_key' => $campaignKey,
|
||||
'campaign_label' => $candidate->campaign_label ?: Str::headline(str_replace(['_', '-'], ' ', $campaignKey)),
|
||||
'score' => (int) $top['score'],
|
||||
'reasons' => array_values(array_unique($top['reasons'])),
|
||||
'sample_collection' => [
|
||||
'id' => (int) $candidate->id,
|
||||
'title' => (string) $candidate->title,
|
||||
'slug' => (string) $candidate->slug,
|
||||
],
|
||||
];
|
||||
})
|
||||
->sortByDesc('score')
|
||||
->take(4)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($candidates === [] && ($seasonKey !== '' || $eventKey !== '' || $eventLabel !== '')) {
|
||||
$fallbackKey = $collection->campaign_key ?: ($eventKey !== '' ? Str::slug($eventKey) : ($seasonKey !== '' ? $seasonKey . '-editorial' : Str::slug($eventLabel)));
|
||||
if ($fallbackKey !== '') {
|
||||
$candidates[] = [
|
||||
'campaign_key' => $fallbackKey,
|
||||
'campaign_label' => $collection->campaign_label ?: ($eventLabel !== '' ? $eventLabel : Str::headline(str_replace(['_', '-'], ' ', $fallbackKey))),
|
||||
'score' => 72,
|
||||
'reasons' => array_values(array_filter([
|
||||
$seasonKey !== '' ? 'Season metadata is already present and can anchor a campaign.' : null,
|
||||
$eventLabel !== '' ? 'Event labeling is already specific enough for campaign framing.' : null,
|
||||
'Surface suggestions indicate this collection is promotable once reviewed.',
|
||||
])),
|
||||
'sample_collection' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'current_context' => [
|
||||
'campaign_key' => $collection->campaign_key,
|
||||
'campaign_label' => $collection->campaign_label,
|
||||
'event_key' => $eventKey !== '' ? $eventKey : null,
|
||||
'event_label' => $eventLabel !== '' ? $eventLabel : null,
|
||||
'season_key' => $seasonKey !== '' ? $seasonKey : null,
|
||||
],
|
||||
'eligibility' => $campaignSummary['eligibility'] ?? [],
|
||||
'recommended_surfaces' => $campaignSummary['recommended_surfaces'] ?? [],
|
||||
'fits' => $candidates,
|
||||
'rationale' => $candidates !== []
|
||||
? 'Suggested from existing campaign-aware collections with overlapping type, season, event, and performance context.'
|
||||
: 'No strong campaign fits were detected yet; tighten seasonal or event metadata first.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
public function suggestRelatedCollectionsToLink(Collection $collection, array $draft = []): array
|
||||
{
|
||||
$alreadyLinkedIds = $collection->manualRelatedCollections()->pluck('collections.id')->map(static fn ($id): int => (int) $id)->all();
|
||||
$candidates = $this->recommendations->relatedPublicCollections($collection, 8)
|
||||
->reject(fn (Collection $candidate): bool => in_array((int) $candidate->id, $alreadyLinkedIds, true))
|
||||
->take(6)
|
||||
->values();
|
||||
|
||||
$suggestions = $candidates->map(function (Collection $candidate) use ($collection): array {
|
||||
$reasons = [];
|
||||
|
||||
if ((string) $candidate->type === (string) $collection->type) {
|
||||
$reasons[] = 'Matches the same collection type.';
|
||||
}
|
||||
|
||||
if ((int) $candidate->user_id === (int) $collection->user_id) {
|
||||
$reasons[] = 'Owned by the same curator.';
|
||||
}
|
||||
|
||||
if (filled($collection->campaign_key) && $candidate->campaign_key === $collection->campaign_key) {
|
||||
$reasons[] = 'Shares the same campaign context.';
|
||||
}
|
||||
|
||||
if (filled($collection->event_key) && $candidate->event_key === $collection->event_key) {
|
||||
$reasons[] = 'Shares the same event context.';
|
||||
}
|
||||
|
||||
if (filled($collection->season_key) && $candidate->season_key === $collection->season_key) {
|
||||
$reasons[] = 'Shares the same seasonal framing.';
|
||||
}
|
||||
|
||||
if ($reasons === []) {
|
||||
$reasons[] = 'Ranks as a closely related public collection based on the platform recommendation model.';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $candidate->id,
|
||||
'title' => (string) $candidate->title,
|
||||
'slug' => (string) $candidate->slug,
|
||||
'owner' => $candidate->displayOwnerName(),
|
||||
'reasons' => $reasons,
|
||||
'link_type' => (int) $candidate->user_id === (int) $collection->user_id ? 'same_curator' : 'discovery_adjacent',
|
||||
];
|
||||
})->all();
|
||||
|
||||
return [
|
||||
'linked_collection_ids' => $alreadyLinkedIds,
|
||||
'suggestions' => $suggestions,
|
||||
'rationale' => $suggestions !== []
|
||||
? 'Suggested from the related-public-collections model after excluding collections already linked manually.'
|
||||
: 'No additional related public collections were strong enough to suggest right now.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
public function explainSmartRules(Collection $collection, array $draft = []): array
|
||||
{
|
||||
$rules = is_array($draft['smart_rules_json'] ?? null)
|
||||
? $draft['smart_rules_json']
|
||||
: $collection->smart_rules_json;
|
||||
|
||||
if (! is_array($rules)) {
|
||||
return [
|
||||
'explanation' => 'This collection is currently curated manually, so there are no smart rules to explain.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
$summary = $this->smartCollections->smartSummary($rules);
|
||||
$preview = $this->smartCollections->preview($collection->user, $rules, true, 6);
|
||||
|
||||
return [
|
||||
'explanation' => sprintf('%s The current rule set matches %d artworks in the preview.', $summary, $preview->total()),
|
||||
'rationale' => 'Translated from the active smart rule JSON into a human-readable curator summary.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
public function suggestSplitThemes(Collection $collection, array $draft = []): array
|
||||
{
|
||||
$grouping = $this->suggestGrouping($collection, $draft);
|
||||
$groups = collect($grouping['groups'] ?? [])->take(2)->values();
|
||||
|
||||
if ($groups->count() < 2) {
|
||||
return [
|
||||
'splits' => [],
|
||||
'rationale' => 'There are not enough distinct recurring themes yet to justify splitting this collection into two focused sets.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'splits' => $groups->map(function (array $group, int $index) use ($collection): array {
|
||||
$label = (string) ($group['label'] ?? ('Theme ' . ($index + 1)));
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'title' => trim(sprintf('%s: %s', $collection->title, $label)),
|
||||
'artwork_ids' => array_values(array_map('intval', $group['artwork_ids'] ?? [])),
|
||||
'count' => (int) ($group['count'] ?? 0),
|
||||
];
|
||||
})->all(),
|
||||
'rationale' => 'Suggested from the two strongest artwork theme clusters so you can split the collection into clearer destination pages.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
public function suggestMergeIdea(Collection $collection, array $draft = []): array
|
||||
{
|
||||
$related = $this->suggestRelatedArtworks($collection, $draft);
|
||||
$context = $this->buildContext($collection, $draft);
|
||||
$artworks = collect($related['artworks'] ?? [])->take(3)->values();
|
||||
|
||||
if ($artworks->isEmpty()) {
|
||||
return [
|
||||
'idea' => null,
|
||||
'rationale' => 'No closely related artworks were found that would strengthen a merged follow-up collection yet.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
$theme = $context['primary_theme'] ?: $context['top_category'] ?: 'Extended Showcase';
|
||||
|
||||
return [
|
||||
'idea' => [
|
||||
'title' => sprintf('%s Extended', Str::title((string) $theme)),
|
||||
'summary' => sprintf('A follow-up collection idea that combines the current theme with %d closely related artworks from the same gallery.', $artworks->count()),
|
||||
'related_artwork_ids' => $artworks->pluck('id')->map(static fn ($id) => (int) $id)->all(),
|
||||
],
|
||||
'rationale' => 'Suggested as a merge or spin-out concept using the current theme and the strongest related artworks not already attached.',
|
||||
'source' => 'heuristic-ai',
|
||||
];
|
||||
}
|
||||
|
||||
private function buildContext(Collection $collection, array $draft = []): array
|
||||
{
|
||||
$artworks = $this->candidateArtworks($collection, $draft, 36);
|
||||
$themes = $this->topThemes($artworks);
|
||||
$categories = $artworks
|
||||
->map(fn (Artwork $artwork) => $artwork->categories->first()?->name)
|
||||
->filter()
|
||||
->countBy()
|
||||
->sortDesc();
|
||||
|
||||
return [
|
||||
'type' => $this->draftString($collection, $draft, 'type') ?: $collection->type,
|
||||
'allow_submissions' => array_key_exists('allow_submissions', $draft)
|
||||
? (bool) $draft['allow_submissions']
|
||||
: (bool) $collection->allow_submissions,
|
||||
'artworks_count' => max(1, $artworks->count()),
|
||||
'creator_count' => max(1, $artworks->pluck('user_id')->filter()->unique()->count()),
|
||||
'primary_theme' => $themes->keys()->first(),
|
||||
'theme_sentence' => $themes->keys()->take(2)->implode(' and '),
|
||||
'top_category' => $categories->keys()->first(),
|
||||
'event_label' => $this->draftString($collection, $draft, 'event_label') ?: (string) ($collection->event_label ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SupportCollection<int, Artwork>
|
||||
*/
|
||||
private function candidateArtworks(Collection $collection, array $draft = [], int $limit = 24): SupportCollection
|
||||
{
|
||||
$mode = $this->draftString($collection, $draft, 'mode') ?: $collection->mode;
|
||||
$smartRules = is_array($draft['smart_rules_json'] ?? null)
|
||||
? $draft['smart_rules_json']
|
||||
: $collection->smart_rules_json;
|
||||
|
||||
if ($mode === Collection::MODE_SMART && is_array($smartRules)) {
|
||||
return $this->smartCollections
|
||||
->preview($collection->user, $smartRules, true, max(6, $limit))
|
||||
->getCollection()
|
||||
->loadMissing(['tags', 'categories.contentType', 'stats']);
|
||||
}
|
||||
|
||||
return $collection->artworks()
|
||||
->with(['tags', 'categories.contentType', 'stats'])
|
||||
->whereNull('artworks.deleted_at')
|
||||
->select('artworks.*')
|
||||
->limit(max(6, $limit))
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SupportCollection<string, float>
|
||||
*/
|
||||
private function topThemes(SupportCollection $artworks): SupportCollection
|
||||
{
|
||||
return $artworks
|
||||
->flatMap(function (Artwork $artwork): array {
|
||||
return $artwork->tags->map(function ($tag): array {
|
||||
return [
|
||||
'label' => (string) $tag->name,
|
||||
'weight' => $tag->pivot?->source === 'ai' ? 1.25 : 1.0,
|
||||
];
|
||||
})->all();
|
||||
})
|
||||
->groupBy('label')
|
||||
->map(fn (SupportCollection $items) => (float) $items->sum('weight'))
|
||||
->sortDesc()
|
||||
->take(6);
|
||||
}
|
||||
|
||||
private function coverScore(Artwork $artwork): float
|
||||
{
|
||||
$stats = $artwork->stats;
|
||||
$views = (int) ($stats?->views ?? $artwork->view_count ?? 0);
|
||||
$likes = (int) ($stats?->favorites ?? $artwork->favourite_count ?? 0);
|
||||
$downloads = (int) ($stats?->downloads ?? 0);
|
||||
$width = max(1, (int) ($artwork->width ?? 1));
|
||||
$height = max(1, (int) ($artwork->height ?? 1));
|
||||
$ratio = $width / $height;
|
||||
$ratioBonus = $ratio >= 1.1 && $ratio <= 1.8 ? 40 : 0;
|
||||
$freshness = $artwork->published_at ? max(0, 30 - min(30, $artwork->published_at->diffInDays(now()))) : 0;
|
||||
|
||||
return ($likes * 8) + ($downloads * 5) + ($views * 0.05) + $ratioBonus + $freshness;
|
||||
}
|
||||
|
||||
private function draftString(Collection $collection, array $draft, string $key): ?string
|
||||
{
|
||||
if (! array_key_exists($key, $draft)) {
|
||||
return $collection->{$key} !== null ? (string) $collection->{$key} : null;
|
||||
}
|
||||
|
||||
$value = $draft[$key];
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key:string,severity:string,label:string,detail:string}
|
||||
*/
|
||||
private function metadataIssue(string $key, string $severity, string $label, string $detail): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'severity' => $severity,
|
||||
'label' => $label,
|
||||
'detail' => $detail,
|
||||
];
|
||||
}
|
||||
|
||||
private function staleReferenceAt(Collection $collection): ?CarbonInterface
|
||||
{
|
||||
return $collection->last_activity_at
|
||||
?? $collection->updated_at
|
||||
?? $collection->published_at;
|
||||
}
|
||||
}
|
||||
20
app/Services/CollectionAiOperationsService.php
Normal file
20
app/Services/CollectionAiOperationsService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
117
app/Services/CollectionAnalyticsService.php
Normal file
117
app/Services/CollectionAnalyticsService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
148
app/Services/CollectionBackgroundJobService.php
Normal file
148
app/Services/CollectionBackgroundJobService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
169
app/Services/CollectionBulkActionService.php
Normal file
169
app/Services/CollectionBulkActionService.php
Normal 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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
403
app/Services/CollectionCampaignService.php
Normal file
403
app/Services/CollectionCampaignService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
47
app/Services/CollectionCanonicalService.php
Normal file
47
app/Services/CollectionCanonicalService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
383
app/Services/CollectionCollaborationService.php
Normal file
383
app/Services/CollectionCollaborationService.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
105
app/Services/CollectionCommentService.php
Normal file
105
app/Services/CollectionCommentService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
62
app/Services/CollectionDashboardService.php
Normal file
62
app/Services/CollectionDashboardService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
173
app/Services/CollectionDiscoveryService.php
Normal file
173
app/Services/CollectionDiscoveryService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
46
app/Services/CollectionExperimentService.php
Normal file
46
app/Services/CollectionExperimentService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
109
app/Services/CollectionFollowService.php
Normal file
109
app/Services/CollectionFollowService.php
Normal 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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
360
app/Services/CollectionHealthService.php
Normal file
360
app/Services/CollectionHealthService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
154
app/Services/CollectionHistoryService.php
Normal file
154
app/Services/CollectionHistoryService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
99
app/Services/CollectionLifecycleService.php
Normal file
99
app/Services/CollectionLifecycleService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
109
app/Services/CollectionLikeService.php
Normal file
109
app/Services/CollectionLikeService.php
Normal 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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
514
app/Services/CollectionLinkService.php
Normal file
514
app/Services/CollectionLinkService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
116
app/Services/CollectionLinkedCollectionsService.php
Normal file
116
app/Services/CollectionLinkedCollectionsService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
381
app/Services/CollectionMergeService.php
Normal file
381
app/Services/CollectionMergeService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
68
app/Services/CollectionModerationService.php
Normal file
68
app/Services/CollectionModerationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
92
app/Services/CollectionObservabilityService.php
Normal file
92
app/Services/CollectionObservabilityService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
117
app/Services/CollectionPartnerProgramService.php
Normal file
117
app/Services/CollectionPartnerProgramService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Services/CollectionPlacementService.php
Normal file
24
app/Services/CollectionPlacementService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
247
app/Services/CollectionProgrammingService.php
Normal file
247
app/Services/CollectionProgrammingService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
61
app/Services/CollectionQualityService.php
Normal file
61
app/Services/CollectionQualityService.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
131
app/Services/CollectionRankingService.php
Normal file
131
app/Services/CollectionRankingService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
243
app/Services/CollectionRecommendationService.php
Normal file
243
app/Services/CollectionRecommendationService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
130
app/Services/CollectionSaveService.php
Normal file
130
app/Services/CollectionSaveService.php
Normal 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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
334
app/Services/CollectionSavedLibraryService.php
Normal file
334
app/Services/CollectionSavedLibraryService.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
235
app/Services/CollectionSearchService.php
Normal file
235
app/Services/CollectionSearchService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
120
app/Services/CollectionSeriesService.php
Normal file
120
app/Services/CollectionSeriesService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
1480
app/Services/CollectionService.php
Normal file
1480
app/Services/CollectionService.php
Normal file
File diff suppressed because it is too large
Load Diff
192
app/Services/CollectionSubmissionService.php
Normal file
192
app/Services/CollectionSubmissionService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
423
app/Services/CollectionSurfaceService.php
Normal file
423
app/Services/CollectionSurfaceService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
94
app/Services/CollectionWorkflowService.php
Normal file
94
app/Services/CollectionWorkflowService.php
Normal 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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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})";
|
||||
}
|
||||
}
|
||||
|
||||
115
app/Services/EditorialAutomationService.php
Normal file
115
app/Services/EditorialAutomationService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
96
app/Services/FollowAnalyticsService.php
Normal file
96
app/Services/FollowAnalyticsService.php
Normal 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
210
app/Services/NovaCards/NovaCardAiAssistService.php
Normal file
210
app/Services/NovaCards/NovaCardAiAssistService.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Services/NovaCards/NovaCardBackgroundService.php
Normal file
64
app/Services/NovaCards/NovaCardBackgroundService.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
app/Services/NovaCards/NovaCardChallengeService.php
Normal file
36
app/Services/NovaCards/NovaCardChallengeService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
162
app/Services/NovaCards/NovaCardCollectionService.php
Normal file
162
app/Services/NovaCards/NovaCardCollectionService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
117
app/Services/NovaCards/NovaCardCommentService.php
Normal file
117
app/Services/NovaCards/NovaCardCommentService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
235
app/Services/NovaCards/NovaCardCreatorPresetService.php
Normal file
235
app/Services/NovaCards/NovaCardCreatorPresetService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
283
app/Services/NovaCards/NovaCardDraftService.php
Normal file
283
app/Services/NovaCards/NovaCardDraftService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
107
app/Services/NovaCards/NovaCardExportService.php
Normal file
107
app/Services/NovaCards/NovaCardExportService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
50
app/Services/NovaCards/NovaCardLineageService.php
Normal file
50
app/Services/NovaCards/NovaCardLineageService.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
355
app/Services/NovaCards/NovaCardPresenter.php
Normal file
355
app/Services/NovaCards/NovaCardPresenter.php
Normal 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(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
290
app/Services/NovaCards/NovaCardProjectNormalizer.php
Normal file
290
app/Services/NovaCards/NovaCardProjectNormalizer.php
Normal 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];
|
||||
}
|
||||
}
|
||||
231
app/Services/NovaCards/NovaCardPublishModerationService.php
Normal file
231
app/Services/NovaCards/NovaCardPublishModerationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
55
app/Services/NovaCards/NovaCardPublishService.php
Normal file
55
app/Services/NovaCards/NovaCardPublishService.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
45
app/Services/NovaCards/NovaCardReactionService.php
Normal file
45
app/Services/NovaCards/NovaCardReactionService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
129
app/Services/NovaCards/NovaCardRelatedCardsService.php
Normal file
129
app/Services/NovaCards/NovaCardRelatedCardsService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
350
app/Services/NovaCards/NovaCardRenderService.php
Normal file
350
app/Services/NovaCards/NovaCardRenderService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
81
app/Services/NovaCards/NovaCardRisingService.php
Normal file
81
app/Services/NovaCards/NovaCardRisingService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
43
app/Services/NovaCards/NovaCardTagService.php
Normal file
43
app/Services/NovaCards/NovaCardTagService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
90
app/Services/NovaCards/NovaCardTrendingService.php
Normal file
90
app/Services/NovaCards/NovaCardTrendingService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
58
app/Services/NovaCards/NovaCardVersionService.php
Normal file
58
app/Services/NovaCards/NovaCardVersionService.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -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 !== '') {
|
||||
|
||||
76
app/Services/Recommendations/RecommendationFeedResolver.php
Normal file
76
app/Services/Recommendations/RecommendationFeedResolver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
1348
app/Services/Recommendations/RecommendationServiceV2.php
Normal file
1348
app/Services/Recommendations/RecommendationServiceV2.php
Normal file
File diff suppressed because it is too large
Load Diff
278
app/Services/Recommendations/SessionRecoService.php
Normal file
278
app/Services/Recommendations/SessionRecoService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
481
app/Services/SmartCollectionService.php
Normal file
481
app/Services/SmartCollectionService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
28
app/Services/Studio/StudioAiAssistEventService.php
Normal file
28
app/Services/Studio/StudioAiAssistEventService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
514
app/Services/Studio/StudioAiAssistService.php
Normal file
514
app/Services/Studio/StudioAiAssistService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
250
app/Services/Studio/StudioAiCategoryMapper.php
Normal file
250
app/Services/Studio/StudioAiCategoryMapper.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
247
app/Services/Studio/StudioAiSuggestionBuilder.php
Normal file
247
app/Services/Studio/StudioAiSuggestionBuilder.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
273
app/Services/UserSuggestionService.php
Normal file
273
app/Services/UserSuggestionService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
143
app/Services/Vision/AiArtworkVectorSearchService.php
Normal file
143
app/Services/Vision/AiArtworkVectorSearchService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
55
app/Services/Vision/ArtworkVectorIndexService.php
Normal file
55
app/Services/Vision/ArtworkVectorIndexService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
45
app/Services/Vision/ArtworkVectorMetadataService.php
Normal file
45
app/Services/Vision/ArtworkVectorMetadataService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
app/Services/Vision/VectorService.php
Normal file
54
app/Services/Vision/VectorService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
774
app/Services/Vision/VisionService.php
Normal file
774
app/Services/Vision/VisionService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user