Files
SkinbaseNova/app/Services/Worlds/WorldSubmissionService.php
2026-04-18 17:02:56 +02:00

547 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Worlds;
use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\User;
use App\Models\World;
use App\Models\WorldSubmission;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class WorldSubmissionService
{
public function __construct(private readonly ArtworkMaturityService $maturity)
{
}
public function eligibleWorldOptions(?User $viewer = null): array
{
return $this->eligibleWorldsQuery()
->get()
->map(fn (World $world): array => $this->mapCreatorWorldOption($world, null, true))
->all();
}
public function artworkSubmissionOptions(Artwork $artwork, User $viewer): array
{
$artwork->loadMissing(['worldSubmissions.world', 'worldSubmissions.reviewer']);
$existing = $artwork->worldSubmissions
->filter(fn (WorldSubmission $submission): bool => $submission->world !== null)
->keyBy(fn (WorldSubmission $submission): int => (int) $submission->world_id);
$eligibleWorlds = $this->eligibleWorldsQuery()->get()->keyBy(fn (World $world): int => (int) $world->id);
$worlds = $eligibleWorlds;
$missingWorldIds = $existing->keys()
->map(fn ($id): int => (int) $id)
->reject(fn (int $id): bool => $eligibleWorlds->has($id))
->values();
if ($missingWorldIds->isNotEmpty()) {
World::query()
->whereIn('id', $missingWorldIds->all())
->get()
->each(fn (World $world) => $worlds->put((int) $world->id, $world));
}
return $worlds
->sortBy([
fn (World $world): int => $existing->has((int) $world->id) ? 0 : 1,
fn (World $world): int => $world->starts_at?->getTimestamp() ?? PHP_INT_MAX,
fn (World $world): string => Str::lower((string) $world->title),
])
->values()
->map(function (World $world) use ($existing): array {
$submission = $existing->get((int) $world->id);
return $this->mapCreatorWorldOption($world, $submission, $this->isEligibleWorld($world));
})
->all();
}
public function syncForArtwork(Artwork $artwork, User $actor, array $entries): void
{
$artwork->loadMissing('worldSubmissions');
$normalizedEntries = collect($entries)
->map(function (array $entry): ?array {
$worldId = (int) ($entry['world_id'] ?? 0);
if ($worldId < 1) {
return null;
}
return [
'world_id' => $worldId,
'note' => Str::limit(trim((string) ($entry['note'] ?? '')), 1000, ''),
];
})
->filter()
->unique('world_id')
->values();
$existing = $artwork->worldSubmissions->keyBy(fn (WorldSubmission $submission): int => (int) $submission->world_id);
$selectedWorldIds = $normalizedEntries->pluck('world_id')->map(fn ($id): int => (int) $id)->all();
$allWorldIds = array_values(array_unique(array_merge($selectedWorldIds, $existing->keys()->map(fn ($id): int => (int) $id)->all())));
$worlds = World::query()
->whereIn('id', $allWorldIds)
->get()
->keyBy(fn (World $world): int => (int) $world->id);
$errors = [];
foreach ($normalizedEntries as $index => $entry) {
$world = $worlds->get((int) $entry['world_id']);
$submission = $existing->get((int) $entry['world_id']);
if (! $world) {
$errors["world_submissions.{$index}.world_id"] = 'Selected world no longer exists.';
continue;
}
if (! $this->isEligibleWorld($world)) {
$errors["world_submissions.{$index}.world_id"] = 'That world is not currently accepting community submissions.';
continue;
}
if ($submission && $submission->isBlockingResubmission()) {
$errors["world_submissions.{$index}.world_id"] = 'This artwork is blocked from that world until a moderator clears the block.';
continue;
}
if ($submission && (string) $submission->status === WorldSubmission::STATUS_REMOVED && ! (bool) $world->allow_readd_after_removal) {
$errors["world_submissions.{$index}.world_id"] = 'That world does not allow re-adding removed artworks right now.';
}
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
DB::transaction(function () use ($normalizedEntries, $artwork, $actor, $existing, $worlds, $selectedWorldIds): void {
foreach ($normalizedEntries as $entry) {
$worldId = (int) $entry['world_id'];
$submission = $existing->get($worldId);
$world = $worlds->get($worldId);
$note = ($world?->submission_note_enabled ?? true) ? ($entry['note'] !== '' ? $entry['note'] : null) : null;
$startingStatus = $world?->submissionStartsAsLive()
? WorldSubmission::STATUS_LIVE
: WorldSubmission::STATUS_PENDING;
$reviewedAt = $startingStatus === WorldSubmission::STATUS_LIVE ? now() : null;
if ($submission && $submission->isBlockingResubmission()) {
continue;
}
if ($submission) {
$payload = [
'mode_snapshot' => $world?->participation_mode,
'note' => $note,
];
if ((string) $submission->status === WorldSubmission::STATUS_REMOVED) {
$payload = array_merge($payload, [
'status' => $startingStatus,
'is_featured' => false,
'reviewer_note' => null,
'moderation_reason' => null,
'reviewed_by_user_id' => null,
'reviewed_at' => $reviewedAt,
'removed_at' => null,
'blocked_at' => null,
'featured_at' => null,
]);
}
$submission->forceFill($payload)->save();
continue;
}
WorldSubmission::query()->create([
'world_id' => $worldId,
'artwork_id' => (int) $artwork->id,
'submitted_by_user_id' => (int) $actor->id,
'status' => $startingStatus,
'is_featured' => false,
'mode_snapshot' => $world?->participation_mode,
'note' => $note,
'reviewed_at' => $reviewedAt,
]);
}
$existing->each(function (WorldSubmission $submission, int $worldId) use ($selectedWorldIds): void {
if (in_array((string) $submission->status, [WorldSubmission::STATUS_LIVE, WorldSubmission::STATUS_REMOVED, WorldSubmission::STATUS_BLOCKED], true)) {
return;
}
if (! in_array($worldId, $selectedWorldIds, true)) {
$submission->delete();
}
});
});
}
public function transition(WorldSubmission $submission, User $reviewer, string $status, ?string $reviewerNote = null): WorldSubmission
{
$payload = [
'status' => $status,
'reviewer_note' => $this->nullableText($reviewerNote),
'moderation_reason' => $this->nullableText($reviewerNote),
];
if ($status === WorldSubmission::STATUS_PENDING) {
$payload['reviewer_note'] = null;
$payload['moderation_reason'] = null;
$payload['reviewed_by_user_id'] = null;
$payload['reviewed_at'] = null;
$payload['removed_at'] = null;
$payload['blocked_at'] = null;
} else {
$payload['reviewed_by_user_id'] = (int) $reviewer->id;
$payload['reviewed_at'] = now();
$payload['removed_at'] = $status === WorldSubmission::STATUS_REMOVED ? now() : null;
$payload['blocked_at'] = $status === WorldSubmission::STATUS_BLOCKED ? now() : null;
}
if ($status !== WorldSubmission::STATUS_LIVE) {
$payload['is_featured'] = false;
$payload['featured_at'] = null;
}
$submission->forceFill($payload)->save();
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
}
public function setFeatured(WorldSubmission $submission, User $reviewer, bool $featured, ?string $reviewerNote = null): WorldSubmission
{
$payload = [
'is_featured' => $featured,
'featured_at' => $featured ? now() : null,
'reviewed_by_user_id' => (int) $reviewer->id,
'reviewed_at' => now(),
];
if ($reviewerNote !== null) {
$payload['reviewer_note'] = $this->nullableText($reviewerNote);
$payload['moderation_reason'] = $this->nullableText($reviewerNote);
}
if ((string) $submission->status !== WorldSubmission::STATUS_LIVE) {
$payload['status'] = WorldSubmission::STATUS_LIVE;
$payload['removed_at'] = null;
$payload['blocked_at'] = null;
}
$submission->forceFill($payload)->save();
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
}
public function studioReviewQueue(World $world): array
{
$world->loadMissing([
'worldSubmissions.artwork.user.profile',
'worldSubmissions.artwork.stats',
'worldSubmissions.artwork.categories',
'worldSubmissions.submittedBy.profile',
'worldSubmissions.reviewer.profile',
]);
$items = $world->worldSubmissions
->sortBy([
fn (WorldSubmission $submission): int => match ((string) $submission->status) {
WorldSubmission::STATUS_PENDING => 0,
WorldSubmission::STATUS_LIVE => $submission->is_featured ? 1 : 2,
WorldSubmission::STATUS_REMOVED => 3,
WorldSubmission::STATUS_BLOCKED => 4,
default => 4,
},
fn (WorldSubmission $submission): int => -1 * ($submission->reviewed_at?->getTimestamp() ?? $submission->created_at?->getTimestamp() ?? 0),
])
->values();
return [
'counts' => [
'pending' => $items->where('status', WorldSubmission::STATUS_PENDING)->count(),
'live' => $items->where('status', WorldSubmission::STATUS_LIVE)->count(),
'removed' => $items->where('status', WorldSubmission::STATUS_REMOVED)->count(),
'blocked' => $items->where('status', WorldSubmission::STATUS_BLOCKED)->count(),
'featured' => $items->where('is_featured', true)->count(),
],
'items' => $items->map(fn (WorldSubmission $submission): array => $this->mapStudioSubmission($submission))->all(),
];
}
public function publicSectionPayload(World $world, ?User $viewer = null): ?array
{
if (! $world->community_section_enabled) {
return null;
}
$query = Artwork::query()
->select('artworks.*', 'world_submissions.status as world_submission_status', 'world_submissions.is_featured as world_submission_is_featured', 'world_submissions.note as world_submission_note', 'world_submissions.reviewed_at as world_submission_reviewed_at')
->join('world_submissions', function ($join) use ($world): void {
$join->on('world_submissions.artwork_id', '=', 'artworks.id')
->where('world_submissions.world_id', '=', $world->id)
->where('world_submissions.status', '=', WorldSubmission::STATUS_LIVE);
})
->with(['user.profile', 'categories.contentType', 'stats'])
->catalogVisible();
$this->maturity->applyViewerFilter($query, $viewer);
$items = $query
->orderByRaw('CASE WHEN world_submissions.is_featured = 1 THEN 0 ELSE 1 END')
->orderByDesc('world_submissions.reviewed_at')
->limit(24)
->get()
->map(fn (Artwork $artwork): array => $this->mapPublicSubmissionArtwork($artwork))
->all();
if ($items === []) {
return null;
}
return [
'title' => 'Community submissions',
'description' => 'Artworks submitted by creators and selected for this world outside the editorial curated-relation system.',
'items' => $items,
];
}
private function eligibleWorldsQuery(): Builder
{
return World::query()
->published()
->where('accepts_submissions', true)
->whereIn('participation_mode', [World::PARTICIPATION_MODE_MANUAL_APPROVAL, World::PARTICIPATION_MODE_AUTO_ADD])
->where(function (Builder $builder): void {
$builder->whereNull('submission_starts_at')
->orWhere('submission_starts_at', '<=', now());
})
->where(function (Builder $builder): void {
$builder->whereNull('submission_ends_at')
->orWhere('submission_ends_at', '>=', now());
})
->orderBy('submission_ends_at')
->orderBy('starts_at')
->orderBy('title');
}
private function isEligibleWorld(World $world): bool
{
return $world->isAcceptingSubmissions();
}
private function mapCreatorWorldOption(World $world, ?WorldSubmission $submission, bool $eligible): array
{
$status = $submission ? (string) $submission->status : null;
$selected = match ($status) {
WorldSubmission::STATUS_PENDING,
WorldSubmission::STATUS_LIVE => true,
default => false,
};
$locked = match ($status) {
WorldSubmission::STATUS_BLOCKED => true,
WorldSubmission::STATUS_PENDING => ! $eligible,
WorldSubmission::STATUS_REMOVED => ! $eligible || ! (bool) $world->allow_readd_after_removal,
default => false,
};
$lockedReason = $locked
? match ($status) {
WorldSubmission::STATUS_BLOCKED => 'This artwork is blocked from this world until a moderator clears the block.',
WorldSubmission::STATUS_PENDING => 'This world is no longer accepting submission changes right now.',
WorldSubmission::STATUS_REMOVED => (bool) $world->allow_readd_after_removal
? 'This world is not currently open for re-adding removed artworks.'
: 'Removed artworks cannot be re-added to this world right now.',
default => 'This world is locked.',
}
: null;
return [
'id' => (int) $world->id,
'title' => (string) $world->title,
'slug' => (string) $world->slug,
'tagline' => (string) ($world->tagline ?? ''),
'summary' => (string) ($world->summary ?? ''),
'cover_url' => $world->coverUrl(),
'timeframe_label' => $this->timeframeLabel($world),
'submission_window_label' => $this->submissionWindowLabel($world),
'submission_guidelines' => (string) ($world->submission_guidelines ?? ''),
'participation_mode' => (string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED),
'participation_mode_label' => $this->participationModeLabel((string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED)),
'submission_note_enabled' => (bool) $world->submission_note_enabled,
'is_accepting_submissions' => $eligible,
'selected' => $selected,
'selection_locked' => $locked,
'selection_locked_reason' => $lockedReason,
'note' => (string) ($submission?->note ?? ''),
'status' => $status,
'status_label' => $status ? $this->statusLabel($status, (bool) ($submission?->is_featured ?? false)) : null,
'reviewer_note' => (string) ($submission?->moderation_reason ?: $submission?->reviewer_note ?? ''),
'is_featured' => (bool) ($submission?->is_featured ?? false),
'submitted_at' => $submission?->created_at?->toIso8601String(),
'reviewed_at' => $submission?->reviewed_at?->toIso8601String(),
'can_resubmit' => $eligible && (bool) $world->allow_readd_after_removal && $status === WorldSubmission::STATUS_REMOVED,
];
}
private function mapStudioSubmission(WorldSubmission $submission): array
{
$artwork = $submission->artwork;
$views = (int) ($artwork?->stats?->views ?? 0);
return [
'id' => (int) $submission->id,
'status' => (string) $submission->status,
'status_label' => $this->statusLabel((string) $submission->status, (bool) $submission->is_featured),
'is_featured' => (bool) $submission->is_featured,
'note' => (string) ($submission->note ?? ''),
'reviewer_note' => (string) ($submission->moderation_reason ?: $submission->reviewer_note ?? ''),
'submitted_at' => $submission->created_at?->toIso8601String(),
'reviewed_at' => $submission->reviewed_at?->toIso8601String(),
'removed_at' => $submission->removed_at?->toIso8601String(),
'blocked_at' => $submission->blocked_at?->toIso8601String(),
'featured_at' => $submission->featured_at?->toIso8601String(),
'submitted_by' => $submission->submittedBy ? [
'id' => (int) $submission->submittedBy->id,
'name' => (string) ($submission->submittedBy->name ?: $submission->submittedBy->username ?: 'Unknown creator'),
'username' => (string) ($submission->submittedBy->username ?? ''),
] : null,
'reviewed_by' => $submission->reviewer ? [
'id' => (int) $submission->reviewer->id,
'name' => (string) ($submission->reviewer->name ?: $submission->reviewer->username ?: 'Moderator'),
] : null,
'artwork' => $artwork ? [
'id' => (int) $artwork->id,
'title' => (string) ($artwork->title ?: 'Untitled artwork'),
'slug' => (string) ($artwork->slug ?? ''),
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)]),
'edit_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
'thumbnail_url' => $artwork->thumbUrl('md'),
'creator_name' => (string) ($artwork->user?->name ?: $artwork->user?->username ?: ''),
'meta' => array_values(array_filter([
$artwork->categories->first()?->name,
$views > 0 ? number_format($views) . ' views' : null,
$artwork->visibility ? Str::headline((string) $artwork->visibility) : null,
])),
] : null,
'actions' => [
'approve' => route('studio.worlds.submissions.approve', ['world' => $submission->world_id, 'submission' => $submission->id]),
'remove' => route('studio.worlds.submissions.remove', ['world' => $submission->world_id, 'submission' => $submission->id]),
'block' => route('studio.worlds.submissions.block', ['world' => $submission->world_id, 'submission' => $submission->id]),
'unblock' => route('studio.worlds.submissions.unblock', ['world' => $submission->world_id, 'submission' => $submission->id]),
'restore' => route('studio.worlds.submissions.restore', ['world' => $submission->world_id, 'submission' => $submission->id]),
'feature' => route('studio.worlds.submissions.feature', ['world' => $submission->world_id, 'submission' => $submission->id]),
'unfeature' => route('studio.worlds.submissions.unfeature', ['world' => $submission->world_id, 'submission' => $submission->id]),
'pending' => route('studio.worlds.submissions.pending', ['world' => $submission->world_id, 'submission' => $submission->id]),
],
];
}
private function mapPublicSubmissionArtwork(Artwork $artwork): array
{
$resource = ArtworkListResource::make($artwork)->toArray(request());
$views = (int) ($artwork->stats?->views ?? 0);
$status = (string) ($artwork->world_submission_status ?? WorldSubmission::STATUS_LIVE);
$isFeatured = (bool) ($artwork->world_submission_is_featured ?? false);
return [
'id' => (int) $artwork->id,
'title' => (string) ($resource['title'] ?? $artwork->title ?? 'Untitled artwork'),
'subtitle' => (string) ($resource['author']['name'] ?? ''),
'description' => Str::limit(trim(strip_tags((string) ($artwork->description ?? ''))), 120),
'url' => (string) ($resource['urls']['canonical'] ?? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)])),
'image' => $resource['thumbnail_url'] ?? $artwork->thumbUrl('md'),
'status' => $status,
'status_label' => $this->statusLabel($status, $isFeatured),
'context_label' => $isFeatured ? 'Community featured' : 'Community submission',
'meta' => array_values(array_filter([
$resource['category']['name'] ?? null,
$views > 0 ? number_format($views) . ' views' : null,
])),
];
}
private function statusLabel(string $status, bool $isFeatured = false): string
{
if ($status === WorldSubmission::STATUS_LIVE && $isFeatured) {
return 'Featured';
}
return match ($status) {
WorldSubmission::STATUS_PENDING => 'Pending',
WorldSubmission::STATUS_LIVE => 'Live',
WorldSubmission::STATUS_REMOVED => 'Removed',
WorldSubmission::STATUS_BLOCKED => 'Blocked',
default => Str::headline($status),
};
}
private function participationModeLabel(string $mode): string
{
return match ($mode) {
World::PARTICIPATION_MODE_MANUAL_APPROVAL => 'Manual approval',
World::PARTICIPATION_MODE_AUTO_ADD => 'Auto add',
World::PARTICIPATION_MODE_CLOSED => 'Closed',
default => Str::headline($mode),
};
}
private function timeframeLabel(World $world): string
{
if ($world->starts_at && $world->ends_at) {
return $world->starts_at->format('M j') . ' - ' . $world->ends_at->format('M j, Y');
}
if ($world->starts_at) {
return 'Starts ' . $world->starts_at->format('M j, Y');
}
if ($world->ends_at) {
return 'Until ' . $world->ends_at->format('M j, Y');
}
return 'Open-ended world';
}
private function submissionWindowLabel(World $world): string
{
$start = $world->submission_starts_at;
$end = $world->submission_ends_at;
if ($start && $end) {
return $start->format('M j') . ' - ' . $end->format('M j, Y');
}
if ($start) {
return 'Opens ' . $start->format('M j, Y');
}
if ($end) {
return 'Open until ' . $end->format('M j, Y');
}
return 'Open submissions';
}
private function nullableText(?string $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
}