`taskstream: true` on POST /v1/projects also creates the project in TaskStream, public, as the caller (their Pireon token is passed on), and the answer carries `taskstream_key` and `taskstream_url`. The key is `taskstream_key` or one made from the default-language slug. The board is opened before the project is saved: if TaskStream refuses it, nothing is created here and the reason comes back as a validation error (502 when TaskStream does not answer). Machine tokens need the server's own TASKSTREAM_TOKEN. Updates ignore the flag, and duplicates do not inherit the link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
4.0 KiB
PHP
124 lines
4.0 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 — and the
|
|
* TaskStream board, which belongs to the original alone.
|
|
*/
|
|
protected const RESET = [
|
|
'published_at', 'unpublish_at', 'review_requested_at', 'review_requested_by', 'reviewer_id',
|
|
'taskstream_project_id', 'taskstream_key',
|
|
];
|
|
|
|
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;
|
|
}
|
|
}
|