Compare commits

..

2 Commits

Author SHA1 Message Date
cab4fbd83e optimizations 2026-03-28 19:15:39 +01:00
0b25d9570a added flags icons 2026-03-28 19:15:21 +01:00
5973 changed files with 1016804 additions and 1605 deletions

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class AggregateDiscoveryFeedbackCommand extends Command
{
protected $signature = 'analytics:aggregate-discovery-feedback {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate discovery feedback events into daily metrics by algorithm and surface';
public function handle(): int
{
if (! Schema::hasTable('user_discovery_events') || ! Schema::hasTable('discovery_feedback_daily_metrics')) {
$this->warn('Required discovery feedback tables are missing.');
return self::SUCCESS;
}
$date = $this->option('date')
? (string) $this->option('date')
: now()->subDay()->toDateString();
$surfaceExpression = $this->surfaceExpression();
$rows = DB::table('user_discovery_events')
->selectRaw('algo_version')
->selectRaw($surfaceExpression . ' AS surface')
->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")
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw('COUNT(DISTINCT artwork_id) AS unique_artworks')
->whereDate('event_date', $date)
->groupBy('algo_version', DB::raw($surfaceExpression))
->get();
foreach ($rows as $row) {
$views = (int) ($row->views ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$favorites = (int) ($row->favorites ?? 0);
$downloads = (int) ($row->downloads ?? 0);
$feedbackActions = $favorites + $downloads;
$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);
$negativeFeedbackActions = $hiddenArtworks + $dislikedTags;
$undoActions = $undoHiddenArtworks + $undoDislikedTags;
DB::table('discovery_feedback_daily_metrics')->updateOrInsert(
[
'metric_date' => $date,
'algo_version' => (string) ($row->algo_version ?? ''),
'surface' => (string) ($row->surface ?? 'unknown'),
],
[
'views' => $views,
'clicks' => $clicks,
'favorites' => $favorites,
'downloads' => $downloads,
'hidden_artworks' => $hiddenArtworks,
'disliked_tags' => $dislikedTags,
'undo_hidden_artworks' => $undoHiddenArtworks,
'undo_disliked_tags' => $undoDislikedTags,
'feedback_actions' => $feedbackActions,
'negative_feedback_actions' => $negativeFeedbackActions,
'undo_actions' => $undoActions,
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_artworks' => (int) ($row->unique_artworks ?? 0),
'ctr' => $views > 0 ? $clicks / $views : 0.0,
'favorite_rate_per_click' => $clicks > 0 ? $favorites / $clicks : 0.0,
'download_rate_per_click' => $clicks > 0 ? $downloads / $clicks : 0.0,
'feedback_rate_per_click' => $clicks > 0 ? $feedbackActions / $clicks : 0.0,
'updated_at' => now(),
'created_at' => now(),
]
);
}
$this->info("Aggregated discovery feedback for {$date}.");
return self::SUCCESS;
}
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')";
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\BackfillArtworkVectorIndexJob;
use Illuminate\Console\Command;
final class BackfillArtworkVectorIndexCommand extends Command
{
protected $signature = 'artworks:vectors-repair {--after-id=0 : Resume after this artwork id} {--batch=200 : Batch size for resumable fan-out} {--public-only : Repair only public, approved, published artworks} {--stale-hours=0 : Repair only artworks never indexed or older than this many hours}';
protected $description = 'Queue resumable vector gateway repair for artworks that already have local embeddings';
public function handle(): int
{
$afterId = max(0, (int) $this->option('after-id'));
$batch = max(1, min((int) $this->option('batch'), 1000));
$publicOnly = (bool) $this->option('public-only');
$staleHours = max(0, (int) $this->option('stale-hours'));
BackfillArtworkVectorIndexJob::dispatch($afterId, $batch, $publicOnly, $staleHours);
$this->info('Queued artwork vector repair (after_id=' . $afterId . ', batch=' . $batch . ', public_only=' . ($publicOnly ? 'yes' : 'no') . ', stale_hours=' . $staleHours . ').');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,571 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Models\UserActivity;
use App\Services\Activity\UserActivityService;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class BackfillUserActivitiesCommand extends Command
{
protected $signature = 'skinbase:backfill-user-activities
{--chunk=1000 : Number of source records to process per batch}
{--user-id= : Backfill only one actor user id}
{--types=all : Comma-separated groups: all, uploads, comments, likes, follows, achievements, forum}
{--dry-run : Preview inserts without writing changes}';
protected $description = 'Backfill historical profile activity into user_activities for existing users.';
public function __construct(private readonly UserActivityService $activities)
{
parent::__construct();
}
public function handle(): int
{
if (! Schema::hasTable('user_activities')) {
$this->error('The user_activities table does not exist. Run migrations first.');
return self::FAILURE;
}
$chunk = max(1, (int) $this->option('chunk'));
$userId = $this->option('user-id') !== null ? max(1, (int) $this->option('user-id')) : null;
$dryRun = (bool) $this->option('dry-run');
$groups = $this->parseGroups((string) $this->option('types'));
if ($groups === null) {
$this->error('Invalid --types value. Use one or more of: all, uploads, comments, likes, follows, achievements, forum.');
return self::FAILURE;
}
if ($userId !== null && ! User::query()->whereKey($userId)->exists()) {
$this->error("User id={$userId} was not found.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No activity rows will be inserted.');
}
$this->info('Backfilling historical profile activity.');
$summary = [];
foreach ($groups as $group) {
$groupSummary = match ($group) {
'uploads' => [
'uploads' => $this->backfillUploads($chunk, $userId, $dryRun),
],
'comments' => [
'comments' => $this->backfillArtworkComments($chunk, $userId, $dryRun),
],
'likes' => [
'likes' => $this->backfillArtworkLikes($chunk, $userId, $dryRun),
'favourites' => $this->backfillArtworkFavourites($chunk, $userId, $dryRun),
],
'follows' => [
'follows' => $this->backfillFollows($chunk, $userId, $dryRun),
],
'achievements' => [
'achievements' => $this->backfillAchievements($chunk, $userId, $dryRun),
],
'forum' => [
'forum_posts' => $this->backfillForumThreads($chunk, $userId, $dryRun),
'forum_replies' => $this->backfillForumReplies($chunk, $userId, $dryRun),
],
default => [],
};
$summary = [...$summary, ...$groupSummary];
}
foreach ($summary as $label => $stats) {
$this->line(sprintf(
'%s: processed=%d inserted=%d existing=%d skipped=%d',
$label,
(int) ($stats['processed'] ?? 0),
(int) ($stats['inserted'] ?? 0),
(int) ($stats['existing'] ?? 0),
(int) ($stats['skipped'] ?? 0),
));
}
$totalProcessed = array_sum(array_map(static fn (array $stats): int => (int) ($stats['processed'] ?? 0), $summary));
$totalInserted = array_sum(array_map(static fn (array $stats): int => (int) ($stats['inserted'] ?? 0), $summary));
$totalExisting = array_sum(array_map(static fn (array $stats): int => (int) ($stats['existing'] ?? 0), $summary));
$totalSkipped = array_sum(array_map(static fn (array $stats): int => (int) ($stats['skipped'] ?? 0), $summary));
$this->info(sprintf(
'Finished. processed=%d inserted=%d existing=%d skipped=%d',
$totalProcessed,
$totalInserted,
$totalExisting,
$totalSkipped,
));
return self::SUCCESS;
}
/**
* @return array<int, string>|null
*/
private function parseGroups(string $value): ?array
{
$items = collect(explode(',', strtolower(trim($value))))
->map(static fn (string $item): string => trim($item))
->filter()
->values();
if ($items->isEmpty() || $items->contains('all')) {
return ['uploads', 'comments', 'likes', 'follows', 'achievements', 'forum'];
}
$allowed = ['uploads', 'comments', 'likes', 'follows', 'achievements', 'forum'];
if ($items->contains(static fn (string $item): bool => ! in_array($item, $allowed, true))) {
return null;
}
return $items->unique()->values()->all();
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillUploads(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artworks')
->select(['id', 'user_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artworks.user_id'))
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->whereNull('deleted_at')
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'uploads',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_UPLOAD,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkComments(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_comments') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_comments')
->select(['id', 'user_id', 'parent_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_comments.user_id'))
->where('is_approved', true)
->whereNull('deleted_at')
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_comments.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'comments',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => $row->parent_id ? UserActivity::TYPE_REPLY : UserActivity::TYPE_COMMENT,
'entity_type' => UserActivity::ENTITY_ARTWORK_COMMENT,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkLikes(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_likes') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_likes')
->select(['id', 'user_id', 'artwork_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_likes.user_id'))
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_likes.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'likes',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_LIKE,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->artwork_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkFavourites(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_favourites') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_favourites')
->select(['id', 'user_id', 'artwork_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_favourites.user_id'))
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_favourites.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'favourites',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FAVOURITE,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->artwork_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillFollows(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('user_followers')) {
return $this->emptyStats();
}
$query = DB::table('user_followers')
->select(['id', 'follower_id', 'user_id', 'created_at'])
->where('follower_id', '>', 0)
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('user_followers.follower_id'))
->whereExists($this->existingUserSubquery('user_followers.user_id'))
->when($userId !== null, fn (Builder $builder) => $builder->where('follower_id', $userId));
return $this->backfillRows(
label: 'follows',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->follower_id,
'type' => UserActivity::TYPE_FOLLOW,
'entity_type' => UserActivity::ENTITY_USER,
'entity_id' => (int) $row->user_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillAchievements(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('user_achievements')) {
return $this->emptyStats();
}
$query = DB::table('user_achievements')
->select(['id', 'user_id', 'achievement_id', 'unlocked_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('user_achievements.user_id'))
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'achievements',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_ACHIEVEMENT,
'entity_type' => UserActivity::ENTITY_ACHIEVEMENT,
'entity_id' => (int) $row->achievement_id,
'meta' => null,
'created_at' => $row->unlocked_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillForumThreads(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('forum_threads')) {
return $this->emptyStats();
}
$query = DB::table('forum_threads')
->select(['id', 'user_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('forum_threads.user_id'))
->where('visibility', 'public')
->whereNull('deleted_at')
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'forum_posts',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FORUM_POST,
'entity_type' => UserActivity::ENTITY_FORUM_THREAD,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillForumReplies(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('forum_posts') || ! Schema::hasTable('forum_threads')) {
return $this->emptyStats();
}
$query = DB::table('forum_posts')
->select(['forum_posts.id', 'forum_posts.user_id', 'forum_posts.created_at'])
->join('forum_threads', 'forum_threads.id', '=', 'forum_posts.thread_id')
->where('forum_posts.user_id', '>', 0)
->whereExists($this->existingUserSubquery('forum_posts.user_id'))
->whereNull('forum_posts.deleted_at')
->where('forum_threads.visibility', 'public')
->whereNull('forum_threads.deleted_at')
->whereRaw('forum_posts.id <> (SELECT MIN(fp2.id) FROM forum_posts as fp2 WHERE fp2.thread_id = forum_posts.thread_id)')
->when(Schema::hasColumn('forum_posts', 'flagged'), fn (Builder $builder) => $builder->where('forum_posts.flagged', false))
->when($userId !== null, fn (Builder $builder) => $builder->where('forum_posts.user_id', $userId));
return $this->backfillRows(
label: 'forum_replies',
query: $query,
chunk: $chunk,
chunkColumn: 'forum_posts.id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FORUM_REPLY,
'entity_type' => UserActivity::ENTITY_FORUM_POST,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
chunkAlias: 'id',
);
}
/**
* @param callable(object): ?array{user_id:int,type:string,entity_type:string,entity_id:int,meta:?array,created_at:mixed} $mapper
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillRows(
string $label,
Builder $query,
int $chunk,
string $chunkColumn,
callable $mapper,
bool $dryRun,
?string $chunkAlias = null,
): array {
$stats = $this->emptyStats();
$query->chunkById($chunk, function (Collection $rows) use (&$stats, $mapper, $dryRun): void {
$stats['processed'] += $rows->count();
$entries = $rows
->map($mapper)
->filter(static fn (?array $entry): bool => $entry !== null && (int) ($entry['user_id'] ?? 0) > 0 && (int) ($entry['entity_id'] ?? 0) > 0 && ! empty($entry['created_at']))
->values();
if ($entries->isEmpty()) {
$stats['skipped'] += $rows->count();
return;
}
$existing = $this->existingKeysForEntries($entries);
$pending = [];
foreach ($entries as $entry) {
$key = $this->entryKey($entry['user_id'], $entry['type'], $entry['entity_type'], $entry['entity_id']);
if (isset($existing[$key])) {
$stats['existing']++;
continue;
}
$pending[] = [
'user_id' => (int) $entry['user_id'],
'type' => (string) $entry['type'],
'entity_type' => (string) $entry['entity_type'],
'entity_id' => (int) $entry['entity_id'],
'meta' => $entry['meta'] !== null
? json_encode($entry['meta'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
: null,
'created_at' => $entry['created_at'],
];
}
if ($pending === []) {
return;
}
if ($dryRun) {
$stats['inserted'] += count($pending);
return;
}
DB::table('user_activities')->insert($pending);
$stats['inserted'] += count($pending);
collect($pending)
->pluck('user_id')
->unique()
->each(fn (int $userId): bool => tap(true, fn () => $this->activities->invalidateUserFeed($userId)));
}, $chunkColumn, $chunkAlias);
$this->line(sprintf('%s backfill complete.', $label));
return $stats;
}
/**
* @param Collection<int, array{user_id:int,type:string,entity_type:string,entity_id:int,meta:?array,created_at:mixed}> $entries
* @return array<string, true>
*/
private function existingKeysForEntries(Collection $entries): array
{
$existing = [];
$entries
->groupBy(fn (array $entry): string => $entry['type'] . '|' . $entry['entity_type'])
->each(function (Collection $groupedEntries, string $groupKey) use (&$existing): void {
[$type, $entityType] = explode('|', $groupKey, 2);
$userIds = $groupedEntries->pluck('user_id')->unique()->values()->all();
$entityIds = $groupedEntries->pluck('entity_id')->unique()->values()->all();
DB::table('user_activities')
->select(['user_id', 'entity_id'])
->where('type', $type)
->where('entity_type', $entityType)
->whereIn('user_id', $userIds)
->whereIn('entity_id', $entityIds)
->get()
->each(function (object $row) use (&$existing, $type, $entityType): void {
$existing[$this->entryKey((int) $row->user_id, $type, $entityType, (int) $row->entity_id)] = true;
});
});
return $existing;
}
private function entryKey(int $userId, string $type, string $entityType, int $entityId): string
{
return $userId . ':' . $type . ':' . $entityType . ':' . $entityId;
}
private function existingUserSubquery(string $column): \Closure
{
return static function ($subquery) use ($column): void {
$subquery->selectRaw('1')
->from('users')
->whereColumn('users.id', $column);
};
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function emptyStats(): array
{
return [
'processed' => 0,
'inserted' => 0,
'existing' => 0,
'skipped' => 0,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\CollectionBackgroundJobService;
use Illuminate\Console\Command;
class DispatchCollectionMaintenanceCommand extends Command
{
protected $signature = 'collections:dispatch-maintenance
{--health : Dispatch health and eligibility refresh jobs}
{--recommendations : Dispatch recommendation refresh jobs}
{--duplicates : Dispatch duplicate scan jobs}';
protected $description = 'Dispatch queued collection maintenance jobs for health, recommendation, and duplicate workflows.';
public function handle(CollectionBackgroundJobService $jobs): int
{
$runHealth = (bool) $this->option('health');
$runRecommendations = (bool) $this->option('recommendations');
$runDuplicates = (bool) $this->option('duplicates');
if (! $runHealth && ! $runRecommendations && ! $runDuplicates) {
$runHealth = true;
$runRecommendations = true;
$runDuplicates = true;
}
$summary = $jobs->dispatchScheduledMaintenance($runHealth, $runRecommendations, $runDuplicates);
foreach ($summary as $key => $payload) {
$this->info(sprintf('%s: %d queued.', ucfirst((string) $key), (int) ($payload['count'] ?? 0)));
}
return self::SUCCESS;
}
}

View File

@@ -5,9 +5,7 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use App\Services\Vision\VectorService;
use Illuminate\Console\Command;
final class IndexArtworkVectorsCommand extends Command
@@ -17,15 +15,16 @@ final class IndexArtworkVectorsCommand extends Command
{--after-id=0 : Resume after this artwork id}
{--batch=100 : Batch size per iteration}
{--limit=0 : Maximum artworks to process in this run}
{--embedded-only : Re-upsert only artworks that already have local embeddings}
{--public-only : Index only public, approved, published artworks}
{--dry-run : Preview requests without sending them}';
protected $description = 'Send artwork image URLs to the vector gateway for indexing';
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
public function handle(VectorService $vectors): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $client->isConfigured()) {
if (! $dryRun && ! $vectors->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
@@ -36,6 +35,7 @@ final class IndexArtworkVectorsCommand extends Command
$limit = max(0, (int) $this->option('limit'));
$publicOnly = (bool) $this->option('public-only');
$nextId = $startId > 0 ? $startId : max(1, $afterId + 1);
$embeddedOnly = (bool) $this->option('embedded-only');
$processed = 0;
$indexed = 0;
@@ -52,12 +52,13 @@ final class IndexArtworkVectorsCommand extends Command
}
$this->info(sprintf(
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s public_only=%s dry_run=%s',
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s embedded_only=%s public_only=%s dry_run=%s',
$startId,
$afterId,
$nextId,
$batch,
$limit > 0 ? (string) $limit : 'all',
$embeddedOnly ? 'yes' : 'no',
$publicOnly ? 'yes' : 'no',
$dryRun ? 'yes' : 'no'
));
@@ -77,6 +78,10 @@ final class IndexArtworkVectorsCommand extends Command
->orderBy('id')
->limit($take);
if ($embeddedOnly) {
$query->whereHas('embeddings');
}
if ($publicOnly) {
$query->public()->published();
}
@@ -99,21 +104,21 @@ final class IndexArtworkVectorsCommand extends Command
$lastId = (int) $artwork->id;
$nextId = $lastId + 1;
$url = $imageUrl->fromArtwork($artwork);
if ($url === null) {
try {
$payload = $vectors->payloadForArtwork($artwork);
} catch (\Throwable $e) {
$skipped++;
$this->warn("Skipped artwork {$artwork->id}: no vision image URL could be generated.");
$this->warn("Skipped artwork {$artwork->id}: {$e->getMessage()}");
continue;
}
$metadata = $this->metadataForArtwork($artwork);
$this->line(sprintf(
'Processing artwork=%d hash=%s thumb_ext=%s url=%s metadata=%s',
(int) $artwork->id,
(string) ($artwork->hash ?? ''),
(string) ($artwork->thumb_ext ?? ''),
$url,
$this->json($metadata)
$payload['url'],
$this->json($payload['metadata'])
));
if ($dryRun) {
@@ -128,7 +133,7 @@ final class IndexArtworkVectorsCommand extends Command
}
try {
$client->upsertByUrl($url, (int) $artwork->id, $metadata);
$vectors->upsertArtwork($artwork);
$indexed++;
$this->info(sprintf(
'Indexed artwork %d successfully. totals: processed=%d indexed=%d skipped=%d failed=%d',
@@ -159,26 +164,4 @@ final class IndexArtworkVectorsCommand extends Command
return is_string($json) ? $json : '{}';
}
/**
* @return array{content_type: string, category: string, user_id: string}
*/
private function metadataForArtwork(Artwork $artwork): array
{
$category = $this->primaryCategory($artwork);
return [
'content_type' => (string) ($category?->contentType?->name ?? ''),
'category' => (string) ($category?->name ?? ''),
'user_id' => (string) ($artwork->user_id ?? ''),
];
}
private function primaryCategory(Artwork $artwork): ?Category
{
/** @var Category|null $category */
$category = $artwork->categories->sortBy('sort_order')->first();
return $category;
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\ActivityEvent;
use App\Services\Activity\UserActivityService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -79,7 +80,7 @@ class PublishScheduledArtworksCommand extends Command
return;
}
$artwork->is_public = true;
$artwork->is_public = $artwork->visibility !== Artwork::VISIBILITY_PRIVATE;
$artwork->published_at = $now;
$artwork->artwork_status = 'published';
$artwork->save();
@@ -103,6 +104,10 @@ class PublishScheduledArtworksCommand extends Command
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logUpload((int) $artwork->user_id, (int) $artwork->id);
} catch (\Throwable) {}
$published++;
$this->line(" Published artwork #{$artwork->id}: \"{$artwork->title}\"");
});

View File

@@ -8,12 +8,12 @@ use App\Services\TrendingService;
use Illuminate\Console\Command;
/**
* php artisan skinbase:recalculate-trending [--period=24h|7d] [--chunk=1000] [--skip-index]
* php artisan skinbase:recalculate-trending [--period=1h|24h|7d] [--chunk=1000] [--skip-index]
*/
class RecalculateTrendingCommand extends Command
{
protected $signature = 'skinbase:recalculate-trending
{--period=7d : Period to recalculate (24h or 7d). Use "all" to run both.}
{--period=7d : Period to recalculate (1h, 24h or 7d). Use "all" to run all three.}
{--chunk=1000 : DB chunk size}
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
@@ -30,11 +30,11 @@ class RecalculateTrendingCommand extends Command
$chunkSize = (int) $this->option('chunk');
$skipIndex = (bool) $this->option('skip-index');
$periods = $period === 'all' ? ['24h', '7d'] : [$period];
$periods = $period === 'all' ? ['1h', '24h', '7d'] : [$period];
foreach ($periods as $p) {
if (! in_array($p, ['24h', '7d'], true)) {
$this->error("Invalid period '{$p}'. Use 24h, 7d, or all.");
if (! in_array($p, ['1h', '24h', '7d'], true)) {
$this->error("Invalid period '{$p}'. Use 1h, 24h, 7d, or all.");
return self::FAILURE;
}

View File

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class RepairLegacyUserJoinDatesCommand extends Command
{
/** @var array<string, bool> */
private array $legacyTableExistsCache = [];
protected $signature = 'skinbase:repair-user-join-dates
{--chunk=500 : Number of users to process per batch}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-table=users : Legacy users table name}
{--only-null : Update only users whose current created_at is null}
{--dry-run : Preview join date updates without writing changes}';
protected $description = 'Backfill current users.created_at from legacy users.joinDate';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$legacyConnection = (string) $this->option('legacy-connection');
$legacyTable = (string) $this->option('legacy-table');
$onlyNull = (bool) $this->option('only-null');
$dryRun = (bool) $this->option('dry-run');
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
$query = DB::table('users')->select(['id', 'created_at']);
if ($onlyNull) {
$query->whereNull('created_at');
}
$this->info('Scanning current users for legacy joinDate backfill.');
$processed = 0;
$matched = 0;
$updated = 0;
$unchanged = 0;
$skipped = 0;
$query
->chunkById($chunk, function (Collection $rows) use (
&$processed,
&$matched,
&$updated,
&$unchanged,
&$skipped,
$legacyConnection,
$legacyTable,
$dryRun
): void {
$legacyById = $this->loadLegacyUsersForChunk($rows, $legacyConnection, $legacyTable);
$activityById = $this->loadLegacyActivityDatesForChunk($rows, $legacyConnection);
foreach ($rows as $row) {
$processed++;
$legacyMatch = $legacyById[(int) $row->id] ?? null;
if ($legacyMatch === null) {
$skipped++;
continue;
}
$matched++;
$legacyJoinDate = $this->parseLegacyJoinDate($legacyMatch->joinDate ?? null);
$dateSource = 'joinDate';
if ($legacyJoinDate === null) {
$activityFallback = $activityById[(int) $row->id] ?? null;
$legacyJoinDate = $activityFallback['date'] ?? null;
$dateSource = $activityFallback['source'] ?? 'activity';
}
if ($legacyJoinDate === null) {
$skipped++;
continue;
}
$currentCreatedAt = $this->parseCurrentDate($row->created_at ?? null);
if ($currentCreatedAt !== null && $currentCreatedAt->equalTo($legacyJoinDate)) {
$unchanged++;
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry] Would update user id=%d created_at %s => %s (%s)',
(int) $row->id,
$currentCreatedAt?->toDateTimeString() ?? '<null>',
$legacyJoinDate->toDateTimeString(),
$dateSource
));
$updated++;
continue;
}
$affected = DB::table('users')
->where('id', (int) $row->id)
->update([
'created_at' => $legacyJoinDate->toDateTimeString(),
]);
if ($affected > 0) {
$updated += $affected;
$this->line(sprintf(
'[update] user id=%d created_at => %s (%s)',
(int) $row->id,
$legacyJoinDate->toDateTimeString(),
$dateSource
));
}
}
}, 'id');
$this->info(sprintf(
'Finished. processed=%d matched=%d updated=%d unchanged=%d skipped=%d',
$processed,
$matched,
$updated,
$unchanged,
$skipped
));
if ($processed === 0) {
$this->info('No users matched the requested scope.');
}
return self::SUCCESS;
}
private function legacyTableExists(string $connection, string $table): bool
{
$cacheKey = strtolower($connection . ':' . $table);
if (array_key_exists($cacheKey, $this->legacyTableExistsCache)) {
return $this->legacyTableExistsCache[$cacheKey];
}
try {
return $this->legacyTableExistsCache[$cacheKey] = DB::connection($connection)->getSchemaBuilder()->hasTable($table);
} catch (\Throwable) {
return $this->legacyTableExistsCache[$cacheKey] = false;
}
}
/**
* @return array<int, object>
*/
private function loadLegacyUsersForChunk(Collection $rows, string $legacyConnection, string $legacyTable): array
{
$legacyById = [];
$ids = $rows
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
if ($ids !== []) {
DB::connection($legacyConnection)
->table($legacyTable)
->select(['user_id', 'joinDate'])
->whereIn('user_id', $ids)
->get()
->each(function (object $legacyRow) use (&$legacyById): void {
$legacyById[(int) $legacyRow->user_id] = $legacyRow;
});
}
return $legacyById;
}
/**
* @return array<int, array{date: Carbon, source: string}>
*/
private function loadLegacyActivityDatesForChunk(Collection $rows, string $legacyConnection): array
{
$activityById = [];
$ids = $rows
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
if ($ids === []) {
return $activityById;
}
if ($this->legacyTableExists($legacyConnection, 'wallz')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('wallz')
->selectRaw('user_id, MIN(datum) as first_at')
->whereIn('user_id', $ids)
->whereRaw("datum IS NOT NULL AND datum <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first upload'
);
}
if ($this->legacyTableExists($legacyConnection, 'forum_topics')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('forum_topics')
->selectRaw('user_id, MIN(post_date) as first_at')
->whereIn('user_id', $ids)
->whereRaw("post_date <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first forum topic'
);
}
if ($this->legacyTableExists($legacyConnection, 'forum_posts')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('forum_posts')
->selectRaw('user_id, MIN(post_date) as first_at')
->whereIn('user_id', $ids)
->whereRaw("post_date <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first forum post'
);
}
if ($this->legacyTableExists($legacyConnection, 'artworks_comments')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('artworks_comments')
->selectRaw("user_id, MIN(TIMESTAMP(`date`, COALESCE(`time`, '00:00:00'))) as first_at")
->whereIn('user_id', $ids)
->whereRaw("`date` IS NOT NULL AND `date` <> '0000-00-00'")
->groupBy('user_id')
->get(),
'first artwork comment'
);
}
if ($this->legacyTableExists($legacyConnection, 'users_comments')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('users_comments')
->selectRaw("user_id, MIN(TIMESTAMP(`date`, COALESCE(`time`, '00:00:00'))) as first_at")
->whereIn('user_id', $ids)
->whereRaw("`date` IS NOT NULL AND `date` <> '0000-00-00'")
->groupBy('user_id')
->get(),
'first profile comment'
);
}
return $activityById;
}
/**
* @param array<int, array{date: Carbon, source: string}> $activityById
*/
private function registerChunkActivityDates(array &$activityById, iterable $rows, string $source): void
{
foreach ($rows as $row) {
$candidate = $this->parseLegacyJoinDate($row->first_at ?? null);
if ($candidate === null) {
continue;
}
$userId = (int) ($row->user_id ?? 0);
if ($userId <= 0) {
continue;
}
$existing = $activityById[$userId]['date'] ?? null;
if ($existing === null || $candidate->lt($existing)) {
$activityById[$userId] = [
'date' => $candidate,
'source' => $source,
];
}
}
}
private function parseLegacyJoinDate(mixed $value): ?Carbon
{
$raw = trim((string) ($value ?? ''));
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return null;
}
try {
return Carbon::parse($raw);
} catch (\Throwable) {
return null;
}
}
private function parseCurrentDate(mixed $value): ?Carbon
{
if ($value instanceof Carbon) {
return $value;
}
$raw = trim((string) ($value ?? ''));
if ($raw === '') {
return null;
}
try {
return Carbon::parse($raw);
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -6,8 +6,7 @@ namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use App\Services\Vision\VectorService;
use Illuminate\Console\Command;
final class SearchArtworkVectorsCommand extends Command
@@ -18,9 +17,9 @@ final class SearchArtworkVectorsCommand extends Command
protected $description = 'Search similar artworks through the vector gateway using an artwork image URL';
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
public function handle(VectorService $vectors): int
{
if (! $client->isConfigured()) {
if (! $vectors->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
@@ -37,26 +36,14 @@ final class SearchArtworkVectorsCommand extends Command
return self::FAILURE;
}
$url = $imageUrl->fromArtwork($artwork);
if ($url === null) {
$this->error("Artwork {$artworkId} does not have a usable CDN image URL.");
return self::FAILURE;
}
try {
$matches = $client->searchByUrl($url, $limit + 1);
$matches = $vectors->similarToArtwork($artwork, $limit);
} catch (\Throwable $e) {
$this->error('Vector search failed: ' . $e->getMessage());
return self::FAILURE;
}
$ids = collect($matches)
->map(fn (array $match): int => (int) $match['id'])
->filter(fn (int $id): bool => $id > 0 && $id !== $artworkId)
->unique()
->take($limit)
->values()
->all();
$ids = collect($matches)->pluck('id')->map(fn (mixed $id): int => (int) $id)->filter()->values()->all();
if ($ids === []) {
$this->warn('No similar artworks were returned by the vector gateway.');
@@ -74,7 +61,7 @@ final class SearchArtworkVectorsCommand extends Command
$rows = [];
foreach ($matches as $match) {
$matchId = (int) ($match['id'] ?? 0);
if ($matchId <= 0 || $matchId === $artworkId) {
if ($matchId <= 0) {
continue;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Collection;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionLifecycleService;
use App\Services\CollectionSurfaceService;
use Illuminate\Console\Command;
class SyncCollectionLifecycleCommand extends Command
{
protected $signature = 'collections:sync-lifecycle';
protected $description = 'Expire pending collection invites, sync collection lifecycle states, and deactivate expired placements.';
public function handle(CollectionCollaborationService $collaborators, CollectionLifecycleService $lifecycle, CollectionSurfaceService $surfaces): int
{
$expiredInvites = $collaborators->expirePendingInvites();
$lifecycleResults = $lifecycle->syncScheduledCollections();
$expiredPlacements = $surfaces->syncPlacements();
$unfeaturedCollections = Collection::query()
->where('is_featured', true)
->whereNotNull('unpublished_at')
->where('unpublished_at', '<=', now())
->update([
'is_featured' => false,
'featured_at' => null,
'updated_at' => now(),
]);
$this->info(sprintf(
'Expired %d pending invites; published %d scheduled collections; expired %d collections; unfeatured %d unpublished collections; deactivated %d expired placements.',
$expiredInvites,
(int) ($lifecycleResults['scheduled'] ?? 0),
(int) ($lifecycleResults['expired'] ?? 0),
$unfeaturedCollections,
$expiredPlacements,
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
final class TestObjectStorageUploadCommand extends Command
{
protected $signature = 'storage:test-upload
{--disk=s3 : Filesystem disk to test}
{--file= : Optional absolute or relative path to an existing local file to upload}
{--path= : Optional remote object key to use}
{--keep : Keep the uploaded test object instead of deleting it afterwards}';
protected $description = 'Upload a probe file to the configured object storage disk and verify that it was stored.';
public function handle(): int
{
$diskName = (string) $this->option('disk');
$diskConfig = config("filesystems.disks.{$diskName}");
if (! is_array($diskConfig)) {
$this->error("Filesystem disk [{$diskName}] is not configured.");
return self::FAILURE;
}
$this->line('Testing object storage upload.');
$this->line('Disk: '.$diskName);
$this->line('Driver: '.(string) ($diskConfig['driver'] ?? 'unknown'));
$this->line('Bucket: '.(string) ($diskConfig['bucket'] ?? 'n/a'));
$this->line('Region: '.(string) ($diskConfig['region'] ?? 'n/a'));
$this->line('Endpoint: '.((string) ($diskConfig['endpoint'] ?? '') !== '' ? (string) $diskConfig['endpoint'] : '[not set]'));
$this->line('Path style: '.((bool) ($diskConfig['use_path_style_endpoint'] ?? false) ? 'true' : 'false'));
if ((string) ($diskConfig['endpoint'] ?? '') === '') {
$this->warn('No endpoint is configured for this S3 disk. Many S3-compatible providers, including Contabo object storage, require AWS_ENDPOINT to be set.');
}
$remotePath = $this->resolveRemotePath();
$keepObject = (bool) $this->option('keep');
$sourceFile = $this->option('file');
$filesystem = Storage::disk($diskName);
try {
if (is_string($sourceFile) && trim($sourceFile) !== '') {
$localPath = $this->resolveLocalPath($sourceFile);
if ($localPath === null) {
$this->error('The file passed to --file does not exist.');
return self::FAILURE;
}
$stream = fopen($localPath, 'rb');
if ($stream === false) {
$this->error('Unable to open the local file for reading.');
return self::FAILURE;
}
try {
$written = $filesystem->put($remotePath, $stream);
} finally {
fclose($stream);
}
$sourceLabel = $localPath;
} else {
$contents = $this->buildProbeContents($diskName);
$written = $filesystem->put($remotePath, $contents);
$sourceLabel = '[generated probe payload]';
}
if ($written !== true) {
$this->error('Upload did not complete successfully. The storage driver returned a failure status.');
return self::FAILURE;
}
$exists = $filesystem->exists($remotePath);
$size = $exists ? $filesystem->size($remotePath) : null;
$this->info('Upload succeeded.');
$this->line('Source: '.$sourceLabel);
$this->line('Object key: '.$remotePath);
$this->line('Exists after upload: '.($exists ? 'yes' : 'no'));
if ($size !== null) {
$this->line('Stored size: '.number_format((int) $size).' bytes');
}
if (! $keepObject && $exists) {
$filesystem->delete($remotePath);
$this->line('Cleanup: deleted uploaded test object');
} elseif ($keepObject) {
$this->warn('Cleanup skipped because --keep was used.');
}
return $exists ? self::SUCCESS : self::FAILURE;
} catch (Throwable $exception) {
$this->error('Object storage test failed.');
$this->line($exception->getMessage());
return self::FAILURE;
}
}
private function resolveRemotePath(): string
{
$provided = trim((string) $this->option('path'));
if ($provided !== '') {
return ltrim(str_replace('\\', '/', $provided), '/');
}
return 'tests/object-storage/'.now()->format('Ymd-His').'-'.Str::random(10).'.txt';
}
private function resolveLocalPath(string $path): ?string
{
$trimmed = trim($path);
if ($trimmed === '') {
return null;
}
if (is_file($trimmed)) {
return $trimmed;
}
$relative = base_path($trimmed);
return is_file($relative) ? $relative : null;
}
private function buildProbeContents(string $diskName): string
{
return implode("\n", [
'Skinbase object storage upload test',
'disk='.$diskName,
'timestamp='.now()->toIso8601String(),
'app_url='.(string) config('app.url'),
'random='.Str::uuid()->toString(),
'',
]);
}
}

View File

@@ -7,6 +7,7 @@ use App\Console\Commands\ImportLegacyUsers;
use App\Console\Commands\ImportCategories;
use App\Console\Commands\MigrateFeaturedWorks;
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
use App\Console\Commands\BackfillArtworkVectorIndexCommand;
use App\Console\Commands\IndexArtworkVectorsCommand;
use App\Console\Commands\SearchArtworkVectorsCommand;
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
@@ -17,15 +18,19 @@ use App\Console\Commands\EvaluateFeedWeightsCommand;
use App\Console\Commands\AiTagArtworksCommand;
use App\Console\Commands\SyncCountriesCommand;
use App\Console\Commands\CompareFeedAbCommand;
use App\Console\Commands\DispatchCollectionMaintenanceCommand;
use App\Console\Commands\RecalculateTrendingCommand;
use App\Console\Commands\RecalculateRankingsCommand;
use App\Console\Commands\MetricsSnapshotHourlyCommand;
use App\Console\Commands\RecalculateHeatCommand;
use App\Jobs\UpdateLeaderboardsJob;
use App\Jobs\RebuildTrendingNovaCardsJob;
use App\Jobs\RecalculateRisingNovaCardsJob;
use App\Jobs\RankComputeArtworkScoresJob;
use App\Jobs\RankBuildListsJob;
use App\Uploads\Commands\CleanupUploadsCommand;
use App\Console\Commands\PublishScheduledArtworksCommand;
use App\Console\Commands\SyncCollectionLifecycleCommand;
class Kernel extends ConsoleKernel
{
@@ -44,7 +49,10 @@ class Kernel extends ConsoleKernel
\App\Console\Commands\ResetAllUserPasswords::class,
CleanupUploadsCommand::class,
PublishScheduledArtworksCommand::class,
SyncCollectionLifecycleCommand::class,
DispatchCollectionMaintenanceCommand::class,
BackfillArtworkEmbeddingsCommand::class,
BackfillArtworkVectorIndexCommand::class,
IndexArtworkVectorsCommand::class,
SearchArtworkVectorsCommand::class,
AggregateSimilarArtworkAnalyticsCommand::class,
@@ -75,6 +83,16 @@ class Kernel extends ConsoleKernel
->name('publish-scheduled-artworks')
->withoutOverlapping(2) // prevent overlap up to 2 minutes
->runInBackground();
$schedule->command('collections:sync-lifecycle')
->everyTenMinutes()
->name('sync-collection-lifecycle')
->withoutOverlapping()
->runInBackground();
$schedule->command('collections:dispatch-maintenance')
->hourly()
->name('dispatch-collection-maintenance')
->withoutOverlapping()
->runInBackground();
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
$schedule->command('analytics:aggregate-tag-interactions')->dailyAt('03:30');
@@ -101,6 +119,12 @@ class Kernel extends ConsoleKernel
->withoutOverlapping()
->runInBackground();
$schedule->job(new RebuildTrendingNovaCardsJob)
->hourlyAt(25)
->name('nova-cards-trending-refresh')
->withoutOverlapping()
->runInBackground();
// ── Rising Engine (Heat / Momentum) ─────────────────────────────────
// Step 1: snapshot metric totals every hour at :00
$schedule->command('nova:metrics-snapshot-hourly')
@@ -114,6 +138,12 @@ class Kernel extends ConsoleKernel
->name('recalculate-heat')
->withoutOverlapping()
->runInBackground();
// Step 2b: bust Nova Cards v3 rising feed cache to stay in sync
$schedule->job(new RecalculateRisingNovaCardsJob)
->everyFifteenMinutes()
->name('nova-cards-rising-cache-refresh')
->withoutOverlapping()
->runInBackground();
// Step 3: prune old snapshots daily at 04:00
$schedule->command('nova:prune-metric-snapshots --keep-days=7')
->dailyAt('04:00');

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionArtworkAttached
{
use Dispatchable, SerializesModels;
/**
* @param array<int, int> $artworkIds
*/
public function __construct(public readonly Collection $collection, public readonly array $artworkIds) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionArtworkRemoved
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly int $artworkId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionCreated
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionDeleted
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionFeatured
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionFollowed
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly int $userId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionLiked
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly int $userId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionShared
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly ?int $userId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionUnfeatured
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionUnfollowed
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly int $userId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionUnliked
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly int $userId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionUpdated
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionViewed
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly ?int $viewerId = null) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SmartCollectionRulesUpdated
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardAutosaved
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public array $changedFields = []) {}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use App\Models\NovaCardBackground;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardBackgroundUploaded
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public NovaCardBackground $background) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardCreated
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardDownloaded
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public ?int $viewerId = null) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardPublished
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardShared
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public ?int $viewerId = null) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardTemplateSelected
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public ?int $previousTemplateId = null, public ?int $templateId = null) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardViewed
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public ?int $viewerId = null) {}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\Collection;
use App\Models\CollectionMember;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionModerationService;
use App\Services\CollectionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class CollectionModerationController extends Controller
{
public function __construct(
private readonly CollectionModerationService $moderation,
private readonly CollectionService $collections,
private readonly CollectionCollaborationService $collaborators,
) {
}
public function updateModeration(Request $request, Collection $collection): JsonResponse
{
$data = $request->validate([
'moderation_status' => ['required', 'in:active,under_review,restricted,hidden'],
]);
$collection = $this->moderation->updateStatus($collection->loadMissing('user'), (string) $data['moderation_status']);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
]);
}
public function updateInteractions(Request $request, Collection $collection): JsonResponse
{
$data = $request->validate([
'allow_comments' => ['sometimes', 'boolean'],
'allow_submissions' => ['sometimes', 'boolean'],
'allow_saves' => ['sometimes', 'boolean'],
]);
$collection = $this->moderation->updateInteractions($collection->loadMissing('user'), $data);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
]);
}
public function unfeature(Request $request, Collection $collection): JsonResponse
{
$collection = $this->moderation->unfeature($collection->loadMissing('user'));
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
]);
}
public function destroyMember(Request $request, Collection $collection, CollectionMember $member): JsonResponse
{
$this->moderation->removeMember($collection, $member);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->fresh()->loadMissing('user'), true),
'members' => $this->collaborators->mapMembers($collection->fresh(), $request->user()),
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\Analytics\DiscoveryFeedbackReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class DiscoveryFeedbackReportController extends Controller
{
public function __construct(private readonly DiscoveryFeedbackReportService $reportService) {}
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'from' => ['nullable', 'date_format:Y-m-d'],
'to' => ['nullable', 'date_format:Y-m-d'],
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
$to = (string) ($validated['to'] ?? now()->toDateString());
$limit = (int) ($validated['limit'] ?? 20);
if ($from > $to) {
return response()->json([
'message' => 'Invalid date range: from must be before or equal to to.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$report = $this->reportService->buildReport($from, $to, $limit);
return response()->json([
'meta' => [
'from' => $from,
'to' => $to,
'limit' => $limit,
'generated_at' => now()->toISOString(),
],
'overview' => $report['overview'],
'daily_feedback' => $report['daily_feedback'],
'trend_summary' => $report['trend_summary'],
'by_surface' => $report['by_surface'],
'by_algo_surface' => $report['by_algo_surface'],
'top_artworks' => $report['top_artworks'],
'latest_aggregated_date' => $report['latest_aggregated_date'],
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\Recommendations\RecommendationFeedResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class FeedEngineDecisionController extends Controller
{
public function __construct(private readonly RecommendationFeedResolver $feedResolver) {}
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'user_id' => ['required', 'integer', 'exists:users,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
]);
$userId = (int) $validated['user_id'];
$algoVersion = isset($validated['algo_version']) ? (string) $validated['algo_version'] : null;
return response()->json([
'meta' => [
'generated_at' => now()->toISOString(),
],
'decision' => $this->feedResolver->inspectDecision($userId, $algoVersion),
], Response::HTTP_OK);
}
}

View File

@@ -3,23 +3,231 @@
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Models\Report;
use App\Models\ReportHistory;
use App\Services\NovaCards\NovaCardPublishModerationService;
use App\Support\Moderation\ReportTargetResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class ModerationReportQueueController extends Controller
{
public function __construct(
private readonly ReportTargetResolver $targets,
private readonly NovaCardPublishModerationService $moderation,
) {}
public function index(Request $request): JsonResponse
{
$status = (string) $request->query('status', 'open');
$status = in_array($status, ['open', 'reviewing', 'closed'], true) ? $status : 'open';
$group = (string) $request->query('group', '');
$items = Report::query()
->with('reporter:id,username')
$query = Report::query()
->with(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username'])
->where('status', $status)
->orderByDesc('id')
->paginate(30);
->orderByDesc('id');
return response()->json($items);
if ($group === 'nova_cards') {
$query->whereIn('target_type', $this->targets->novaCardTargetTypes());
}
$items = $query->paginate(30);
return response()->json([
'data' => collect($items->items())
->map(fn (Report $report): array => $this->serializeReport($report))
->values()
->all(),
'meta' => [
'current_page' => $items->currentPage(),
'last_page' => $items->lastPage(),
'per_page' => $items->perPage(),
'total' => $items->total(),
'from' => $items->firstItem(),
'to' => $items->lastItem(),
],
]);
}
public function update(Request $request, Report $report): JsonResponse
{
$data = $request->validate([
'status' => 'sometimes|in:open,reviewing,closed',
'moderator_note' => 'sometimes|nullable|string|max:2000',
]);
$before = [];
$after = [];
$user = $request->user();
DB::transaction(function () use ($data, $report, $user, &$before, &$after): void {
if (array_key_exists('status', $data) && $data['status'] !== $report->status) {
$before['status'] = (string) $report->status;
$after['status'] = (string) $data['status'];
$report->status = $data['status'];
}
if (array_key_exists('moderator_note', $data)) {
$normalizedNote = is_string($data['moderator_note']) ? trim($data['moderator_note']) : null;
$normalizedNote = $normalizedNote !== '' ? $normalizedNote : null;
if ($normalizedNote !== $report->moderator_note) {
$before['moderator_note'] = $report->moderator_note;
$after['moderator_note'] = $normalizedNote;
$report->moderator_note = $normalizedNote;
}
}
if ($before !== [] || $after !== []) {
$report->last_moderated_by_id = $user?->id;
$report->last_moderated_at = now();
$report->save();
$report->historyEntries()->create([
'actor_user_id' => $user?->id,
'action_type' => 'report_updated',
'summary' => $this->buildUpdateSummary($before, $after),
'note' => $report->moderator_note,
'before_json' => $before !== [] ? $before : null,
'after_json' => $after !== [] ? $after : null,
'created_at' => now(),
]);
}
});
$report = $report->fresh(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username']);
return response()->json([
'report' => $this->serializeReport($report),
]);
}
public function moderateTarget(Request $request, Report $report): JsonResponse
{
$data = $request->validate([
'action' => 'required|in:approve_card,flag_card,reject_card',
'disposition' => 'nullable|in:' . implode(',', array_keys(NovaCardPublishModerationService::DISPOSITION_LABELS)),
]);
$card = $this->targets->resolveModerationCard($report);
abort_unless($card !== null, 422, 'This report does not have a Nova Card moderation target.');
DB::transaction(function () use ($card, $data, $report, $request): void {
$before = [
'card_id' => (int) $card->id,
'moderation_status' => (string) $card->moderation_status,
];
$nextStatus = match ($data['action']) {
'approve_card' => NovaCard::MOD_APPROVED,
'flag_card' => NovaCard::MOD_FLAGGED,
'reject_card' => NovaCard::MOD_REJECTED,
};
$card = $this->moderation->recordStaffOverride(
$card,
$nextStatus,
$request->user(),
'report_queue',
[
'note' => $report->moderator_note,
'report_id' => $report->id,
'disposition' => $data['disposition'] ?? null,
],
);
$report->last_moderated_by_id = $request->user()?->id;
$report->last_moderated_at = now();
$report->save();
$report->historyEntries()->create([
'actor_user_id' => $request->user()?->id,
'action_type' => 'target_moderated',
'summary' => $this->buildTargetModerationSummary($data['action'], $card),
'note' => $report->moderator_note,
'before_json' => $before,
'after_json' => [
'card_id' => (int) $card->id,
'moderation_status' => (string) $card->moderation_status,
'action' => (string) $data['action'],
],
'created_at' => now(),
]);
});
$report = $report->fresh(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username']);
return response()->json([
'report' => $this->serializeReport($report),
]);
}
private function buildUpdateSummary(array $before, array $after): string
{
$parts = [];
if (array_key_exists('status', $after)) {
$parts[] = sprintf('Status %s -> %s', $before['status'], $after['status']);
}
if (array_key_exists('moderator_note', $after)) {
$parts[] = $after['moderator_note'] ? 'Moderator note updated' : 'Moderator note cleared';
}
return $parts !== [] ? implode(' • ', $parts) : 'Report reviewed';
}
private function buildTargetModerationSummary(string $action, NovaCard $card): string
{
return match ($action) {
'approve_card' => sprintf('Approved card #%d', $card->id),
'flag_card' => sprintf('Flagged card #%d', $card->id),
'reject_card' => sprintf('Rejected card #%d', $card->id),
default => sprintf('Updated card #%d', $card->id),
};
}
private function serializeReport(Report $report): array
{
return [
'id' => (int) $report->id,
'status' => (string) $report->status,
'target_type' => (string) $report->target_type,
'target_id' => (int) $report->target_id,
'reason' => (string) $report->reason,
'details' => $report->details,
'moderator_note' => $report->moderator_note,
'created_at' => optional($report->created_at)?->toISOString(),
'updated_at' => optional($report->updated_at)?->toISOString(),
'last_moderated_at' => optional($report->last_moderated_at)?->toISOString(),
'reporter' => $report->reporter ? [
'id' => (int) $report->reporter->id,
'username' => (string) $report->reporter->username,
] : null,
'last_moderated_by' => $report->lastModeratedBy ? [
'id' => (int) $report->lastModeratedBy->id,
'username' => (string) $report->lastModeratedBy->username,
] : null,
'target' => $this->targets->summarize($report),
'history' => $report->historyEntries
->take(8)
->map(fn (ReportHistory $entry): array => [
'id' => (int) $entry->id,
'action_type' => (string) $entry->action_type,
'summary' => $entry->summary,
'note' => $entry->note,
'before' => $entry->before_json,
'after' => $entry->after_json,
'created_at' => optional($entry->created_at)?->toISOString(),
'actor' => $entry->actor ? [
'id' => (int) $entry->actor->id,
'username' => (string) $entry->actor->username,
] : null,
])
->values()
->all(),
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Activity\UserActivityService;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
@@ -128,6 +129,15 @@ class ArtworkCommentController extends Controller
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logComment(
(int) $request->user()->id,
(int) $comment->id,
$parentId !== null,
['artwork_id' => (int) $artwork->id],
);
} catch (\Throwable) {}
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
}

View File

@@ -37,7 +37,8 @@ class ArtworkController extends Controller
(int) $user->id,
(string) $data['title'],
isset($data['description']) ? (string) $data['description'] : null,
$categoryId
$categoryId,
(bool) ($data['is_mature'] ?? false)
);
return response()->json([

View File

@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Notifications\ArtworkLikedNotification;
use App\Services\FollowService;
use App\Services\Activity\UserActivityService;
use App\Services\UserStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
@@ -64,6 +65,10 @@ final class ArtworkInteractionController extends Controller
targetId: $artworkId,
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logFavourite((int) $request->user()->id, $artworkId);
} catch (\Throwable) {}
} elseif (! $state && $changed) {
$svc->decrementFavoritesReceived($creatorId);
}
@@ -88,6 +93,9 @@ final class ArtworkInteractionController extends Controller
if ($request->boolean('state', true) && $changed) {
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
$actorId = (int) $request->user()->id;
try {
app(UserActivityService::class)->logLike($actorId, $artworkId);
} catch (\Throwable) {}
if ($creatorId > 0 && $creatorId !== $actorId) {
app(XPService::class)->awardArtworkLikeReceived($creatorId, $artworkId, $actorId);
$creator = \App\Models\User::query()->find($creatorId);

View File

@@ -17,7 +17,7 @@ final class DiscoveryEventController extends Controller
{
$payload = $request->validate([
'event_id' => ['nullable', 'uuid'],
'event_type' => ['required', 'string', 'in:view,click,favorite,download'],
'event_type' => ['required', 'string', 'in:view,click,favorite,download,dwell,scroll'],
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'occurred_at' => ['nullable', 'date'],
'algo_version' => ['nullable', 'string', 'max:64'],

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use App\Models\UserNegativeSignal;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
final class DiscoveryNegativeSignalController extends Controller
{
public function hideArtwork(Request $request): JsonResponse
{
$payload = $request->validate([
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'source' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$signal = UserNegativeSignal::query()->updateOrCreate(
[
'user_id' => (int) $request->user()->id,
'signal_type' => 'hide_artwork',
'artwork_id' => (int) $payload['artwork_id'],
],
[
'tag_id' => null,
'algo_version' => $payload['algo_version'] ?? null,
'source' => $payload['source'] ?? 'api',
'meta' => (array) ($payload['meta'] ?? []),
]
);
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: (int) $payload['artwork_id'],
eventType: 'hide_artwork',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: (array) ($payload['meta'] ?? [])
);
return response()->json([
'stored' => true,
'signal_id' => (int) $signal->id,
'signal_type' => 'hide_artwork',
], Response::HTTP_ACCEPTED);
}
public function dislikeTag(Request $request): JsonResponse
{
$payload = $request->validate([
'tag_id' => ['nullable', 'integer', 'exists:tags,id'],
'tag_slug' => ['nullable', 'string', 'max:191'],
'artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'source' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$tagId = isset($payload['tag_id']) ? (int) $payload['tag_id'] : null;
if ($tagId === null && ! empty($payload['tag_slug'])) {
$tagId = Tag::query()->where('slug', (string) $payload['tag_slug'])->value('id');
}
abort_if($tagId === null || $tagId <= 0, Response::HTTP_UNPROCESSABLE_ENTITY, 'A valid tag is required.');
$signal = UserNegativeSignal::query()->updateOrCreate(
[
'user_id' => (int) $request->user()->id,
'signal_type' => 'dislike_tag',
'tag_id' => $tagId,
],
[
'artwork_id' => null,
'algo_version' => $payload['algo_version'] ?? null,
'source' => $payload['source'] ?? 'api',
'meta' => (array) ($payload['meta'] ?? []),
]
);
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: isset($payload['artwork_id']) ? (int) $payload['artwork_id'] : (int) (($payload['meta']['artwork_id'] ?? 0)),
eventType: 'dislike_tag',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: array_merge((array) ($payload['meta'] ?? []), ['tag_id' => $tagId])
);
return response()->json([
'stored' => true,
'signal_id' => (int) $signal->id,
'signal_type' => 'dislike_tag',
'tag_id' => $tagId,
], Response::HTTP_ACCEPTED);
}
public function unhideArtwork(Request $request): JsonResponse
{
$payload = $request->validate([
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$deleted = UserNegativeSignal::query()
->where('user_id', (int) $request->user()->id)
->where('signal_type', 'hide_artwork')
->where('artwork_id', (int) $payload['artwork_id'])
->delete();
if ($deleted > 0) {
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: (int) $payload['artwork_id'],
eventType: 'unhide_artwork',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: (array) ($payload['meta'] ?? [])
);
}
return response()->json([
'revoked' => $deleted > 0,
'signal_type' => 'hide_artwork',
'artwork_id' => (int) $payload['artwork_id'],
], Response::HTTP_OK);
}
public function undislikeTag(Request $request): JsonResponse
{
$payload = $request->validate([
'tag_id' => ['nullable', 'integer', 'exists:tags,id'],
'tag_slug' => ['nullable', 'string', 'max:191'],
'artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$tagId = isset($payload['tag_id']) ? (int) $payload['tag_id'] : null;
if ($tagId === null && ! empty($payload['tag_slug'])) {
$tagId = Tag::query()->where('slug', (string) $payload['tag_slug'])->value('id');
}
abort_if($tagId === null || $tagId <= 0, Response::HTTP_UNPROCESSABLE_ENTITY, 'A valid tag is required.');
$deleted = UserNegativeSignal::query()
->where('user_id', (int) $request->user()->id)
->where('signal_type', 'dislike_tag')
->where('tag_id', $tagId)
->delete();
if ($deleted > 0) {
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: isset($payload['artwork_id']) ? (int) $payload['artwork_id'] : (int) (($payload['meta']['artwork_id'] ?? 0)),
eventType: 'undo_dislike_tag',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: array_merge((array) ($payload['meta'] ?? []), ['tag_id' => $tagId])
);
}
return response()->json([
'revoked' => $deleted > 0,
'signal_type' => 'dislike_tag',
'tag_id' => $tagId,
], Response::HTTP_OK);
}
/**
* @param array<string, mixed> $meta
*/
private function recordFeedbackEvent(int $userId, int $artworkId, string $eventType, ?string $algoVersion = null, array $meta = []): void
{
if ($artworkId <= 0 || ! Schema::hasTable('user_discovery_events')) {
return;
}
$categoryId = DB::table('artwork_category')
->where('artwork_id', $artworkId)
->orderBy('category_id')
->value('category_id');
DB::table('user_discovery_events')->insert([
'event_id' => (string) Str::uuid(),
'user_id' => $userId,
'artwork_id' => $artworkId,
'category_id' => $categoryId !== null ? (int) $categoryId : null,
'event_type' => $eventType,
'event_version' => (string) config('discovery.event_version', 'event-v1'),
'algo_version' => (string) ($algoVersion ?: config('discovery.v2.algo_version', config('discovery.algo_version', 'clip-cosine-v1'))),
'weight' => 0.0,
'event_date' => now()->toDateString(),
'occurred_at' => now()->toDateTimeString(),
'meta' => json_encode($meta, JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
}
}

View File

@@ -5,13 +5,13 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Recommendations\PersonalizedFeedService;
use App\Services\Recommendations\RecommendationFeedResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class FeedController extends Controller
{
public function __construct(private readonly PersonalizedFeedService $feedService)
public function __construct(private readonly RecommendationFeedResolver $feedResolver)
{
}
@@ -23,7 +23,7 @@ final class FeedController extends Controller
'algo_version' => ['nullable', 'string', 'max:64'],
]);
$result = $this->feedService->getFeed(
$result = $this->feedResolver->getFeed(
userId: (int) $request->user()->id,
limit: isset($payload['limit']) ? (int) $payload['limit'] : 24,
cursor: isset($payload['cursor']) ? (string) $payload['cursor'] : null,

View File

@@ -44,6 +44,8 @@ final class FollowController extends Controller
return response()->json([
'following' => true,
'followers_count' => $this->followService->followersCount((int) $target->id),
'following_count' => $this->followService->followingCount((int) $actor->id),
'context' => $this->followService->relationshipContext((int) $actor->id, (int) $target->id),
]);
}
@@ -59,6 +61,8 @@ final class FollowController extends Controller
return response()->json([
'following' => false,
'followers_count' => $this->followService->followersCount((int) $target->id),
'following_count' => $this->followService->followingCount((int) $actor->id),
'context' => $this->followService->relationshipContext((int) $actor->id, (int) $target->id),
]);
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Vision\VectorService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
final class ImageSearchController extends Controller
{
public function __construct(private readonly VectorService $vectors)
{
}
public function __invoke(Request $request): JsonResponse
{
$payload = $request->validate([
'image' => ['required', 'file', 'image', 'max:10240'],
'limit' => ['nullable', 'integer', 'min:1', 'max:24'],
]);
if (! $this->vectors->isConfigured()) {
return response()->json([
'data' => [],
'reason' => 'vector_gateway_not_configured',
], 503);
}
$limit = (int) ($payload['limit'] ?? 12);
try {
$items = $this->vectors->searchByUploadedImage($payload['image'], $limit);
} catch (RuntimeException $e) {
return response()->json([
'data' => [],
'reason' => 'vector_gateway_error',
'message' => $e->getMessage(),
], 502);
}
return response()->json([
'data' => $items,
'meta' => [
'source' => 'vector_gateway',
'limit' => $limit,
],
]);
}
}

View File

@@ -18,6 +18,7 @@ use App\Services\Messaging\ConversationStateService;
use App\Services\Messaging\MessagingPayloadFactory;
use App\Services\Messaging\MessageSearchIndexer;
use App\Services\Messaging\SendMessageAction;
use App\Services\Messaging\UnreadCounterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -31,6 +32,7 @@ class MessageController extends Controller
private readonly ConversationStateService $conversationState,
private readonly MessagingPayloadFactory $payloadFactory,
private readonly SendMessageAction $sendMessage,
private readonly UnreadCounterService $unreadCounters,
) {}
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
@@ -80,6 +82,10 @@ class MessageController extends Controller
return response()->json([
'data' => $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterMessageId),
'conversation' => $this->payloadFactory->conversationSummary($conversation->fresh(), (int) $request->user()->id),
'summary' => [
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
],
]);
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardAiAssistService;
use App\Services\NovaCards\NovaCardPresenter;
use App\Services\NovaCards\NovaCardRelatedCardsService;
use App\Services\NovaCards\NovaCardRisingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NovaCardDiscoveryController extends Controller
{
public function __construct(
private readonly NovaCardPresenter $presenter,
private readonly NovaCardRisingService $rising,
private readonly NovaCardRelatedCardsService $related,
private readonly NovaCardAiAssistService $aiAssist,
) {
}
/**
* GET /api/cards/rising
* Returns recently published cards gaining traction fast.
*/
public function rising(Request $request): JsonResponse
{
$limit = min((int) $request->query('limit', 18), 36);
$cards = $this->rising->risingCards($limit);
return response()->json([
'data' => $this->presenter->cards($cards->all(), false, $request->user()),
]);
}
/**
* GET /api/cards/{id}/related
* Returns related cards for a given card.
*/
public function related(Request $request, int $id): JsonResponse
{
$card = NovaCard::query()->publiclyVisible()->findOrFail($id);
$limit = min((int) $request->query('limit', 8), 16);
$relatedCards = $this->related->related($card, $limit);
return response()->json([
'data' => $this->presenter->cards($relatedCards->all(), false, $request->user()),
]);
}
/**
* GET /api/cards/{id}/ai-suggest
* Returns AI-assist suggestions for the given draft card.
* The creator must own the card.
*/
public function suggest(Request $request, int $id): JsonResponse
{
$card = NovaCard::query()
->where('user_id', $request->user()->id)
->findOrFail($id);
$suggestions = $this->aiAssist->allSuggestions($card);
return response()->json([
'data' => $suggestions,
]);
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Events\NovaCards\NovaCardBackgroundUploaded;
use App\Events\NovaCards\NovaCardPublished;
use App\Http\Controllers\Controller;
use App\Http\Requests\NovaCards\SaveNovaCardDraftRequest;
use App\Http\Requests\NovaCards\StoreNovaCardDraftRequest;
use App\Http\Requests\NovaCards\UploadNovaCardBackgroundRequest;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardBackgroundService;
use App\Services\NovaCards\NovaCardDraftService;
use App\Services\NovaCards\NovaCardPresenter;
use App\Services\NovaCards\NovaCardPublishService;
use App\Services\NovaCards\NovaCardRenderService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NovaCardDraftController extends Controller
{
public function __construct(
private readonly NovaCardDraftService $drafts,
private readonly NovaCardBackgroundService $backgrounds,
private readonly NovaCardRenderService $renders,
private readonly NovaCardPublishService $publishes,
private readonly NovaCardPresenter $presenter,
) {
}
public function store(StoreNovaCardDraftRequest $request): JsonResponse
{
$card = $this->drafts->createDraft($request->user(), $request->validated());
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
], Response::HTTP_CREATED);
}
public function show(Request $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
public function update(SaveNovaCardDraftRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$card = $this->drafts->autosave($card, $request->validated());
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
public function autosave(SaveNovaCardDraftRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$card = $this->drafts->autosave($card, $request->validated());
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
'meta' => [
'saved_at' => now()->toISOString(),
],
]);
}
public function background(UploadNovaCardBackgroundRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$background = $this->backgrounds->storeUploadedBackground($request->user(), $request->file('background'));
$card = $this->drafts->autosave($card, [
'background_type' => 'upload',
'background_image_id' => $background->id,
'project_json' => [
'background' => [
'type' => 'upload',
'background_image_id' => $background->id,
],
],
]);
event(new NovaCardBackgroundUploaded($card, $background));
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
'background' => [
'id' => (int) $background->id,
'processed_url' => $background->processedUrl(),
'width' => (int) $background->width,
'height' => (int) $background->height,
],
], Response::HTTP_CREATED);
}
public function render(Request $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$result = $this->renders->render($card->loadMissing('backgroundImage'));
return response()->json([
'data' => $this->presenter->card($card->fresh()->loadMissing(['user.profile', 'category', 'template', 'backgroundImage', 'tags']), true, $request->user()),
'render' => $result,
]);
}
public function publish(SaveNovaCardDraftRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
if ($request->validated() !== []) {
$card = $this->drafts->autosave($card, $request->validated());
}
if (trim((string) $card->title) === '' || trim((string) $card->quote_text) === '') {
return response()->json([
'message' => 'Title and quote text are required before publishing.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$card = $this->publishes->publishNow($card->loadMissing('backgroundImage'));
event(new NovaCardPublished($card));
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
public function destroy(Request $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
if ($card->status === NovaCard::STATUS_PUBLISHED && in_array($card->visibility, [NovaCard::VISIBILITY_PUBLIC, NovaCard::VISIBILITY_UNLISTED], true)) {
return response()->json([
'message' => 'Published cards cannot be deleted from the draft API.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$card->delete();
return response()->json([
'ok' => true,
]);
}
private function editableCard(Request $request, int $id): NovaCard
{
return NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
->where('user_id', $request->user()->id)
->findOrFail($id);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Jobs\UpdateNovaCardStatsJob;
use App\Events\NovaCards\NovaCardDownloaded;
use App\Events\NovaCards\NovaCardShared;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NovaCardEngagementController extends Controller
{
public function share(Request $request, int $id): JsonResponse
{
$card = $this->card($request, $id);
$card->increment('shares_count');
$card->refresh();
UpdateNovaCardStatsJob::dispatch($card->id);
event(new NovaCardShared($card, $request->user()?->id));
return response()->json([
'ok' => true,
'shares_count' => (int) $card->shares_count,
]);
}
public function download(Request $request, int $id): JsonResponse
{
$card = $this->card($request, $id);
abort_unless($card->allow_download && $card->previewUrl() !== null, 404);
$card->increment('downloads_count');
$card->refresh();
UpdateNovaCardStatsJob::dispatch($card->id);
event(new NovaCardDownloaded($card, $request->user()?->id));
return response()->json([
'ok' => true,
'downloads_count' => (int) $card->downloads_count,
'download_url' => $card->previewUrl(),
]);
}
private function card(Request $request, int $id): NovaCard
{
$card = NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
->published()
->findOrFail($id);
abort_unless($card->canBeViewedBy($request->user()), 404);
return $card;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Models\NovaCardExport;
use App\Services\NovaCards\NovaCardExportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NovaCardExportController extends Controller
{
public function __construct(
private readonly NovaCardExportService $exports,
) {
}
/**
* Request an export for the given card.
*
* POST /api/cards/{id}/export
*/
public function store(Request $request, int $id): JsonResponse
{
$card = NovaCard::query()
->where(function ($q) use ($request): void {
// Owner can export any status; others can only export published cards.
$q->where('user_id', $request->user()->id)
->orWhere(function ($inner) use ($request): void {
$inner->where('status', NovaCard::STATUS_PUBLISHED)
->where('visibility', NovaCard::VISIBILITY_PUBLIC)
->where('allow_export', true);
});
})
->findOrFail($id);
$data = $request->validate([
'export_type' => ['required', 'string', 'in:' . implode(',', array_keys(NovaCardExportService::EXPORT_SPECS))],
'options' => ['sometimes', 'array'],
]);
$export = $this->exports->requestExport(
$request->user(),
$card,
$data['export_type'],
(array) ($data['options'] ?? []),
);
return response()->json([
'data' => $this->exports->getStatus($export),
], $export->wasRecentlyCreated ? Response::HTTP_ACCEPTED : Response::HTTP_OK);
}
/**
* Poll export status.
*
* GET /api/cards/exports/{exportId}
*/
public function show(Request $request, int $exportId): JsonResponse
{
$export = NovaCardExport::query()
->where('user_id', $request->user()->id)
->findOrFail($exportId);
return response()->json([
'data' => $this->exports->getStatus($export),
]);
}
}

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Jobs\UpdateNovaCardStatsJob;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardReaction;
use App\Models\NovaCardVersion;
use App\Services\NovaCards\NovaCardChallengeService;
use App\Services\NovaCards\NovaCardCollectionService;
use App\Services\NovaCards\NovaCardDraftService;
use App\Services\NovaCards\NovaCardLineageService;
use App\Services\NovaCards\NovaCardPresenter;
use App\Services\NovaCards\NovaCardReactionService;
use App\Services\NovaCards\NovaCardVersionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NovaCardInteractionController extends Controller
{
public function __construct(
private readonly NovaCardReactionService $reactions,
private readonly NovaCardCollectionService $collections,
private readonly NovaCardDraftService $drafts,
private readonly NovaCardVersionService $versions,
private readonly NovaCardChallengeService $challenges,
private readonly NovaCardLineageService $lineage,
private readonly NovaCardPresenter $presenter,
) {
}
public function lineage(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
return response()->json([
'data' => $this->lineage->resolve($card, $request->user()),
]);
}
public function like(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_LIKE, true);
return response()->json(['ok' => true, ...$state]);
}
public function unlike(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_LIKE, false);
return response()->json(['ok' => true, ...$state]);
}
public function favorite(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_FAVORITE, true);
return response()->json(['ok' => true, ...$state]);
}
public function unfavorite(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_FAVORITE, false);
return response()->json(['ok' => true, ...$state]);
}
public function collections(Request $request): JsonResponse
{
return response()->json([
'data' => $this->collections->listCollections($request->user()),
]);
}
public function storeCollection(Request $request): JsonResponse
{
$payload = $request->validate([
'name' => ['required', 'string', 'min:2', 'max:120'],
'slug' => ['nullable', 'string', 'max:140'],
'description' => ['nullable', 'string', 'max:500'],
'visibility' => ['nullable', 'in:private,public'],
]);
$collection = $this->collections->createCollection($request->user(), $payload);
return response()->json([
'collection' => [
'id' => (int) $collection->id,
'slug' => (string) $collection->slug,
'name' => (string) $collection->name,
'description' => $collection->description,
'visibility' => (string) $collection->visibility,
'cards_count' => (int) $collection->cards_count,
],
], Response::HTTP_CREATED);
}
public function updateCollection(Request $request, int $id): JsonResponse
{
$collection = $this->ownedCollection($request, $id);
$payload = $request->validate([
'name' => ['required', 'string', 'min:2', 'max:120'],
'slug' => ['nullable', 'string', 'max:140'],
'description' => ['nullable', 'string', 'max:500'],
'visibility' => ['nullable', 'in:private,public'],
]);
$collection->update([
'name' => $payload['name'],
'slug' => $payload['slug'] ?: $collection->slug,
'description' => $payload['description'] ?? null,
'visibility' => $payload['visibility'] ?? $collection->visibility,
]);
return response()->json([
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
]);
}
public function storeCollectionItem(Request $request, int $id): JsonResponse
{
$collection = $this->ownedCollection($request, $id);
$payload = $request->validate([
'card_id' => ['required', 'integer'],
'note' => ['nullable', 'string', 'max:500'],
'sort_order' => ['nullable', 'integer', 'min:0'],
]);
$card = $this->visibleCard($request, (int) $payload['card_id']);
$this->collections->addCardToCollection($collection, $card, $payload['note'] ?? null, $payload['sort_order'] ?? null);
return response()->json([
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
], Response::HTTP_CREATED);
}
public function destroyCollectionItem(Request $request, int $id, int $cardId): JsonResponse
{
$collection = $this->ownedCollection($request, $id);
$card = NovaCard::query()->findOrFail($cardId);
$this->collections->removeCardFromCollection($collection, $card);
return response()->json([
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
]);
}
public function challenges(Request $request): JsonResponse
{
return response()->json([
'data' => $this->presenter->options()['challenge_feed'] ?? [],
]);
}
public function assets(Request $request): JsonResponse
{
return response()->json([
'data' => $this->presenter->options()['asset_packs'] ?? [],
]);
}
public function templates(Request $request): JsonResponse
{
return response()->json([
'packs' => $this->presenter->options()['template_packs'] ?? [],
'templates' => $this->presenter->options()['templates'] ?? [],
]);
}
public function save(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$payload = $request->validate([
'collection_id' => ['nullable', 'integer'],
'note' => ['nullable', 'string', 'max:500'],
]);
$collection = $this->collections->saveCard($request->user(), $card, $payload['collection_id'] ?? null, $payload['note'] ?? null);
return response()->json([
'ok' => true,
'saves_count' => (int) $card->fresh()->saves_count,
'collection' => [
'id' => (int) $collection->id,
'name' => (string) $collection->name,
'slug' => (string) $collection->slug,
],
]);
}
public function unsave(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$payload = $request->validate([
'collection_id' => ['nullable', 'integer'],
]);
$this->collections->unsaveCard($request->user(), $card, $payload['collection_id'] ?? null);
return response()->json([
'ok' => true,
'saves_count' => (int) $card->fresh()->saves_count,
]);
}
public function remix(Request $request, int $id): JsonResponse
{
$source = $this->visibleCard($request, $id);
abort_unless($source->allow_remix, 422, 'This card does not allow remixes.');
$card = $this->drafts->createRemix($request->user(), $source);
$source->increment('remixes_count');
UpdateNovaCardStatsJob::dispatch($source->id);
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
], Response::HTTP_CREATED);
}
public function duplicate(Request $request, int $id): JsonResponse
{
$source = $this->ownedCard($request, $id);
$card = $this->drafts->createDuplicate($request->user(), $source->loadMissing('tags'));
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
], Response::HTTP_CREATED);
}
public function versions(Request $request, int $id): JsonResponse
{
$card = $this->ownedCard($request, $id);
$versions = $card->versions()
->latest('version_number')
->get()
->map(fn (NovaCardVersion $version): array => [
'id' => (int) $version->id,
'version_number' => (int) $version->version_number,
'label' => $version->label,
'created_at' => $version->created_at?->toISOString(),
'snapshot_json' => is_array($version->snapshot_json) ? $version->snapshot_json : [],
])
->values()
->all();
return response()->json(['data' => $versions]);
}
public function restoreVersion(Request $request, int $id, int $versionId): JsonResponse
{
$card = $this->ownedCard($request, $id);
$version = $card->versions()->findOrFail($versionId);
$card = $this->versions->restore($card, $version, $request->user());
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
public function submitChallenge(Request $request, int $challengeId, int $id): JsonResponse
{
$payload = $request->validate([
'note' => ['nullable', 'string', 'max:500'],
]);
return $this->submitChallengeWithPayload($request, $challengeId, $id, $payload['note'] ?? null);
}
public function submitChallengeByChallenge(Request $request, int $id): JsonResponse
{
$payload = $request->validate([
'card_id' => ['required', 'integer'],
'note' => ['nullable', 'string', 'max:500'],
]);
return $this->submitChallengeWithPayload($request, $id, (int) $payload['card_id'], $payload['note'] ?? null);
}
private function submitChallengeWithPayload(Request $request, int $challengeId, int $cardId, ?string $note = null): JsonResponse
{
$card = $this->ownedCard($request, $cardId);
abort_unless($card->status === NovaCard::STATUS_PUBLISHED, 422, 'Publish the card before entering a challenge.');
$challenge = NovaCardChallenge::query()
->where('status', NovaCardChallenge::STATUS_ACTIVE)
->findOrFail($challengeId);
$entry = $this->challenges->submit($request->user(), $challenge, $card, $note);
UpdateNovaCardStatsJob::dispatch($card->id);
return response()->json([
'entry' => [
'id' => (int) $entry->id,
'challenge_id' => (int) $entry->challenge_id,
'card_id' => (int) $entry->card_id,
'status' => (string) $entry->status,
],
'challenge_entries_count' => (int) $card->fresh()->challenge_entries_count,
]);
}
private function visibleCard(Request $request, int $id): NovaCard
{
$card = NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard', 'rootCard'])
->published()
->findOrFail($id);
abort_unless($card->canBeViewedBy($request->user()), 404);
return $card;
}
private function ownedCard(Request $request, int $id): NovaCard
{
return NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'versions'])
->where('user_id', $request->user()->id)
->findOrFail($id);
}
private function ownedCollection(Request $request, int $id): \App\Models\NovaCardCollection
{
return \App\Models\NovaCardCollection::query()
->where('user_id', $request->user()->id)
->findOrFail($id);
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Models\NovaCardCreatorPreset;
use App\Services\NovaCards\NovaCardCreatorPresetService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NovaCardPresetController extends Controller
{
public function __construct(
private readonly NovaCardCreatorPresetService $presets,
) {
}
public function index(Request $request): JsonResponse
{
$type = $request->query('type');
$items = $this->presets->listForUser(
$request->user(),
is_string($type) && in_array($type, NovaCardCreatorPreset::TYPES, true) ? $type : null,
);
return response()->json([
'data' => $items->map(fn ($p) => $this->presets->toArray($p))->values()->all(),
]);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'preset_type' => ['required', 'string', 'in:' . implode(',', NovaCardCreatorPreset::TYPES)],
'config_json' => ['required', 'array'],
'is_default' => ['sometimes', 'boolean'],
]);
$preset = $this->presets->create($request->user(), $data);
return response()->json([
'data' => $this->presets->toArray($preset),
], Response::HTTP_CREATED);
}
public function update(Request $request, int $id): JsonResponse
{
$preset = NovaCardCreatorPreset::query()
->where('user_id', $request->user()->id)
->findOrFail($id);
$data = $request->validate([
'name' => ['sometimes', 'string', 'max:120'],
'config_json' => ['sometimes', 'array'],
'is_default' => ['sometimes', 'boolean'],
]);
$preset = $this->presets->update($request->user(), $preset, $data);
return response()->json([
'data' => $this->presets->toArray($preset),
]);
}
public function destroy(Request $request, int $id): JsonResponse
{
$preset = NovaCardCreatorPreset::query()->findOrFail($id);
$this->presets->delete($request->user(), $preset);
return response()->json([
'ok' => true,
]);
}
/**
* Capture a preset from an existing published card.
*/
public function captureFromCard(Request $request, int $cardId): JsonResponse
{
$card = NovaCard::query()
->where('user_id', $request->user()->id)
->findOrFail($cardId);
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'preset_type' => ['required', 'string', 'in:' . implode(',', NovaCardCreatorPreset::TYPES)],
]);
$preset = $this->presets->captureFromCard(
$request->user(),
$card,
$data['name'],
$data['preset_type'],
);
return response()->json([
'data' => $this->presets->toArray($preset),
], Response::HTTP_CREATED);
}
/**
* Apply a saved preset to a draft card, returning a project_json patch.
*/
public function applyToCard(Request $request, int $presetId, int $cardId): JsonResponse
{
$preset = NovaCardCreatorPreset::query()
->where('user_id', $request->user()->id)
->findOrFail($presetId);
$card = NovaCard::query()
->where('user_id', $request->user()->id)
->whereIn('status', [NovaCard::STATUS_DRAFT, NovaCard::STATUS_PUBLISHED])
->findOrFail($cardId);
$currentProject = is_array($card->project_json) ? $card->project_json : [];
$patch = $this->presets->applyToProjectPatch($preset, $currentProject);
return response()->json([
'data' => [
'preset' => $this->presets->toArray($preset),
'project_patch' => $patch,
],
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Activity\UserActivityService;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Models\User;
final class ProfileActivityController extends Controller
{
public function __construct(private readonly UserActivityService $activities) {}
public function __invoke(Request $request, string $username): JsonResponse
{
$normalized = UsernamePolicy::normalize($username);
$user = User::query()
->with('profile:user_id,avatar_hash')
->whereRaw('LOWER(username) = ?', [$normalized])
->where('is_active', true)
->whereNull('deleted_at')
->firstOrFail();
return response()->json(
$this->activities->feedForUser(
$user,
(string) $request->query('filter', 'all'),
(int) $request->query('page', 1),
(int) $request->query('per_page', UserActivityService::DEFAULT_PER_PAGE),
)
);
}
}

View File

@@ -3,22 +3,22 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\Report;
use App\Models\Story;
use App\Models\User;
use App\Support\Moderation\ReportTargetResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class ReportController extends Controller
{
public function __construct(private readonly ReportTargetResolver $targets) {}
public function store(Request $request): JsonResponse
{
$user = $request->user();
$data = $request->validate([
'target_type' => 'required|in:message,conversation,user,story',
'target_type' => ['required', Rule::in($this->targets->supportedTargetTypes())],
'target_id' => 'required|integer|min:1',
'reason' => 'required|string|max:120',
'details' => 'nullable|string|max:4000',
@@ -27,32 +27,7 @@ class ReportController extends Controller
$targetType = $data['target_type'];
$targetId = (int) $data['target_id'];
if ($targetType === 'message') {
$message = Message::query()->findOrFail($targetId);
$allowed = ConversationParticipant::query()
->where('conversation_id', $message->conversation_id)
->where('user_id', $user->id)
->whereNull('left_at')
->exists();
abort_unless($allowed, 403, 'You are not allowed to report this message.');
}
if ($targetType === 'conversation') {
$allowed = ConversationParticipant::query()
->where('conversation_id', $targetId)
->where('user_id', $user->id)
->whereNull('left_at')
->exists();
abort_unless($allowed, 403, 'You are not allowed to report this conversation.');
}
if ($targetType === 'user') {
User::query()->findOrFail($targetId);
}
if ($targetType === 'story') {
Story::query()->findOrFail($targetId);
}
$this->targets->validateForReporter($user, $targetType, $targetId);
$report = Report::query()->create([
'reporter_id' => $user->id,

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\Vision\VectorService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
final class SimilarAiArtworksController extends Controller
{
public function __construct(private readonly VectorService $vectors)
{
}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::query()->public()->published()->find($id);
if ($artwork === null) {
return response()->json(['error' => 'Artwork not found'], 404);
}
if (! $this->vectors->isConfigured()) {
return response()->json([
'data' => [],
'reason' => 'vector_gateway_not_configured',
], 503);
}
$limit = max(1, min(24, (int) $request->query('limit', 12)));
try {
$items = $this->vectors->similarToArtwork($artwork, $limit);
} catch (RuntimeException $e) {
return response()->json([
'data' => [],
'reason' => 'vector_gateway_error',
'message' => $e->getMessage(),
], 502);
}
return response()->json([
'data' => $items,
'meta' => [
'source' => 'vector_gateway',
'artwork_id' => $artwork->id,
'limit' => $limit,
],
]);
}
}

View File

@@ -22,11 +22,14 @@ final class TagController extends Controller
// Short results cached for 2 min; empty-query (popular suggestions) for 5 min.
$ttl = $q === '' ? 300 : 120;
$cacheKey = 'tags.search.v2.' . ($q === '' ? '__empty__' : md5($q));
$legacyCacheKey = 'tags.search.' . ($q === '' ? '__empty__' : md5($q));
$data = Cache::remember($cacheKey, $ttl, function () use ($q): mixed {
return $this->tagDiscoveryService->searchSuggestions($q, 20);
});
Cache::put($legacyCacheKey, $data, $ttl);
return response()->json(['data' => $data]);
}
@@ -34,11 +37,14 @@ final class TagController extends Controller
{
$limit = (int) ($request->validated()['limit'] ?? 20);
$cacheKey = 'tags.popular.v2.' . $limit;
$legacyCacheKey = 'tags.popular.' . $limit;
$data = Cache::remember($cacheKey, 300, function () use ($limit): mixed {
return $this->tagDiscoveryService->popularTags($limit);
});
Cache::put($legacyCacheKey, $data, 300);
return response()->json(['data' => $data]);
}
}

View File

@@ -31,6 +31,7 @@ use App\Services\Upload\Contracts\UploadDraftServiceInterface;
use Carbon\Carbon;
use App\Uploads\Jobs\VirusScanJob;
use App\Uploads\Services\PublishService;
use App\Services\Activity\UserActivityService;
use App\Uploads\Exceptions\UploadNotFoundException;
use App\Uploads\Exceptions\UploadOwnershipException;
use App\Uploads\Exceptions\UploadPublishValidationException;
@@ -490,6 +491,8 @@ final class UploadController extends Controller
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
// Scheduled-publishing fields
'mode' => ['nullable', 'string', 'in:now,schedule'],
'publish_at' => ['nullable', 'string', 'date'],
@@ -548,6 +551,9 @@ final class UploadController extends Controller
if (array_key_exists('description', $validated)) {
$artwork->description = $validated['description'];
}
if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) {
$artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false);
}
$artwork->slug = $slug;
$artwork->artwork_timezone = $validated['timezone'] ?? null;
@@ -572,6 +578,7 @@ final class UploadController extends Controller
if ($mode === 'schedule' && $publishAt) {
// Scheduled: store publish_at but don't make public yet
$artwork->visibility = $visibility;
$artwork->is_public = false;
$artwork->is_approved = true;
$artwork->publish_at = $publishAt;
@@ -599,6 +606,7 @@ final class UploadController extends Controller
}
// Publish immediately
$artwork->visibility = $visibility;
$artwork->is_public = ($visibility !== 'private');
$artwork->is_approved = true;
$artwork->published_at = now();
@@ -629,6 +637,10 @@ final class UploadController extends Controller
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logUpload((int) $user->id, (int) $artwork->id);
} catch (\Throwable) {}
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,

View File

@@ -7,11 +7,9 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\TagNormalizer;
use App\Services\Vision\VisionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Synchronous Vision tag suggestions for the upload wizard.
@@ -26,178 +24,21 @@ use Illuminate\Support\Str;
final class UploadVisionSuggestController extends Controller
{
public function __construct(
private readonly VisionService $vision,
private readonly TagNormalizer $normalizer,
) {}
public function __invoke(int $id, Request $request): JsonResponse
{
if (! (bool) config('vision.enabled', true)) {
if (! $this->vision->isEnabled()) {
return response()->json(['tags' => [], 'vision_enabled' => false]);
}
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound($request->user(), $artwork);
$limit = (int) $request->query('limit', 10);
$imageUrl = $this->buildImageUrl((string) $artwork->hash);
if ($imageUrl === null) {
return response()->json([
'tags' => [],
'vision_enabled' => true,
'reason' => 'image_url_unavailable',
]);
}
$gatewayBase = trim((string) config('vision.gateway.base_url', config('vision.clip.base_url', '')));
if ($gatewayBase === '') {
return response()->json([
'tags' => [],
'vision_enabled' => true,
'reason' => 'gateway_not_configured',
]);
}
$url = rtrim($gatewayBase, '/') . '/analyze/all';
$limit = min(20, max(5, (int) ($request->query('limit', 10))));
$timeout = (int) config('vision.gateway.timeout_seconds', 10);
$cTimeout = (int) config('vision.gateway.connect_timeout_seconds', 3);
$ref = (string) Str::uuid();
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = Http::acceptJson()
->connectTimeout(max(1, $cTimeout))
->timeout(max(1, $timeout))
->withHeaders(['X-Request-ID' => $ref])
->post($url, [
'url' => $imageUrl,
'limit' => $limit,
]);
if (! $response->ok()) {
Log::warning('vision-suggest: non-ok response', [
'ref' => $ref,
'artwork_id' => $id,
'status' => $response->status(),
'body' => Str::limit($response->body(), 400),
]);
return response()->json([
'tags' => [],
'vision_enabled' => true,
'reason' => 'gateway_error_' . $response->status(),
]);
}
$tags = $this->parseGatewayResponse($response->json());
return response()->json([
'tags' => $tags,
'vision_enabled' => true,
'source' => 'gateway_sync',
]);
} catch (\Throwable $e) {
Log::warning('vision-suggest: request failed', [
'ref' => $ref,
'artwork_id' => $id,
'error' => $e->getMessage(),
]);
return response()->json([
'tags' => [],
'vision_enabled' => true,
'reason' => 'gateway_exception',
]);
}
}
// ── helpers ──────────────────────────────────────────────────────────────
private function buildImageUrl(string $hash): ?string
{
$base = rtrim((string) config('cdn.files_url', ''), '/');
if ($base === '') {
return null;
}
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
$clean = str_pad($clean, 6, '0');
$seg = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
$variant = (string) config('vision.image_variant', 'md');
return $base . '/img/' . implode('/', $seg) . '/' . $variant . '.webp';
}
/**
* Parse the /analyze/all gateway response.
*
* The gateway returns a unified object:
* { clip: [{tag, confidence}], blip: ["caption1"], yolo: [{tag, confidence}] }
* or a flat list of tags directly.
*
* @param mixed $json
* @return array<int, array{name: string, slug: string, confidence: float|null, source: string, is_ai: true}>
*/
private function parseGatewayResponse(mixed $json): array
{
$raw = [];
if (! is_array($json)) {
return [];
}
// Unified gateway response
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'];
}
}
// Flat lists
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'];
}
}
}
}
// Deduplicate by slug, keep highest confidence
$bySlug = [];
foreach ($raw as $r) {
$slug = $this->normalizer->normalize((string) ($r['tag'] ?? ''));
if ($slug === '') {
continue;
}
$conf = isset($r['confidence']) && is_numeric($r['confidence']) ? (float) $r['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' => $r['source'] ?? 'vision',
'is_ai' => true,
];
}
}
// Sort by confidence desc
$sorted = array_values($bySlug);
usort($sorted, static fn ($a, $b) => ($b['confidence'] ?? 0) <=> ($a['confidence'] ?? 0));
return $sorted;
return response()->json($this->vision->suggestTags($artwork, $this->normalizer, $limit));
}
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\UserSuggestionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UserSuggestionsController extends Controller
{
public function __construct(private readonly UserSuggestionService $suggestions)
{
}
public function __invoke(Request $request): JsonResponse
{
$limit = min((int) $request->query('limit', 8), 24);
return response()->json([
'data' => $this->suggestions->suggestFor($request->user(), $limit),
]);
}
}

View File

@@ -37,7 +37,7 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate();
return redirect()->intended('/');
return redirect()->intended(route('dashboard'));
}
/**

View File

@@ -9,6 +9,7 @@ use App\Models\User;
use App\Services\Auth\DisposableEmailService;
use App\Services\Auth\RegistrationVerificationTokenService;
use App\Services\Security\CaptchaVerifier;
use App\Services\Security\TurnstileVerifier;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@@ -20,6 +21,7 @@ class RegisteredUserController extends Controller
{
public function __construct(
private readonly CaptchaVerifier $captchaVerifier,
private readonly TurnstileVerifier $turnstileVerifier,
private readonly DisposableEmailService $disposableEmailService,
private readonly RegistrationVerificationTokenService $verificationTokenService,
)
@@ -75,6 +77,13 @@ class RegisteredUserController extends Controller
$ip
);
if ($this->turnstileVerifier->isEnabled()) {
$verified = $this->turnstileVerifier->verify(
(string) $request->input($this->captchaVerifier->inputName(), ''),
$ip
);
}
if (! $verified) {
return back()
->withInput($request->except('website'))
@@ -204,6 +213,23 @@ class RegisteredUserController extends Controller
private function shouldRequireCaptcha(?string $ip): bool
{
if (! $this->captchaVerifier->isEnabled()) {
if (! $this->turnstileVerifier->isEnabled()) {
return false;
}
if (! (bool) config('registration.enable_turnstile', true)) {
return false;
}
return $this->turnstileVerifier->isEnabled() && $this->shouldRequireCaptchaForIp($ip);
}
return $this->shouldRequireCaptchaForIp($ip);
}
private function shouldRequireCaptchaForIp(?string $ip): bool
{
if (! $this->captchaVerifier->isEnabled() && ! $this->turnstileVerifier->isEnabled()) {
return false;
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Collection;
use App\Models\CollectionMember;
use App\Models\User;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CollectionCollaborationController extends Controller
{
public function __construct(
private readonly CollectionCollaborationService $collaborators,
private readonly CollectionService $collections,
) {
}
public function store(Request $request, Collection $collection): JsonResponse
{
$this->authorize('manageMembers', $collection);
$data = $request->validate([
'username' => ['required', 'string', 'min:3', 'max:20'],
'role' => ['required', 'in:editor,contributor,viewer'],
'note' => ['nullable', 'string', 'max:320'],
'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:90'],
'expires_at' => ['nullable', 'date', 'after:now'],
]);
$invitee = User::query()
->whereRaw('LOWER(username) = ?', [strtolower((string) $data['username'])])
->firstOrFail();
$member = $this->collaborators->inviteMember(
$collection,
$request->user(),
$invitee,
(string) $data['role'],
$data['note'] ?? null,
isset($data['expires_in_days']) ? (int) $data['expires_in_days'] : null,
$data['expires_at'] ?? null,
);
return response()->json([
'ok' => true,
'member' => $member,
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
public function transfer(Request $request, Collection $collection, CollectionMember $member): JsonResponse
{
$collection = $this->collaborators->transferOwnership($collection, $member, $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
public function update(Request $request, Collection $collection, CollectionMember $member): JsonResponse
{
$this->authorize('manageMembers', $collection);
abort_unless((int) $member->collection_id === (int) $collection->id, 404);
$data = $request->validate([
'role' => ['required', 'in:editor,contributor,viewer'],
]);
$this->collaborators->updateMemberRole($member, $request->user(), (string) $data['role']);
return response()->json([
'ok' => true,
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
public function destroy(Request $request, Collection $collection, CollectionMember $member): JsonResponse
{
$this->authorize('manageMembers', $collection);
abort_unless((int) $member->collection_id === (int) $collection->id, 404);
$this->collaborators->revokeMember($member, $request->user());
return response()->json([
'ok' => true,
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
public function accept(Request $request, CollectionMember $member): JsonResponse
{
$collection = $member->collection;
$this->collaborators->acceptInvite($member, $request->user());
return response()->json([
'ok' => true,
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
public function decline(Request $request, CollectionMember $member): JsonResponse
{
$collection = $member->collection;
$this->collaborators->declineInvite($member, $request->user());
return response()->json([
'ok' => true,
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Collection;
use App\Models\CollectionComment;
use App\Services\CollectionCommentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CollectionCommentController extends Controller
{
public function __construct(
private readonly CollectionCommentService $comments,
) {
}
public function index(Request $request, Collection $collection): JsonResponse
{
abort_unless($collection->canBeViewedBy($request->user()), 404);
return response()->json([
'data' => $this->comments->mapComments($collection, $request->user()),
]);
}
public function store(Request $request, Collection $collection): JsonResponse
{
$this->authorize('comment', $collection);
$data = $request->validate([
'body' => ['required', 'string', 'min:2', 'max:4000'],
'parent_id' => ['nullable', 'integer', 'min:1'],
]);
$parent = null;
if (! empty($data['parent_id'])) {
$parent = CollectionComment::query()->findOrFail((int) $data['parent_id']);
abort_unless((int) $parent->collection_id === (int) $collection->id, 404);
}
$this->comments->create($collection->loadMissing('user'), $request->user(), (string) $data['body'], $parent);
return response()->json([
'ok' => true,
'comments' => $this->comments->mapComments($collection, $request->user()),
'comments_count' => (int) $collection->fresh()->comments_count,
]);
}
public function destroy(Request $request, Collection $collection, CollectionComment $comment): JsonResponse
{
abort_unless((int) $comment->collection_id === (int) $collection->id, 404);
$this->comments->delete($comment->load('collection'), $request->user());
return response()->json([
'ok' => true,
'comments' => $this->comments->mapComments($collection, $request->user()),
'comments_count' => (int) $collection->fresh()->comments_count,
]);
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Collection;
use App\Services\CollectionFollowService;
use App\Services\CollectionLikeService;
use App\Services\CollectionSaveService;
use App\Services\CollectionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CollectionEngagementController extends Controller
{
public function __construct(
private readonly CollectionLikeService $likes,
private readonly CollectionFollowService $follows,
private readonly CollectionSaveService $saves,
private readonly CollectionService $collections,
) {
}
public function like(Request $request, Collection $collection): JsonResponse
{
abort_unless($collection->isPubliclyEngageable(), 404);
$this->likes->like($request->user(), $collection);
return response()->json([
'ok' => true,
'liked' => true,
'likes_count' => (int) $collection->fresh()->likes_count,
]);
}
public function unlike(Request $request, Collection $collection): JsonResponse
{
abort_unless($collection->isPubliclyEngageable(), 404);
$this->likes->unlike($request->user(), $collection);
return response()->json([
'ok' => true,
'liked' => false,
'likes_count' => (int) $collection->fresh()->likes_count,
]);
}
public function follow(Request $request, Collection $collection): JsonResponse
{
abort_unless($collection->isPubliclyEngageable(), 404);
$this->follows->follow($request->user(), $collection);
return response()->json([
'ok' => true,
'following' => true,
'followers_count' => (int) $collection->fresh()->followers_count,
]);
}
public function unfollow(Request $request, Collection $collection): JsonResponse
{
abort_unless($collection->isPubliclyEngageable(), 404);
$this->follows->unfollow($request->user(), $collection);
return response()->json([
'ok' => true,
'following' => false,
'followers_count' => (int) $collection->fresh()->followers_count,
]);
}
public function share(Request $request, Collection $collection): JsonResponse
{
abort_unless($collection->isPubliclyEngageable(), 404);
$collection = $this->collections->recordShare($collection, $request->user());
return response()->json([
'ok' => true,
'shares_count' => (int) $collection->shares_count,
]);
}
public function save(Request $request, Collection $collection): JsonResponse
{
$payload = $request->validate([
'context' => ['nullable', 'string', 'max:80'],
'context_meta' => ['nullable', 'array'],
]);
$this->saves->save(
$request->user(),
$collection,
isset($payload['context']) ? (string) $payload['context'] : null,
(array) ($payload['context_meta'] ?? []),
);
return response()->json([
'ok' => true,
'saved' => true,
'saves_count' => (int) $collection->fresh()->saves_count,
]);
}
public function unsave(Request $request, Collection $collection): JsonResponse
{
$this->saves->unsave($request->user(), $collection);
return response()->json([
'ok' => true,
'saved' => false,
'saves_count' => (int) $collection->fresh()->saves_count,
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\CollectionSubmission;
use App\Services\CollectionSubmissionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CollectionSubmissionController extends Controller
{
public function __construct(
private readonly CollectionSubmissionService $submissions,
) {
}
public function store(Request $request, Collection $collection): JsonResponse
{
$this->authorize('submit', $collection);
$data = $request->validate([
'artwork_id' => ['required', 'integer', 'min:1'],
'message' => ['nullable', 'string', 'max:1000'],
]);
$artwork = Artwork::query()->findOrFail((int) $data['artwork_id']);
$submission = $this->submissions->submit($collection, $request->user(), $artwork, $data['message'] ?? null);
return response()->json([
'ok' => true,
'submission' => $submission,
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
]);
}
public function approve(Request $request, CollectionSubmission $submission): JsonResponse
{
$submission = $this->submissions->approve($submission, $request->user());
return response()->json([
'ok' => true,
'submissions' => $this->submissions->mapSubmissions($submission->collection, $request->user()),
]);
}
public function reject(Request $request, CollectionSubmission $submission): JsonResponse
{
$submission = $this->submissions->reject($submission, $request->user());
return response()->json([
'ok' => true,
'submissions' => $this->submissions->mapSubmissions($submission->collection, $request->user()),
]);
}
public function destroy(Request $request, CollectionSubmission $submission): JsonResponse
{
$collection = $submission->collection;
$this->submissions->withdraw($submission, $request->user());
return response()->json([
'ok' => true,
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
]);
}
}

View File

@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ContentType;
use App\Support\AvatarUrl;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -16,10 +18,19 @@ class DashboardGalleryController extends Controller
$perPage = 24;
$query = Artwork::query()
->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',
])
->where('user_id', (int) $user->id)
->orderBy('published_at', 'desc');
$artworks = $query->paginate($perPage)->withQueryString();
$artworks->setCollection(
$artworks->getCollection()->map(fn (Artwork $artwork) => $this->presentArtwork($artwork))
);
$mainCategories = ContentType::orderBy('id')
->get(['name', 'slug'])
@@ -48,4 +59,32 @@ class DashboardGalleryController extends Controller
'page_canonical' => url('/dashboard/gallery'),
]);
}
private function presentArtwork(Artwork $artwork): object
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
return (object) [
'id' => $artwork->id,
'name' => $artwork->title,
'content_type_name' => $primary?->contentType?->name ?? '',
'content_type_slug' => $primary?->contentType?->slug ?? '',
'category_name' => $primary?->name ?? '',
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'avatar_url' => AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
),
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Forum;
use App\Http\Controllers\Controller;
use App\Services\Activity\UserActivityService;
use App\Models\ForumCategory;
use App\Models\ForumPost;
use App\Models\ForumPostReport;
@@ -229,6 +230,12 @@ class ForumController extends Controller
'edited_at' => null,
]);
try {
app(UserActivityService::class)->logForumPost((int) $user->id, (int) $thread->id, [
'category_id' => (int) $category->id,
]);
} catch (\Throwable) {}
return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]);
}
@@ -242,7 +249,7 @@ class ForumController extends Controller
'content' => ['required', 'string', 'min:2'],
]);
ForumPost::create([
$post = ForumPost::create([
'thread_id' => $thread->id,
'user_id' => (int) $user->id,
'content' => $validated['content'],
@@ -250,6 +257,12 @@ class ForumController extends Controller
'edited_at' => null,
]);
try {
app(UserActivityService::class)->logForumReply((int) $user->id, (int) $post->id, [
'thread_id' => (int) $thread->id,
]);
} catch (\Throwable) {}
$thread->last_post_at = now();
$thread->save();

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\NovaCard;
use App\Models\NovaCardComment;
use App\Services\NovaCards\NovaCardCommentService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class NovaCardCommentController extends Controller
{
public function __construct(
private readonly NovaCardCommentService $comments,
) {
}
public function store(Request $request, NovaCard $card): RedirectResponse
{
$this->authorize('comment', $card);
$data = $request->validate([
'body' => ['required', 'string', 'min:2', 'max:4000'],
]);
$this->comments->create($card->loadMissing('user'), $request->user(), (string) $data['body']);
return redirect()->to($card->publicUrl() . '#comments')->with('status', 'Comment posted.');
}
public function destroy(Request $request, NovaCard $card, NovaCardComment $comment): RedirectResponse
{
abort_unless((int) $comment->card_id === (int) $card->id, 404);
$this->comments->delete($comment->load(['card.user']), $request->user());
return redirect()->to($card->publicUrl() . '#comments')->with('status', 'Comment removed.');
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Collection;
use App\Services\CollectionAiCurationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CollectionAiController extends Controller
{
public function __construct(
private readonly CollectionAiCurationService $ai,
) {
}
public function suggestTitle(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestTitle($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function suggestSummary(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestSummary($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function suggestCover(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestCover($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function suggestGrouping(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestGrouping($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function suggestRelatedArtworks(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestRelatedArtworks($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function suggestTags(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestTags($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function suggestSeoDescription(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestSeoDescription($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function explainSmartRules(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->explainSmartRules($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function suggestSplitThemes(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestSplitThemes($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function suggestMergeIdea(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestMergeIdea($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function detectWeakMetadata(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->detectWeakMetadata($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function suggestStaleRefresh(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestStaleRefresh($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function suggestCampaignFit(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestCampaignFit($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
public function suggestRelatedCollectionsToLink(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'suggestion' => $this->ai->suggestRelatedCollectionsToLink($collection->loadMissing('user'), (array) $request->input('draft', [])),
]);
}
}

View File

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Collections\CollectionBulkActionsRequest;
use App\Http\Requests\Collections\CollectionOwnerSearchRequest;
use App\Http\Requests\Collections\CollectionTargetActionRequest;
use App\Http\Requests\Collections\UpdateCollectionWorkflowRequest;
use App\Models\Collection;
use App\Models\CollectionHistory;
use App\Services\CollectionAiOperationsService;
use App\Services\CollectionAnalyticsService;
use App\Services\CollectionBackgroundJobService;
use App\Services\CollectionBulkActionService;
use App\Services\CollectionCanonicalService;
use App\Services\CollectionDashboardService;
use App\Services\CollectionHistoryService;
use App\Services\CollectionHealthService;
use App\Services\CollectionMergeService;
use App\Services\CollectionSearchService;
use App\Services\CollectionService;
use App\Services\CollectionWorkflowService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CollectionInsightsController extends Controller
{
public function __construct(
private readonly CollectionDashboardService $dashboard,
private readonly CollectionAnalyticsService $analytics,
private readonly CollectionHistoryService $history,
private readonly CollectionAiOperationsService $aiOperations,
private readonly CollectionBackgroundJobService $backgroundJobs,
private readonly CollectionBulkActionService $bulkActions,
private readonly CollectionService $collections,
private readonly CollectionSearchService $search,
private readonly CollectionHealthService $health,
private readonly CollectionWorkflowService $workflow,
private readonly CollectionCanonicalService $canonical,
private readonly CollectionMergeService $merge,
) {
}
public function dashboard(Request $request): Response
{
$payload = $this->dashboard->build($request->user());
return Inertia::render('Collection/CollectionDashboard', [
'summary' => $payload['summary'],
'topPerforming' => $this->collections->mapCollectionCardPayloads($payload['top_performing'], true),
'needsAttention' => $this->collections->mapCollectionCardPayloads($payload['needs_attention'], true),
'expiringCampaigns' => $this->collections->mapCollectionCardPayloads($payload['expiring_campaigns'], true),
'healthWarnings' => $payload['health_warnings'],
'filterOptions' => [
'types' => [
Collection::TYPE_PERSONAL,
Collection::TYPE_COMMUNITY,
Collection::TYPE_EDITORIAL,
],
'visibilities' => [
Collection::VISIBILITY_PUBLIC,
Collection::VISIBILITY_UNLISTED,
Collection::VISIBILITY_PRIVATE,
],
'lifecycleStates' => [
Collection::LIFECYCLE_DRAFT,
Collection::LIFECYCLE_SCHEDULED,
Collection::LIFECYCLE_PUBLISHED,
Collection::LIFECYCLE_FEATURED,
Collection::LIFECYCLE_ARCHIVED,
Collection::LIFECYCLE_HIDDEN,
Collection::LIFECYCLE_RESTRICTED,
Collection::LIFECYCLE_UNDER_REVIEW,
Collection::LIFECYCLE_EXPIRED,
],
'workflowStates' => [
Collection::WORKFLOW_DRAFT,
Collection::WORKFLOW_IN_REVIEW,
Collection::WORKFLOW_APPROVED,
Collection::WORKFLOW_PROGRAMMED,
Collection::WORKFLOW_ARCHIVED,
],
'healthStates' => [
Collection::HEALTH_HEALTHY,
Collection::HEALTH_NEEDS_METADATA,
Collection::HEALTH_STALE,
Collection::HEALTH_LOW_CONTENT,
Collection::HEALTH_BROKEN_ITEMS,
Collection::HEALTH_WEAK_COVER,
Collection::HEALTH_LOW_ENGAGEMENT,
Collection::HEALTH_ATTRIBUTION_INCOMPLETE,
Collection::HEALTH_NEEDS_REVIEW,
Collection::HEALTH_DUPLICATE_RISK,
Collection::HEALTH_MERGE_CANDIDATE,
],
],
'endpoints' => [
'managePattern' => route('settings.collections.show', ['collection' => '__COLLECTION__']),
'analyticsPattern' => route('settings.collections.analytics', ['collection' => '__COLLECTION__']),
'historyPattern' => route('settings.collections.history', ['collection' => '__COLLECTION__']),
'healthPattern' => route('settings.collections.health', ['collection' => '__COLLECTION__']),
'search' => route('settings.collections.search'),
'bulkActions' => route('settings.collections.bulk-actions'),
],
'seo' => [
'title' => 'Collections Dashboard — Skinbase Nova',
'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.',
'canonical' => route('settings.collections.dashboard'),
'robots' => 'noindex,follow',
],
])->rootView('collections');
}
public function analytics(Request $request, Collection $collection): Response
{
$this->authorize('update', $collection);
$collection->loadMissing(['user.profile', 'coverArtwork']);
return Inertia::render('Collection/CollectionAnalytics', [
'collection' => $this->collections->mapCollectionDetailPayload($collection, true),
'analytics' => $this->analytics->overview($collection, (int) $request->integer('days', 30)),
'historyUrl' => route('settings.collections.history', ['collection' => $collection->id]),
'dashboardUrl' => route('settings.collections.dashboard'),
'seo' => [
'title' => sprintf('%s Analytics — Skinbase Nova', $collection->title),
'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title),
'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]),
'robots' => 'noindex,follow',
],
])->rootView('collections');
}
public function history(Request $request, Collection $collection): Response
{
$this->authorize('update', $collection);
$collection->loadMissing(['user.profile', 'coverArtwork']);
$history = $this->history->historyFor($collection, (int) $request->integer('per_page', 40));
return Inertia::render('Collection/CollectionHistory', [
'collection' => $this->collections->mapCollectionDetailPayload($collection, true),
'history' => $this->history->mapPaginator($history),
'canRestoreHistory' => $this->isStaff($request),
'dashboardUrl' => route('settings.collections.dashboard'),
'analyticsUrl' => route('settings.collections.analytics', ['collection' => $collection->id]),
'restorePattern' => route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => '__HISTORY__']),
'seo' => [
'title' => sprintf('%s History — Skinbase Nova', $collection->title),
'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title),
'canonical' => route('settings.collections.history', ['collection' => $collection->id]),
'robots' => 'noindex,follow',
],
])->rootView('collections');
}
public function restoreHistory(Request $request, Collection $collection, CollectionHistory $history): JsonResponse
{
$this->authorize('update', $collection);
abort_unless($this->isStaff($request), 403);
$collection = $this->history->restore($collection->loadMissing('user'), $history, $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'health' => $this->health->summary($collection),
'restored_history_entry_id' => (int) $history->id,
]);
}
public function qualityReview(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'ok' => true,
'review' => $this->aiOperations->qualityReview($collection->loadMissing(['user.profile', 'coverArtwork'])),
]);
}
public function search(CollectionOwnerSearchRequest $request): JsonResponse
{
$filters = $request->validated();
$results = $this->search->ownerSearch($request->user(), $filters, (int) config('collections.v5.search.owner_per_page', 20));
return response()->json([
'ok' => true,
'collections' => $this->collections->mapCollectionCardPayloads($results->items(), true),
'filters' => $filters,
'meta' => [
'current_page' => $results->currentPage(),
'last_page' => $results->lastPage(),
'per_page' => $results->perPage(),
'total' => $results->total(),
],
]);
}
public function bulkActions(CollectionBulkActionsRequest $request): JsonResponse
{
$payload = $request->validated();
$result = $this->bulkActions->apply($request->user(), $payload);
$dashboard = $this->dashboard->build($request->user());
return response()->json([
'ok' => true,
'action' => $result['action'],
'count' => $result['count'],
'message' => $result['message'],
'collections' => $this->collections->mapCollectionCardPayloads($result['collections'], true),
'items' => $result['items'],
'summary' => $dashboard['summary'],
'healthWarnings' => $dashboard['health_warnings'],
]);
}
public function health(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$collection->loadMissing(['user.profile', 'coverArtwork']);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection, true),
'health' => $this->health->summary($collection),
'duplicate_candidates' => $this->collections->mapCollectionCardPayloads($this->merge->duplicateCandidates($collection), true),
'history_url' => route('settings.collections.history', ['collection' => $collection->id]),
]);
}
public function workflowUpdate(UpdateCollectionWorkflowRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$payload = $request->validated();
if (! $this->isStaff($request)) {
unset($payload['program_key'], $payload['partner_key'], $payload['experiment_key'], $payload['placement_eligibility']);
}
$collection = $this->workflow->update($collection->loadMissing('user'), $payload, $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'health' => $this->health->summary($collection),
]);
}
public function qualityRefresh(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return response()->json([
'ok' => true,
'queued' => true,
'result' => $this->backgroundJobs->dispatchQualityRefresh($collection->loadMissing('user'), $request->user()),
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'health' => $this->health->summary($collection),
]);
}
public function canonicalize(CollectionTargetActionRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$payload = $request->validated();
$target = Collection::query()->findOrFail((int) $payload['target_collection_id']);
$this->authorize('update', $target);
$collection = $this->canonical->designate($collection->loadMissing('user'), $target->loadMissing('user'), $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'target' => $this->collections->mapCollectionCardPayloads([$target->fresh()->loadMissing('user')], true)[0],
'canonical_target' => $this->collections->mapCollectionCardPayloads([$target->fresh()->loadMissing('user')], true)[0],
'duplicate_candidates' => $this->merge->reviewCandidates($collection->fresh()->loadMissing('user'), true),
]);
}
public function merge(CollectionTargetActionRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$payload = $request->validated();
$target = Collection::query()->findOrFail((int) $payload['target_collection_id']);
$this->authorize('update', $target);
$result = $this->merge->mergeInto($collection->loadMissing('user'), $target->loadMissing('user'), $request->user());
return response()->json([
'ok' => true,
'source' => $this->collections->mapCollectionDetailPayload($result['source']->loadMissing('user'), true),
'target' => $this->collections->mapCollectionDetailPayload($result['target']->loadMissing('user'), true),
'attached_artwork_ids' => $result['attached_artwork_ids'],
'canonical_target' => $this->collections->mapCollectionCardPayloads([$result['target']->loadMissing('user')], true)[0],
'duplicate_candidates' => $this->merge->reviewCandidates($result['source']->fresh()->loadMissing('user'), true),
]);
}
public function rejectDuplicate(CollectionTargetActionRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$payload = $request->validated();
$target = Collection::query()->findOrFail((int) $payload['target_collection_id']);
$this->authorize('update', $target);
$collection = $this->merge->rejectCandidate($collection->loadMissing('user'), $target->loadMissing('user'), $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'duplicate_candidates' => $this->merge->reviewCandidates($collection->fresh()->loadMissing('user'), true),
'canonical_target' => $collection->canonical_collection_id
? $this->collections->mapCollectionCardPayloads([
Collection::query()->findOrFail((int) $collection->canonical_collection_id)->loadMissing('user'),
], true)[0]
: null,
]);
}
private function isStaff(Request $request): bool
{
$user = $request->user();
return $user !== null && ($user->isAdmin() || $user->isModerator());
}
}

View File

@@ -0,0 +1,519 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Collections\AttachCollectionArtworksRequest;
use App\Http\Requests\Collections\ReorderCollectionArtworksRequest;
use App\Http\Requests\Collections\ReorderProfileCollectionsRequest;
use App\Http\Requests\Collections\SmartCollectionRulesRequest;
use App\Http\Requests\Collections\StoreCollectionRequest;
use App\Http\Requests\Collections\UpdateCollectionCampaignRequest;
use App\Http\Requests\Collections\UpdateCollectionEntityLinksRequest;
use App\Http\Requests\Collections\UpdateCollectionRequest;
use App\Http\Requests\Collections\UpdateCollectionLifecycleRequest;
use App\Http\Requests\Collections\UpdateCollectionLinkedCollectionsRequest;
use App\Http\Requests\Collections\UpdateCollectionPresentationRequest;
use App\Http\Requests\Collections\UpdateCollectionSeriesRequest;
use App\Models\Artwork;
use App\Models\Collection;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionCampaignService;
use App\Services\CollectionCommentService;
use App\Services\CollectionLinkService;
use App\Services\CollectionLinkedCollectionsService;
use App\Services\CollectionMergeService;
use App\Services\CollectionSeriesService;
use App\Services\CollectionSubmissionService;
use App\Services\CollectionService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
class CollectionManageController extends Controller
{
public function __construct(
private readonly CollectionService $collections,
private readonly CollectionCampaignService $campaigns,
private readonly CollectionCollaborationService $collaborators,
private readonly CollectionSubmissionService $submissions,
private readonly CollectionCommentService $comments,
private readonly CollectionLinkService $entityLinks,
private readonly CollectionLinkedCollectionsService $linkedCollections,
private readonly CollectionMergeService $merge,
private readonly CollectionSeriesService $series,
) {
}
public function create(Request $request)
{
$this->authorize('create', Collection::class);
$initialMode = $request->query('mode') === Collection::MODE_SMART
? Collection::MODE_SMART
: Collection::MODE_MANUAL;
return Inertia::render('Collection/CollectionManage', [
'mode' => 'create',
'collection' => null,
'layoutModules' => $this->collections->getLayoutModuleDefinitions(),
'attachedArtworks' => [],
'availableArtworks' => [],
'smartPreview' => null,
'smartRuleOptions' => $this->collections->getSmartRuleOptions($request->user()),
'initialMode' => $initialMode,
'featuredLimit' => (int) config('collections.featured_limit', 3),
'owner' => $this->ownerPayload($request),
'members' => [],
'submissions' => [],
'comments' => [],
'duplicateCandidates' => [],
'canonicalTarget' => null,
'inviteExpiryDays' => (int) config('collections.invites.expires_after_days', 7),
'endpoints' => [
'store' => route('settings.collections.store'),
'smartPreview' => route('settings.collections.smart.preview'),
'profileCollections' => route('profile.tab', [
'username' => strtolower((string) $request->user()->username),
'tab' => 'collections',
]),
],
])->rootView('collections');
}
public function show(Request $request, Collection $collection)
{
$this->authorize('manageArtworks', $collection);
$collection->loadMissing(['user.profile', 'coverArtwork']);
return Inertia::render('Collection/CollectionManage', [
'mode' => 'edit',
'collection' => $this->collections->mapCollectionDetailPayload($collection, true),
'layoutModules' => $this->collections->mapCollectionDetailPayload($collection, true)['layout_modules'],
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
'availableArtworks' => $this->collections->getAvailableArtworkOptions($collection, $request->user()),
'smartPreview' => $collection->isSmart() && is_array($collection->smart_rules_json)
? $this->collections->previewSmartCollection($request->user(), $collection->smart_rules_json)
: null,
'smartRuleOptions' => $this->collections->getSmartRuleOptions($request->user()),
'initialMode' => $collection->mode,
'featuredLimit' => (int) config('collections.featured_limit', 3),
'owner' => $this->ownerPayload($request),
'members' => $this->collaborators->mapMembers($collection, $request->user()),
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
'comments' => $this->comments->mapComments($collection, $request->user()),
'duplicateCandidates' => $this->merge->reviewCandidates($collection->loadMissing('user'), true),
'canonicalTarget' => $collection->canonical_collection_id
? $this->collections->mapCollectionCardPayloads([
Collection::query()->findOrFail((int) $collection->canonical_collection_id)->loadMissing('user'),
], true)[0]
: null,
'linkedCollections' => $this->collections->mapCollectionCardPayloads($this->linkedCollections->linkedCollections($collection), true),
'linkedCollectionOptions' => $this->collections->mapCollectionCardPayloads($this->linkedCollections->manageableLinkOptions($collection, $request->user()), true),
'entityLinks' => $this->entityLinks->links($collection, false),
'entityLinkOptions' => $this->entityLinks->manageableOptions($collection),
'inviteExpiryDays' => (int) config('collections.invites.expires_after_days', 7),
'endpoints' => [
'update' => route('settings.collections.update', ['collection' => $collection->id]),
'updatePresentation' => route('settings.collections.presentation', ['collection' => $collection->id]),
'updateCampaign' => route('settings.collections.campaign', ['collection' => $collection->id]),
'updateSeries' => route('settings.collections.series', ['collection' => $collection->id]),
'updateLifecycle' => route('settings.collections.lifecycle', ['collection' => $collection->id]),
'syncLinkedCollections' => route('settings.collections.linked.sync', ['collection' => $collection->id]),
'syncEntityLinks' => route('settings.collections.entity-links.sync', ['collection' => $collection->id]),
'delete' => route('settings.collections.destroy', ['collection' => $collection->id]),
'attach' => route('settings.collections.artworks.attach', ['collection' => $collection->id]),
'reorder' => route('settings.collections.artworks.reorder', ['collection' => $collection->id]),
'available' => route('settings.collections.artworks.available', ['collection' => $collection->id]),
'removePattern' => route('settings.collections.artworks.remove', ['collection' => $collection->id, 'artwork' => '__ARTWORK__']),
'edit' => route('settings.collections.edit', ['collection' => $collection->id]),
'feature' => route('settings.collections.feature', ['collection' => $collection->id]),
'unfeature' => route('settings.collections.unfeature', ['collection' => $collection->id]),
'smartPreview' => route('settings.collections.smart.preview'),
'aiSuggestTitle' => route('settings.collections.ai.suggest-title', ['collection' => $collection->id]),
'aiSuggestSummary' => route('settings.collections.ai.suggest-summary', ['collection' => $collection->id]),
'aiSuggestCover' => route('settings.collections.ai.suggest-cover', ['collection' => $collection->id]),
'aiSuggestGrouping' => route('settings.collections.ai.suggest-grouping', ['collection' => $collection->id]),
'aiSuggestRelatedArtworks' => route('settings.collections.ai.suggest-related-artworks', ['collection' => $collection->id]),
'aiSuggestTags' => route('settings.collections.ai.suggest-tags', ['collection' => $collection->id]),
'aiSuggestSeoDescription' => route('settings.collections.ai.suggest-seo-description', ['collection' => $collection->id]),
'aiExplainSmartRules' => route('settings.collections.ai.explain-smart-rules', ['collection' => $collection->id]),
'aiSuggestSplitThemes' => route('settings.collections.ai.suggest-split-themes', ['collection' => $collection->id]),
'aiSuggestMergeIdea' => route('settings.collections.ai.suggest-merge-idea', ['collection' => $collection->id]),
'aiQualityReview' => route('settings.collections.ai.quality-review', ['collection' => $collection->id]),
'updateSmartRules' => route('settings.collections.smart.rules', ['collection' => $collection->id]),
'inviteMember' => route('settings.collections.members.store', ['collection' => $collection->id]),
'memberUpdatePattern' => route('settings.collections.members.update', ['collection' => $collection->id, 'member' => '__MEMBER__']),
'memberTransferPattern' => route('settings.collections.members.transfer', ['collection' => $collection->id, 'member' => '__MEMBER__']),
'memberDeletePattern' => route('settings.collections.members.destroy', ['collection' => $collection->id, 'member' => '__MEMBER__']),
'acceptMemberPattern' => route('settings.collections.members.accept', ['member' => '__MEMBER__']),
'declineMemberPattern' => route('settings.collections.members.decline', ['member' => '__MEMBER__']),
'adminModerationUpdate' => $this->isAdmin($request) ? route('api.admin.collections.moderation.update', ['collection' => $collection->id]) : null,
'adminInteractionsUpdate' => $this->isAdmin($request) ? route('api.admin.collections.interactions.update', ['collection' => $collection->id]) : null,
'adminUnfeature' => $this->isAdmin($request) ? route('api.admin.collections.unfeature', ['collection' => $collection->id]) : null,
'adminMemberRemovePattern' => $this->isAdmin($request) ? route('api.admin.collections.members.destroy', ['collection' => $collection->id, 'member' => '__MEMBER__']) : null,
'submissionStore' => route('collections.submissions.store', ['collection' => $collection->id]),
'submissionApprovePattern' => route('collections.submissions.approve', ['submission' => '__SUBMISSION__']),
'submissionRejectPattern' => route('collections.submissions.reject', ['submission' => '__SUBMISSION__']),
'submissionDeletePattern' => route('collections.submissions.destroy', ['submission' => '__SUBMISSION__']),
'commentsIndex' => route('collections.comments.index', ['collection' => $collection->id]),
'commentsStore' => route('collections.comments.store', ['collection' => $collection->id]),
'commentDeletePattern' => route('collections.comments.destroy', ['collection' => $collection->id, 'comment' => '__COMMENT__']),
'public' => route('profile.collections.show', [
'username' => strtolower((string) $collection->user->username),
'slug' => $collection->slug,
]),
'dashboard' => route('settings.collections.dashboard'),
'analytics' => route('settings.collections.analytics', ['collection' => $collection->id]),
'history' => route('settings.collections.history', ['collection' => $collection->id]),
'canonicalize' => route('settings.collections.canonicalize', ['collection' => $collection->id]),
'merge' => route('settings.collections.merge', ['collection' => $collection->id]),
'rejectDuplicate' => route('settings.collections.merge.reject', ['collection' => $collection->id]),
'staffSurfaces' => $this->isAdmin($request) ? route('settings.collections.surfaces.index') : null,
'staffProgramming' => $this->isAdmin($request) ? route('staff.collections.programming') : null,
'profileCollections' => route('profile.tab', [
'username' => strtolower((string) $collection->user->username),
'tab' => 'collections',
]),
],
'viewer' => [
'is_admin' => $this->isAdmin($request),
],
])->rootView('collections');
}
public function edit(Request $request, Collection $collection)
{
return $this->show($request, $collection);
}
public function store(StoreCollectionRequest $request): RedirectResponse|JsonResponse
{
$this->authorize('create', Collection::class);
$collection = $this->collections->createCollection($request->user(), $request->validated());
$redirectTo = (string) route('settings.collections.show', ['collection' => $collection->id]);
return $this->jsonOrRedirect($request, [
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'members' => $this->collaborators->mapMembers($collection, $request->user()),
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
'comments' => $this->comments->mapComments($collection, $request->user()),
'redirect' => $redirectTo,
], $redirectTo);
}
public function update(UpdateCollectionRequest $request, Collection $collection): RedirectResponse|JsonResponse
{
$this->authorize('update', $collection);
$collection = $this->collections->updateCollection($collection->loadMissing('user'), $request->validated(), $request->user());
$redirectTo = (string) route('settings.collections.show', ['collection' => $collection->id]);
return $this->jsonOrRedirect($request, [
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
'members' => $this->collaborators->mapMembers($collection, $request->user()),
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
'comments' => $this->comments->mapComments($collection, $request->user()),
'redirect' => $redirectTo,
], $redirectTo);
}
public function updatePresentation(UpdateCollectionPresentationRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return $this->updateScopedSettings($request, $collection, $request->validated());
}
public function updateCampaign(UpdateCollectionCampaignRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$collection = $this->campaigns->updateCampaign($collection, $request->validated(), $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'campaign' => $this->campaigns->campaignSummary($collection),
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
'members' => $this->collaborators->mapMembers($collection, $request->user()),
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
'comments' => $this->comments->mapComments($collection, $request->user()),
]);
}
public function updateSeries(UpdateCollectionSeriesRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$collection = $this->series->updateSeries($collection, $request->validated(), $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'series' => $this->series->summary($collection),
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
'members' => $this->collaborators->mapMembers($collection, $request->user()),
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
'comments' => $this->comments->mapComments($collection, $request->user()),
]);
}
public function updateLifecycle(UpdateCollectionLifecycleRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
return $this->updateScopedSettings($request, $collection, $request->validated());
}
public function syncLinkedCollections(UpdateCollectionLinkedCollectionsRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$collection = $this->linkedCollections->syncLinks($collection->loadMissing('user'), $request->user(), $request->validated('related_collection_ids', []));
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'linkedCollections' => $this->collections->mapCollectionCardPayloads($this->linkedCollections->linkedCollections($collection), true),
'linkedCollectionOptions' => $this->collections->mapCollectionCardPayloads($this->linkedCollections->manageableLinkOptions($collection, $request->user()), true),
]);
}
public function syncEntityLinks(UpdateCollectionEntityLinksRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$collection = $this->entityLinks->syncLinks($collection->loadMissing('user'), $request->user(), $request->validated('entity_links', []));
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'entityLinks' => $this->entityLinks->links($collection, false),
'entityLinkOptions' => $this->entityLinks->manageableOptions($collection),
]);
}
public function destroy(Request $request, Collection $collection): RedirectResponse|JsonResponse
{
$this->authorize('delete', $collection);
$profileCollectionsUrl = (string) route('profile.tab', [
'username' => strtolower((string) $collection->user->username),
'tab' => 'collections',
]);
$this->collections->deleteCollection($collection);
return $this->jsonOrRedirect($request, [
'ok' => true,
'redirect' => $profileCollectionsUrl,
], $profileCollectionsUrl);
}
public function attachArtworks(AttachCollectionArtworksRequest $request, Collection $collection): JsonResponse
{
$this->authorize('manageArtworks', $collection);
$collection = $this->collections->attachArtworks($collection->loadMissing('user'), $request->user(), $request->validated('artwork_ids'));
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
'availableArtworks' => $this->collections->getAvailableArtworkOptions($collection, $request->user()),
]);
}
public function artworkCollectionOptions(Request $request): JsonResponse
{
$artwork = $this->resolveArtworkFromRequest($request, 4);
abort_unless((int) $artwork->user_id === (int) $request->user()->id, 404);
return response()->json([
'data' => $this->collections->getCollectionOptionsForArtwork($request->user(), $artwork),
'meta' => [
'create_url' => route('settings.collections.create'),
'artwork_id' => (int) $artwork->id,
],
]);
}
public function removeArtwork(Request $request, Collection $collection): JsonResponse
{
$this->authorize('manageArtworks', $collection);
$artwork = $this->resolveArtworkFromRequest($request, 5);
$isAttached = $collection->artworks()->where('artworks.id', $artwork->id)->exists();
abort_unless($isAttached, 404);
$collection = $this->collections->removeArtwork($collection->loadMissing('user'), $artwork);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
'availableArtworks' => $this->collections->getAvailableArtworkOptions($collection, $request->user()),
]);
}
public function reorderArtworks(ReorderCollectionArtworksRequest $request, Collection $collection): JsonResponse
{
$this->authorize('manageArtworks', $collection);
$collection = $this->collections->reorderArtworks($collection->loadMissing('user'), $request->validated('ordered_artwork_ids'));
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
]);
}
public function availableArtworks(Request $request, Collection $collection): JsonResponse
{
$this->authorize('manageArtworks', $collection);
$request->validate([
'search' => ['nullable', 'string', 'max:120'],
]);
return response()->json([
'data' => $this->collections->getAvailableArtworkOptions($collection, $request->user(), (string) $request->input('search', '')),
]);
}
public function feature(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$collection = $this->collections->featureCollection($collection);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
]);
}
public function unfeature(Request $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$collection = $this->collections->unfeatureCollection($collection);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
]);
}
public function reorderProfile(ReorderProfileCollectionsRequest $request): JsonResponse
{
$this->collections->reorderProfileCollections($request->user(), $request->validated('collection_ids'));
$items = $this->collections->getProfileCollections($request->user(), $request->user(), 24);
return response()->json([
'ok' => true,
'collections' => $this->collections->mapCollectionCardPayloads($items, true),
]);
}
public function smartPreview(SmartCollectionRulesRequest $request): JsonResponse
{
return response()->json([
'ok' => true,
'preview' => $this->collections->previewSmartCollection($request->user(), $request->validated('smart_rules_json')),
]);
}
public function updateSmartRules(SmartCollectionRulesRequest $request, Collection $collection): JsonResponse
{
$this->authorize('update', $collection);
$collection = $this->collections->updateCollection($collection->loadMissing('user'), [
'title' => $collection->title,
'slug' => $collection->slug,
'description' => $collection->description,
'subtitle' => $collection->subtitle,
'summary' => $collection->summary,
'visibility' => $collection->visibility,
'mode' => Collection::MODE_SMART,
'sort_mode' => (string) (($request->validated('smart_rules_json')['sort'] ?? null) ?: $collection->sort_mode),
'smart_rules_json' => $request->validated('smart_rules_json'),
]);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'preview' => $this->collections->previewSmartCollection($request->user(), $collection->smart_rules_json ?? []),
]);
}
private function ownerPayload(Request $request): array
{
$user = $request->user();
return [
'id' => $user->id,
'username' => $user->username,
'name' => $user->name,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 96),
];
}
private function isAdmin(Request $request): bool
{
$user = $request->user();
return $user !== null && method_exists($user, 'hasRole') && $user->hasRole('admin');
}
private function updateScopedSettings(Request $request, Collection $collection, array $attributes): JsonResponse
{
$collection = $this->collections->updateCollection($collection->loadMissing('user'), $attributes, $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'attachedArtworks' => $this->collections->mapAttachedArtworks($collection),
'members' => $this->collaborators->mapMembers($collection, $request->user()),
'submissions' => $this->submissions->mapSubmissions($collection, $request->user()),
'comments' => $this->comments->mapComments($collection, $request->user()),
]);
}
private function jsonOrRedirect(Request $request, array $payload, string $redirectTo): RedirectResponse|JsonResponse
{
if ($request->expectsJson()) {
return response()->json($payload);
}
return redirect($redirectTo);
}
private function resolveArtworkFromRequest(Request $request, int $fallbackSegment): Artwork
{
$routeValue = $request->route('artwork');
if ($routeValue instanceof Artwork) {
return $routeValue;
}
$artworkId = is_scalar($routeValue)
? (int) $routeValue
: (int) $request->segment($fallbackSegment);
return Artwork::query()->findOrFail($artworkId);
}
}

View File

@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Collections\CollectionProgramAssignmentRequest;
use App\Http\Requests\Collections\CollectionProgrammingCollectionRequest;
use App\Http\Requests\Collections\CollectionProgrammingMergePairRequest;
use App\Http\Requests\Collections\CollectionProgrammingMetadataRequest;
use App\Http\Requests\Collections\CollectionProgrammingPreviewRequest;
use App\Models\Collection;
use App\Models\CollectionProgramAssignment;
use App\Services\CollectionBackgroundJobService;
use App\Services\CollectionObservabilityService;
use App\Services\CollectionProgrammingService;
use App\Services\CollectionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CollectionProgrammingController extends Controller
{
public function __construct(
private readonly CollectionProgrammingService $programming,
private readonly CollectionService $collections,
private readonly CollectionBackgroundJobService $backgroundJobs,
private readonly CollectionObservabilityService $observability,
) {
}
public function index(Request $request): Response|JsonResponse
{
$this->authorizeStaff($request);
$assignments = $this->programming->assignments();
if ($request->expectsJson()) {
return response()->json([
'ok' => true,
'assignments' => $assignments->map(fn (CollectionProgramAssignment $assignment): array => $this->mapAssignment($assignment))->values()->all(),
]);
}
$collectionOptions = Collection::query()
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->where(function ($query): void {
$query->where(function ($public): void {
$public->where('visibility', Collection::VISIBILITY_PUBLIC)
->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED])
->where('moderation_status', Collection::MODERATION_ACTIVE);
})->orWhereNotNull('program_key');
})
->orderByDesc('ranking_score')
->orderByDesc('updated_at')
->limit(40)
->get();
$programKeyOptions = $assignments->pluck('program_key')
->merge($collectionOptions->pluck('program_key'))
->filter(fn ($value): bool => is_string($value) && $value !== '')
->unique()
->sort()
->values()
->all();
return Inertia::render('Collection/CollectionStaffProgramming', [
'assignments' => $assignments->map(fn (CollectionProgramAssignment $assignment): array => $this->mapAssignment($assignment))->values()->all(),
'collectionOptions' => $this->collections->mapCollectionCardPayloads($collectionOptions, true),
'programKeyOptions' => $programKeyOptions,
'mergeQueue' => $this->programming->mergeQueue(true),
'observabilitySummary' => $this->observability->summary(),
'historyPattern' => route('settings.collections.history', ['collection' => '__COLLECTION__']),
'viewer' => [
'isAdmin' => $this->isAdmin($request),
],
'endpoints' => [
'store' => route('staff.collections.programs.store'),
'updatePattern' => route('staff.collections.programs.update', ['program' => '__PROGRAM__']),
'publicProgramPattern' => route('collections.program.show', ['programKey' => '__PROGRAM__']),
'preview' => route('staff.collections.surfaces.preview'),
'refreshEligibility' => route('staff.collections.eligibility.refresh'),
'duplicateScan' => route('staff.collections.duplicate-scan'),
'refreshRecommendations' => route('staff.collections.recommendation-refresh'),
'metadataUpdate' => route('staff.collections.metadata.update'),
'canonicalizeCandidate' => route('staff.collections.merge-queue.canonicalize'),
'mergeCandidate' => route('staff.collections.merge-queue.merge'),
'rejectCandidate' => route('staff.collections.merge-queue.reject'),
'managePattern' => route('settings.collections.show', ['collection' => '__COLLECTION__']),
'surfaces' => route('settings.collections.surfaces.index'),
],
'seo' => [
'title' => 'Collection Programming — Skinbase Nova',
'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.',
'canonical' => route('staff.collections.programming'),
'robots' => 'noindex,follow',
],
])->rootView('collections');
}
public function storeProgram(CollectionProgramAssignmentRequest $request): JsonResponse
{
$this->authorizeStaff($request);
$assignment = $this->programming->upsertAssignment($request->validated(), $request->user());
return response()->json([
'ok' => true,
'assignment' => $this->mapAssignment($assignment),
]);
}
public function updateProgram(CollectionProgramAssignmentRequest $request, CollectionProgramAssignment $program): JsonResponse
{
$this->authorizeStaff($request);
$payload = $request->validated();
$payload['id'] = (int) $program->id;
$assignment = $this->programming->upsertAssignment($payload, $request->user());
return response()->json([
'ok' => true,
'assignment' => $this->mapAssignment($assignment),
]);
}
public function preview(CollectionProgrammingPreviewRequest $request): JsonResponse
{
$this->authorizeStaff($request);
$payload = $request->validated();
return response()->json([
'ok' => true,
'collections' => $this->collections->mapCollectionCardPayloads(
$this->programming->previewProgram((string) $payload['program_key'], (int) ($payload['limit'] ?? 12)),
true,
),
]);
}
public function refreshEligibility(CollectionProgrammingCollectionRequest $request): JsonResponse
{
$this->authorizeStaff($request);
$collection = $this->resolveCollection($request);
return response()->json([
'ok' => true,
'queued' => true,
'result' => $this->backgroundJobs->dispatchHealthRefresh($collection, $request->user()),
]);
}
public function duplicateScan(CollectionProgrammingCollectionRequest $request): JsonResponse
{
$this->authorizeStaff($request);
$collection = $this->resolveCollection($request);
return response()->json([
'ok' => true,
'queued' => true,
'result' => $this->backgroundJobs->dispatchDuplicateScan($collection, $request->user()),
]);
}
public function refreshRecommendations(CollectionProgrammingCollectionRequest $request): JsonResponse
{
$this->authorizeStaff($request);
$collection = $this->resolveCollection($request);
return response()->json([
'ok' => true,
'queued' => true,
'result' => $this->backgroundJobs->dispatchRecommendationRefresh($collection, $request->user()),
]);
}
public function updateMetadata(CollectionProgrammingMetadataRequest $request): JsonResponse
{
$this->authorizeStaff($request);
$payload = $request->validated();
$collection = Collection::query()->findOrFail((int) $payload['collection_id']);
$result = $this->programming->syncHooks($collection, $payload, $request->user());
return response()->json([
'ok' => true,
'message' => 'Experiment and program governance hooks updated.',
'collection' => $this->collections->mapCollectionCardPayloads([$result['collection']->loadMissing('user')], true)[0],
'diagnostics' => $result['diagnostics'],
]);
}
public function canonicalizeCandidate(CollectionProgrammingMergePairRequest $request): JsonResponse
{
$this->authorizeStaff($request);
[$source, $target] = $this->resolveMergePair($request);
$payload = $this->programming->canonicalizePair($source, $target, $request->user());
return response()->json([
'ok' => true,
'message' => 'Canonical target updated from the staff merge queue.',
'source' => $this->collections->mapCollectionCardPayloads([$payload['source']->loadMissing('user')], true)[0],
'target' => $this->collections->mapCollectionCardPayloads([$payload['target']->loadMissing('user')], true)[0],
'mergeQueue' => $payload['mergeQueue'],
]);
}
public function mergeCandidate(CollectionProgrammingMergePairRequest $request): JsonResponse
{
$this->authorizeStaff($request);
[$source, $target] = $this->resolveMergePair($request);
$payload = $this->programming->mergePair($source, $target, $request->user());
return response()->json([
'ok' => true,
'message' => 'Collections merged from the staff merge queue.',
'source' => $this->collections->mapCollectionCardPayloads([$payload['source']->loadMissing('user')], true)[0],
'target' => $this->collections->mapCollectionCardPayloads([$payload['target']->loadMissing('user')], true)[0],
'attached_artwork_ids' => $payload['attached_artwork_ids'],
'mergeQueue' => $payload['mergeQueue'],
]);
}
public function rejectCandidate(CollectionProgrammingMergePairRequest $request): JsonResponse
{
$this->authorizeStaff($request);
[$source, $target] = $this->resolveMergePair($request);
$payload = $this->programming->rejectPair($source, $target, $request->user());
return response()->json([
'ok' => true,
'message' => 'Duplicate candidate dismissed from the staff merge queue.',
'source' => $this->collections->mapCollectionCardPayloads([$payload['source']->loadMissing('user')], true)[0],
'target' => $this->collections->mapCollectionCardPayloads([$payload['target']->loadMissing('user')], true)[0],
'mergeQueue' => $payload['mergeQueue'],
]);
}
private function resolveCollection(CollectionProgrammingCollectionRequest $request): ?Collection
{
$payload = $request->validated();
if (! isset($payload['collection_id'])) {
return null;
}
return Collection::query()->find((int) $payload['collection_id']);
}
/**
* @return array{0: Collection, 1: Collection}
*/
private function resolveMergePair(CollectionProgrammingMergePairRequest $request): array
{
$payload = $request->validated();
return [
Collection::query()->findOrFail((int) $payload['source_collection_id']),
Collection::query()->findOrFail((int) $payload['target_collection_id']),
];
}
private function mapAssignment(CollectionProgramAssignment $assignment): array
{
$assignment->loadMissing(['collection.user', 'creator']);
return [
'id' => (int) $assignment->id,
'program_key' => (string) $assignment->program_key,
'campaign_key' => $assignment->campaign_key,
'placement_scope' => $assignment->placement_scope,
'starts_at' => optional($assignment->starts_at)?->toISOString(),
'ends_at' => optional($assignment->ends_at)?->toISOString(),
'priority' => (int) $assignment->priority,
'notes' => $assignment->notes,
'collection' => $this->collections->mapCollectionCardPayloads([$assignment->collection], true)[0],
'creator' => $assignment->creator ? [
'id' => (int) $assignment->creator->id,
'username' => (string) $assignment->creator->username,
'name' => $assignment->creator->name,
] : null,
];
}
private function authorizeStaff(Request $request): void
{
$user = $request->user();
abort_unless($user && ($user->isAdmin() || $user->isModerator()), 403);
}
private function isAdmin(Request $request): bool
{
$user = $request->user();
return $user !== null && $user->isAdmin();
}
}

View File

@@ -0,0 +1,459 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Collection;
use App\Models\CollectionSurfaceDefinition;
use App\Models\CollectionSurfacePlacement;
use App\Services\CollectionCampaignService;
use App\Services\CollectionHistoryService;
use App\Services\CollectionService;
use App\Services\CollectionSurfaceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class CollectionSurfaceController extends Controller
{
public function __construct(
private readonly CollectionSurfaceService $surfaces,
private readonly CollectionService $collections,
private readonly CollectionHistoryService $history,
private readonly CollectionCampaignService $campaigns,
) {
}
public function index(Request $request): Response
{
$this->authorizeStaff($request);
$conflicts = $this->surfaces->placementConflicts();
$conflictPlacementIds = $conflicts->pluck('placement_ids')->flatten()->unique()->map(fn ($id) => (int) $id)->all();
$definitions = $this->surfaces->definitions()->map(function ($definition): array {
return $this->mapDefinition($definition);
})->values()->all();
$placements = $this->surfaces->placements()->map(function ($placement) use ($conflictPlacementIds): array {
return array_merge($this->mapPlacement($placement), [
'has_conflict' => in_array((int) $placement->id, $conflictPlacementIds, true),
]);
})->values()->all();
return Inertia::render('Collection/CollectionStaffSurfaces', [
'definitions' => $definitions,
'placements' => $placements,
'conflicts' => $conflicts->all(),
'surfaceKeyOptions' => collect($definitions)->pluck('surface_key')->values()->all(),
'collectionOptions' => $this->collections->mapCollectionCardPayloads(
Collection::query()->public()->orderByDesc('ranking_score')->limit(30)->get(),
true,
),
'endpoints' => [
'definitionsStore' => route('settings.collections.surfaces.definitions.store'),
'definitionsUpdatePattern' => route('settings.collections.surfaces.definitions.update', ['definition' => '__DEFINITION__']),
'definitionsDeletePattern' => route('settings.collections.surfaces.definitions.destroy', ['definition' => '__DEFINITION__']),
'placementsStore' => route('settings.collections.surfaces.placements.store'),
'placementsUpdatePattern' => route('settings.collections.surfaces.placements.update', ['placement' => '__PLACEMENT__']),
'placementsDeletePattern' => route('settings.collections.surfaces.placements.destroy', ['placement' => '__PLACEMENT__']),
'previewPattern' => route('settings.collections.surfaces.preview', ['definition' => '__DEFINITION__']),
'batchEditorial' => route('settings.collections.surfaces.batch-editorial'),
],
'seo' => [
'title' => 'Collection Surfaces - Skinbase Nova',
'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.',
'canonical' => route('settings.collections.surfaces.index'),
'robots' => 'noindex,follow',
],
])->rootView('collections');
}
public function storeDefinition(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$definition = $this->surfaces->upsertDefinition($this->validateDefinition($request));
return response()->json([
'ok' => true,
'definition' => $this->mapDefinition($definition->fresh()),
]);
}
public function updateDefinition(Request $request, CollectionSurfaceDefinition $definition): JsonResponse
{
$this->authorizeStaff($request);
$payload = $this->validateDefinition($request);
$payload['surface_key'] = $definition->surface_key;
$updatedDefinition = $this->surfaces->upsertDefinition($payload);
return response()->json([
'ok' => true,
'definition' => $this->mapDefinition($updatedDefinition->fresh()),
]);
}
public function destroyDefinition(Request $request, CollectionSurfaceDefinition $definition): JsonResponse
{
$this->authorizeStaff($request);
abort_if(
CollectionSurfacePlacement::query()->where('surface_key', $definition->surface_key)->exists(),
422,
'Remove all placements from this surface before deleting the definition.'
);
$deletedId = (int) $definition->id;
$definition->delete();
return response()->json([
'ok' => true,
'deleted_definition_id' => $deletedId,
]);
}
public function storePlacement(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$payload = $this->validatePlacement($request);
$payload['created_by_user_id'] = $request->user()?->id;
$collection = Collection::query()->findOrFail((int) $payload['collection_id']);
abort_unless($collection->isFeatureablePublicly(), 422, 'Only public, active collections can be placed on public surfaces.');
$placement = $this->surfaces->upsertPlacement($payload)->loadMissing([
'collection.user:id,username,name',
'collection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
]);
$this->history->record(
$collection->fresh(),
$request->user(),
'placement_assigned',
'Collection assigned to a staff surface.',
null,
$this->placementHistoryPayload($placement)
);
return response()->json([
'ok' => true,
'placement' => $this->mapPlacement($placement),
'conflicts' => $this->surfaces->placementConflicts()->all(),
]);
}
public function updatePlacement(Request $request, CollectionSurfacePlacement $placement): JsonResponse
{
$this->authorizeStaff($request);
$before = $this->placementHistoryPayload($placement);
$originalCollectionId = (int) $placement->collection_id;
$payload = $this->validatePlacement($request);
$payload['id'] = (int) $placement->id;
$payload['created_by_user_id'] = $placement->created_by_user_id ?: $request->user()?->id;
$collection = Collection::query()->findOrFail((int) $payload['collection_id']);
abort_unless($collection->isFeatureablePublicly(), 422, 'Only public, active collections can be placed on public surfaces.');
$updatedPlacement = $this->surfaces->upsertPlacement($payload)->loadMissing([
'collection.user:id,username,name',
'collection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
]);
if ($originalCollectionId !== (int) $updatedPlacement->collection_id) {
$originalCollection = Collection::query()->find($originalCollectionId);
if ($originalCollection) {
$this->history->record(
$originalCollection->fresh(),
$request->user(),
'placement_removed',
'Collection removed from a staff surface assignment.',
$before,
null
);
}
$this->history->record(
$collection->fresh(),
$request->user(),
'placement_assigned',
'Collection assigned to a staff surface.',
null,
$this->placementHistoryPayload($updatedPlacement)
);
} else {
$this->history->record(
$collection->fresh(),
$request->user(),
'placement_updated',
'Collection staff surface assignment updated.',
$before,
$this->placementHistoryPayload($updatedPlacement)
);
}
return response()->json([
'ok' => true,
'placement' => $this->mapPlacement($updatedPlacement),
'conflicts' => $this->surfaces->placementConflicts()->all(),
]);
}
public function destroyPlacement(Request $request, CollectionSurfacePlacement $placement): JsonResponse
{
$this->authorizeStaff($request);
$before = $this->placementHistoryPayload($placement);
$collection = $placement->collection()->first();
$deletedId = (int) $placement->id;
$this->surfaces->deletePlacement($placement);
if ($collection) {
$this->history->record(
$collection->fresh(),
$request->user(),
'placement_removed',
'Collection removed from a staff surface assignment.',
$before,
null
);
}
return response()->json([
'ok' => true,
'deleted_placement_id' => $deletedId,
'conflicts' => $this->surfaces->placementConflicts()->all(),
]);
}
public function preview(Request $request, CollectionSurfaceDefinition $definition): JsonResponse
{
$this->authorizeStaff($request);
$items = $this->surfaces->resolveSurfaceItems(
$definition->surface_key,
(int) $request->integer('limit', (int) $definition->max_items)
);
return response()->json([
'ok' => true,
'collections' => $this->collections->mapCollectionCardPayloads($items, true),
]);
}
public function batchEditorial(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$payload = $this->validateBatchEditorial($request);
$collectionIds = collect($payload['collection_ids'] ?? [])->map(fn ($id) => (int) $id)->filter()->values()->all();
abort_if(count($collectionIds) === 0, 422, 'Choose at least one collection for the batch editorial run.');
if (! ($payload['apply'] ?? false)) {
return response()->json([
'ok' => true,
'mode' => 'preview',
'plan' => $this->campaigns->batchEditorialPlan($collectionIds, $payload),
]);
}
$result = DB::transaction(function () use ($collectionIds, $payload, $request): array {
$actor = $request->user();
$applied = $this->campaigns->applyBatchEditorialPlan($collectionIds, $payload, $actor);
$resultsByCollection = collect($applied['results'] ?? [])->keyBy('collection_id');
foreach ($applied['plan']['items'] as $item) {
$collectionId = (int) Arr::get($item, 'collection.id');
$collection = Collection::query()->find($collectionId);
$itemResult = $resultsByCollection->get($collectionId, []);
if (! $collection) {
continue;
}
$summaryParts = [];
if (count($item['campaign_updates'] ?? []) > 0) {
$summaryParts[] = 'campaign metadata refreshed';
}
if (is_array($item['placement'] ?? null)) {
$summaryParts[] = ($item['placement']['eligible'] ?? false)
? sprintf('placement planned for %s', (string) $item['placement']['surface_key'])
: 'placement skipped';
}
if (($itemResult['placement']['status'] ?? null) === 'created') {
$this->history->record(
$collection->fresh(),
$actor,
'placement_assigned',
'Collection assigned to a staff surface via batch editorial tools.',
null,
$item['placement'] ?? null
);
}
if (($itemResult['placement']['status'] ?? null) === 'updated') {
$this->history->record(
$collection->fresh(),
$actor,
'placement_updated',
'Collection staff surface assignment updated via batch editorial tools.',
null,
$item['placement'] ?? null
);
}
$this->history->record(
$collection->fresh(),
$actor,
'batch_editorial_updated',
'Staff batch editorial tools updated campaign planning.',
null,
[
'summary' => $summaryParts,
'campaign_updates' => $item['campaign_updates'] ?? [],
'placement' => $item['placement'] ?? null,
]
);
}
return $applied;
});
return response()->json([
'ok' => true,
'mode' => 'apply',
'plan' => $result['plan'],
'results' => $result['results'],
'placements' => $this->surfaces->placements()->map(fn ($placement): array => $this->mapPlacement($placement))->values()->all(),
'conflicts' => $this->surfaces->placementConflicts()->all(),
]);
}
private function authorizeStaff(Request $request): void
{
$user = $request->user();
abort_unless($user && ($user->isAdmin() || $user->isModerator()), 403);
}
private function validateDefinition(Request $request): array
{
return $request->validate([
'surface_key' => ['required', 'string', 'max:120'],
'title' => ['required', 'string', 'max:160'],
'description' => ['nullable', 'string', 'max:400'],
'mode' => ['required', 'in:manual,automatic,hybrid'],
'ranking_mode' => ['required', 'in:ranking_score,recent_activity,quality_score'],
'max_items' => ['nullable', 'integer', 'min:1', 'max:24'],
'is_active' => ['nullable', 'boolean'],
'starts_at' => ['nullable', 'date'],
'ends_at' => ['nullable', 'date', 'after:starts_at'],
'fallback_surface_key' => ['nullable', 'string', 'max:120', 'different:surface_key'],
'rules_json' => ['nullable', 'array'],
]);
}
private function validatePlacement(Request $request): array
{
return $request->validate([
'id' => ['nullable', 'integer', 'exists:collection_surface_placements,id'],
'surface_key' => ['required', 'string', 'max:120'],
'collection_id' => ['required', 'integer', 'exists:collections,id'],
'placement_type' => ['required', 'in:manual,campaign,scheduled_override'],
'priority' => ['nullable', 'integer', 'min:-100', 'max:100'],
'starts_at' => ['nullable', 'date'],
'ends_at' => ['nullable', 'date', 'after:starts_at'],
'is_active' => ['nullable', 'boolean'],
'campaign_key' => ['nullable', 'string', 'max:80'],
'notes' => ['nullable', 'string', 'max:1000'],
]);
}
private function validateBatchEditorial(Request $request): array
{
return $request->validate([
'collection_ids' => ['required', 'array', 'min:1', 'max:24'],
'collection_ids.*' => ['integer', 'distinct', 'exists:collections,id'],
'campaign_key' => ['nullable', 'string', 'max:80'],
'campaign_label' => ['nullable', 'string', 'max:120'],
'event_key' => ['nullable', 'string', 'max:80'],
'event_label' => ['nullable', 'string', 'max:120'],
'season_key' => ['nullable', 'string', 'max:80'],
'banner_text' => ['nullable', 'string', 'max:160'],
'badge_label' => ['nullable', 'string', 'max:80'],
'spotlight_style' => ['nullable', 'string', 'max:60'],
'editorial_notes' => ['nullable', 'string', 'max:4000'],
'surface_key' => ['nullable', 'string', 'max:120'],
'placement_type' => ['nullable', 'string', 'in:manual,campaign,scheduled_override'],
'priority' => ['nullable', 'integer', 'min:-100', 'max:100'],
'starts_at' => ['nullable', 'date'],
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
'is_active' => ['nullable', 'boolean'],
'notes' => ['nullable', 'string', 'max:1000'],
'apply' => ['nullable', 'boolean'],
]);
}
private function mapDefinition(CollectionSurfaceDefinition $definition): array
{
return [
'id' => (int) $definition->id,
'surface_key' => $definition->surface_key,
'title' => $definition->title,
'description' => $definition->description,
'mode' => $definition->mode,
'ranking_mode' => $definition->ranking_mode,
'max_items' => (int) $definition->max_items,
'is_active' => (bool) $definition->is_active,
'starts_at' => $definition->starts_at?->toISOString(),
'ends_at' => $definition->ends_at?->toISOString(),
'fallback_surface_key' => $definition->fallback_surface_key,
'rules_json' => $definition->rules_json,
];
}
private function mapPlacement(CollectionSurfacePlacement $placement): array
{
return [
'id' => (int) $placement->id,
'surface_key' => $placement->surface_key,
'placement_type' => $placement->placement_type,
'priority' => (int) $placement->priority,
'starts_at' => $placement->starts_at?->toISOString(),
'ends_at' => $placement->ends_at?->toISOString(),
'is_active' => (bool) $placement->is_active,
'campaign_key' => $placement->campaign_key,
'notes' => $placement->notes,
'collection' => $placement->collection
? ($this->collections->mapCollectionCardPayloads(collect([$placement->collection]), true)[0] ?? null)
: null,
];
}
private function placementHistoryPayload(CollectionSurfacePlacement $placement): array
{
return [
'placement_id' => (int) $placement->id,
'surface_key' => (string) $placement->surface_key,
'collection_id' => (int) $placement->collection_id,
'placement_type' => (string) $placement->placement_type,
'priority' => (int) $placement->priority,
'starts_at' => $placement->starts_at?->toISOString(),
'ends_at' => $placement->ends_at?->toISOString(),
'is_active' => (bool) $placement->is_active,
'campaign_key' => $placement->campaign_key,
'notes' => $placement->notes,
];
}
}

View File

@@ -0,0 +1,545 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\NovaCards\AdminStoreNovaCardAssetPackRequest;
use App\Http\Requests\NovaCards\AdminStoreNovaCardCategoryRequest;
use App\Http\Requests\NovaCards\AdminStoreNovaCardChallengeRequest;
use App\Http\Requests\NovaCards\AdminStoreNovaCardTemplateRequest;
use App\Http\Requests\NovaCards\AdminUpdateNovaCardRequest;
use App\Models\NovaCardAssetPack;
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardCollection;
use App\Models\Report;
use App\Models\NovaCardTemplate;
use App\Models\User;
use App\Services\NovaCards\NovaCardCollectionService;
use App\Services\NovaCards\NovaCardPublishModerationService;
use App\Services\NovaCards\NovaCardPresenter;
use App\Support\Moderation\ReportTargetResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class NovaCardAdminController extends Controller
{
public function __construct(
private readonly NovaCardPresenter $presenter,
private readonly ReportTargetResolver $reportTargets,
private readonly NovaCardCollectionService $collections,
private readonly NovaCardPublishModerationService $moderation,
) {}
public function index(Request $request): Response
{
$cards = NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
->latest('updated_at')
->paginate(24)
->withQueryString();
$featuredCreators = User::query()
->whereHas('novaCards', fn ($query) => $query->publiclyVisible())
->withCount([
'novaCards as public_cards_count' => fn ($query) => $query->publiclyVisible(),
'novaCards as featured_cards_count' => fn ($query) => $query->publiclyVisible()->where('featured', true),
])
->withSum([
'novaCards as total_views_count' => fn ($query) => $query->publiclyVisible(),
], 'views_count')
->orderByDesc('nova_featured_creator')
->orderByDesc('featured_cards_count')
->orderByDesc('public_cards_count')
->orderBy('username')
->limit(8)
->get();
$reportCounts = Report::query()
->selectRaw('status, COUNT(*) as aggregate')
->whereIn('target_type', $this->reportTargets->novaCardTargetTypes())
->groupBy('status')
->pluck('aggregate', 'status');
return Inertia::render('Collection/NovaCardsAdminIndex', [
'cards' => $this->presenter->paginator($cards, false, $request->user()),
'featuredCreators' => $featuredCreators->map(fn (User $creator): array => [
'id' => (int) $creator->id,
'username' => (string) $creator->username,
'name' => $creator->name,
'display_name' => $creator->name ?: '@' . $creator->username,
'public_url' => route('cards.creator', ['username' => $creator->username]),
'nova_featured_creator' => (bool) $creator->nova_featured_creator,
'public_cards_count' => (int) ($creator->public_cards_count ?? 0),
'featured_cards_count' => (int) ($creator->featured_cards_count ?? 0),
'total_views_count' => (int) ($creator->total_views_count ?? 0),
])->values()->all(),
'categories' => NovaCardCategory::query()->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,
'active' => (bool) $category->active,
'order_num' => (int) $category->order_num,
'cards_count' => (int) $category->cards()->count(),
])->values()->all(),
'stats' => [
'pending' => NovaCard::query()->where('moderation_status', NovaCard::MOD_PENDING)->count(),
'flagged' => NovaCard::query()->where('moderation_status', NovaCard::MOD_FLAGGED)->count(),
'featured' => NovaCard::query()->where('featured', true)->count(),
'published' => NovaCard::query()->where('status', NovaCard::STATUS_PUBLISHED)->count(),
'remixable' => NovaCard::query()->where('allow_remix', true)->count(),
'challenges' => NovaCardChallenge::query()->count(),
],
'reportingQueue' => [
'enabled' => true,
'pending' => (int) ($reportCounts['open'] ?? 0),
'label' => 'Nova Cards report queue',
'description' => 'Review open, investigating, and resolved reports for cards, challenge prompts, and challenge entries.',
'statuses' => [
'open' => (int) ($reportCounts['open'] ?? 0),
'reviewing' => (int) ($reportCounts['reviewing'] ?? 0),
'closed' => (int) ($reportCounts['closed'] ?? 0),
],
],
'endpoints' => [
'updateCardPattern' => route('cp.cards.update', ['card' => '__CARD__']),
'updateCreatorPattern' => route('cp.cards.creators.update', ['user' => '__CREATOR__']),
'templates' => route('cp.cards.templates.index'),
'assetPacks' => route('cp.cards.asset-packs.index'),
'challenges' => route('cp.cards.challenges.index'),
'collections' => route('cp.cards.collections.index'),
'storeCategory' => route('cp.cards.categories.store'),
'updateCategoryPattern' => route('cp.cards.categories.update', ['category' => '__CATEGORY__']),
'reportsQueue' => route('api.admin.reports.queue', ['group' => 'nova_cards']),
'updateReportPattern' => route('api.admin.reports.update', ['report' => '__REPORT__']),
'moderateReportTargetPattern' => route('api.admin.reports.moderate-target', ['report' => '__REPORT__']),
],
'moderationDispositionOptions' => [
NovaCard::MOD_PENDING => $this->moderation->dispositionOptions(NovaCard::MOD_PENDING),
NovaCard::MOD_APPROVED => $this->moderation->dispositionOptions(NovaCard::MOD_APPROVED),
NovaCard::MOD_FLAGGED => $this->moderation->dispositionOptions(NovaCard::MOD_FLAGGED),
NovaCard::MOD_REJECTED => $this->moderation->dispositionOptions(NovaCard::MOD_REJECTED),
],
'editorOptions' => $this->presenter->options(),
])->rootView('collections');
}
public function templates(Request $request): Response
{
return Inertia::render('Collection/NovaCardsTemplateAdmin', [
'templates' => NovaCardTemplate::query()->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,
'config_json' => $template->config_json,
'supported_formats' => $template->supported_formats,
'active' => (bool) $template->active,
'official' => (bool) $template->official,
'order_num' => (int) $template->order_num,
])->values()->all(),
'editorOptions' => $this->presenter->options(),
'endpoints' => [
'store' => route('cp.cards.templates.store'),
'updatePattern' => route('cp.cards.templates.update', ['template' => '__TEMPLATE__']),
'cards' => route('cp.cards.index'),
],
])->rootView('collections');
}
public function assetPacks(Request $request): Response
{
return Inertia::render('Collection/NovaCardsAssetPackAdmin', [
'packs' => NovaCardAssetPack::query()->orderBy('type')->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,
'order_num' => (int) $pack->order_num,
])->values()->all(),
'endpoints' => [
'store' => route('cp.cards.asset-packs.store'),
'updatePattern' => route('cp.cards.asset-packs.update', ['assetPack' => '__PACK__']),
'cards' => route('cp.cards.index'),
],
])->rootView('collections');
}
public function challenges(Request $request): Response
{
return Inertia::render('Collection/NovaCardsChallengeAdmin', [
'challenges' => NovaCardChallenge::query()->with('winnerCard')->orderByDesc('featured')->orderByDesc('starts_at')->get()->map(fn (NovaCardChallenge $challenge): array => [
'id' => (int) $challenge->id,
'slug' => (string) $challenge->slug,
'title' => (string) $challenge->title,
'description' => $challenge->description,
'prompt' => $challenge->prompt,
'rules_json' => $challenge->rules_json,
'status' => (string) $challenge->status,
'official' => (bool) $challenge->official,
'featured' => (bool) $challenge->featured,
'winner_card_id' => $challenge->winner_card_id ? (int) $challenge->winner_card_id : null,
'entries_count' => (int) $challenge->entries_count,
'starts_at' => optional($challenge->starts_at)?->format('Y-m-d\TH:i'),
'ends_at' => optional($challenge->ends_at)?->format('Y-m-d\TH:i'),
])->values()->all(),
'cards' => NovaCard::query()->published()->latest('published_at')->limit(100)->get()->map(fn (NovaCard $card): array => [
'id' => (int) $card->id,
'title' => (string) $card->title,
])->values()->all(),
'endpoints' => [
'store' => route('cp.cards.challenges.store'),
'updatePattern' => route('cp.cards.challenges.update', ['challenge' => '__CHALLENGE__']),
'cards' => route('cp.cards.index'),
],
])->rootView('collections');
}
public function collections(Request $request): Response
{
return Inertia::render('Collection/NovaCardsCollectionAdmin', [
'collections' => NovaCardCollection::query()
->with(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags'])
->orderByDesc('featured')
->orderByDesc('official')
->orderByDesc('updated_at')
->get()
->map(fn (NovaCardCollection $collection): array => $this->presenter->collection($collection, $request->user(), true))
->values()
->all(),
'cards' => NovaCard::query()
->published()
->latest('published_at')
->limit(200)
->get()
->map(fn (NovaCard $card): array => [
'id' => (int) $card->id,
'title' => (string) $card->title,
'slug' => (string) $card->slug,
'creator' => $card->user?->username,
])
->values()
->all(),
'admins' => \App\Models\User::query()
->whereIn('role', ['admin', 'moderator'])
->orderBy('username')
->limit(50)
->get(['id', 'username', 'name'])
->map(fn ($user): array => [
'id' => (int) $user->id,
'username' => (string) $user->username,
'name' => $user->name,
])
->values()
->all(),
'endpoints' => [
'store' => route('cp.cards.collections.store'),
'updatePattern' => route('cp.cards.collections.update', ['collection' => '__COLLECTION__']),
'attachCardPattern' => route('cp.cards.collections.cards.store', ['collection' => '__COLLECTION__']),
'detachCardPattern' => route('cp.cards.collections.cards.destroy', ['collection' => '__COLLECTION__', 'card' => '__CARD__']),
'cards' => route('cp.cards.index'),
],
])->rootView('collections');
}
public function storeTemplate(AdminStoreNovaCardTemplateRequest $request): JsonResponse
{
$template = NovaCardTemplate::query()->create($request->validated());
return response()->json([
'template' => [
'id' => (int) $template->id,
'slug' => (string) $template->slug,
'name' => (string) $template->name,
'description' => $template->description,
'preview_image' => $template->preview_image,
'config_json' => $template->config_json,
'supported_formats' => $template->supported_formats,
'active' => (bool) $template->active,
'official' => (bool) $template->official,
'order_num' => (int) $template->order_num,
],
]);
}
public function updateTemplate(AdminStoreNovaCardTemplateRequest $request, NovaCardTemplate $template): JsonResponse
{
$template->update($request->validated());
return response()->json([
'template' => [
'id' => (int) $template->id,
'slug' => (string) $template->slug,
'name' => (string) $template->name,
'description' => $template->description,
'preview_image' => $template->preview_image,
'config_json' => $template->config_json,
'supported_formats' => $template->supported_formats,
'active' => (bool) $template->active,
'official' => (bool) $template->official,
'order_num' => (int) $template->order_num,
],
]);
}
public function storeAssetPack(AdminStoreNovaCardAssetPackRequest $request): JsonResponse
{
$pack = NovaCardAssetPack::query()->create($request->validated());
return response()->json([
'pack' => [
'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,
'order_num' => (int) $pack->order_num,
],
]);
}
public function updateAssetPack(AdminStoreNovaCardAssetPackRequest $request, NovaCardAssetPack $assetPack): JsonResponse
{
$assetPack->update($request->validated());
return response()->json([
'pack' => [
'id' => (int) $assetPack->id,
'slug' => (string) $assetPack->slug,
'name' => (string) $assetPack->name,
'description' => $assetPack->description,
'type' => (string) $assetPack->type,
'preview_image' => $assetPack->preview_image,
'manifest_json' => $assetPack->manifest_json,
'official' => (bool) $assetPack->official,
'active' => (bool) $assetPack->active,
'order_num' => (int) $assetPack->order_num,
],
]);
}
public function storeChallenge(AdminStoreNovaCardChallengeRequest $request): JsonResponse
{
$challenge = NovaCardChallenge::query()->create($request->validated() + [
'user_id' => $request->user()->id,
]);
return response()->json([
'challenge' => [
'id' => (int) $challenge->id,
'slug' => (string) $challenge->slug,
'title' => (string) $challenge->title,
'description' => $challenge->description,
'prompt' => $challenge->prompt,
'rules_json' => $challenge->rules_json,
'status' => (string) $challenge->status,
'official' => (bool) $challenge->official,
'featured' => (bool) $challenge->featured,
'winner_card_id' => $challenge->winner_card_id ? (int) $challenge->winner_card_id : null,
'entries_count' => (int) $challenge->entries_count,
'starts_at' => optional($challenge->starts_at)?->format('Y-m-d\TH:i'),
'ends_at' => optional($challenge->ends_at)?->format('Y-m-d\TH:i'),
],
]);
}
public function updateChallenge(AdminStoreNovaCardChallengeRequest $request, NovaCardChallenge $challenge): JsonResponse
{
$challenge->update($request->validated());
return response()->json([
'challenge' => [
'id' => (int) $challenge->id,
'slug' => (string) $challenge->slug,
'title' => (string) $challenge->title,
'description' => $challenge->description,
'prompt' => $challenge->prompt,
'rules_json' => $challenge->rules_json,
'status' => (string) $challenge->status,
'official' => (bool) $challenge->official,
'featured' => (bool) $challenge->featured,
'winner_card_id' => $challenge->winner_card_id ? (int) $challenge->winner_card_id : null,
'entries_count' => (int) $challenge->entries_count,
'starts_at' => optional($challenge->starts_at)?->format('Y-m-d\TH:i'),
'ends_at' => optional($challenge->ends_at)?->format('Y-m-d\TH:i'),
],
]);
}
public function storeCategory(AdminStoreNovaCardCategoryRequest $request): JsonResponse
{
$category = NovaCardCategory::query()->create($request->validated());
return response()->json([
'category' => [
'id' => (int) $category->id,
'slug' => (string) $category->slug,
'name' => (string) $category->name,
'description' => $category->description,
'active' => (bool) $category->active,
'order_num' => (int) $category->order_num,
],
]);
}
public function updateCategory(AdminStoreNovaCardCategoryRequest $request, NovaCardCategory $category): JsonResponse
{
$category->update($request->validated());
return response()->json([
'category' => [
'id' => (int) $category->id,
'slug' => (string) $category->slug,
'name' => (string) $category->name,
'description' => $category->description,
'active' => (bool) $category->active,
'order_num' => (int) $category->order_num,
],
]);
}
public function storeCollection(Request $request): JsonResponse
{
$payload = $request->validate([
'user_id' => ['required', 'integer', 'exists:users,id'],
'slug' => ['nullable', 'string', 'max:140'],
'name' => ['required', 'string', 'min:2', 'max:120'],
'description' => ['nullable', 'string', 'max:1000'],
'visibility' => ['required', 'in:private,public'],
'official' => ['nullable', 'boolean'],
'featured' => ['nullable', 'boolean'],
]);
$collection = $this->collections->createManagedCollection($payload);
return response()->json([
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
]);
}
public function updateCollection(Request $request, NovaCardCollection $collection): JsonResponse
{
$payload = $request->validate([
'user_id' => ['required', 'integer', 'exists:users,id'],
'slug' => ['required', 'string', 'max:140'],
'name' => ['required', 'string', 'min:2', 'max:120'],
'description' => ['nullable', 'string', 'max:1000'],
'visibility' => ['required', 'in:private,public'],
'official' => ['nullable', 'boolean'],
'featured' => ['nullable', 'boolean'],
]);
$collection->update([
'user_id' => (int) $payload['user_id'],
'slug' => $payload['slug'],
'name' => $payload['name'],
'description' => $payload['description'] ?? null,
'visibility' => $payload['visibility'],
'official' => (bool) ($payload['official'] ?? false),
'featured' => (bool) ($payload['featured'] ?? false),
]);
return response()->json([
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
]);
}
public function storeCollectionCard(Request $request, NovaCardCollection $collection): JsonResponse
{
$payload = $request->validate([
'card_id' => ['required', 'integer', 'exists:nova_cards,id'],
'note' => ['nullable', 'string', 'max:1000'],
]);
$card = NovaCard::query()->findOrFail((int) $payload['card_id']);
$this->collections->addCardToCollection($collection, $card, $payload['note'] ?? null);
return response()->json([
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
]);
}
public function destroyCollectionCard(Request $request, NovaCardCollection $collection, NovaCard $card): JsonResponse
{
$this->collections->removeCardFromCollection($collection, $card);
return response()->json([
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
]);
}
public function updateCard(AdminUpdateNovaCardRequest $request, NovaCard $card): JsonResponse
{
$attributes = $request->validated();
$requestedDisposition = $attributes['disposition'] ?? null;
$hasModerationChange = array_key_exists('moderation_status', $attributes)
&& $attributes['moderation_status'] !== $card->moderation_status;
$hasDispositionChange = array_key_exists('disposition', $attributes)
&& $requestedDisposition !== (($this->moderation->latestOverride($card) ?? [])['disposition'] ?? null);
if (isset($attributes['featured']) && (! isset($attributes['status']) || $attributes['status'] !== NovaCard::STATUS_PUBLISHED)) {
$attributes['featured'] = (bool) $attributes['featured'] && $card->status === NovaCard::STATUS_PUBLISHED;
}
unset($attributes['disposition']);
$card->update($attributes);
if ($hasModerationChange || $hasDispositionChange) {
$card = $this->moderation->recordStaffOverride(
$card->fresh(),
(string) ($attributes['moderation_status'] ?? $card->moderation_status),
$request->user(),
'admin_card_update',
[
'disposition' => $requestedDisposition,
],
);
}
return response()->json([
'card' => $this->presenter->card($card->fresh()->loadMissing(['user.profile', 'category', 'template', 'backgroundImage', 'tags']), false, $request->user()),
]);
}
public function updateCreator(Request $request, User $user): JsonResponse
{
$attributes = $request->validate([
'nova_featured_creator' => ['required', 'boolean'],
]);
$user->update($attributes);
$publicCardsCount = $user->novaCards()->publiclyVisible()->count();
$featuredCardsCount = $user->novaCards()->publiclyVisible()->where('featured', true)->count();
$totalViewsCount = (int) ($user->novaCards()->publiclyVisible()->sum('views_count') ?? 0);
return response()->json([
'creator' => [
'id' => (int) $user->id,
'username' => (string) $user->username,
'name' => $user->name,
'display_name' => $user->name ?: '@' . $user->username,
'public_url' => route('cards.creator', ['username' => $user->username]),
'nova_featured_creator' => (bool) $user->nova_featured_creator,
'public_cards_count' => $publicCardsCount,
'featured_cards_count' => $featuredCardsCount,
'total_views_count' => $totalViewsCount,
],
]);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Http\Requests\Studio\ApplyArtworkAiAssistRequest;
use App\Services\Studio\StudioAiAssistEventService;
use App\Services\Studio\StudioAiAssistService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class StudioArtworkAiAssistApiController extends Controller
{
public function __construct(
private readonly StudioAiAssistService $aiAssist,
private readonly StudioAiAssistEventService $eventService,
) {
}
public function show(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType', 'artworkAiAssist'])->findOrFail($id);
return response()->json([
'data' => $this->aiAssist->payloadFor($artwork),
]);
}
public function analyze(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType'])->findOrFail($id);
$direct = (bool) $request->boolean('direct');
$intent = $request->validate([
'direct' => ['sometimes', 'boolean'],
'intent' => ['sometimes', 'nullable', 'string', 'in:analyze,title,description,tags,category,similar'],
])['intent'] ?? null;
if ($direct) {
$assist = $this->aiAssist->analyzeDirect($artwork, false, $intent);
return response()->json([
'success' => true,
'status' => $assist->status,
'direct' => true,
'data' => $this->aiAssist->payloadFor($artwork->fresh(['tags', 'categories.contentType', 'artworkAiAssist'])),
]);
}
$assist = $this->aiAssist->queueAnalysis($artwork, false, $intent);
return response()->json([
'success' => true,
'status' => $assist->status,
'direct' => false,
], 202);
}
public function regenerate(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType'])->findOrFail($id);
$direct = (bool) $request->boolean('direct');
$intent = $request->validate([
'direct' => ['sometimes', 'boolean'],
'intent' => ['sometimes', 'nullable', 'string', 'in:analyze,title,description,tags,category,similar'],
])['intent'] ?? null;
if ($direct) {
$assist = $this->aiAssist->analyzeDirect($artwork, true, $intent);
return response()->json([
'success' => true,
'status' => $assist->status,
'direct' => true,
'data' => $this->aiAssist->payloadFor($artwork->fresh(['tags', 'categories.contentType', 'artworkAiAssist'])),
]);
}
$assist = $this->aiAssist->queueAnalysis($artwork, true, $intent);
return response()->json([
'success' => true,
'status' => $assist->status,
'direct' => false,
], 202);
}
public function apply(ApplyArtworkAiAssistRequest $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType', 'artworkAiAssist'])->findOrFail($id);
return response()->json([
'success' => true,
'data' => $this->aiAssist->applySuggestions($artwork, $request->validated()),
]);
}
public function event(Request $request, int $id): JsonResponse
{
$payload = $request->validate([
'event_type' => ['required', 'string', 'max:64'],
'meta' => ['sometimes', 'array'],
]);
$artwork = $request->user()->artworks()->with('artworkAiAssist')->findOrFail($id);
$this->eventService->record(
$artwork,
(string) $payload['event_type'],
(array) ($payload['meta'] ?? []),
$artwork->artworkAiAssist,
);
return response()->json(['success' => true], 201);
}
}

View File

@@ -5,17 +5,23 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\ArtworkVersion;
use App\Services\ArtworkSearchIndexer;
use App\Services\TagService;
use App\Services\ArtworkVersioningService;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use App\Services\Tags\TagDiscoveryService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
/**
@@ -29,6 +35,7 @@ final class StudioArtworksApiController extends Controller
private readonly ArtworkVersioningService $versioningService,
private readonly ArtworkSearchIndexer $searchIndexer,
private readonly TagDiscoveryService $tagDiscoveryService,
private readonly TagService $tagService,
) {}
/**
@@ -117,21 +124,74 @@ final class StudioArtworksApiController extends Controller
'title' => 'sometimes|string|max:255',
'description' => 'sometimes|nullable|string|max:5000',
'is_public' => 'sometimes|boolean',
'visibility' => 'sometimes|string|in:public,unlisted,private',
'mode' => 'sometimes|string|in:now,schedule',
'publish_at' => 'sometimes|nullable|string|date',
'timezone' => 'sometimes|nullable|string|max:64',
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id',
'tags' => 'sometimes|array|max:15',
'tags.*' => 'string|max:64',
'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'tags_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'category_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
]);
if (isset($validated['is_public'])) {
if ($validated['is_public'] && !$artwork->is_public) {
$validated['published_at'] = $artwork->published_at ?? now();
$visibility = (string) ($validated['visibility'] ?? ($artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE)));
$mode = (string) ($validated['mode'] ?? ($artwork->artwork_status === 'scheduled' ? 'schedule' : 'now'));
$timezone = array_key_exists('timezone', $validated)
? $validated['timezone']
: $artwork->artwork_timezone;
$publishAt = null;
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
try {
$publishAt = Carbon::parse($validated['publish_at'])->utc();
} catch (\Throwable) {
return response()->json(['errors' => ['publish_at' => ['Invalid publish date/time.']]], 422);
}
if ($publishAt->lte(now()->addMinute())) {
return response()->json(['errors' => ['publish_at' => ['Scheduled publish time must be at least 1 minute in the future.']]], 422);
}
} elseif ($mode === 'schedule') {
return response()->json(['errors' => ['publish_at' => ['Choose a date and time for scheduled publishing.']]], 422);
}
// Extract tags and category before updating core fields
$tags = $validated['tags'] ?? null;
$categoryId = $validated['category_id'] ?? null;
unset($validated['tags'], $validated['category_id']);
$contentTypeId = $validated['content_type_id'] ?? null;
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone']);
$validated['visibility'] = $visibility;
$validated['artwork_timezone'] = $timezone;
if ($mode === 'schedule' && $publishAt) {
$validated['is_public'] = false;
$validated['is_approved'] = true;
$validated['publish_at'] = $publishAt;
$validated['published_at'] = null;
$validated['artwork_status'] = 'scheduled';
} else {
$validated['is_public'] = $visibility !== Artwork::VISIBILITY_PRIVATE;
$validated['is_approved'] = true;
$validated['publish_at'] = null;
$validated['artwork_status'] = 'published';
if (($validated['is_public'] ?? false) && ! $artwork->published_at) {
$validated['published_at'] = now();
}
if ($visibility === Artwork::VISIBILITY_PRIVATE) {
$validated['published_at'] = $artwork->published_at;
}
}
if ($categoryId === null && $contentTypeId !== null) {
$categoryId = $this->resolveCategoryIdForContentType((int) $contentTypeId);
}
$artwork->update($validated);
@@ -140,22 +200,26 @@ final class StudioArtworksApiController extends Controller
$artwork->categories()->sync([(int) $categoryId]);
}
// Sync tags (by slug/name)
// Sync tags through the shared tag service so pivot source/usage rules stay valid.
if ($tags !== null) {
$tagIds = [];
foreach ($tags as $tagSlug) {
$tag = \App\Models\Tag::firstOrCreate(
['slug' => \Illuminate\Support\Str::slug($tagSlug)],
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
try {
$this->tagService->syncStudioTags(
$artwork,
$tags,
(string) ($validated['tags_source'] ?? 'manual')
);
$tagIds[$tag->id] = ['source' => 'studio_edit', 'confidence' => 1.0];
} catch (ValidationException $exception) {
return response()->json(['errors' => $exception->errors()], 422);
}
$artwork->tags()->sync($tagIds);
}
// Reindex in Meilisearch
try {
$artwork->searchable();
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $e) {
// Meilisearch may be unavailable
}
@@ -171,14 +235,49 @@ final class StudioArtworksApiController extends Controller
'title' => $artwork->title,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'artwork_timezone' => $artwork->artwork_timezone,
'slug' => $artwork->slug,
'content_type_id' => $primaryCategory?->contentType?->id,
'category_id' => $primaryCategory?->id,
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
'title_source' => $artwork->title_source ?: 'manual',
'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual',
],
]);
}
private function resolveCategoryIdForContentType(int $contentTypeId): ?int
{
$contentType = ContentType::query()->find($contentTypeId);
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;
}
/**
* POST /api/studio/artworks/{id}/toggle
* Toggle publish/unpublish/archive for a single artwork.
@@ -247,8 +346,11 @@ final class StudioArtworksApiController extends Controller
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'is_approved' => (bool) $artwork->is_approved,
'published_at' => $artwork->published_at?->toIso8601String(),
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'created_at' => $artwork->created_at?->toIso8601String(),
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
'category' => $artwork->categories->first()?->name,

View File

@@ -71,7 +71,7 @@ final class StudioController extends Controller
public function edit(Request $request, int $id): Response
{
$artwork = $request->user()->artworks()
->with(['stats', 'categories.contentType', 'tags'])
->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist'])
->findOrFail($id);
$primaryCategory = $artwork->categories->first();
@@ -83,7 +83,12 @@ final class StudioController extends Controller
'slug' => $artwork->slug,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'),
'is_approved' => (bool) $artwork->is_approved,
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'artwork_timezone' => $artwork->artwork_timezone,
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'file_name' => $artwork->file_name,
@@ -97,6 +102,11 @@ final class StudioController extends Controller
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(),
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
'ai_status' => $artwork->ai_status,
'title_source' => $artwork->title_source ?: 'manual',
'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual',
// Versioning
'version_count' => (int) ($artwork->version_count ?? 1),
'requires_reapproval' => (bool) $artwork->requires_reapproval,

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardPresenter;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class StudioNovaCardsController extends Controller
{
public function __construct(
private readonly NovaCardPresenter $presenter,
) {
}
public function index(Request $request): Response
{
$cards = NovaCard::query()
->with(['category', 'template', 'backgroundImage', 'tags', 'user.profile'])
->where('user_id', $request->user()->id)
->latest('updated_at')
->paginate(18)
->withQueryString();
$baseQuery = NovaCard::query()->where('user_id', $request->user()->id);
return Inertia::render('Studio/StudioCardsIndex', [
'cards' => $this->presenter->paginator($cards, false, $request->user()),
'stats' => [
'all' => (clone $baseQuery)->count(),
'drafts' => (clone $baseQuery)->where('status', NovaCard::STATUS_DRAFT)->count(),
'processing' => (clone $baseQuery)->where('status', NovaCard::STATUS_PROCESSING)->count(),
'published' => (clone $baseQuery)->where('status', NovaCard::STATUS_PUBLISHED)->count(),
],
'editorOptions' => $this->presenter->options(),
'endpoints' => [
'create' => route('studio.cards.create'),
'editPattern' => route('studio.cards.edit', ['id' => '__CARD__']),
'previewPattern' => route('studio.cards.preview', ['id' => '__CARD__']),
'analyticsPattern' => route('studio.cards.analytics', ['id' => '__CARD__']),
'draftStore' => route('api.cards.drafts.store'),
],
]);
}
public function create(Request $request): Response
{
$options = $this->presenter->optionsWithPresets($this->presenter->options(), $request->user());
return Inertia::render('Studio/StudioCardEditor', [
'card' => null,
'previewMode' => false,
'mobileSteps' => $this->mobileSteps(),
'editorOptions' => $options,
'endpoints' => $this->editorEndpoints(),
]);
}
public function edit(Request $request, int $id): Response
{
$card = $this->ownedCard($request, $id);
$options = $this->presenter->optionsWithPresets($this->presenter->options(), $request->user());
return Inertia::render('Studio/StudioCardEditor', [
'card' => $this->presenter->card($card, true, $request->user()),
'versions' => $this->versionPayloads($card),
'previewMode' => false,
'mobileSteps' => $this->mobileSteps(),
'editorOptions' => $options,
'endpoints' => $this->editorEndpoints(),
]);
}
public function preview(Request $request, int $id): Response
{
$card = $this->ownedCard($request, $id);
$options = $this->presenter->optionsWithPresets($this->presenter->options(), $request->user());
return Inertia::render('Studio/StudioCardEditor', [
'card' => $this->presenter->card($card, true, $request->user()),
'versions' => $this->versionPayloads($card),
'previewMode' => true,
'mobileSteps' => $this->mobileSteps(),
'editorOptions' => $options,
'endpoints' => $this->editorEndpoints(),
]);
}
public function analytics(Request $request, int $id): Response
{
$card = $this->ownedCard($request, $id);
return Inertia::render('Studio/StudioCardAnalytics', [
'card' => $this->presenter->card($card, false, $request->user()),
'analytics' => [
'views' => (int) $card->views_count,
'likes' => (int) $card->likes_count,
'favorites' => (int) $card->favorites_count,
'saves' => (int) $card->saves_count,
'remixes' => (int) $card->remixes_count,
'comments' => (int) $card->comments_count,
'challenge_entries' => (int) $card->challenge_entries_count,
'shares' => (int) $card->shares_count,
'downloads' => (int) $card->downloads_count,
'trending_score' => (float) $card->trending_score,
'last_engaged_at' => optional($card->last_engaged_at)?->toDayDateTimeString(),
],
]);
}
public function remix(Request $request, int $id): RedirectResponse
{
$source = NovaCard::query()->published()->findOrFail($id);
abort_unless($source->canBeViewedBy($request->user()), 404);
$card = app(\App\Services\NovaCards\NovaCardDraftService::class)->createRemix($request->user(), $source->loadMissing('tags'));
return redirect()->route('studio.cards.edit', ['id' => $card->id]);
}
private function mobileSteps(): array
{
return [
['key' => 'format', 'label' => 'Format', 'description' => 'Choose the canvas shape and basic direction.'],
['key' => 'background', 'label' => 'Template & Background', 'description' => 'Pick the visual foundation for the card.'],
['key' => 'content', 'label' => 'Text', 'description' => 'Write the quote, author, and source.'],
['key' => 'style', 'label' => 'Style', 'description' => 'Fine-tune typography and layout.'],
['key' => 'preview', 'label' => 'Preview', 'description' => 'Check the live composition before publish.'],
['key' => 'publish', 'label' => 'Publish', 'description' => 'Review metadata and release settings.'],
];
}
private function editorEndpoints(): array
{
return [
'draftStore' => route('api.cards.drafts.store'),
'draftShowPattern' => route('api.cards.drafts.show', ['id' => '__CARD__']),
'draftUpdatePattern' => route('api.cards.drafts.update', ['id' => '__CARD__']),
'draftAutosavePattern' => route('api.cards.drafts.autosave', ['id' => '__CARD__']),
'draftBackgroundPattern' => route('api.cards.drafts.background', ['id' => '__CARD__']),
'draftRenderPattern' => route('api.cards.drafts.render', ['id' => '__CARD__']),
'draftPublishPattern' => route('api.cards.drafts.publish', ['id' => '__CARD__']),
'draftDeletePattern' => route('api.cards.drafts.destroy', ['id' => '__CARD__']),
'draftVersionsPattern' => route('api.cards.drafts.versions', ['id' => '__CARD__']),
'draftRestorePattern' => route('api.cards.drafts.restore', ['id' => '__CARD__', 'versionId' => '__VERSION__']),
'remixPattern' => route('api.cards.remix', ['id' => '__CARD__']),
'duplicatePattern' => route('api.cards.duplicate', ['id' => '__CARD__']),
'collectionsIndex' => route('api.cards.collections.index'),
'collectionsStore' => route('api.cards.collections.store'),
'savePattern' => route('api.cards.save', ['id' => '__CARD__']),
'likePattern' => route('api.cards.like', ['id' => '__CARD__']),
'favoritePattern' => route('api.cards.favorite', ['id' => '__CARD__']),
'challengeSubmitPattern' => route('api.cards.challenges.submit', ['challengeId' => '__CHALLENGE__', 'id' => '__CARD__']),
'studioCards' => route('studio.cards.index'),
'studioAnalyticsPattern' => route('studio.cards.analytics', ['id' => '__CARD__']),
// v3 endpoints
'presetsIndex' => route('api.cards.presets.index'),
'presetsStore' => route('api.cards.presets.store'),
'presetUpdatePattern' => route('api.cards.presets.update', ['id' => '__PRESET__']),
'presetDestroyPattern' => route('api.cards.presets.destroy', ['id' => '__PRESET__']),
'presetApplyPattern' => route('api.cards.presets.apply', ['presetId' => '__PRESET__', 'cardId' => '__CARD__']),
'capturePresetPattern' => route('api.cards.presets.capture', ['cardId' => '__CARD__']),
'aiSuggestPattern' => route('api.cards.ai-suggest', ['id' => '__CARD__']),
'exportPattern' => route('api.cards.export.store', ['id' => '__CARD__']),
'exportStatusPattern' => route('api.cards.exports.show', ['exportId' => '__EXPORT__']),
];
}
private function versionPayloads(NovaCard $card): array
{
return $card->versions()->latest('version_number')->get()->map(fn ($version): array => [
'id' => (int) $version->id,
'version_number' => (int) $version->version_number,
'label' => $version->label,
'created_at' => $version->created_at?->toISOString(),
'snapshot_json' => is_array($version->snapshot_json) ? $version->snapshot_json : [],
])->values()->all();
}
private function ownedCard(Request $request, int $id): NovaCard
{
return NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'versions'])
->where('user_id', $request->user()->id)
->findOrFail($id);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\Collections\ReorderSavedCollectionListItemsRequest;
use App\Models\Collection;
use App\Models\CollectionSavedList;
use App\Services\CollectionSavedLibraryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CollectionSavedLibraryController extends Controller
{
public function __construct(
private readonly CollectionSavedLibraryService $savedLibrary,
) {
}
public function storeList(Request $request): JsonResponse
{
$list = $this->savedLibrary->createList(
$request->user(),
(string) $request->validate([
'title' => ['required', 'string', 'min:2', 'max:120'],
])['title'],
);
return response()->json([
'ok' => true,
'list' => [
'id' => (int) $list->id,
'title' => $list->title,
'slug' => $list->slug,
'items_count' => 0,
'url' => route('me.saved.collections.lists.show', ['listSlug' => $list->slug]),
],
]);
}
public function storeItem(Request $request, Collection $collection): JsonResponse
{
$payload = $request->validate([
'saved_list_id' => ['required', 'integer', 'exists:collection_saved_lists,id'],
]);
$list = CollectionSavedList::query()->findOrFail((int) $payload['saved_list_id']);
$item = $this->savedLibrary->addToList($request->user(), $list, $collection);
return response()->json([
'ok' => true,
'added' => $item->wasRecentlyCreated,
'item' => [
'id' => (int) $item->id,
'saved_list_id' => (int) $item->saved_list_id,
'collection_id' => (int) $item->collection_id,
'order_num' => (int) $item->order_num,
],
'list' => [
'id' => (int) $list->id,
'items_count' => $this->savedLibrary->itemsCount($list),
],
]);
}
public function destroyItem(Request $request, CollectionSavedList $list, Collection $collection): JsonResponse
{
$removed = $this->savedLibrary->removeFromList($request->user(), $list, $collection);
return response()->json([
'ok' => true,
'removed' => $removed,
'list' => [
'id' => (int) $list->id,
'items_count' => $this->savedLibrary->itemsCount($list),
],
]);
}
public function reorderItems(ReorderSavedCollectionListItemsRequest $request, CollectionSavedList $list): JsonResponse
{
$orderedCollectionIds = $request->validated('collection_ids');
$this->savedLibrary->reorderList($request->user(), $list, $orderedCollectionIds);
return response()->json([
'ok' => true,
'list' => [
'id' => (int) $list->id,
'items_count' => $this->savedLibrary->itemsCount($list),
],
'ordered_collection_ids' => collect($orderedCollectionIds)
->map(static fn ($id) => (int) $id)
->values()
->all(),
]);
}
public function updateNote(Request $request, Collection $collection): JsonResponse
{
$payload = $request->validate([
'note' => ['nullable', 'string', 'max:1000'],
]);
$note = $this->savedLibrary->upsertNote($request->user(), $collection, $payload['note'] ?? null);
return response()->json([
'ok' => true,
'note' => $note ? [
'collection_id' => (int) $note->collection_id,
'note' => (string) $note->note,
] : null,
]);
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\User;
use App\Events\Collections\CollectionViewed;
use App\Http\Controllers\Controller;
use App\Models\Collection;
use App\Models\User;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionCommentService;
use App\Services\CollectionDiscoveryService;
use App\Services\CollectionFollowService;
use App\Services\CollectionLinkService;
use App\Services\CollectionLikeService;
use App\Services\CollectionLinkedCollectionsService;
use App\Services\CollectionRecommendationService;
use App\Services\CollectionSaveService;
use App\Services\CollectionSeriesService;
use App\Services\CollectionSubmissionService;
use App\Services\CollectionService;
use App\Support\UsernamePolicy;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class ProfileCollectionController extends Controller
{
public function __construct(
private readonly CollectionService $collections,
private readonly CollectionLikeService $likes,
private readonly CollectionFollowService $follows,
private readonly CollectionSaveService $saves,
private readonly CollectionCollaborationService $collaborators,
private readonly CollectionSubmissionService $submissions,
private readonly CollectionCommentService $comments,
private readonly CollectionDiscoveryService $discovery,
private readonly CollectionRecommendationService $recommendations,
private readonly CollectionLinkService $entityLinks,
private readonly CollectionLinkedCollectionsService $linkedCollections,
private readonly CollectionSeriesService $series,
) {
}
public function show(Request $request, string $username, string $slug)
{
$normalized = UsernamePolicy::normalize($username);
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
if (! $user) {
$redirect = DB::table('username_redirects')
->whereRaw('LOWER(old_username) = ?', [$normalized])
->value('new_username');
if ($redirect) {
return redirect()->route('profile.collections.show', [
'username' => strtolower((string) $redirect),
'slug' => $slug,
], 301);
}
abort(404);
}
if ($username !== strtolower((string) $user->username)) {
return redirect()->route('profile.collections.show', [
'username' => strtolower((string) $user->username),
'slug' => $slug,
], 301);
}
$collection = Collection::query()
->with([
'user.profile',
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
'canonicalCollection.user.profile',
])
->where('user_id', $user->id)
->where('slug', $slug)
->firstOrFail();
$viewer = $request->user();
$ownerView = $viewer && (int) $viewer->id === (int) $user->id;
if ($collection->canonical_collection_id) {
$canonicalTarget = $collection->canonicalCollection;
if ($canonicalTarget instanceof Collection
&& (int) $canonicalTarget->id !== (int) $collection->id
&& $canonicalTarget->user instanceof User
&& $canonicalTarget->canBeViewedBy($viewer)) {
return redirect()->route('profile.collections.show', [
'username' => strtolower((string) $canonicalTarget->user->username),
'slug' => $canonicalTarget->slug,
], 301);
}
}
if (! $collection->canBeViewedBy($viewer)) {
abort(404);
}
$collection = $this->collections->recordView($collection);
$this->saves->touchSavedCollectionView($viewer, $collection);
$artworks = $this->collections->getCollectionDetailArtworks($collection, $ownerView, 24);
$collectionPayload = $this->collections->mapCollectionDetailPayload($collection, $ownerView);
$manualRelatedCollections = $this->linkedCollections->publicLinkedCollections($collection, 6);
$recommendedCollections = $this->recommendations->relatedPublicCollections($collection, 6);
$seriesContext = $collection->inSeries()
? $this->series->seriesContext($collection)
: ['previous' => null, 'next' => null, 'items' => [], 'title' => null, 'description' => null];
event(new CollectionViewed($collection, $viewer?->id));
return Inertia::render('Collection/CollectionShow', [
'collection' => $collectionPayload,
'artworks' => $this->collections->mapArtworkPaginator($artworks),
'owner' => $collectionPayload['owner'],
'isOwner' => $ownerView,
'manageUrl' => $ownerView ? route('settings.collections.show', ['collection' => $collection->id]) : null,
'editUrl' => $ownerView ? route('settings.collections.edit', ['collection' => $collection->id]) : null,
'analyticsUrl' => $ownerView && $collection->supportsAnalytics() ? route('settings.collections.analytics', ['collection' => $collection->id]) : null,
'historyUrl' => $ownerView ? route('settings.collections.history', ['collection' => $collection->id]) : null,
'engagement' => [
'liked' => $this->likes->isLiked($viewer, $collection),
'following' => $this->follows->isFollowing($viewer, $collection),
'saved' => $this->saves->isSaved($viewer, $collection),
'can_interact' => ! $ownerView && $collection->isPubliclyEngageable(),
'like_url' => route('collections.like', ['collection' => $collection->id]),
'unlike_url' => route('collections.unlike', ['collection' => $collection->id]),
'follow_url' => route('collections.follow', ['collection' => $collection->id]),
'unfollow_url' => route('collections.unfollow', ['collection' => $collection->id]),
'save_url' => route('collections.save', ['collection' => $collection->id]),
'unsave_url' => route('collections.unsave', ['collection' => $collection->id]),
'share_url' => route('collections.share', ['collection' => $collection->id]),
'login_url' => route('login'),
],
'members' => $this->collaborators->mapMembers($collection, $viewer),
'submissions' => $this->submissions->mapSubmissions($collection, $viewer),
'comments' => $this->comments->mapComments($collection, $viewer),
'entityLinks' => $this->entityLinks->links($collection, true),
'relatedCollections' => $this->collections->mapCollectionCardPayloads(
$manualRelatedCollections
->concat($recommendedCollections)
->unique('id')
->take(6)
->values(),
false
),
'seriesContext' => [
'key' => $seriesContext['key'] ?? $collection->series_key,
'title' => $seriesContext['title'] ?? $collection->series_title,
'description' => $seriesContext['description'] ?? $collection->series_description,
'url' => ! empty($seriesContext['key']) ? route('collections.series.show', ['seriesKey' => $seriesContext['key']]) : null,
'previous' => $seriesContext['previous'] ? ($this->collections->mapCollectionCardPayloads(collect([$seriesContext['previous']]), false)[0] ?? null) : null,
'next' => $seriesContext['next'] ? ($this->collections->mapCollectionCardPayloads(collect([$seriesContext['next']]), false)[0] ?? null) : null,
'siblings' => $this->collections->mapCollectionCardPayloads(collect($seriesContext['items'] ?? [])->filter(fn (Collection $item) => (int) $item->id !== (int) $collection->id), false),
],
'submitEndpoint' => route('collections.submissions.store', ['collection' => $collection->id]),
'commentsEndpoint' => route('collections.comments.store', ['collection' => $collection->id]),
'submissionArtworkOptions' => $viewer ? $this->collections->getSubmissionArtworkOptions($viewer) : [],
'canSubmit' => $collection->canReceiveSubmissionsFrom($viewer),
'canComment' => $collection->canReceiveCommentsFrom($viewer),
'profileCollectionsUrl' => route('profile.tab', [
'username' => strtolower((string) $user->username),
'tab' => 'collections',
]),
'featuredCollectionsUrl' => route('collections.featured'),
'reportEndpoint' => $viewer ? route('api.reports.store') : null,
'seo' => [
'title' => $collection->is_featured
? sprintf('Featured: %s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName())
: sprintf('%s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()),
'description' => $collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase Nova.', $collection->title, $collection->displayOwnerName()),
'canonical' => $collectionPayload['public_url'],
'og_image' => $collectionPayload['cover_image'],
'robots' => $collection->visibility === Collection::VISIBILITY_PUBLIC ? 'index,follow' : 'noindex,nofollow',
],
])->rootView('collections');
}
public function showSeries(Request $request, string $seriesKey)
{
$seriesCollections = $this->series->publicSeriesItems($seriesKey);
abort_if($seriesCollections->isEmpty(), 404);
$mappedCollections = $this->collections->mapCollectionCardPayloads($seriesCollections, false);
$leadCollection = $mappedCollections[0] ?? null;
$ownersCount = $seriesCollections->pluck('user_id')->unique()->count();
$artworksCount = $seriesCollections->sum(fn (Collection $collection) => (int) $collection->artworks_count);
$latestActivityAt = $seriesCollections->max('last_activity_at');
$seriesMeta = $this->series->metadataFor($seriesCollections);
$seriesTitle = $seriesMeta['title'] ?: collect($mappedCollections)
->pluck('campaign_label')
->filter()
->first();
$seriesDescription = $seriesMeta['description'];
return Inertia::render('Collection/CollectionSeriesShow', [
'seriesKey' => $seriesKey,
'title' => $seriesTitle ?: sprintf('Collection Series: %s', str_replace(['-', '_'], ' ', $seriesKey)),
'description' => $seriesDescription ?: sprintf('Browse the %s collection series in sequence, with public entries ordered for smooth navigation across related curations.', $seriesKey),
'collections' => $mappedCollections,
'leadCollection' => $leadCollection,
'stats' => [
'collections' => $seriesCollections->count(),
'owners' => $ownersCount,
'artworks' => $artworksCount,
'latest_activity_at' => optional($latestActivityAt)?->toISOString(),
],
'seo' => [
'title' => sprintf('Series: %s — Skinbase Nova', $seriesKey),
'description' => sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey),
'canonical' => route('collections.series.show', ['seriesKey' => $seriesKey]),
'robots' => 'index,follow',
],
])->rootView('collections');
}
}

View File

@@ -24,7 +24,10 @@ use App\Services\AvatarService;
use App\Services\ArtworkService;
use App\Services\FollowService;
use App\Services\AchievementService;
use App\Services\CollectionService;
use App\Services\FollowAnalyticsService;
use App\Services\LeaderboardService;
use App\Services\UserSuggestionService;
use App\Services\Countries\CountryCatalogService;
use App\Services\ThumbnailPresenter;
use App\Services\ThumbnailService;
@@ -69,8 +72,11 @@ class ProfileController extends Controller
private readonly CaptchaVerifier $captchaVerifier,
private readonly XPService $xp,
private readonly AchievementService $achievements,
private readonly CollectionService $collections,
private readonly FollowAnalyticsService $followAnalytics,
private readonly LeaderboardService $leaderboards,
private readonly CountryCatalogService $countryCatalog,
private readonly UserSuggestionService $userSuggestions,
)
{
}
@@ -1003,9 +1009,11 @@ class ProfileController extends Controller
$followerCount = 0;
$recentFollowers = collect();
$viewerIsFollowing = false;
$followingCount = 0;
if (Schema::hasTable('user_followers')) {
$followerCount = DB::table('user_followers')->where('user_id', $user->id)->count();
$followingCount = DB::table('user_followers')->where('follower_id', $user->id)->count();
$recentFollowers = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.follower_id')
@@ -1033,6 +1041,30 @@ class ProfileController extends Controller
}
}
$liveUploadsCount = 0;
if (Schema::hasTable('artworks')) {
$liveUploadsCount = (int) DB::table('artworks')
->where('user_id', $user->id)
->whereNull('deleted_at')
->count();
}
$liveAwardsReceivedCount = 0;
if (Schema::hasTable('artwork_awards') && Schema::hasTable('artworks')) {
$liveAwardsReceivedCount = (int) DB::table('artwork_awards as aw')
->join('artworks as a', 'a.id', '=', 'aw.artwork_id')
->where('a.user_id', $user->id)
->whereNull('a.deleted_at')
->count();
}
$statsPayload = array_merge($stats ? (array) $stats : [], [
'uploads_count' => $liveUploadsCount,
'awards_received_count' => $liveAwardsReceivedCount,
'followers_count' => (int) $followerCount,
'following_count' => (int) $followingCount,
]);
// ── Profile comments ─────────────────────────────────────────────────
$profileComments = collect();
if (Schema::hasTable('profile_comments')) {
@@ -1066,6 +1098,11 @@ class ProfileController extends Controller
}
$xpSummary = $this->xp->summary((int) $user->id);
$followContext = $viewer && $viewer->id !== $user->id
? $this->followService->relationshipContext((int) $viewer->id, (int) $user->id)
: null;
$followAnalytics = $this->followAnalytics->summaryForUser((int) $user->id, $followerCount);
$suggestedUsers = $viewer ? $this->userSuggestions->suggestFor($viewer, 4) : [];
$creatorStories = Story::query()
->published()
@@ -1100,6 +1137,9 @@ class ProfileController extends Controller
'published_at' => $story->published_at?->toISOString(),
]);
$profileCollections = $this->collections->getProfileCollections($user, $viewer);
$profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner);
// ── Profile data ─────────────────────────────────────────────────────
$profile = $user->profile;
$country = $this->countryCatalog->resolveUserCountry($user);
@@ -1193,14 +1233,18 @@ class ProfileController extends Controller
'artworks' => $artworkPayload,
'featuredArtworks' => $featuredArtworks->values(),
'favourites' => $favourites,
'stats' => $stats,
'stats' => $statsPayload,
'socialLinks' => $socialLinks,
'followerCount' => $followerCount,
'recentFollowers' => $recentFollowers->values(),
'followContext' => $followContext,
'followAnalytics' => $followAnalytics,
'suggestedUsers' => $suggestedUsers,
'viewerIsFollowing' => $viewerIsFollowing,
'heroBgUrl' => $heroBgUrl,
'profileComments' => $profileComments->values(),
'creatorStories' => $creatorStories->values(),
'collections' => $profileCollectionsPayload,
'achievements' => $achievementSummary,
'leaderboardRank' => $leaderboardRank,
'countryName' => $countryName,
@@ -1209,6 +1253,10 @@ class ProfileController extends Controller
'initialTab' => $resolvedInitialTab,
'profileUrl' => $canonical,
'galleryUrl' => $galleryUrl,
'collectionCreateUrl' => $isOwner ? route('settings.collections.create') : null,
'collectionReorderUrl' => $isOwner ? route('settings.collections.reorder-profile') : null,
'collectionsFeaturedUrl' => route('collections.featured'),
'collectionFeatureLimit' => (int) config('collections.featured_limit', 3),
'profileTabUrls' => $profileTabUrls,
])->withViewData([
'page_title' => $galleryOnly

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\Collections\CollectionSavedLibraryRequest;
use App\Models\Collection;
use App\Models\CollectionSavedList;
use App\Services\CollectionRecommendationService;
use App\Services\CollectionSavedLibraryService;
use App\Services\CollectionService;
use Inertia\Inertia;
use Inertia\Response;
class SavedCollectionController extends Controller
{
public function __construct(
private readonly CollectionService $collections,
private readonly CollectionSavedLibraryService $savedLibrary,
private readonly CollectionRecommendationService $recommendations,
) {
}
public function index(CollectionSavedLibraryRequest $request): Response
{
return $this->renderSavedLibrary($request);
}
public function showList(CollectionSavedLibraryRequest $request, string $listSlug): Response
{
$list = $this->savedLibrary->findListBySlugForUser($request->user(), $listSlug);
return $this->renderSavedLibrary($request, $list);
}
private function renderSavedLibrary(CollectionSavedLibraryRequest $request, ?CollectionSavedList $activeList = null): Response
{
$savedCollections = $this->collections->getSavedCollectionsForUser($request->user(), 120);
$filter = (string) ($request->validated('filter') ?? 'all');
$sort = (string) ($request->validated('sort') ?? 'saved_desc');
$query = trim((string) ($request->validated('q') ?? ''));
$listId = $activeList ? (int) $activeList->id : ($request->filled('list') ? (int) $request->query('list') : null);
$preserveListOrder = false;
$listOrder = null;
if ($activeList) {
$preserveListOrder = true;
$allowedCollectionIds = $this->savedLibrary->collectionIdsForList($request->user(), $activeList);
$listOrder = array_flip($allowedCollectionIds);
$savedCollections = $savedCollections
->filter(fn ($collection) => in_array((int) $collection->id, $allowedCollectionIds, true))
->sortBy(fn ($collection) => $listOrder[(int) $collection->id] ?? PHP_INT_MAX)
->values();
} elseif ($listId) {
$preserveListOrder = true;
$activeList = $request->user()->savedCollectionLists()->withCount('items')->findOrFail($listId);
$allowedCollectionIds = $this->savedLibrary->collectionIdsForList($request->user(), $activeList);
$listOrder = array_flip($allowedCollectionIds);
$savedCollections = $savedCollections
->filter(fn ($collection) => in_array((int) $collection->id, $allowedCollectionIds, true))
->sortBy(fn ($collection) => $listOrder[(int) $collection->id] ?? PHP_INT_MAX)
->values();
}
$savedCollectionIds = $savedCollections->pluck('id')->map(static fn ($id): int => (int) $id)->all();
$notes = $this->savedLibrary->notesFor($request->user(), $savedCollectionIds);
$saveMetadata = $this->savedLibrary->saveMetadataFor($request->user(), $savedCollectionIds);
$filterCounts = $this->filterCounts($savedCollections, $notes);
$savedCollections = $savedCollections
->filter(fn (Collection $collection): bool => $this->matchesSearch($collection, $query))
->filter(fn (Collection $collection): bool => $this->matchesFilter($collection, $filter, $notes))
->values();
if (! ($preserveListOrder && $sort === 'saved_desc')) {
$savedCollections = $this->sortCollections($savedCollections, $sort)->values();
}
$collectionPayloads = $this->collections->mapCollectionCardPayloads($savedCollections, false);
$collectionIds = collect($collectionPayloads)->pluck('id')->map(static fn ($id) => (int) $id)->all();
$memberships = $this->savedLibrary->membershipsFor($request->user(), $collectionIds);
$savedLists = collect($this->savedLibrary->listsFor($request->user()))
->map(function (array $list) use ($filter, $sort, $query): array {
return [
...$list,
'url' => route('me.saved.collections.lists.show', ['listSlug' => $list['slug']]) . ($filter !== 'all' || $sort !== 'saved_desc'
? ('?' . http_build_query(array_filter([
'filter' => $filter !== 'all' ? $filter : null,
'sort' => $sort !== 'saved_desc' ? $sort : null,
'q' => $query !== '' ? $query : null,
])))
: ''),
];
})
->values()
->all();
$filterOptions = [
['key' => 'all', 'label' => 'All', 'count' => $filterCounts['all'] ?? 0],
['key' => 'editorial', 'label' => 'Editorial', 'count' => $filterCounts['editorial'] ?? 0],
['key' => 'community', 'label' => 'Community', 'count' => $filterCounts['community'] ?? 0],
['key' => 'personal', 'label' => 'Personal', 'count' => $filterCounts['personal'] ?? 0],
['key' => 'seasonal', 'label' => 'Seasonal or campaign', 'count' => $filterCounts['seasonal'] ?? 0],
['key' => 'noted', 'label' => 'With notes', 'count' => $filterCounts['noted'] ?? 0],
['key' => 'revisited', 'label' => 'Revisited', 'count' => $filterCounts['revisited'] ?? 0],
];
$sortOptions = [
['key' => 'saved_desc', 'label' => 'Recently saved'],
['key' => 'saved_asc', 'label' => 'Oldest saved'],
['key' => 'updated_desc', 'label' => 'Recently updated'],
['key' => 'revisited_desc', 'label' => 'Recently revisited'],
['key' => 'ranking_desc', 'label' => 'Highest ranking'],
['key' => 'title_asc', 'label' => 'Title A-Z'],
];
return Inertia::render('Collection/SavedCollections', [
'collections' => collect($collectionPayloads)->map(function (array $collection) use ($memberships, $notes, $saveMetadata): array {
return [
...$collection,
'saved_list_ids' => $memberships[(int) $collection['id']] ?? [],
'saved_note' => $notes[(int) $collection['id']] ?? null,
'saved_because' => $saveMetadata[(int) $collection['id']]['saved_because'] ?? null,
'last_viewed_at' => $saveMetadata[(int) $collection['id']]['last_viewed_at'] ?? null,
];
})->all(),
'recentlyRevisited' => $this->collections->mapCollectionCardPayloads($this->savedLibrary->recentlyRevisited($request->user(), 6), false),
'recommendedCollections' => $this->collections->mapCollectionCardPayloads($this->recommendations->recommendedForUser($request->user(), 6), false),
'savedLists' => $savedLists,
'activeList' => $activeList ? [
'id' => (int) $activeList->id,
'title' => (string) $activeList->title,
'slug' => (string) $activeList->slug,
'items_count' => (int) $activeList->items_count,
'url' => route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]),
] : null,
'activeFilters' => [
'q' => $query,
'filter' => $filter,
'sort' => $sort,
'list' => $listId,
],
'filterOptions' => $filterOptions,
'sortOptions' => $sortOptions,
'endpoints' => [
'createList' => route('me.saved.collections.lists.store'),
'addToListPattern' => route('me.saved.collections.lists.items.store', ['collection' => '__COLLECTION__']),
'removeFromListPattern' => route('me.saved.collections.lists.items.destroy', ['list' => '__LIST__', 'collection' => '__COLLECTION__']),
'reorderItemsPattern' => route('me.saved.collections.lists.items.reorder', ['list' => '__LIST__']),
'updateNotePattern' => route('me.saved.collections.notes.update', ['collection' => '__COLLECTION__']),
'unsavePattern' => route('collections.unsave', ['collection' => '__COLLECTION__']),
],
'libraryUrl' => route('me.saved.collections'),
'browseUrl' => route('collections.featured'),
'seo' => [
'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase Nova', $activeList->title) : 'Saved Collections — Skinbase Nova',
'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase Nova.', $activeList->title) : 'Your saved collections on Skinbase Nova.',
'canonical' => $activeList ? route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]) : route('me.saved.collections'),
'robots' => 'noindex,follow',
],
])->rootView('collections');
}
private function matchesSearch(Collection $collection, string $query): bool
{
if ($query === '') {
return true;
}
$haystacks = [
$collection->title,
$collection->subtitle,
$collection->summary,
$collection->description,
$collection->campaign_label,
$collection->season_key,
$collection->event_label,
$collection->series_title,
optional($collection->user)->username,
optional($collection->user)->name,
];
$needle = mb_strtolower($query);
return collect($haystacks)
->filter(fn ($value): bool => is_string($value) && $value !== '')
->contains(fn (string $value): bool => str_contains(mb_strtolower($value), $needle));
}
/**
* @param array<int, string> $notes
*/
private function matchesFilter(Collection $collection, string $filter, array $notes): bool
{
return match ($filter) {
'editorial' => $collection->type === Collection::TYPE_EDITORIAL,
'community' => $collection->type === Collection::TYPE_COMMUNITY,
'personal' => $collection->type === Collection::TYPE_PERSONAL,
'seasonal' => filled($collection->season_key) || filled($collection->event_key) || filled($collection->campaign_key),
'noted' => filled($notes[(int) $collection->id] ?? null),
'revisited' => $this->timestamp($collection->saved_last_viewed_at) !== $this->timestamp($collection->saved_at),
default => true,
};
}
/**
* @param array<int, string> $notes
* @return array<string, int>
*/
private function filterCounts($collections, array $notes): array
{
return [
'all' => $collections->count(),
'editorial' => $collections->where('type', Collection::TYPE_EDITORIAL)->count(),
'community' => $collections->where('type', Collection::TYPE_COMMUNITY)->count(),
'personal' => $collections->where('type', Collection::TYPE_PERSONAL)->count(),
'seasonal' => $collections->filter(fn (Collection $collection): bool => filled($collection->season_key) || filled($collection->event_key) || filled($collection->campaign_key))->count(),
'noted' => $collections->filter(fn (Collection $collection): bool => filled($notes[(int) $collection->id] ?? null))->count(),
'revisited' => $collections->filter(fn (Collection $collection): bool => $this->timestamp($collection->saved_last_viewed_at) !== $this->timestamp($collection->saved_at))->count(),
];
}
private function sortCollections($collections, string $sort)
{
return match ($sort) {
'saved_asc' => $collections->sortBy(fn (Collection $collection): int => $this->timestamp($collection->saved_at)),
'updated_desc' => $collections->sortByDesc(fn (Collection $collection): int => $this->timestamp($collection->updated_at)),
'revisited_desc' => $collections->sortByDesc(fn (Collection $collection): int => $this->timestamp($collection->saved_last_viewed_at)),
'ranking_desc' => $collections->sortByDesc(fn (Collection $collection): float => (float) ($collection->ranking_score ?? 0)),
'title_asc' => $collections->sortBy(fn (Collection $collection): string => mb_strtolower((string) $collection->title)),
default => $collections->sortByDesc(fn (Collection $collection): int => $this->timestamp($collection->saved_at)),
};
}
private function timestamp(mixed $value): int
{
if ($value instanceof \DateTimeInterface) {
return $value->getTimestamp();
}
if (is_numeric($value)) {
return (int) $value;
}
if (is_string($value) && $value !== '') {
$timestamp = strtotime($value);
return $timestamp !== false ? $timestamp : 0;
}
return 0;
}
}

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Collection;
use App\Services\CollectionCampaignService;
use App\Services\CollectionDiscoveryService;
use App\Services\CollectionPartnerProgramService;
use App\Services\CollectionRecommendationService;
use App\Services\CollectionSearchService;
use App\Services\CollectionService;
use App\Services\CollectionSurfaceService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class CollectionDiscoveryController extends Controller
{
public function __construct(
private readonly CollectionDiscoveryService $discovery,
private readonly CollectionService $collections,
private readonly CollectionRecommendationService $recommendations,
private readonly CollectionSearchService $search,
private readonly CollectionSurfaceService $surfaces,
private readonly CollectionCampaignService $campaigns,
private readonly CollectionPartnerProgramService $partnerPrograms,
) {
}
public function search(Request $request)
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'type' => ['nullable', 'string', 'max:40'],
'category' => ['nullable', 'string', 'max:80'],
'style' => ['nullable', 'string', 'max:80'],
'theme' => ['nullable', 'string', 'max:80'],
'color' => ['nullable', 'string', 'max:80'],
'quality_tier' => ['nullable', 'string', 'max:40'],
'lifecycle_state' => ['nullable', 'string', 'max:40'],
'mode' => ['nullable', 'string', 'max:40'],
'campaign_key' => ['nullable', 'string', 'max:80'],
'program_key' => ['nullable', 'string', 'max:80'],
'workflow_state' => ['nullable', 'string', 'max:40'],
'health_state' => ['nullable', 'string', 'max:40'],
'sort' => ['nullable', 'in:trending,recent,quality,evergreen'],
]);
$results = $this->search->publicSearch($filters, (int) config('collections.v5.search.public_per_page', 18));
return Inertia::render('Collection/CollectionFeaturedIndex', [
'eyebrow' => 'Search',
'title' => 'Search collections',
'description' => filled($filters['q'] ?? null)
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
'seo' => [
'title' => 'Search Collections — Skinbase Nova',
'description' => 'Search public collections by category, theme, quality tier, and curator context.',
'canonical' => route('collections.search'),
'robots' => 'index,follow',
],
'collections' => $this->collections->mapCollectionCardPayloads($results->items(), false, $request->user()),
'communityCollections' => [],
'editorialCollections' => [],
'recentCollections' => [],
'trendingCollections' => [],
'seasonalCollections' => [],
'campaign' => null,
'search' => [
'filters' => $filters,
'options' => $this->search->publicFilterOptions(),
'meta' => [
'current_page' => $results->currentPage(),
'last_page' => $results->lastPage(),
'per_page' => $results->perPage(),
'total' => $results->total(),
],
'links' => [
'next' => $results->nextPageUrl(),
'prev' => $results->previousPageUrl(),
],
],
])->rootView('collections');
}
public function featured(Request $request)
{
$featuredCollections = $this->surfaces->resolveSurfaceItems('discover.featured_collections', (int) config('collections.discovery.featured_limit', 18));
return $this->renderIndex(
viewer: $request->user(),
eyebrow: 'Discovery',
title: 'Featured collections',
description: 'A rotating set of standout galleries from across Skinbase Nova. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.',
collections: $featuredCollections->isNotEmpty() ? $featuredCollections : $this->discovery->publicFeaturedCollections((int) config('collections.discovery.featured_limit', 18)),
communityCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_COMMUNITY, 6),
editorialCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_EDITORIAL, 6),
recentCollections: $this->discovery->publicRecentCollections(6),
trendingCollections: $this->discovery->publicTrendingCollections(6),
seasonalCollections: $this->discovery->publicSeasonalCollections(6),
);
}
public function recommended(Request $request)
{
$collections = $this->recommendations->recommendedForUser($request->user(), (int) config('collections.discovery.featured_limit', 18));
return $this->renderIndex(
viewer: $request->user(),
eyebrow: $request->user() ? 'For You' : 'Discovery',
title: $request->user() ? 'Recommended collections' : 'Collections worth exploring',
description: $request->user()
? 'A safe, public-only recommendation feed based on the collections you save, follow, and engage with. Private, restricted, and unlisted sets are excluded.'
: 'A safe public collection mix built from current momentum, editorial quality, and community interest.',
collections: $collections,
recentCollections: $this->discovery->publicRecentCollections(6),
trendingCollections: $this->discovery->publicTrendingCollections(6),
editorialCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_EDITORIAL, 6),
);
}
public function trending(Request $request)
{
return $this->renderIndex(
viewer: $request->user(),
eyebrow: 'Trending',
title: 'Trending collections',
description: 'The collections drawing the strongest blend of follows, likes, saves, comments, and recent activity right now.',
collections: $this->discovery->publicTrendingCollections((int) config('collections.discovery.featured_limit', 18)),
recentCollections: $this->discovery->publicRecentlyActiveCollections(6),
);
}
public function editorial(Request $request)
{
return $this->renderIndex(
viewer: $request->user(),
eyebrow: 'Editorial',
title: 'Editorial collections',
description: 'Staff picks, campaign showcases, and premium curated sets with stronger scheduling and presentation rules.',
collections: $this->discovery->publicCollectionsByType(Collection::TYPE_EDITORIAL, (int) config('collections.discovery.featured_limit', 18)),
);
}
public function community(Request $request)
{
return $this->renderIndex(
viewer: $request->user(),
eyebrow: 'Community',
title: 'Community collections',
description: 'Collaborative and submission-friendly showcases that celebrate both the curator and the creators featured inside them.',
collections: $this->discovery->publicCollectionsByType(Collection::TYPE_COMMUNITY, (int) config('collections.discovery.featured_limit', 18)),
);
}
public function seasonal(Request $request)
{
return $this->renderIndex(
viewer: $request->user(),
eyebrow: 'Seasonal',
title: 'Seasonal and event collections',
description: 'Collections prepared for campaigns, seasonal spotlights, and event-driven showcases with dedicated metadata.',
collections: $this->discovery->publicSeasonalCollections((int) config('collections.discovery.featured_limit', 18)),
editorialCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_EDITORIAL, 6),
communityCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_COMMUNITY, 6),
);
}
public function campaign(Request $request, string $campaignKey)
{
$landing = $this->campaigns->publicLanding($campaignKey, (int) config('collections.discovery.featured_limit', 18));
$campaign = $landing['campaign'];
abort_if(collect($landing['collections'])->isEmpty(), 404);
return $this->renderIndex(
viewer: $request->user(),
eyebrow: 'Campaign',
title: $campaign['label'],
description: $campaign['description'],
collections: $landing['collections'],
communityCollections: $landing['community_collections'],
editorialCollections: $landing['editorial_collections'],
recentCollections: $landing['recent_collections'],
trendingCollections: $landing['trending_collections'],
campaign: $campaign,
);
}
public function program(Request $request, string $programKey)
{
$landing = $this->partnerPrograms->publicLanding($programKey, (int) config('collections.discovery.featured_limit', 18));
$program = $landing['program'];
abort_if(! $program || collect($landing['collections'])->isEmpty(), 404);
return Inertia::render('Collection/CollectionFeaturedIndex', [
'eyebrow' => 'Program',
'title' => $program['label'],
'description' => $program['description'],
'seo' => [
'title' => sprintf('%s — Skinbase Nova', $program['label']),
'description' => $program['description'],
'canonical' => route('collections.program.show', ['programKey' => $program['key']]),
'robots' => 'index,follow',
],
'collections' => $this->collections->mapCollectionCardPayloads($landing['collections'], false, $request->user()),
'communityCollections' => $this->collections->mapCollectionCardPayloads($landing['community_collections'] ?? collect(), false, $request->user()),
'editorialCollections' => $this->collections->mapCollectionCardPayloads($landing['editorial_collections'] ?? collect(), false, $request->user()),
'recentCollections' => $this->collections->mapCollectionCardPayloads($landing['recent_collections'] ?? collect(), false, $request->user()),
'trendingCollections' => [],
'seasonalCollections' => [],
'campaign' => null,
'program' => $program,
])->rootView('collections');
}
private function renderIndex(
$viewer,
string $eyebrow,
string $title,
string $description,
$collections,
$communityCollections = null,
$editorialCollections = null,
$recentCollections = null,
$trendingCollections = null,
$seasonalCollections = null,
$campaign = null,
) {
return Inertia::render('Collection/CollectionFeaturedIndex', [
'eyebrow' => $eyebrow,
'title' => $title,
'description' => $description,
'seo' => [
'title' => sprintf('%s — Skinbase Nova', $title),
'description' => $description,
'canonical' => url()->current(),
'robots' => 'index,follow',
],
'collections' => $this->collections->mapCollectionCardPayloads($collections, false, $viewer),
'communityCollections' => $this->collections->mapCollectionCardPayloads($communityCollections ?? collect(), false, $viewer),
'editorialCollections' => $this->collections->mapCollectionCardPayloads($editorialCollections ?? collect(), false, $viewer),
'recentCollections' => $this->collections->mapCollectionCardPayloads($recentCollections ?? collect(), false, $viewer),
'trendingCollections' => $this->collections->mapCollectionCardPayloads($trendingCollections ?? collect(), false, $viewer),
'seasonalCollections' => $this->collections->mapCollectionCardPayloads($seasonalCollections ?? collect(), false, $viewer),
'campaign' => $campaign,
])->rootView('collections');
}
}

View File

@@ -4,11 +4,13 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\CommunityActivityService;
use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\EarlyGrowth\FeedBlender;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Recommendation\RecommendationService;
use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserSuggestionService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -31,9 +33,11 @@ final class DiscoverController extends Controller
public function __construct(
private readonly ArtworkService $artworkService,
private readonly ArtworkSearchService $searchService,
private readonly RecommendationService $recoService,
private readonly RecommendationFeedResolver $feedResolver,
private readonly FeedBlender $feedBlender,
private readonly GridFiller $gridFiller,
private readonly CommunityActivityService $communityActivity,
private readonly UserSuggestionService $userSuggestions,
) {}
// ─── /discover/trending ──────────────────────────────────────────────────
@@ -225,51 +229,38 @@ final class DiscoverController extends Controller
/**
* Personalised "For You" feed page.
*
* Uses RecommendationService (Phase 1 tag-affinity + creator-affinity pipeline)
* and renders the standard discover grid view. Guest users are redirected
* to the trending page per spec.
* Uses the newer personalized feed service so the web surface stays aligned
* with the API recommendation stack and discovery-event training loop.
*/
public function forYou(Request $request)
{
$user = $request->user();
$limit = 40;
$limit = max(1, min(50, (int) $request->query('limit', 40)));
$cursor = $request->query('cursor') ?: null;
// Retrieve the paginated feed (service handles Meilisearch + reranking + cache)
$feedResult = $this->recoService->forYouFeed(
user: $user,
limit: $limit,
$feedResult = $this->feedResolver->getFeed(
userId: (int) $user->id,
limit: $limit,
cursor: is_string($cursor) ? $cursor : null,
algoVersion: $request->filled('algo_version') ? (string) $request->query('algo_version') : null,
);
$artworkItems = $feedResult['data'] ?? [];
// Build a simple presentable collection
$artworks = collect($artworkItems)->map(function (array $item) {
$width = isset($item['width']) && $item['width'] > 0 ? (int) $item['width'] : null;
$height = isset($item['height']) && $item['height'] > 0 ? (int) $item['height'] : null;
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($item['author_id'] ?? 0), null, 64);
return (object) [
'id' => $item['id'] ?? 0,
'name' => $item['title'] ?? 'Untitled',
'category_name' => $item['category_name'] ?? '',
'category_slug' => $item['category_slug'] ?? '',
'thumb_url' => $item['thumbnail_url'] ?? null,
'thumb_srcset' => $item['thumbnail_url'] ?? null,
'uname' => $item['author'] ?? 'Artist',
'username' => $item['username'] ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $item['published_at'] ?? null,
'slug' => $item['slug'] ?? '',
'width' => $width,
'height' => $height,
];
});
$artworks = collect($feedResult['data'] ?? [])->map(
fn (array $item) => $this->presentRecommendedArtwork($item)
)->values();
$meta = $feedResult['meta'] ?? [];
$nextCursor = $meta['next_cursor'] ?? null;
if ($request->ajax()) {
return response()->json([
'artworks' => $artworks->map(fn (object $artwork) => (array) $artwork)->all(),
'next_cursor' => $nextCursor,
'has_more' => ! empty($nextCursor),
'meta' => $meta,
]);
}
return view('web.discover.for-you', [
'artworks' => $artworks,
'page_title' => 'For You',
@@ -277,6 +268,7 @@ final class DiscoverController extends Controller
'description' => 'Artworks picked for you based on your taste.',
'icon' => 'fa-wand-magic-sparkles',
'next_cursor' => $nextCursor,
'feed_meta' => $meta,
'cache_status' => $meta['cache_status'] ?? null,
]);
}
@@ -304,20 +296,7 @@ final class DiscoverController extends Controller
}
// Suggested creators: most-followed users the viewer doesn't follow yet
$suggestedCreators = DB::table('users')
->join('user_statistics', 'users.id', '=', 'user_statistics.user_id')
->where('users.id', '!=', $user->id)
->whereNotNull('users.email_verified_at')
->where('users.is_active', true)
->orderByDesc('user_statistics.followers_count')
->limit(8)
->select(
'users.id',
'users.name',
'users.username',
'user_statistics.followers_count',
)
->get();
$suggestedCreators = $this->userSuggestions->suggestFor($user, 6);
return view('web.discover.index', [
'artworks' => collect(),
@@ -328,6 +307,9 @@ final class DiscoverController extends Controller
'empty' => true,
'fallback_trending' => $fallbackArtworks,
'fallback_creators' => $suggestedCreators,
'following_activity' => [],
'network_trending' => [],
'suggested_users' => $suggestedCreators,
]);
}
@@ -347,12 +329,23 @@ final class DiscoverController extends Controller
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$networkActivity = $this->communityActivity->getFeed(
viewer: $user,
filter: 'following',
page: 1,
perPage: 8,
actorUserId: null,
);
return view('web.discover.index', [
'artworks' => $artworks,
'page_title' => 'Following Feed',
'section' => 'following',
'description' => 'The latest artworks from creators you follow.',
'icon' => 'fa-user-group',
'following_activity' => $networkActivity['data'] ?? [],
'network_trending' => $this->buildNetworkTrendingArtworks($followingIds->all(), 8),
'suggested_users' => $this->userSuggestions->suggestFor($user, 6),
]);
}
@@ -443,4 +436,81 @@ final class DiscoverController extends Controller
'height' => $artwork->height ?? null,
];
}
/**
* @param array<string, mixed> $item
*/
private function presentRecommendedArtwork(array $item): object
{
$width = isset($item['width']) && (int) $item['width'] > 0 ? (int) $item['width'] : null;
$height = isset($item['height']) && (int) $item['height'] > 0 ? (int) $item['height'] : null;
return (object) [
'id' => (int) ($item['id'] ?? 0),
'name' => (string) ($item['title'] ?? 'Untitled'),
'thumb_url' => $item['thumbnail_url'] ?? null,
'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
'uname' => (string) ($item['author'] ?? 'Artist'),
'username' => (string) ($item['username'] ?? ''),
'avatar_url' => $item['avatar_url'] ?? null,
'published_at' => $item['published_at'] ?? null,
'slug' => (string) ($item['slug'] ?? ''),
'url' => $item['url'] ?? null,
'width' => $width,
'height' => $height,
'content_type_name' => (string) ($item['content_type_name'] ?? ''),
'content_type_slug' => (string) ($item['content_type_slug'] ?? ''),
'category_name' => (string) ($item['category_name'] ?? ''),
'category_slug' => (string) ($item['category_slug'] ?? ''),
'primary_tag' => $item['primary_tag'] ?? null,
'tags' => is_array($item['tags'] ?? null) ? $item['tags'] : [],
'recommendation_source' => (string) ($item['source'] ?? 'mixed'),
'recommendation_reason' => (string) ($item['reason'] ?? 'Picked for you'),
'recommendation_score' => isset($item['score']) ? round((float) $item['score'], 4) : null,
'recommendation_algo_version' => (string) ($item['algo_version'] ?? ''),
'hide_artwork_endpoint' => route('api.discovery.feedback.hide-artwork'),
'dislike_tag_endpoint' => route('api.discovery.feedback.dislike-tag'),
];
}
private function buildNetworkTrendingArtworks(array $followingIds, int $limit = 8): array
{
if ($followingIds === []) {
return [];
}
return Artwork::query()
->public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash', 'stats:artwork_id,views,favorites,comments_count,heat_score'])
->whereIn('user_id', $followingIds)
->where('published_at', '>=', now()->subDays(30))
->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'artworks.id')
->orderByDesc(DB::raw('COALESCE(ast.heat_score, 0)'))
->orderByDesc(DB::raw('COALESCE(ast.favorites, 0)'))
->orderByDesc('artworks.published_at')
->limit(max(1, $limit))
->select('artworks.*')
->get()
->map(fn (Artwork $artwork) => [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $artwork->slug]),
'thumb_url' => $artwork->thumb_url,
'published_at' => $artwork->published_at?->diffForHumans(),
'author' => [
'username' => $artwork->user?->username,
'name' => $artwork->user?->name,
'avatar_url' => $artwork->user?->profile?->avatar_url,
'profile_url' => $artwork->user?->username ? '/@' . strtolower((string) $artwork->user->username) : null,
],
'stats' => [
'favorites' => (int) ($artwork->stats?->favorites ?? 0),
'views' => (int) ($artwork->stats?->views ?? 0),
'comments' => (int) ($artwork->stats?->comments_count ?? 0),
],
])
->values()
->all();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,14 @@ class ForumBotProtectionMiddleware
public function handle(Request $request, Closure $next, string $action = 'generic'): Response|RedirectResponse|JsonResponse
{
if (! (bool) config('forum_bot_protection.enabled', true)) {
return $next($request);
}
if ($this->shouldBypassForLocalE2E($request)) {
return $next($request);
}
$assessment = $this->botProtectionService->assess($request, $action);
$request->attributes->set('forum_bot_assessment', $assessment);
@@ -93,4 +101,19 @@ class ForumBotProtectionMiddleware
return in_array($action, (array) config('forum_bot_protection.captcha.actions', []), true);
}
private function shouldBypassForLocalE2E(Request $request): bool
{
if (! app()->environment(['local', 'testing'])) {
return false;
}
if ($request->cookies->get('e2e_bot_bypass') === '1') {
return true;
}
$userAgent = strtolower((string) $request->userAgent());
return str_contains($userAgent, 'headlesschrome') || str_contains($userAgent, 'playwright');
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
final class UpdateLastVisit
{
private const SESSION_KEY = 'last_visit.logged_at';
private const THROTTLE_SECONDS = 300;
private static ?bool $usersTableHasLastVisitAt = null;
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$user = $request->user();
if (! $user) {
return $response;
}
if (! $this->usersTableHasLastVisitAt()) {
return $response;
}
$now = now();
$session = $request->hasSession() ? $request->session() : null;
$lastLoggedAt = $session?->get(self::SESSION_KEY);
if (is_numeric($lastLoggedAt) && ((int) $lastLoggedAt + self::THROTTLE_SECONDS) > $now->getTimestamp()) {
return $response;
}
$lastVisitAt = $user->last_visit_at;
if ($lastVisitAt !== null && method_exists($lastVisitAt, 'getTimestamp')) {
if (($lastVisitAt->getTimestamp() + self::THROTTLE_SECONDS) > $now->getTimestamp()) {
$session?->put(self::SESSION_KEY, $lastVisitAt->getTimestamp());
return $response;
}
}
DB::table('users')
->where('id', $user->id)
->update([
'last_visit_at' => $now,
'updated_at' => $now,
]);
$user->forceFill(['last_visit_at' => $now]);
$session?->put(self::SESSION_KEY, $now->getTimestamp());
return $response;
}
private function usersTableHasLastVisitAt(): bool
{
return self::$usersTableHasLastVisitAt ??= Schema::hasColumn('users', 'last_visit_at');
}
}

View File

@@ -27,6 +27,7 @@ final class ArtworkCreateRequest extends FormRequest
'category' => 'nullable|integer|exists:categories,id',
'tags' => 'nullable|string|max:200',
'license' => 'nullable|boolean',
'is_mature' => 'nullable|boolean',
];
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Collections;
use Illuminate\Foundation\Http\FormRequest;
class AttachCollectionArtworksRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'artwork_ids' => ['required', 'array', 'min:1'],
'artwork_ids.*' => ['integer', 'distinct'],
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Http\FormRequest;
class CollectionBulkActionsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'action' => ['required', 'string', 'in:archive,assign_campaign,update_lifecycle,request_ai_review,mark_editorial_review'],
'collection_ids' => ['required', 'array', 'min:1'],
'collection_ids.*' => ['integer', 'distinct'],
'campaign_key' => ['nullable', 'string', 'max:80'],
'campaign_label' => ['nullable', 'string', 'max:120'],
'lifecycle_state' => ['nullable', 'string', 'in:' . implode(',', [
Collection::LIFECYCLE_DRAFT,
Collection::LIFECYCLE_PUBLISHED,
Collection::LIFECYCLE_ARCHIVED,
])],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
$action = (string) $this->input('action', '');
if ($action === 'assign_campaign' && blank($this->input('campaign_key'))) {
$validator->errors()->add('campaign_key', 'Campaign key is required for campaign assignment.');
}
if ($action === 'update_lifecycle' && blank($this->input('lifecycle_state'))) {
$validator->errors()->add('lifecycle_state', 'Lifecycle state is required for lifecycle updates.');
}
});
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Http\FormRequest;
class CollectionOwnerSearchRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'q' => ['nullable', 'string', 'max:120'],
'type' => ['nullable', 'string', 'in:' . implode(',', [
Collection::TYPE_PERSONAL,
Collection::TYPE_COMMUNITY,
Collection::TYPE_EDITORIAL,
])],
'visibility' => ['nullable', 'string', 'in:' . implode(',', [
Collection::VISIBILITY_PUBLIC,
Collection::VISIBILITY_UNLISTED,
Collection::VISIBILITY_PRIVATE,
])],
'lifecycle_state' => ['nullable', 'string', 'in:' . implode(',', [
Collection::LIFECYCLE_DRAFT,
Collection::LIFECYCLE_SCHEDULED,
Collection::LIFECYCLE_PUBLISHED,
Collection::LIFECYCLE_FEATURED,
Collection::LIFECYCLE_ARCHIVED,
Collection::LIFECYCLE_HIDDEN,
Collection::LIFECYCLE_RESTRICTED,
Collection::LIFECYCLE_UNDER_REVIEW,
Collection::LIFECYCLE_EXPIRED,
])],
'mode' => ['nullable', 'string', 'in:' . implode(',', [
Collection::MODE_MANUAL,
Collection::MODE_SMART,
])],
'campaign_key' => ['nullable', 'string', 'max:80'],
'program_key' => ['nullable', 'string', 'max:80'],
'workflow_state' => ['nullable', 'string', 'in:' . implode(',', [
Collection::WORKFLOW_DRAFT,
Collection::WORKFLOW_IN_REVIEW,
Collection::WORKFLOW_APPROVED,
Collection::WORKFLOW_PROGRAMMED,
Collection::WORKFLOW_ARCHIVED,
])],
'health_state' => ['nullable', 'string', 'in:' . implode(',', [
Collection::HEALTH_HEALTHY,
Collection::HEALTH_NEEDS_METADATA,
Collection::HEALTH_STALE,
Collection::HEALTH_LOW_CONTENT,
Collection::HEALTH_BROKEN_ITEMS,
Collection::HEALTH_WEAK_COVER,
Collection::HEALTH_LOW_ENGAGEMENT,
Collection::HEALTH_ATTRIBUTION_INCOMPLETE,
Collection::HEALTH_NEEDS_REVIEW,
Collection::HEALTH_DUPLICATE_RISK,
Collection::HEALTH_MERGE_CANDIDATE,
])],
'partner_key' => ['nullable', 'string', 'max:80'],
'experiment_key' => ['nullable', 'string', 'max:80'],
'placement_eligibility' => ['nullable', 'boolean'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Collections;
use Illuminate\Foundation\Http\FormRequest;
class CollectionProgramAssignmentRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'collection_id' => ['required', 'integer', 'exists:collections,id'],
'program_key' => ['required', 'string', 'max:80'],
'campaign_key' => ['nullable', 'string', 'max:80'],
'placement_scope' => ['nullable', 'string', 'max:80'],
'starts_at' => ['nullable', 'date'],
'ends_at' => ['nullable', 'date', 'after:starts_at'],
'priority' => ['nullable', 'integer', 'min:-100', 'max:100'],
'notes' => ['nullable', 'string', 'max:1000'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Collections;
use Illuminate\Foundation\Http\FormRequest;
class CollectionProgrammingCollectionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'collection_id' => ['nullable', 'integer', 'exists:collections,id'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Collections;
use Illuminate\Foundation\Http\FormRequest;
class CollectionProgrammingMergePairRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'source_collection_id' => ['required', 'integer', 'exists:collections,id'],
'target_collection_id' => ['required', 'integer', 'exists:collections,id', 'different:source_collection_id'],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Collections;
use Illuminate\Foundation\Http\FormRequest;
class CollectionProgrammingMetadataRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'collection_id' => ['required', 'integer', 'exists:collections,id'],
'experiment_key' => ['nullable', 'string', 'max:80'],
'experiment_treatment' => ['nullable', 'string', 'max:80'],
'placement_variant' => ['nullable', 'string', 'max:80'],
'ranking_mode_variant' => ['nullable', 'string', 'max:80'],
'collection_pool_version' => ['nullable', 'string', 'max:80'],
'test_label' => ['nullable', 'string', 'max:120'],
'placement_eligibility' => ['nullable', 'boolean'],
'promotion_tier' => ['nullable', 'string', 'max:40'],
'partner_key' => ['nullable', 'string', 'max:80'],
'trust_tier' => ['nullable', 'string', 'max:40'],
'sponsorship_state' => ['nullable', 'string', 'max:40'],
'ownership_domain' => ['nullable', 'string', 'max:80'],
'commercial_review_state' => ['nullable', 'string', 'max:40'],
'legal_review_state' => ['nullable', 'string', 'max:40'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Collections;
use Illuminate\Foundation\Http\FormRequest;
class CollectionProgrammingPreviewRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'program_key' => ['required', 'string', 'max:80'],
'limit' => ['nullable', 'integer', 'min:1', 'max:24'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Collections;
use Illuminate\Foundation\Http\FormRequest;
class CollectionSavedLibraryRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'q' => ['nullable', 'string', 'max:120'],
'filter' => ['nullable', 'string', 'in:all,editorial,community,personal,seasonal,noted,revisited'],
'sort' => ['nullable', 'string', 'in:saved_desc,saved_asc,updated_desc,revisited_desc,ranking_desc,title_asc'],
'list' => ['nullable', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Http\FormRequest;
class CollectionTargetActionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'target_collection_id' => ['required', 'integer', 'exists:collections,id'],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
$targetCollectionId = (int) $this->input('target_collection_id');
$collection = $this->route('collection');
if ($collection instanceof Collection && $targetCollectionId === (int) $collection->id) {
$validator->errors()->add('target_collection_id', 'Choose a different target collection.');
}
});
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Collections;
use Illuminate\Foundation\Http\FormRequest;
class ReorderCollectionArtworksRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'ordered_artwork_ids' => ['required', 'array'],
'ordered_artwork_ids.*' => ['integer', 'distinct'],
];
}
}

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