Save workspace changes
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkRelation;
|
||||
use App\Models\User;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class ArtworkEvolutionService
|
||||
{
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function relationTypes(): array
|
||||
{
|
||||
return [
|
||||
ArtworkRelation::TYPE_REMAKE_OF,
|
||||
ArtworkRelation::TYPE_REMASTER_OF,
|
||||
ArtworkRelation::TYPE_REVISION_OF,
|
||||
ArtworkRelation::TYPE_INSPIRED_BY,
|
||||
ArtworkRelation::TYPE_VARIATION_OF,
|
||||
];
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkMaturityService $maturity,
|
||||
private readonly GroupService $groups,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
public function relationTypeOptions(): array
|
||||
{
|
||||
return array_map(fn (string $type): array => [
|
||||
'value' => $type,
|
||||
'label' => $this->relationTypeLabel($type),
|
||||
'short_label' => $this->relationTypeShortLabel($type),
|
||||
], self::relationTypes());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{target_artwork_id?: int|null, relation_type?: string|null, note?: string|null} $payload
|
||||
*/
|
||||
public function syncPrimaryRelation(Artwork $sourceArtwork, User $actor, array $payload): ?ArtworkRelation
|
||||
{
|
||||
$this->ensureManageable($actor, $sourceArtwork, 'You can only update evolution links for artworks you manage.');
|
||||
|
||||
$targetArtworkId = (int) ($payload['target_artwork_id'] ?? 0);
|
||||
$relationType = $this->normalizeRelationType((string) ($payload['relation_type'] ?? ArtworkRelation::TYPE_REMAKE_OF));
|
||||
$note = $this->normalizeNote($payload['note'] ?? null);
|
||||
|
||||
if ($targetArtworkId <= 0) {
|
||||
ArtworkRelation::query()->where('source_artwork_id', (int) $sourceArtwork->id)->delete();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($targetArtworkId === (int) $sourceArtwork->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'evolution_target_artwork_id' => 'Choose an older artwork, not the artwork you are editing right now.',
|
||||
]);
|
||||
}
|
||||
|
||||
$targetArtwork = Artwork::query()
|
||||
->with(['group.members'])
|
||||
->find($targetArtworkId);
|
||||
|
||||
if (! $targetArtwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'evolution_target_artwork_id' => 'Choose a valid artwork to link as the original version.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->ensureManageable($actor, $targetArtwork, 'You can only link artworks that you are allowed to manage.');
|
||||
|
||||
if (! $this->isPubliclyVisible($targetArtwork)) {
|
||||
throw ValidationException::withMessages([
|
||||
'evolution_target_artwork_id' => 'Choose a published public artwork for the original version.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $this->isOlderVersionCandidate($sourceArtwork, $targetArtwork)) {
|
||||
throw ValidationException::withMessages([
|
||||
'evolution_target_artwork_id' => 'Choose an older artwork as the original version for this Then & Now story.',
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($sourceArtwork, $targetArtwork, $actor, $relationType, $note): ArtworkRelation {
|
||||
ArtworkRelation::query()
|
||||
->where('source_artwork_id', (int) $sourceArtwork->id)
|
||||
->delete();
|
||||
|
||||
return ArtworkRelation::query()->create([
|
||||
'source_artwork_id' => (int) $sourceArtwork->id,
|
||||
'target_artwork_id' => (int) $targetArtwork->id,
|
||||
'relation_type' => $relationType,
|
||||
'note' => $note,
|
||||
'sort_order' => 0,
|
||||
'created_by_user_id' => (int) $actor->id,
|
||||
])->load([
|
||||
'targetArtwork.user.profile',
|
||||
'targetArtwork.group',
|
||||
'targetArtwork.categories.contentType',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function editorRelation(Artwork $artwork, User $actor): ?array
|
||||
{
|
||||
$relation = ArtworkRelation::query()
|
||||
->with(['targetArtwork.user.profile', 'targetArtwork.group', 'targetArtwork.categories.contentType'])
|
||||
->where('source_artwork_id', (int) $artwork->id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (! $relation || ! $relation->targetArtwork) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $relation->id,
|
||||
'relation_type' => (string) $relation->relation_type,
|
||||
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
|
||||
'short_label' => $this->relationTypeShortLabel((string) $relation->relation_type),
|
||||
'note' => $relation->note,
|
||||
'target_artwork' => $this->mapStudioOption($relation->targetArtwork, $actor),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function manageableSearchOptions(Artwork $sourceArtwork, User $actor, string $search = '', int $limit = 18): array
|
||||
{
|
||||
$this->ensureManageable($actor, $sourceArtwork, 'You can only search evolution links for artworks you manage.');
|
||||
|
||||
$manageableGroupIds = collect($this->groups->studioOptionsForUser($actor))
|
||||
->filter(fn (array $group): bool => (bool) data_get($group, 'permissions.can_publish_artworks', false))
|
||||
->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$term = trim($search);
|
||||
$query = Artwork::query()
|
||||
->with(['user.profile', 'group', 'categories.contentType'])
|
||||
->whereKeyNot((int) $sourceArtwork->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now())
|
||||
->where(function ($builder) use ($actor, $manageableGroupIds): void {
|
||||
$builder->where('user_id', (int) $actor->id);
|
||||
|
||||
if ($manageableGroupIds->isNotEmpty()) {
|
||||
$builder->orWhereIn('group_id', $manageableGroupIds->all());
|
||||
}
|
||||
});
|
||||
|
||||
if ($term !== '') {
|
||||
$like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $term) . '%';
|
||||
|
||||
$query->where(function ($builder) use ($like): void {
|
||||
$builder->where('title', 'like', $like)
|
||||
->orWhere('slug', 'like', $like)
|
||||
->orWhereHas('group', fn ($groupQuery) => $groupQuery->where('name', 'like', $like))
|
||||
->orWhereHas('user', fn ($userQuery) => $userQuery
|
||||
->where('name', 'like', $like)
|
||||
->orWhere('username', 'like', $like));
|
||||
});
|
||||
}
|
||||
|
||||
$referenceTimestamp = $this->comparisonTimestamp($sourceArtwork);
|
||||
|
||||
return $query
|
||||
->orderByRaw('CASE WHEN user_id = ? THEN 0 ELSE 1 END', [(int) $actor->id])
|
||||
->orderByRaw('CASE WHEN published_at IS NULL THEN 1 ELSE 0 END')
|
||||
->orderByDesc('published_at')
|
||||
->limit(max(1, min($limit, 36)))
|
||||
->get()
|
||||
->filter(fn (Artwork $candidate): bool => $referenceTimestamp === null
|
||||
|| $this->comparisonTimestamp($candidate)?->lte($referenceTimestamp)
|
||||
|| $candidate->published_at === null)
|
||||
->map(fn (Artwork $candidate): array => $this->mapStudioOption($candidate, $actor))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function publicPayload(Artwork $artwork, ?User $viewer = null): ?array
|
||||
{
|
||||
$primaryRelation = ArtworkRelation::query()
|
||||
->with([
|
||||
'sourceArtwork.user.profile',
|
||||
'sourceArtwork.group',
|
||||
'sourceArtwork.categories.contentType',
|
||||
'targetArtwork.user.profile',
|
||||
'targetArtwork.group',
|
||||
'targetArtwork.categories.contentType',
|
||||
])
|
||||
->where('source_artwork_id', (int) $artwork->id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
$incomingRelations = ArtworkRelation::query()
|
||||
->with([
|
||||
'sourceArtwork.user.profile',
|
||||
'sourceArtwork.group',
|
||||
'sourceArtwork.categories.contentType',
|
||||
'targetArtwork.user.profile',
|
||||
'targetArtwork.group',
|
||||
'targetArtwork.categories.contentType',
|
||||
])
|
||||
->where('target_artwork_id', (int) $artwork->id)
|
||||
->orderByDesc('updated_at')
|
||||
->orderByDesc('id')
|
||||
->limit(4)
|
||||
->get();
|
||||
|
||||
$primary = $primaryRelation ? $this->mapPrimaryPanel($primaryRelation, $viewer) : null;
|
||||
$updates = $incomingRelations
|
||||
->map(fn (ArtworkRelation $relation): ?array => $this->mapIncomingUpdate($relation, $viewer))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($primary === null && $updates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'eyebrow' => 'Artwork Evolution',
|
||||
'primary' => $primary,
|
||||
'updates' => $updates,
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureManageable(User $actor, Artwork $artwork, string $message): void
|
||||
{
|
||||
if (! Gate::forUser($actor)->allows('update', $artwork)) {
|
||||
throw ValidationException::withMessages([
|
||||
'evolution_target_artwork_id' => $message,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function isPubliclyVisible(Artwork $artwork): bool
|
||||
{
|
||||
return ! $artwork->trashed()
|
||||
&& (bool) $artwork->is_public
|
||||
&& (bool) $artwork->is_approved
|
||||
&& $artwork->published_at !== null
|
||||
&& $artwork->published_at->lte(now());
|
||||
}
|
||||
|
||||
private function isOlderVersionCandidate(Artwork $sourceArtwork, Artwork $targetArtwork): bool
|
||||
{
|
||||
$sourceTimestamp = $this->comparisonTimestamp($sourceArtwork);
|
||||
$targetTimestamp = $this->comparisonTimestamp($targetArtwork);
|
||||
|
||||
if ($sourceTimestamp === null || $targetTimestamp === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $targetTimestamp->lt($sourceTimestamp);
|
||||
}
|
||||
|
||||
private function comparisonTimestamp(Artwork $artwork): ?Carbon
|
||||
{
|
||||
$value = $artwork->published_at ?: $artwork->created_at;
|
||||
|
||||
return $value instanceof Carbon ? $value : ($value ? Carbon::parse($value) : null);
|
||||
}
|
||||
|
||||
private function normalizeRelationType(string $type): string
|
||||
{
|
||||
$normalized = Str::lower(trim($type));
|
||||
|
||||
return in_array($normalized, self::relationTypes(), true)
|
||||
? $normalized
|
||||
: ArtworkRelation::TYPE_REMAKE_OF;
|
||||
}
|
||||
|
||||
private function normalizeNote(mixed $note): ?string
|
||||
{
|
||||
$resolved = trim((string) $note);
|
||||
|
||||
return $resolved !== '' ? $resolved : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapStudioOption(Artwork $artwork, User $actor): array
|
||||
{
|
||||
$category = $artwork->categories->sortBy('sort_order')->first();
|
||||
$publishedAt = $artwork->published_at;
|
||||
$year = $publishedAt?->year ?: $artwork->created_at?->year;
|
||||
|
||||
return [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
|
||||
'year' => $year,
|
||||
'published_at' => optional($publishedAt)->toIsoString(),
|
||||
'thumbnail' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? null,
|
||||
'url' => route('art.show', [
|
||||
'id' => (int) $artwork->id,
|
||||
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
|
||||
]),
|
||||
'studio_edit_url' => route('studio.artworks.edit', ['id' => (int) $artwork->id]),
|
||||
'content_type' => $category?->contentType?->name,
|
||||
'category' => $category?->name,
|
||||
'is_manageable' => Gate::forUser($actor)->allows('update', $artwork),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function mapPrimaryPanel(ArtworkRelation $relation, ?User $viewer): ?array
|
||||
{
|
||||
$beforeArtwork = $relation->targetArtwork;
|
||||
$afterArtwork = $relation->sourceArtwork;
|
||||
|
||||
if (! $beforeArtwork || ! $afterArtwork) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
|
||||
$after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));
|
||||
|
||||
if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$beforeYear = $before['year'] ?? null;
|
||||
$afterYear = $after['year'] ?? null;
|
||||
$yearsApart = $this->yearsApart($beforeYear, $afterYear);
|
||||
|
||||
return [
|
||||
'id' => (int) $relation->id,
|
||||
'relation_type' => (string) $relation->relation_type,
|
||||
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
|
||||
'heading' => 'Then & Now',
|
||||
'summary' => $this->primarySummary($beforeYear, $yearsApart),
|
||||
'years_apart' => $yearsApart,
|
||||
'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
|
||||
'note' => $relation->note,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'compare' => [
|
||||
'available' => $this->compareAvailable($before, $after),
|
||||
'title' => 'Then & Now comparison',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function mapIncomingUpdate(ArtworkRelation $relation, ?User $viewer): ?array
|
||||
{
|
||||
$beforeArtwork = $relation->targetArtwork;
|
||||
$afterArtwork = $relation->sourceArtwork;
|
||||
|
||||
if (! $beforeArtwork || ! $afterArtwork) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
|
||||
$after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));
|
||||
|
||||
if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$yearsApart = $this->yearsApart($before['year'] ?? null, $after['year'] ?? null);
|
||||
|
||||
return [
|
||||
'id' => (int) $relation->id,
|
||||
'relation_type' => (string) $relation->relation_type,
|
||||
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
|
||||
'heading' => 'Updated Version',
|
||||
'summary' => $this->incomingSummary($after['year'] ?? null, $yearsApart),
|
||||
'years_apart' => $yearsApart,
|
||||
'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
|
||||
'note' => $relation->note,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'compare' => [
|
||||
'available' => $this->compareAvailable($before, $after),
|
||||
'title' => 'Compare versions',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapPublicCard(Artwork $artwork, ?User $viewer, string $roleLabel): array
|
||||
{
|
||||
$category = $artwork->categories->sortBy('sort_order')->first();
|
||||
$md = ThumbnailPresenter::present($artwork, 'md');
|
||||
$lg = ThumbnailPresenter::present($artwork, 'lg');
|
||||
$xl = ThumbnailPresenter::present($artwork, 'xl');
|
||||
$publishedAt = $artwork->published_at;
|
||||
|
||||
return $this->maturity->decoratePayload([
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'url' => route('art.show', [
|
||||
'id' => (int) $artwork->id,
|
||||
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
|
||||
]),
|
||||
'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
|
||||
'published_at' => optional($publishedAt)->toIsoString(),
|
||||
'year' => $publishedAt?->year ?: $artwork->created_at?->year,
|
||||
'role_label' => $roleLabel,
|
||||
'thumbnail' => $md['url'] ?? null,
|
||||
'image_md' => $md['url'] ?? null,
|
||||
'image_lg' => $lg['url'] ?? null,
|
||||
'image_xl' => $xl['url'] ?? null,
|
||||
'width' => (int) ($artwork->width ?? 0),
|
||||
'height' => (int) ($artwork->height ?? 0),
|
||||
'content_type' => $category?->contentType?->name,
|
||||
'category' => $category?->name,
|
||||
], $artwork, $viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $card
|
||||
*/
|
||||
private function shouldOmitForViewer(array $card): bool
|
||||
{
|
||||
return (bool) data_get($card, 'maturity.should_hide', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $before
|
||||
* @param array<string, mixed> $after
|
||||
*/
|
||||
private function compareAvailable(array $before, array $after): bool
|
||||
{
|
||||
return ! empty($before['image_lg']) && ! empty($after['image_lg']);
|
||||
}
|
||||
|
||||
private function yearsApart(mixed $beforeYear, mixed $afterYear): ?int
|
||||
{
|
||||
if (! is_numeric($beforeYear) || ! is_numeric($afterYear)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max(0, (int) $afterYear - (int) $beforeYear);
|
||||
}
|
||||
|
||||
private function primarySummary(mixed $beforeYear, ?int $yearsApart): string
|
||||
{
|
||||
if (is_numeric($beforeYear) && $yearsApart !== null && $yearsApart > 0) {
|
||||
return sprintf('This artwork revisits an earlier version from %d, %d years later.', (int) $beforeYear, $yearsApart);
|
||||
}
|
||||
|
||||
if (is_numeric($beforeYear)) {
|
||||
return sprintf('This artwork revisits an earlier version from %d.', (int) $beforeYear);
|
||||
}
|
||||
|
||||
return 'This artwork revisits an earlier version from the creator archive.';
|
||||
}
|
||||
|
||||
private function incomingSummary(mixed $afterYear, ?int $yearsApart): string
|
||||
{
|
||||
if (is_numeric($afterYear) && $yearsApart !== null && $yearsApart > 0) {
|
||||
return sprintf('This artwork was later revisited in %d, %d years later.', (int) $afterYear, $yearsApart);
|
||||
}
|
||||
|
||||
if (is_numeric($afterYear)) {
|
||||
return sprintf('This artwork was later revisited in %d.', (int) $afterYear);
|
||||
}
|
||||
|
||||
return 'This artwork later received an updated version from the same creator.';
|
||||
}
|
||||
|
||||
private function relationTypeLabel(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
ArtworkRelation::TYPE_REMASTER_OF => 'Remaster of',
|
||||
ArtworkRelation::TYPE_REVISION_OF => 'Revision of',
|
||||
ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired by',
|
||||
ArtworkRelation::TYPE_VARIATION_OF => 'Variation of',
|
||||
default => 'Remake of',
|
||||
};
|
||||
}
|
||||
|
||||
private function relationTypeShortLabel(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
ArtworkRelation::TYPE_REMASTER_OF => 'Remaster',
|
||||
ArtworkRelation::TYPE_REVISION_OF => 'Update',
|
||||
ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired take',
|
||||
ArtworkRelation::TYPE_VARIATION_OF => 'Variation',
|
||||
default => 'Remake',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user