Files
landing-api/app/Support/Duplicator.php
Josep Piguillem 366568b793 Duplicate records as drafts, and preview them from the form
The copy keeps the content, stack and team, but is unpublished, out of any
review, and gets a title marked as a copy and a free slug. It replaces
Filament's replicate action, which left the copy without a slug and still
published. The same copy is available through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:14:51 +02:00

122 lines
3.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\PageStatus;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* A copy of a record to start the next one from: same text, same stack, same
* team, but a draft — off the site, out of any review, and with a title and
* an address that say it is a copy.
*/
final class Duplicator
{
/**
* @var array<class-string<Model>, array<int, string>>
*/
public const RELATIONS = [
Page::class => [],
Project::class => ['technologies', 'services', 'members'],
Service::class => ['technologies', 'projects'],
Call::class => ['technologies'],
];
/**
* Never copied: where it stands rather than what it says.
*/
protected const RESET = [
'published_at', 'unpublish_at', 'review_requested_at', 'review_requested_by', 'reviewer_id',
];
public static function supports(Model $record): bool
{
return array_key_exists($record::class, self::RELATIONS);
}
public static function duplicate(Model $record): Model
{
if (! self::supports($record)) {
throw new InvalidArgumentException($record::class.' cannot be duplicated.');
}
// Read again: a table row carries aggregates — view counts, summed
// stars — that are not columns and cannot be inserted.
$record = $record->fresh() ?? $record;
return DB::transaction(function () use ($record): Model {
$copy = $record->replicate(array_values(array_filter(
self::RESET,
fn (string $column) => array_key_exists($column, $record->getAttributes()),
)));
$titles = [];
$slugs = [];
foreach ((array) $record->getTranslations('title') as $locale => $title) {
if (filled($title)) {
$titles[$locale] = $title.' '.__('panel.duplicate.title_suffix', [], $locale);
}
}
foreach ((array) $record->getTranslations('slug') as $locale => $slug) {
if (filled($slug)) {
$slugs[$locale] = self::freeSlug($record, $locale, $slug.'-'.__('panel.duplicate.slug_suffix', [], $locale));
}
}
$copy->setTranslations('title', $titles);
$copy->setTranslations('slug', $slugs);
match (true) {
$copy instanceof Page => $copy->forceFill(['status' => PageStatus::Draft, 'author_id' => Auth::id() ?? $record->author_id]),
$copy instanceof Service => $copy->forceFill(['is_published' => false]),
default => null,
};
$copy->save();
foreach (self::RELATIONS[$record::class] as $relation) {
$related = $record->{$relation}()->get();
$copy->{$relation}()->sync(
$relation === 'members'
? $related->mapWithKeys(fn ($member) => [$member->id => ['role' => $member->pivot->role]])->all()
: $related->pluck('id')->all(),
);
}
return $copy;
});
}
/**
* The slug, or the slug with a number after it, whichever nobody has.
*/
protected static function freeSlug(Model $record, string $locale, string $slug): string
{
$candidate = $slug;
$n = 2;
$query = fn (string $value) => $record::query()
->when(method_exists($record, 'bootSoftDeletes'), fn ($q) => $q->withTrashed())
->where("slug->{$locale}", $value)
->exists();
while ($query($candidate)) {
$candidate = "{$slug}-{$n}";
$n++;
}
return $candidate;
}
}