Files
landing-api/app/Support/Duplicator.php
Josep Piguillem 9f970808ca
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Tag blog posts and sign them with a member
Tags are named in every language and made from the page form; the byline is
the member who signs a post on the site. The public API sends both, filters
posts by tag and lists the tags in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:01:18 +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 => ['tags'],
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;
}
}