fix(KAN-14): preserve template inheritance and project data
CI/CD / preconditions (pull_request) Successful in 4s
CI/CD / test_and_check_app (pull_request) Failing after 2m14s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-10-15 11:38:17 +02:00
parent 5769d84526
commit 1291a0fcad
61 changed files with 2696 additions and 335 deletions
@@ -2,9 +2,13 @@
namespace App\Actions\Projects;
use App\Actions\Notifications\NormalizeNotificationFilters;
use App\Models\Project;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class DuplicateProjectConfiguration
{
@@ -32,6 +36,7 @@ class DuplicateProjectConfiguration
public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
) {}
/** @return list<string> */
@@ -108,7 +113,7 @@ class DuplicateProjectConfiguration
unset($configuration[self::COPY_BOARD_WORKFLOW]);
}
return $configuration;
return $this->projectConfigurationIdentity->normalize($configuration);
}
/** @param list<string> $copyAreas @return array<string, mixed> */
@@ -133,23 +138,24 @@ class DuplicateProjectConfiguration
];
}
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
static fn ($column, int $index): array => [$column->id => $index],
$columnKeys = $sourceProject->boardColumns->values()->mapWithKeys(
static fn ($column): array => [$column->id => $column->configuration_key],
);
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
static fn ($status, int $index): array => [$status->id => $index],
$statusKeys = $sourceProject->ticketStatuses->values()->mapWithKeys(
static fn ($status): array => [$status->id => $status->configuration_key],
);
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
static fn ($column): array => ['configuration_key' => $column->configuration_key, 'name' => $column->name, 'position' => $column->position],
)->all();
}
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => [
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
'configuration_key' => $status->configuration_key,
'board_column_key' => $status->board_column_id === null ? null : $columnKeys->get($status->board_column_id),
'name' => $status->name,
'position' => $status->position,
],
@@ -158,9 +164,12 @@ class DuplicateProjectConfiguration
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
static fn (int $id): ?int => $statusIndexes->get($id),
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
static fn ($status): array => [
'status_key' => $status->configuration_key,
'target_keys' => $status->allowedTargetStatuses->pluck('id')->map(
static fn (int $id): ?string => $statusKeys->get($id),
)->filter()->sort()->values()->all(),
],
)->all();
}
@@ -179,7 +188,7 @@ class DuplicateProjectConfiguration
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
'filters' => $item->filters ?? [],
'filters' => $this->mapNotificationFilters($item->filters ?? [], $statusKeys->all()),
],
];
$relations = [
@@ -190,7 +199,9 @@ class DuplicateProjectConfiguration
foreach ($simpleAreas as $area => $mapper) {
if (in_array($area, $selectedAreas, true)) {
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map(
static fn ($item): array => ['configuration_key' => $item->configuration_key, ...$mapper($item)],
)->all();
}
}
@@ -200,95 +211,124 @@ class DuplicateProjectConfiguration
/**
* @param array<string, mixed> $configuration
* @param array<int|string, int|string> $statusReplacements
* @return list<string> areas which retained referenced local values
* @return list<string> Areas which retain referenced project configuration.
*/
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
public function apply(Project $targetProject, array $configuration, array $statusReplacements = [], bool $adoptExisting = false): array
{
return DB::transaction(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
$targetProject = Project::query()->whereKey($targetProject->id)->lockForUpdate()->firstOrFail();
return $this->applyLocked($targetProject, $configuration, $statusReplacements, $adoptExisting);
}, 3);
}
/** @param array<string, mixed> $configuration @param array<int|string, int|string> $statusReplacements @return list<string> */
private function applyLocked(Project $targetProject, array $configuration, array $statusReplacements, bool $adoptExisting): array
{
$configuration = $this->normalizeConfiguration($configuration);
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
$blockedAreas = [];
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
}
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
$blocked = $this->syncModels(
$targetProject->boardColumns()->get(),
$configuration[self::COPY_BOARD_COLUMNS],
fn (array $item) => $targetProject->boardColumns()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
$currentStatuses = $targetProject->ticketStatuses()->withCount('tickets')->get();
$nextStatuses = collect($configuration[self::COPY_TICKET_STATUSES] ?? [])->keyBy('configuration_key');
$columnKeys = array_column($configuration[self::COPY_BOARD_COLUMNS], 'configuration_key');
$statusesCanChange = isset($configuration[self::COPY_TICKET_STATUSES]) && $currentStatuses->every(
static fn ($status): bool => $nextStatuses->has($status->configuration_key)
|| $status->tickets_count === 0 || array_key_exists($status->id, $statusReplacements),
);
if ($blocked) {
$canDeleteColumn = static fn (Model $column): bool => $currentStatuses->where('board_column_id', $column->id)->every(
static function ($status) use ($column, $statusesCanChange, $nextStatuses, $columnKeys): bool {
if (! $statusesCanChange) {
return false;
}
$next = $nextStatuses->get($status->configuration_key);
$nextColumn = $next['board_column_key'] ?? null;
return $next === null || ($nextColumn !== $column->configuration_key
&& ($nextColumn === null || in_array($nextColumn, $columnKeys, true)));
},
);
if ($this->syncModels($targetProject, self::COPY_BOARD_COLUMNS, $configuration[self::COPY_BOARD_COLUMNS],
$canDeleteColumn, $adoptExisting)) {
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
}
}
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
$columns = $targetProject->boardColumns()->get()->values();
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
$columnIndex = $item['board_column_index'] ?? null;
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
unset($item['board_column_index']);
$columns = $targetProject->boardColumns()->get()->keyBy('configuration_key');
$existingStatuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns, $existingStatuses): array {
$columnKey = $item['board_column_key'] ?? null;
$item['board_column_id'] = $columnKey === null
? null
: ($columns->get($columnKey)?->id ?? $existingStatuses->get($item['configuration_key'])?->board_column_id);
$item['slug'] = Str::slug($item['name']);
unset($item['board_column_key']);
return $item;
})->all();
$blocked = $this->syncModels(
$targetProject->ticketStatuses()->get(),
$items,
fn (array $item) => $targetProject->ticketStatuses()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
function (Model $status) use ($statusReplacements): bool {
if (! $status->tickets()->exists()) {
return true;
}
$replacementId = $statusReplacements[$status->id] ?? null;
if ($replacementId === null) {
return false;
}
$status->tickets()->update(['ticket_status_id' => $replacementId]);
return true;
},
);
if ($blocked) {
$templateKeys = array_column($items, 'configuration_key');
$canDelete = static fn (Model $status): bool => ! $status->tickets()->exists()
|| array_key_exists($status->id, $statusReplacements);
$beforeDelete = function (Model $status) use ($statusReplacements, $targetProject, $templateKeys): void {
$replacement = $statusReplacements[$status->id] ?? null;
if ($replacement === null) {
return;
}
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)
->where('configuration_key', (string) $replacement)->first();
if ($replacementStatus === null && is_numeric($replacement)) {
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)->find($replacement);
}
if ($replacementStatus === null) {
throw ValidationException::withMessages([
'status_replacements.'.$status->id => 'Choose a replacement from the current template.',
]);
}
$status->tickets()->update(['ticket_status_id' => $replacementStatus->id]);
$this->transferStatusTransitions($targetProject, $status, $replacementStatus, $statusReplacements, $templateKeys);
};
if ($this->syncModels($targetProject, self::COPY_TICKET_STATUSES, $items, $canDelete, $adoptExisting, $beforeDelete)) {
$blockedAreas[] = self::COPY_TICKET_STATUSES;
}
}
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
$statuses = $targetProject->ticketStatuses()->get()->values();
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
$status = $statuses->get($index);
$statuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $transition) {
$status = $statuses->get($transition['status_key']);
if ($status !== null) {
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
$status->allowedTargetStatuses()->sync(collect($transition['target_keys'])->map(
static fn (string $key): ?int => $statuses->get($key)?->id,
)->filter()->all());
}
}
}
$relations = [
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
$guards = [
self::COPY_TICKET_TYPES => static fn (Model $item): bool => ! $item->tickets()->exists(),
self::COPY_TICKET_PRIORITIES => static fn (Model $item): bool => ! $item->tickets()->exists(),
self::COPY_TICKET_FIELDS => static fn (Model $item): bool => ! $item->ticketFieldValues()->exists(),
self::COPY_TICKET_LINK_TYPES => static fn (Model $item): bool => ! $item->ticketLinks()->exists(),
self::COPY_REPOSITORY_CONNECTIONS => null,
self::COPY_NOTIFICATION_ACTIONS => null,
];
foreach ($relations as $area => [$relation, $canDelete]) {
foreach ($guards as $area => $canDelete) {
if (! isset($configuration[$area])) {
continue;
}
$blocked = $this->syncModels(
$targetProject->{$relation}()->get(),
$configuration[$area],
fn (array $item) => $targetProject->{$relation}()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
$canDelete,
);
if ($blocked) {
$items = $configuration[$area];
if ($area === self::COPY_NOTIFICATION_ACTIONS) {
$statusIds = $targetProject->ticketStatuses()->pluck('id', 'configuration_key')->all();
$items = array_map(fn (array $item): array => [
...$item, 'filters' => $this->mapNotificationFilters($item['filters'] ?? [], $statusIds),
], $items);
}
if ($this->syncModels($targetProject, $area, $items, $canDelete, $adoptExisting)) {
$blockedAreas[] = $area;
}
}
@@ -297,9 +337,8 @@ class DuplicateProjectConfiguration
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
}
return array_values(array_unique($blockedAreas));
return $blockedAreas;
});
foreach ($blockedAreas as $area) {
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
}
@@ -310,30 +349,113 @@ class DuplicateProjectConfiguration
/** @param list<string> $copyAreas */
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
{
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
$this->apply($targetProject, $this->export($sourceProject, $copyAreas), adoptExisting: true);
}
/** @param list<array<string, mixed>> $filters @param array<int|string, int|string> $mapping @return list<array<string, mixed>> */
public function mapNotificationFilters(array $filters, array $mapping): array
{
return array_map(static function (array $filter) use ($mapping): array {
if (! in_array($filter['attribute'], ['status.from.id', 'status.to.id'], true)) {
return $filter;
}
$map = static fn (string $value): string => (string) ($mapping[$value] ?? $value);
$filter['value'] = is_array($filter['value'])
? array_map($map, $filter['value'])
: ($filter['value'] === null ? null : $map($filter['value']));
return $filter;
}, app(NormalizeNotificationFilters::class)->handle($filters));
}
/** @param array<int|string, int|string> $replacements @param list<string> $templateKeys */
private function transferStatusTransitions(Project $project, TicketStatus $removed, TicketStatus $replacement, array $replacements, array $templateKeys): void
{
$statuses = $project->ticketStatuses()->get();
$targets = $statuses->whereIn('configuration_key', $templateKeys);
$mapping = [];
foreach ($statuses as $status) {
if (in_array($status->configuration_key, $templateKeys, true)) {
$mapping[$status->id] = $status->id;
continue;
}
$target = $replacements[$status->id] ?? null;
if (is_scalar($target)) {
$mapping[$status->id] = $targets->firstWhere('configuration_key', (string) $target)?->id
?? (is_numeric($target) ? $targets->firstWhere('id', (int) $target)?->id : null);
}
}
$mapping[$removed->id] = $replacement->id;
$edges = DB::table('ticket_status_transitions')->where('from_ticket_status_id', $removed->id)
->orWhere('to_ticket_status_id', $removed->id)->get();
foreach ($edges as $edge) {
$from = $mapping[$edge->from_ticket_status_id] ?? null;
$to = $mapping[$edge->to_ticket_status_id] ?? null;
if ($from !== null && $to !== null && $from !== $to) {
$statuses->firstWhere('id', $from)->allowedTargetStatuses()->syncWithoutDetaching([$to]);
}
}
}
/**
* @param EloquentCollection<int, Model> $models
* @param array<int, array<string, mixed>> $items
* @param list<array<string, mixed>> $items
*/
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
private function syncModels(Project $project, string $area, array $items, ?callable $canDelete, bool $adoptExisting, ?callable $beforeDelete = null): bool
{
foreach ($items as $index => $item) {
$model = $models->get($index);
$model === null ? $create($item) : $update($model, $item);
$relation = ProjectConfigurationIdentity::relations()[$area];
$models = $project->{$relation}()->get();
$byKey = $models->keyBy('configuration_key');
$matched = [];
$assignments = [];
foreach ($items as $item) {
$model = $byKey->get($item['configuration_key']);
if ($model === null && $adoptExisting) {
$candidates = $models->filter(fn (Model $candidate): bool => ! in_array($candidate->id, $matched, true)
&& $this->projectConfigurationIdentity->signature($area, $candidate->attributesToArray()) === $this->projectConfigurationIdentity->signature($area, $item));
$model = $candidates->count() === 1 ? $candidates->first() : null;
}
if ($model !== null) {
$matched[] = $model->id;
if ($area === self::COPY_TICKET_FIELDS && $model->field_type !== $item['field_type'] && $model->ticketFieldValues()->exists()) {
return true;
}
if ($area === self::COPY_TICKET_TYPES && $model->is_parent && ! ($item['is_parent'] ?? false)
&& $model->tickets()->whereHas('childTickets')->exists()) {
return true;
}
}
$assignments[] = [$model, $item];
}
$removed = $models->reject(static fn (Model $model): bool => in_array($model->id, $matched, true));
foreach ($removed as $model) {
if ($canDelete !== null && ! $canDelete($model)) {
return true;
}
}
$blocked = false;
foreach ($models->slice(count($items)) as $model) {
if ($canDelete !== null && ! $canDelete($model)) {
$blocked = true;
if ($area === self::COPY_REPOSITORY_CONNECTIONS) {
foreach ($models as $model) {
$model->forceFill(['provider_repository_id' => 'template-sync-'.Str::uuid()])->saveQuietly();
}
}
continue;
if ($models->isNotEmpty() && $models->first()->getAttribute('position') !== null) {
$offset = max((int) $models->max('position'), (int) collect($items)->max('position')) + $models->count() + count($items) + 1;
foreach ($models->values() as $index => $model) {
$model->forceFill(['position' => $offset + $index])->saveQuietly();
}
}
foreach ($assignments as [$model, $item]) {
$model === null ? $project->{$relation}()->create($item) : $model->forceFill($item)->saveQuietly();
}
foreach ($removed as $model) {
if ($beforeDelete !== null) {
$beforeDelete($model);
}
$model->deleteQuietly();
}
return $blocked;
return false;
}
}