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>
This commit is contained in:
2026-10-01 10:14:51 +02:00
parent 70521d4bc9
commit 366568b793
18 changed files with 486 additions and 64 deletions

121
app/Support/Duplicator.php Normal file
View File

@@ -0,0 +1,121 @@
<?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;
}
}