diff --git a/app/Actions/ProjectBoard/ManagesTicketStatusPositions.php b/app/Actions/ProjectBoard/ManagesTicketStatusPositions.php index 07493c9..6c7c3b5 100644 --- a/app/Actions/ProjectBoard/ManagesTicketStatusPositions.php +++ b/app/Actions/ProjectBoard/ManagesTicketStatusPositions.php @@ -2,6 +2,7 @@ namespace App\Actions\ProjectBoard; +use App\Actions\Projects\RefreshProjectBaseTemplate; use App\Models\Project; use App\Models\TicketStatus; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -23,6 +24,7 @@ trait ManagesTicketStatusPositions 'position' => $index + 1, ])->saveQuietly(); }); + app(RefreshProjectBaseTemplate::class)->handle($project, 'ticket_statuses'); } private function statusContextLabel(TicketStatus $ticketStatus): string diff --git a/app/Actions/ProjectBoard/ReordersBoardColumns.php b/app/Actions/ProjectBoard/ReordersBoardColumns.php index 0657fe5..bd88a95 100644 --- a/app/Actions/ProjectBoard/ReordersBoardColumns.php +++ b/app/Actions/ProjectBoard/ReordersBoardColumns.php @@ -2,6 +2,7 @@ namespace App\Actions\ProjectBoard; +use App\Actions\Projects\RefreshProjectBaseTemplate; use App\Models\BoardColumn; use App\Models\Project; use Illuminate\Support\Collection; @@ -50,5 +51,9 @@ trait ReordersBoardColumns 'position' => $index + 1, ])->saveQuietly(); }); + $project = $columns->first()?->project; + if ($project !== null) { + app(RefreshProjectBaseTemplate::class)->handle($project, 'board_columns'); + } } } diff --git a/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php b/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php index 63416ff..5988e12 100644 --- a/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php +++ b/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php @@ -5,8 +5,10 @@ namespace App\Actions\ProjectBoard; use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\ProjectTemplateOverrideManager; use App\Actions\Projects\RefreshProjectBaseTemplate; +use App\Models\Project; use App\Models\TicketStatus; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; class UpdateTicketStatusTransitions { @@ -20,22 +22,25 @@ class UpdateTicketStatusTransitions */ public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus { - $allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', [])) - ->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId) - ->unique() - ->values() - ->all(); + return DB::transaction(function () use ($ticketStatus, $validated): TicketStatus { + Project::query()->whereKey($ticketStatus->project_id)->lockForUpdate()->firstOrFail(); + $allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', [])) + ->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId) + ->unique() + ->values() + ->all(); - $ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds); - $this->projectTemplateOverrideManager->mark( - $ticketStatus->project, - DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS, - ); - $this->refreshProjectBaseTemplate->handle( - $ticketStatus->project, - DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS, - ); + $ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds); + $this->projectTemplateOverrideManager->mark( + $ticketStatus->project, + DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS, + ); + $this->refreshProjectBaseTemplate->handle( + $ticketStatus->project, + DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS, + ); - return $ticketStatus->fresh(); + return $ticketStatus->fresh(); + }, 3); } } diff --git a/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php index 2a43375..d31ecfb 100644 --- a/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php +++ b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php @@ -7,6 +7,7 @@ use App\Actions\Projects\ProjectTemplateOverrideManager; use App\Actions\Projects\RefreshProjectBaseTemplate; use App\Models\Project; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; class UpdateProjectTicketMetadataSettings { @@ -20,19 +21,22 @@ class UpdateProjectTicketMetadataSettings */ public function handle(Project $project, array $validated): Project { - $project->update([ - 'show_author' => Arr::get($validated, 'show_author', false), - 'show_story_points' => Arr::get($validated, 'show_story_points', false), - ]); - $this->projectTemplateOverrideManager->mark( - $project, - DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, - ); - $this->refreshProjectBaseTemplate->handle( - $project, - DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, - ); + return DB::transaction(function () use ($project, $validated): Project { + Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); + $project->update([ + 'show_author' => Arr::get($validated, 'show_author', false), + 'show_story_points' => Arr::get($validated, 'show_story_points', false), + ]); + $this->projectTemplateOverrideManager->mark( + $project, + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + ); + $this->refreshProjectBaseTemplate->handle( + $project, + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + ); - return $project->fresh(); + return $project->fresh(); + }, 3); } } diff --git a/app/Actions/Projects/CreateProject.php b/app/Actions/Projects/CreateProject.php index 8d86e7c..ef45f12 100644 --- a/app/Actions/Projects/CreateProject.php +++ b/app/Actions/Projects/CreateProject.php @@ -36,6 +36,7 @@ class CreateProject if ($creationMode === 'template' && $sourceTemplateId !== null) { $sourceTemplate = ProjectTemplate::query() ->published() + ->lockForUpdate() ->findOrFail((int) $sourceTemplateId); $project->forceFill([ @@ -72,6 +73,7 @@ class CreateProject $this->duplicateProjectConfiguration->apply( $project->fresh() ?? $project, $sourceTemplate->configuration_snapshot ?? [], + adoptExisting: true, ); } diff --git a/app/Actions/Projects/CreateProjectBaseTemplate.php b/app/Actions/Projects/CreateProjectBaseTemplate.php index 29623e3..186632b 100644 --- a/app/Actions/Projects/CreateProjectBaseTemplate.php +++ b/app/Actions/Projects/CreateProjectBaseTemplate.php @@ -15,11 +15,18 @@ class CreateProjectBaseTemplate { $copyAreas = DuplicateProjectConfiguration::copyableAreas(); + $slug = strtolower($project->key).'-base-template'; + $suffix = 1; + $candidate = $slug; + while (ProjectTemplate::query()->where('slug', $candidate)->exists()) { + $candidate = $slug.'-'.++$suffix; + } + return ProjectTemplate::query()->create([ 'project_id' => $project->id, 'source_project_id' => $project->id, 'name' => "{$project->name} base template", - 'slug' => strtolower($project->key).'-base-template', + 'slug' => $candidate, 'is_published' => false, 'copy_areas' => $copyAreas, 'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas), diff --git a/app/Actions/Projects/CreateProjectTemplate.php b/app/Actions/Projects/CreateProjectTemplate.php index 463267e..fe0963e 100644 --- a/app/Actions/Projects/CreateProjectTemplate.php +++ b/app/Actions/Projects/CreateProjectTemplate.php @@ -5,28 +5,61 @@ namespace App\Actions\Projects; use App\Models\Project; use App\Models\ProjectTemplate; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; class CreateProjectTemplate { public function __construct( private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, + private readonly ProjectConfigurationIdentity $projectConfigurationIdentity, ) {} + /** @param array $attributes */ + public function empty(array $attributes): ProjectTemplate + { + $areas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', [])); + + return ProjectTemplate::query()->create([ + 'name' => Arr::get($attributes, 'name'), + 'slug' => Arr::get($attributes, 'slug'), + 'is_published' => true, + 'copy_areas' => $areas, + 'configuration_snapshot' => collect($this->duplicateProjectConfiguration->normalizeConfiguration( + Arr::get($attributes, 'configuration_snapshot', $this->projectConfigurationIdentity->defaults()), + ))->only($areas)->all(), + ]); + } + /** * @param array{name: string, slug: string, copy_areas?: array} $attributes */ public function handle(Project $sourceProject, array $attributes): ProjectTemplate { - $copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); + return DB::transaction(function () use ($sourceProject, $attributes): ProjectTemplate { + $sourceProject = Project::query()->whereKey($sourceProject->id)->lockForUpdate()->firstOrFail(); + $copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', [])); + $template = $sourceProject->projectTemplate; + if ($template === null || $template->is_published || $template->project_id !== $sourceProject->id) { + $template = new ProjectTemplate; + $template->slug = Arr::get($attributes, 'slug'); + } + $template->forceFill([ + 'project_id' => null, + 'source_project_id' => $sourceProject->id, + 'name' => Arr::get($attributes, 'name'), + 'is_published' => true, + 'copy_areas' => $copyAreas, + 'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas), + ])->save(); + $sourceProject->forceFill([ + 'project_template_id' => $template->id, + 'template_overrides' => array_values(array_diff(DuplicateProjectConfiguration::copyableAreas(), $copyAreas)), + ])->saveQuietly(); + foreach ($sourceProject->configurationRelations() as $relation) { + $sourceProject->{$relation}()->update(['project_template_id' => $template->id]); + } - return ProjectTemplate::query()->create([ - 'project_id' => null, - 'source_project_id' => $sourceProject->id, - 'name' => $attributes['name'], - 'slug' => $attributes['slug'], - 'is_published' => true, - 'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas), - 'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas), - ]); + return $template; + }, 3); } } diff --git a/app/Actions/Projects/DeleteProjectTemplate.php b/app/Actions/Projects/DeleteProjectTemplate.php new file mode 100644 index 0000000..ad843bc --- /dev/null +++ b/app/Actions/Projects/DeleteProjectTemplate.php @@ -0,0 +1,31 @@ +whereKey($template->id)->lockForUpdate()->firstOrFail(); + $projects = $template->linkedProjects()->orderBy('id')->lockForUpdate()->get(); + $template->delete(); + foreach ($projects as $project) { + $base = $project->baseTemplate()->first() ?? $this->createProjectBaseTemplate->handle($project); + $base->forceFill(['configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, DuplicateProjectConfiguration::copyableAreas())])->saveQuietly(); + $project->forceFill(['project_template_id' => $base->id, 'template_overrides' => []])->saveQuietly(); + foreach ($project->configurationRelations() as $relation) { + $project->{$relation}()->update(['project_template_id' => $base->id]); + } + } + }, 3); + } +} diff --git a/app/Actions/Projects/DuplicateProjectConfiguration.php b/app/Actions/Projects/DuplicateProjectConfiguration.php index 2513340..95a5900 100644 --- a/app/Actions/Projects/DuplicateProjectConfiguration.php +++ b/app/Actions/Projects/DuplicateProjectConfiguration.php @@ -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 */ @@ -108,7 +113,7 @@ class DuplicateProjectConfiguration unset($configuration[self::COPY_BOARD_WORKFLOW]); } - return $configuration; + return $this->projectConfigurationIdentity->normalize($configuration); } /** @param list $copyAreas @return array */ @@ -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 $configuration * @param array $statusReplacements - * @return list areas which retained referenced local values + * @return list 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 $configuration @param array $statusReplacements @return list */ + 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 $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> $filters @param array $mapping @return list> */ + 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 $replacements @param list $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 $models - * @param array> $items + * @param list> $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; } } diff --git a/app/Actions/Projects/ProjectConfigurationIdentity.php b/app/Actions/Projects/ProjectConfigurationIdentity.php new file mode 100644 index 0000000..086a62e --- /dev/null +++ b/app/Actions/Projects/ProjectConfigurationIdentity.php @@ -0,0 +1,95 @@ + */ + public static function relations(): array + { + return [ + 'board_columns' => 'boardColumns', 'ticket_statuses' => 'ticketStatuses', + 'ticket_types' => 'ticketTypes', 'ticket_priorities' => 'ticketPriorities', + 'ticket_fields' => 'ticketFieldDefinitions', 'ticket_link_types' => 'ticketLinkTypes', + 'repository_connections' => 'repositoryConnections', 'notification_actions' => 'notificationActions', + ]; + } + + /** @param array $configuration @return array */ + public function normalize(array $configuration): array + { + foreach (self::relations() as $area => $relation) { + if (! array_key_exists($area, $configuration)) { + continue; + } + foreach ($configuration[$area] as $index => &$item) { + $item['configuration_key'] ??= (string) Uuid::uuid5( + Uuid::NAMESPACE_URL, 'kanbangit:'.$area.':'.$index.':'.$this->signature($area, $item), + ); + } + unset($item); + } + + $columns = array_column($configuration['board_columns'] ?? [], 'configuration_key'); + foreach ($configuration['ticket_statuses'] ?? [] as $index => $status) { + if (! array_key_exists('board_column_key', $status)) { + $columnIndex = $status['board_column_index'] ?? null; + $status['board_column_key'] = $columnIndex === null ? null : ($columns[$columnIndex] ?? null); + } + unset($status['board_column_index']); + $configuration['ticket_statuses'][$index] = $status; + } + + $statuses = array_column($configuration['ticket_statuses'] ?? [], 'configuration_key'); + foreach ($configuration['status_transitions'] ?? [] as $index => $transition) { + if (array_key_exists('status_key', $transition)) { + continue; + } + if (! isset($statuses[$index])) { + continue; + } + $configuration['status_transitions'][$index] = [ + 'status_key' => $statuses[$index], + 'target_keys' => array_values(array_filter(array_map( + static fn (int $target): ?string => $statuses[$target] ?? null, $transition, + ))), + ]; + } + + return $configuration; + } + + /** @param array $item */ + public function signature(string $area, array $item): string + { + return $area === 'repository_connections' + ? ($item['provider'] ?? '').':'.($item['base_url'] ?? '').':'.($item['repository_full_name'] ?? '') + : (string) ($item['name'] ?? $item['label'] ?? ''); + } + + /** @return array */ + public function defaults(): array + { + $configuration = array_fill_keys(DuplicateProjectConfiguration::copyableAreas(), []); + $configuration['project_settings'] = ['show_author' => true, 'show_story_points' => true]; + $configuration['ticket_statuses'] = [[ + 'configuration_key' => (string) Str::uuid(), 'name' => 'Backlog', + 'position' => 1, 'board_column_key' => null, + ]]; + $configuration['ticket_types'] = [ + ['configuration_key' => (string) Str::uuid(), 'name' => 'Task', 'position' => 1, 'is_parent' => false], + ['configuration_key' => (string) Str::uuid(), 'name' => 'Release', 'position' => 2, 'is_parent' => true], + ]; + $configuration['ticket_priorities'] = [[ + 'configuration_key' => (string) Str::uuid(), 'name' => 'Medium', 'position' => 1, + ]]; + $configuration['status_transitions'] = [[ + 'status_key' => $configuration['ticket_statuses'][0]['configuration_key'], 'target_keys' => [], + ]]; + + return $configuration; + } +} diff --git a/app/Actions/Projects/ProjectTemplateOverrideManager.php b/app/Actions/Projects/ProjectTemplateOverrideManager.php index f01339e..44783a3 100644 --- a/app/Actions/Projects/ProjectTemplateOverrideManager.php +++ b/app/Actions/Projects/ProjectTemplateOverrideManager.php @@ -3,6 +3,7 @@ namespace App\Actions\Projects; use App\Models\Project; +use Illuminate\Support\Facades\DB; class ProjectTemplateOverrideManager { @@ -14,18 +15,16 @@ class ProjectTemplateOverrideManager return; } - $project->loadMissing('projectTemplate'); - - if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) { - return; - } - - $overrides = array_values(array_unique([ - ...($project->template_overrides ?? []), - $area, - ])); - - $project->forceFill(['template_overrides' => $overrides])->saveQuietly(); + DB::transaction(function () use ($project, $area): void { + $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); + $project->load('projectTemplate'); + if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) { + return; + } + $project->forceFill(['template_overrides' => array_values(array_unique([ + ...($project->template_overrides ?? []), $area, + ]))])->saveQuietly(); + }, 3); } /** @@ -33,8 +32,10 @@ class ProjectTemplateOverrideManager */ public function clear(Project $project, array $areas): void { - $remaining = array_values(array_diff($project->template_overrides ?? [], $areas)); - $project->forceFill(['template_overrides' => $remaining])->saveQuietly(); + DB::transaction(function () use ($project, $areas): void { + $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); + $project->forceFill(['template_overrides' => array_values(array_diff($project->template_overrides ?? [], $areas))])->saveQuietly(); + }, 3); } public function withoutRecording(callable $callback): mixed diff --git a/app/Actions/Projects/RefreshProjectBaseTemplate.php b/app/Actions/Projects/RefreshProjectBaseTemplate.php index 095ab11..f22cec2 100644 --- a/app/Actions/Projects/RefreshProjectBaseTemplate.php +++ b/app/Actions/Projects/RefreshProjectBaseTemplate.php @@ -3,6 +3,8 @@ namespace App\Actions\Projects; use App\Models\Project; +use App\Models\ProjectTemplate; +use Illuminate\Support\Facades\DB; class RefreshProjectBaseTemplate { @@ -12,21 +14,17 @@ class RefreshProjectBaseTemplate public function handle(Project $project, string $area): void { - $project->loadMissing('projectTemplate'); - - if ($project->projectTemplate === null || $project->projectTemplate->is_published) { - return; - } - - $snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration( - $project->projectTemplate->configuration_snapshot ?? [], - ); - - $project->projectTemplate->forceFill([ - 'configuration_snapshot' => array_replace( - $snapshot, - $this->duplicateProjectConfiguration->export($project->fresh(), [$area]), - ), - ])->saveQuietly(); + DB::transaction(function () use ($project, $area): void { + $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); + $template = ProjectTemplate::query()->find($project->project_template_id); + if ($template === null || $template->is_published) { + return; + } + $template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail(); + $snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []); + $template->forceFill(['configuration_snapshot' => array_replace( + $snapshot, $this->duplicateProjectConfiguration->export($project, [$area]), + )])->saveQuietly(); + }, 3); } } diff --git a/app/Actions/Projects/ResolveProjectTemplateState.php b/app/Actions/Projects/ResolveProjectTemplateState.php index 99d3519..e171d81 100644 --- a/app/Actions/Projects/ResolveProjectTemplateState.php +++ b/app/Actions/Projects/ResolveProjectTemplateState.php @@ -71,7 +71,9 @@ class ResolveProjectTemplateState $inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value')); return [ - 'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)), + 'effectiveConfiguration' => $currentConfiguration, + 'localConfiguration' => array_diff_key($currentConfiguration, $templateConfiguration), + 'localAreas' => collect(array_diff(array_keys($currentConfiguration), array_keys($templateConfiguration)))->map(fn (string $area): array => ['label' => $this->labelForArea($area), 'value' => $area])->values()->all(), 'inheritedAreas' => $inheritedAreas, 'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues), 'inheritsFromTemplate' => $projectTemplate->is_published, @@ -96,24 +98,20 @@ class ResolveProjectTemplateState return []; } - $templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? []; + $templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? null; if (! is_array($templateStatuses)) { return []; } - $statuses = $project->ticketStatuses()->withCount('tickets')->get()->values(); - $replacementOptions = $statuses->take(count($templateStatuses))->map( - static fn ($status): array => ['id' => $status->id, 'name' => $status->name], + $statuses = $project->ticketStatuses()->withCount('tickets')->get(); + $templateKeys = array_column($templateStatuses, 'configuration_key'); + $replacementOptions = collect($templateStatuses)->map( + static fn (array $status): array => ['id' => $status['configuration_key'], 'name' => $status['name']], )->all(); - $removedStatuses = $statuses->slice(count($templateStatuses)) - ->filter(static fn ($status): bool => $status->tickets_count > 0) - ->map(static fn ($status): array => [ - 'id' => $status->id, - 'name' => $status->name, - 'ticketCount' => $status->tickets_count, - ]) - ->values() - ->all(); + $removedStatuses = $statuses + ->filter(static fn ($status): bool => ! in_array($status->configuration_key, $templateKeys, true) && $status->tickets_count > 0) + ->map(static fn ($status): array => ['id' => $status->id, 'name' => $status->name, 'ticketCount' => $status->tickets_count]) + ->values()->all(); return $removedStatuses === [] ? [] : [ DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [ diff --git a/app/Actions/Projects/UpdateProjectTemplate.php b/app/Actions/Projects/UpdateProjectTemplate.php index c8231e4..8d48d71 100644 --- a/app/Actions/Projects/UpdateProjectTemplate.php +++ b/app/Actions/Projects/UpdateProjectTemplate.php @@ -19,37 +19,34 @@ class UpdateProjectTemplate */ public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate { - $projectTemplate->loadMissing('sourceProject'); - - $sourceProject = $projectTemplate->sourceProject; - abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.'); - - $copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); - $normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas); - $previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration( - $projectTemplate->configuration_snapshot ?? [], - ); - $nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas); - $affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)])) - ->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null)) - ->values() - ->all(); - - DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void { - ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail(); - + return DB::transaction(function () use ($projectTemplate, $attributes): ProjectTemplate { + $projectTemplate = ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail(); + $previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration($projectTemplate->configuration_snapshot ?? []); + $copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', $projectTemplate->copy_areas ?? [])); + $nextConfiguration = collect($this->duplicateProjectConfiguration->normalizeConfiguration( + Arr::get($attributes, 'configuration_snapshot', $previousConfiguration), + ))->only($copyAreas)->all(); + $affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)])) + ->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null)) + ->values()->all(); $projectTemplate->forceFill([ - 'name' => $attributes['name'], - 'copy_areas' => $normalizedCopyAreas, + 'name' => Arr::get($attributes, 'name'), + 'copy_areas' => $copyAreas, 'configuration_snapshot' => $nextConfiguration, ])->save(); Project::query() ->where('project_template_id', $projectTemplate->id) ->orderBy('id') - ->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void { + ->chunkById(100, function ($linkedProjects) use ($projectTemplate, $affectedAreas, $nextConfiguration, $previousConfiguration): void { foreach ($linkedProjects as $linkedProject) { - $linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail(); + $linkedProject = Project::query() + ->whereKey($linkedProject->id) + ->where('project_template_id', $projectTemplate->id) + ->lockForUpdate()->first(); + if ($linkedProject === null) { + continue; + } $currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas); $overrides = $linkedProject->template_overrides ?? []; $replacementConfiguration = []; @@ -81,8 +78,8 @@ class UpdateProjectTemplate } } }); - }); - return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects'); + return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects'); + }, 3); } } diff --git a/app/Actions/Projects/UpdateProjectTemplateLink.php b/app/Actions/Projects/UpdateProjectTemplateLink.php index c046b03..494b547 100644 --- a/app/Actions/Projects/UpdateProjectTemplateLink.php +++ b/app/Actions/Projects/UpdateProjectTemplateLink.php @@ -5,6 +5,7 @@ namespace App\Actions\Projects; use App\Models\Project; use App\Models\ProjectTemplate; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class UpdateProjectTemplateLink { @@ -16,6 +17,7 @@ class UpdateProjectTemplateLink public function switch(Project $project, ProjectTemplate $template): void { DB::transaction(function () use ($project, $template): void { + $template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail(); $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); $configuration = $this->duplicateProjectConfiguration->normalizeConfiguration( $template->configuration_snapshot ?? [], @@ -42,19 +44,27 @@ class UpdateProjectTemplateLink * @param list $areas * @param array $statusReplacements */ - public function reset(Project $project, array $areas, array $statusReplacements = []): void + public function reset(Project $project, array $areas, array $statusReplacements = []): array { $template = $project->projectTemplate; if ($template === null) { - return; + return []; } - DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void { + return DB::transaction(function () use ($project, $template, $areas, $statusReplacements): array { + $template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail(); $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); - $configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration( - $template->configuration_snapshot ?? [], - ))->only($areas)->all(); + if ($project->project_template_id !== $template->id) { + throw ValidationException::withMessages(['areas' => 'The linked template changed. Reload this project before resetting.']); + } + $baseline = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []); + $configuration = collect($baseline)->only($areas)->all(); + if (in_array('ticket_statuses', $areas, true) + && ! in_array('status_transitions', $project->template_overrides ?? [], true) + && isset($baseline['status_transitions'])) { + $configuration['status_transitions'] = $baseline['status_transitions']; + } $blockedAreas = $this->duplicateProjectConfiguration->apply( $project, $configuration, @@ -64,6 +74,8 @@ class UpdateProjectTemplateLink $project->fresh(), array_values(array_diff(array_keys($configuration), $blockedAreas)), ); - }); + + return $blockedAreas; + }, 3); } } diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index ad847c7..a5fd8c8 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -161,7 +161,7 @@ class ProjectController extends Controller $areas = array_values(Arr::get($validated, 'areas', array_keys($configuration))); $statusReplacements = Arr::get($validated, 'status_replacements', []); - $this->updateProjectTemplateLink->reset( + $blockedAreas = $this->updateProjectTemplateLink->reset( $project->load('projectTemplate'), $areas, $statusReplacements, @@ -171,6 +171,10 @@ class ProjectController extends Controller ? "Selected settings were reset to the {$template->name} template." : "{$project->name} was reset to the {$template->name} template baseline."; + if ($blockedAreas !== []) { + $status = 'Some settings remain overridden because tickets or other configuration still use them.'; + } + return back()->with('status', $status); } diff --git a/app/Http/Controllers/ProjectTemplateController.php b/app/Http/Controllers/ProjectTemplateController.php index e9c1b6a..7814e8d 100644 --- a/app/Http/Controllers/ProjectTemplateController.php +++ b/app/Http/Controllers/ProjectTemplateController.php @@ -3,9 +3,12 @@ namespace App\Http\Controllers; use App\Actions\Projects\CreateProjectTemplate; +use App\Actions\Projects\DeleteProjectTemplate; use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\ProjectConfigurationIdentity; use App\Actions\Projects\UpdateProjectTemplate; use App\Http\Requests\StoreProjectTemplateRequest; +use App\Http\Requests\StoreStandaloneProjectTemplateRequest; use App\Http\Requests\UpdateProjectTemplateRequest; use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectTemplateResource; @@ -20,6 +23,7 @@ class ProjectTemplateController extends Controller { public function __construct( private readonly CreateProjectTemplate $createProjectTemplate, + private readonly DeleteProjectTemplate $deleteProjectTemplate, private readonly UpdateProjectTemplate $updateProjectTemplate, ) {} @@ -38,6 +42,22 @@ class ProjectTemplateController extends Controller ]); } + public function createEmpty(ProjectConfigurationIdentity $identity): Response + { + return Inertia::render('project-templates/Create', [ + 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), + 'project' => null, + 'initialConfiguration' => $identity->defaults(), + ]); + } + + public function storeEmpty(StoreStandaloneProjectTemplateRequest $request): RedirectResponse + { + $template = $this->createProjectTemplate->empty($request->validated()); + + return to_route('project-templates.edit', $template)->with('status', "Template {$template->name} was created."); + } + public function edit(ProjectTemplate $projectTemplate): Response { abort_if(! $projectTemplate->is_published, 404); @@ -81,7 +101,7 @@ class ProjectTemplateController extends Controller abort_if(! $projectTemplate->is_published, 404); $templateName = $projectTemplate->name; - $projectTemplate->delete(); + $this->deleteProjectTemplate->handle($projectTemplate); return to_route('project-templates.index') ->with('status', "Template {$templateName} was deleted."); diff --git a/app/Http/Requests/Concerns/ValidatesProjectTemplateConfiguration.php b/app/Http/Requests/Concerns/ValidatesProjectTemplateConfiguration.php new file mode 100644 index 0000000..6780011 --- /dev/null +++ b/app/Http/Requests/Concerns/ValidatesProjectTemplateConfiguration.php @@ -0,0 +1,141 @@ + */ + protected function configurationRules(): array + { + $rules = ['configuration_snapshot' => ['sometimes', 'array:'.implode(',', DuplicateProjectConfiguration::copyableAreas())]]; + foreach (ProjectConfigurationIdentity::relations() as $area => $relation) { + $path = 'configuration_snapshot.'.$area; + $rules[$path] = ['sometimes', 'array', 'max:200']; + $rules[$path.'.*.configuration_key'] = ['required', 'uuid', 'distinct:strict']; + } + $rules['configuration_snapshot.project_settings'] = ['sometimes', 'array:show_author,show_story_points']; + foreach (['show_author', 'show_story_points'] as $field) { + $rules['configuration_snapshot.project_settings.'.$field] = ['required_with:configuration_snapshot.project_settings', 'boolean']; + } + foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities'] as $area) { + $rules["configuration_snapshot.{$area}.*.name"] = ['required', 'string', 'max:255']; + } + foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_fields', 'ticket_link_types'] as $area) { + $rules["configuration_snapshot.{$area}.*.position"] = ['required', 'integer', 'min:1', 'max:10000']; + if ($area !== 'ticket_statuses') { + $rules["configuration_snapshot.{$area}.*.position"][] = 'distinct:strict'; + } + } + $fields = [ + 'board_columns' => 'configuration_key,name,position', + 'ticket_statuses' => 'configuration_key,name,position,board_column_key', + 'ticket_types' => 'configuration_key,name,position,is_parent', + 'ticket_priorities' => 'configuration_key,name,position', + 'ticket_fields' => 'configuration_key,label,position,field_type', + 'ticket_link_types' => 'configuration_key,label,counterpart_label,position', + 'repository_connections' => 'configuration_key,provider,base_url,provider_repository_id,owner_name,repository_name,repository_full_name,repository_url', + 'notification_actions' => 'configuration_key,name,enabled,event_type,channel,channel_configuration,message_template,delivery_url,filters', + ]; + foreach ($fields as $area => $allowed) { + $rules["configuration_snapshot.{$area}.*"] = ['array:'.$allowed]; + } + $rules['configuration_snapshot.ticket_statuses.*.board_column_key'] = ['nullable', 'uuid']; + $rules['configuration_snapshot.ticket_types.*.is_parent'] = ['required', 'boolean']; + $rules['configuration_snapshot.ticket_fields.*.field_type'] = ['required', Rule::in(['number', 'user'])]; + $rules['configuration_snapshot.ticket_fields.*.label'] = ['required', 'string', 'max:255']; + $rules['configuration_snapshot.ticket_link_types.*.label'] = ['required', 'string', 'max:255']; + $rules['configuration_snapshot.ticket_link_types.*.counterpart_label'] = ['required', 'string', 'max:255']; + $rules['configuration_snapshot.status_transitions'] = ['sometimes', 'array', 'max:200']; + $rules['configuration_snapshot.status_transitions.*'] = ['array:status_key,target_keys']; + $rules['configuration_snapshot.status_transitions.*.status_key'] = ['required', 'uuid', 'distinct:strict']; + $rules['configuration_snapshot.status_transitions.*.target_keys'] = ['present', 'array', 'max:200']; + $rules['configuration_snapshot.status_transitions.*.target_keys.*'] = ['uuid']; + foreach (['provider_repository_id', 'owner_name', 'repository_name', 'repository_full_name'] as $field) { + $rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'string', 'max:255']; + } + $rules['configuration_snapshot.repository_connections.*.provider'] = ['required', Rule::in(['gitea'])]; + foreach (['base_url', 'repository_url'] as $field) { + $rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'url:http,https', 'max:255']; + } + $prefix = 'configuration_snapshot.notification_actions.*.'; + $rules[$prefix.'name'] = ['required', 'string', 'max:255']; + $rules[$prefix.'enabled'] = ['required', 'boolean']; + $rules[$prefix.'event_type'] = ['required', Rule::in(NotificationEventType::values())]; + $rules[$prefix.'channel'] = ['required', Rule::in(NotificationChannel::values())]; + $rules[$prefix.'delivery_url'] = ['required', 'url:http,https', 'max:2048']; + $rules[$prefix.'message_template'] = ['nullable', 'string', 'max:2000']; + $rules[$prefix.'channel_configuration'] = ['sometimes', 'array:allowed_mentions']; + $rules[$prefix.'channel_configuration.allowed_mentions'] = ['sometimes', 'array']; + $rules[$prefix.'channel_configuration.allowed_mentions.*'] = [Rule::in(['everyone', 'roles', 'users'])]; + $rules[$prefix.'filters'] = ['sometimes', 'array', 'max:25']; + $rules[$prefix.'filters.*'] = ['array:attribute,operator,value']; + $rules[$prefix.'filters.*.attribute'] = ['required', Rule::in(NotificationFilterAttribute::values())]; + $rules[$prefix.'filters.*.operator'] = ['required', Rule::in(NotificationFilterOperator::values())]; + $rules[$prefix.'filters.*.value'] = ['nullable']; + + return $rules; + } + + /** @return array */ + public function after(): array + { + return [function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + $validated = $validator->validated(); + $configuration = Arr::get($validated, 'configuration_snapshot', []); + $areas = Arr::get($validated, 'copy_areas', []); + $columns = array_column(Arr::get($configuration, 'board_columns', []), 'configuration_key'); + $statuses = array_column(Arr::get($configuration, 'ticket_statuses', []), 'configuration_key'); + foreach (Arr::get($configuration, 'ticket_statuses', []) as $index => $status) { + $key = Arr::get($status, 'board_column_key'); + if ($key !== null && in_array('board_columns', $areas, true) && ! in_array($key, $columns, true)) { + $validator->errors()->add("configuration_snapshot.ticket_statuses.{$index}.board_column_key", 'Choose a column from this template.'); + } + } + foreach (Arr::get($configuration, 'status_transitions', []) as $index => $transition) { + if (! in_array(Arr::get($transition, 'status_key'), $statuses, true) + || array_diff(Arr::get($transition, 'target_keys', []), $statuses) !== []) { + $validator->errors()->add("configuration_snapshot.status_transitions.{$index}", 'Choose statuses from this template.'); + } + if (in_array('status_transitions', $areas, true) && ! in_array('ticket_statuses', $areas, true) + && Arr::get($transition, 'target_keys', []) !== []) { + $validator->errors()->add('copy_areas', 'Include ticket statuses when defining template transitions.'); + } + } + $repositoryIdentifiers = []; + foreach (Arr::get($configuration, 'repository_connections', []) as $index => $connection) { + $identifier = serialize([$connection['provider'], $connection['base_url'], $connection['provider_repository_id']]); + if (isset($repositoryIdentifiers[$identifier])) { + $validator->errors()->add("configuration_snapshot.repository_connections.{$index}.provider_repository_id", 'This repository is already included in the template.'); + } + $repositoryIdentifiers[$identifier] = true; + } + foreach (Arr::get($configuration, 'notification_actions', []) as $index => $action) { + if (Arr::get($action, 'channel') === 'discord' && blank(Arr::get($action, 'message_template'))) { + $validator->errors()->add("configuration_snapshot.notification_actions.{$index}.message_template", 'A Discord message is required.'); + } + foreach (Arr::get($action, 'filters', []) as $filterIndex => $filter) { + $operator = Arr::get($filter, 'operator'); + $value = Arr::get($filter, 'value'); + $valid = is_string($value) || $value === null || (is_array($value) && collect($value)->every(static fn ($item): bool => is_string($item))); + if (! $valid || (in_array($operator, ['equals', 'not_equals', 'in'], true) && blank($value)) + || (in_array($operator, ['exists', 'not_exists'], true) && filled($value))) { + $validator->errors()->add("configuration_snapshot.notification_actions.{$index}.filters.{$filterIndex}.value", 'Choose a value that matches the filter operator.'); + } + } + } + }]; + } +} diff --git a/app/Http/Requests/ResetProjectTemplateRequest.php b/app/Http/Requests/ResetProjectTemplateRequest.php index 670193d..8bebc3d 100644 --- a/app/Http/Requests/ResetProjectTemplateRequest.php +++ b/app/Http/Requests/ResetProjectTemplateRequest.php @@ -34,11 +34,7 @@ class ResetProjectTemplateRequest extends FormRequest Rule::in([$project->key]), ], 'status_replacements' => ['sometimes', 'array'], - 'status_replacements.*' => [ - 'required', - 'integer', - Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), - ], + 'status_replacements.*' => ['required'], ]; } @@ -54,40 +50,34 @@ class ResetProjectTemplateRequest extends FormRequest /** @var Project $project */ $project = $this->route('project'); $validated = $validator->validated(); - $areas = array_values(Arr::get( - $validated, - 'areas', - DuplicateProjectConfiguration::copyableAreas(), - )); - + $project->loadMissing('projectTemplate'); + $configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []); + $areas = array_values(Arr::get($validated, 'areas', array_keys($configuration))); + foreach ($areas as $area) { + if (! array_key_exists($area, $configuration)) { + $validator->errors()->add('areas', 'This template does not define the selected settings area.'); + } + } if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) { return; } - - $project->loadMissing('projectTemplate'); - $configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration( - $project->projectTemplate?->configuration_snapshot ?? [], - ); - $templateStatuses = Arr::get( - $configuration, - DuplicateProjectConfiguration::COPY_TICKET_STATUSES, - [], - ); - $statuses = $project->ticketStatuses()->withCount('tickets')->get()->values(); - $allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id'); + $templateKeys = array_column($configuration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [], 'configuration_key'); + $statuses = $project->ticketStatuses()->withCount('tickets')->get(); + $allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all(); $replacements = Arr::get($validated, 'status_replacements', []); - - foreach ($statuses->slice(count($templateStatuses)) as $status) { - if ($status->tickets_count === 0) { + foreach ($replacements as $id => $replacement) { + if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement) + || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) { + $validator->errors()->add("status_replacements.{$id}", 'Choose a replacement from this template for a status in this project.'); + } + } + foreach ($statuses as $status) { + if (in_array($status->configuration_key, $templateKeys, true) || $status->tickets_count === 0) { continue; } - - $replacementId = (int) Arr::get($replacements, (string) $status->id, 0); - if (! $allowedReplacementIds->contains($replacementId)) { - $validator->errors()->add( - "status_replacements.{$status->id}", - "Choose a replacement status for {$status->name}.", - ); + $replacement = Arr::get($replacements, (string) $status->id); + if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) { + $validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}."); } } }, diff --git a/app/Http/Requests/StoreProjectTemplateRequest.php b/app/Http/Requests/StoreProjectTemplateRequest.php index fe4f99e..9eddc4f 100644 --- a/app/Http/Requests/StoreProjectTemplateRequest.php +++ b/app/Http/Requests/StoreProjectTemplateRequest.php @@ -12,7 +12,8 @@ class StoreProjectTemplateRequest extends FormRequest { public function authorize(): bool { - return $this->user()?->can('create-project') ?? false; + return ($this->user()?->can('create-project') ?? false) + && ($this->route('project') === null || $this->user()->can('admin-project', $this->route('project'))); } protected function prepareForValidation(): void @@ -29,10 +30,14 @@ class StoreProjectTemplateRequest extends FormRequest */ public function rules(): array { + $project = $this->route('project'); + $baseTemplate = $project?->projectTemplate; + $publishingBase = $baseTemplate !== null && ! $baseTemplate->is_published; + return [ 'name' => ['required', 'string', 'max:255'], - 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')], - 'copy_areas' => ['required', 'array', 'min:1'], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')->ignore($publishingBase ? $baseTemplate->id : null), Rule::when($publishingBase, Rule::in([$baseTemplate?->slug]))], + 'copy_areas' => ['present', 'array'], 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], ]; } diff --git a/app/Http/Requests/StoreStandaloneProjectTemplateRequest.php b/app/Http/Requests/StoreStandaloneProjectTemplateRequest.php new file mode 100644 index 0000000..4384b85 --- /dev/null +++ b/app/Http/Requests/StoreStandaloneProjectTemplateRequest.php @@ -0,0 +1,15 @@ +configurationRules()]; + } +} diff --git a/app/Http/Requests/UpdateProjectTemplateRequest.php b/app/Http/Requests/UpdateProjectTemplateRequest.php index 1e037ca..65508c4 100644 --- a/app/Http/Requests/UpdateProjectTemplateRequest.php +++ b/app/Http/Requests/UpdateProjectTemplateRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration; use App\Models\ProjectTemplate; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -10,6 +11,8 @@ use Illuminate\Validation\Rule; class UpdateProjectTemplateRequest extends FormRequest { + use ValidatesProjectTemplateConfiguration; + public function authorize(): bool { return $this->user()?->can('create-project') ?? false; @@ -25,8 +28,9 @@ class UpdateProjectTemplateRequest extends FormRequest $requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false; return [ + ...$this->configurationRules(), 'name' => ['required', 'string', 'max:255'], - 'copy_areas' => ['required', 'array', 'min:1'], + 'copy_areas' => ['present', 'array'], 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], 'confirm_linked_projects_update' => [ Rule::excludeIf(! $requiresImpactConfirmation), diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php index 12022c8..ff07db5 100644 --- a/app/Http/Resources/ProjectResource.php +++ b/app/Http/Resources/ProjectResource.php @@ -32,6 +32,8 @@ class ProjectResource extends JsonResource ? null : [ 'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []), + 'local_configuration' => Arr::get($templateState, 'localConfiguration', []), + 'local_areas' => Arr::get($templateState, 'localAreas', []), 'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []), 'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []), 'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false), diff --git a/app/Models/BoardColumn.php b/app/Models/BoardColumn.php index 3c8081f..adc37e7 100644 --- a/app/Models/BoardColumn.php +++ b/app/Models/BoardColumn.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; -#[Fillable(['name', 'position', 'project_id', 'project_template_id'])] +#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])] class BoardColumn extends Model { /** @use HasFactory */ diff --git a/app/Models/Concerns/SyncsProjectTemplateOwnership.php b/app/Models/Concerns/SyncsProjectTemplateOwnership.php index cf02f1d..8adfe0b 100644 --- a/app/Models/Concerns/SyncsProjectTemplateOwnership.php +++ b/app/Models/Concerns/SyncsProjectTemplateOwnership.php @@ -5,11 +5,44 @@ namespace App\Models\Concerns; use App\Models\Project; use App\Models\ProjectTemplate; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; trait SyncsProjectTemplateOwnership { + /** @param array $options */ + public function save(array $options = []): bool + { + return DB::transaction(function () use ($options): bool { + $this->syncProjectTemplateOwnership(); + if ($this->getAttribute('project_id') !== null) { + $project = Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first(); + if ($project?->project_template_id !== null) { + $this->setAttribute('project_template_id', $project->project_template_id); + } + } + + return parent::save($options); + }, 3); + } + + public function delete(): ?bool + { + return DB::transaction(function (): ?bool { + if ($this->getAttribute('project_id') !== null) { + Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first(); + } + + return parent::delete(); + }, 3); + } + protected function syncProjectTemplateOwnership(): void { + if ($this->getAttribute('configuration_key') === null) { + $this->setAttribute('configuration_key', (string) Str::uuid()); + } + $projectTemplateId = $this->getAttribute('project_template_id'); $projectId = $this->getAttribute('project_id'); diff --git a/app/Models/NotificationAction.php b/app/Models/NotificationAction.php index 70bbd92..21d1367 100644 --- a/app/Models/NotificationAction.php +++ b/app/Models/NotificationAction.php @@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable([ +#[Fillable(['configuration_key', 'channel', 'channel_configuration', 'delivery_url', diff --git a/app/Models/RepositoryConnection.php b/app/Models/RepositoryConnection.php index 81725c1..c5f6805 100644 --- a/app/Models/RepositoryConnection.php +++ b/app/Models/RepositoryConnection.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -#[Fillable([ +#[Fillable(['configuration_key', 'base_url', 'owner_name', 'project_id', diff --git a/app/Models/TicketFieldDefinition.php b/app/Models/TicketFieldDefinition.php index 2f7afe4..b6aead5 100644 --- a/app/Models/TicketFieldDefinition.php +++ b/app/Models/TicketFieldDefinition.php @@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])] +#[Fillable(['configuration_key', 'field_type', 'label', 'position', 'project_id', 'project_template_id'])] class TicketFieldDefinition extends Model { /** diff --git a/app/Models/TicketLinkType.php b/app/Models/TicketLinkType.php index e42e97a..7c0081c 100644 --- a/app/Models/TicketLinkType.php +++ b/app/Models/TicketLinkType.php @@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])] +#[Fillable(['configuration_key', 'label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])] class TicketLinkType extends Model { /** @use HasFactory */ diff --git a/app/Models/TicketPriority.php b/app/Models/TicketPriority.php index 94588f9..45d2fc7 100644 --- a/app/Models/TicketPriority.php +++ b/app/Models/TicketPriority.php @@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['name', 'position', 'project_id', 'project_template_id'])] +#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])] class TicketPriority extends Model { /** @use HasFactory */ diff --git a/app/Models/TicketStatus.php b/app/Models/TicketStatus.php index 6aaa879..a658943 100644 --- a/app/Models/TicketStatus.php +++ b/app/Models/TicketStatus.php @@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Str; -#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])] +#[Fillable(['configuration_key', 'board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])] class TicketStatus extends Model { /** @use HasFactory */ diff --git a/app/Models/TicketType.php b/app/Models/TicketType.php index 9675876..734b3ae 100644 --- a/app/Models/TicketType.php +++ b/app/Models/TicketType.php @@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])] +#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id', 'is_parent'])] class TicketType extends Model { /** @use HasFactory */ diff --git a/app/Observers/ProjectConfigurationObserver.php b/app/Observers/ProjectConfigurationObserver.php index 8e9bf21..558815a 100644 --- a/app/Observers/ProjectConfigurationObserver.php +++ b/app/Observers/ProjectConfigurationObserver.php @@ -30,6 +30,9 @@ class ProjectConfigurationObserver public function deleted(Model $model): void { $this->markOverride($model); + if ($model instanceof TicketStatus && $model->project !== null) { + $this->refreshProjectBaseTemplate->handle($model->project, DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS); + } } private function markOverride(Model $model): void diff --git a/database/factories/BoardColumnFactory.php b/database/factories/BoardColumnFactory.php index 461c0e7..83d52aa 100644 --- a/database/factories/BoardColumnFactory.php +++ b/database/factories/BoardColumnFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use App\Models\BoardColumn; use App\Models\Project; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; /** * @extends Factory @@ -19,6 +20,7 @@ class BoardColumnFactory extends Factory public function definition(): array { return [ + 'configuration_key' => (string) Str::uuid(), 'name' => fake()->unique()->words(2, true), 'position' => 1, 'project_id' => Project::factory(), diff --git a/database/factories/NotificationActionFactory.php b/database/factories/NotificationActionFactory.php index 70cc628..63841f9 100644 --- a/database/factories/NotificationActionFactory.php +++ b/database/factories/NotificationActionFactory.php @@ -7,6 +7,7 @@ use App\Enums\NotificationEventType; use App\Models\NotificationAction; use App\Models\Project; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; /** * @extends Factory @@ -21,6 +22,7 @@ class NotificationActionFactory extends Factory public function definition(): array { return [ + 'configuration_key' => (string) Str::uuid(), 'channel' => NotificationChannel::Webhook, 'channel_configuration' => [], 'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(), diff --git a/database/factories/RepositoryConnectionFactory.php b/database/factories/RepositoryConnectionFactory.php index 060c2ce..46fde65 100644 --- a/database/factories/RepositoryConnectionFactory.php +++ b/database/factories/RepositoryConnectionFactory.php @@ -6,6 +6,7 @@ use App\Actions\Git\GitProviderKey; use App\Models\Project; use App\Models\RepositoryConnection; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; /** * @extends Factory @@ -20,6 +21,7 @@ class RepositoryConnectionFactory extends Factory public function definition(): array { return [ + 'configuration_key' => (string) Str::uuid(), 'base_url' => 'https://git.'.fake()->domainName(), 'owner_name' => fake()->userName(), 'project_id' => Project::factory(), diff --git a/database/factories/TicketFieldDefinitionFactory.php b/database/factories/TicketFieldDefinitionFactory.php index 1b4c088..6de5ff5 100644 --- a/database/factories/TicketFieldDefinitionFactory.php +++ b/database/factories/TicketFieldDefinitionFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use App\Models\Project; use App\Models\TicketFieldDefinition; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; /** * @extends Factory @@ -19,6 +20,7 @@ class TicketFieldDefinitionFactory extends Factory public function definition(): array { return [ + 'configuration_key' => (string) Str::uuid(), 'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES), 'label' => fake()->unique()->words(2, true), 'position' => 1, diff --git a/database/factories/TicketLinkTypeFactory.php b/database/factories/TicketLinkTypeFactory.php index 08204b4..224a231 100644 --- a/database/factories/TicketLinkTypeFactory.php +++ b/database/factories/TicketLinkTypeFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use App\Models\Project; use App\Models\TicketLinkType; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; /** * @extends Factory @@ -21,6 +22,7 @@ class TicketLinkTypeFactory extends Factory $label = fake()->unique()->words(2, true); return [ + 'configuration_key' => (string) Str::uuid(), 'label' => $label, 'counterpart_label' => $label, 'position' => 1, diff --git a/database/factories/TicketPriorityFactory.php b/database/factories/TicketPriorityFactory.php index c1baec5..a5c68c8 100644 --- a/database/factories/TicketPriorityFactory.php +++ b/database/factories/TicketPriorityFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use App\Models\Project; use App\Models\TicketPriority; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; /** * @extends Factory @@ -19,6 +20,7 @@ class TicketPriorityFactory extends Factory public function definition(): array { return [ + 'configuration_key' => (string) Str::uuid(), 'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']), 'position' => 1, 'project_id' => Project::factory(), diff --git a/database/factories/TicketStatusFactory.php b/database/factories/TicketStatusFactory.php index 68eadec..c37bb20 100644 --- a/database/factories/TicketStatusFactory.php +++ b/database/factories/TicketStatusFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use App\Models\Project; use App\Models\TicketStatus; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; /** * @extends Factory @@ -19,6 +20,7 @@ class TicketStatusFactory extends Factory public function definition(): array { return [ + 'configuration_key' => (string) Str::uuid(), 'board_column_id' => null, 'name' => fake()->unique()->words(2, true), 'position' => 1, diff --git a/database/factories/TicketTypeFactory.php b/database/factories/TicketTypeFactory.php index fff3e0d..e7f8a28 100644 --- a/database/factories/TicketTypeFactory.php +++ b/database/factories/TicketTypeFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use App\Models\Project; use App\Models\TicketType; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; /** * @extends Factory @@ -19,6 +20,7 @@ class TicketTypeFactory extends Factory public function definition(): array { return [ + 'configuration_key' => (string) Str::uuid(), 'is_parent' => false, 'name' => fake()->unique()->jobTitle(), 'position' => 1, diff --git a/database/migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php b/database/migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php new file mode 100644 index 0000000..456d95d --- /dev/null +++ b/database/migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php @@ -0,0 +1,165 @@ +uuid('configuration_key')->nullable()->index(); + }); + DB::table($name)->orderBy('id')->chunkById(100, function ($rows) use ($name): void { + foreach ($rows as $row) { + DB::table($name)->where('id', $row->id)->update(['configuration_key' => (string) Str::uuid()]); + } + }); + } + + $configuration = app(DuplicateProjectConfiguration::class); + $identity = app(ProjectConfigurationIdentity::class); + + ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration, $identity): void { + $snapshot = $configuration->normalizeConfiguration($template->configuration_snapshot ?? []); + $source = $template->sourceProject; + if (! $template->is_published && $template->project !== null) { + $snapshot = $configuration->export($template->project, DuplicateProjectConfiguration::copyableAreas()); + } elseif ($source !== null) { + $sourceConfiguration = $configuration->export($source, array_keys($snapshot)); + $replacements = []; + $usedKeys = []; + foreach (ProjectConfigurationIdentity::relations() as $area => $relation) { + foreach ($snapshot[$area] ?? [] as $index => $item) { + $matches = collect($sourceConfiguration[$area] ?? [])->filter( + fn (array $candidate): bool => $identity->signature($area, $candidate) === $identity->signature($area, $item), + ); + $baselineMatches = collect($snapshot[$area])->filter(fn (array $candidate): bool => $identity->signature($area, $candidate) === $identity->signature($area, $item)); + if ($matches->count() > 1 || $baselineMatches->count() > 1) { + $matches = $matches->filter(static fn (array $candidate): bool => ($candidate['position'] ?? null) === ($item['position'] ?? null)); + if ($area === 'ticket_statuses') { + $columnKey = $replacements[$item['board_column_key'] ?? ''] ?? ($item['board_column_key'] ?? null); + $matches = $matches->filter(static fn (array $candidate): bool => ($candidate['board_column_key'] ?? null) === $columnKey); + } + } + if ($matches->count() === 1 && ! in_array($matches->first()['configuration_key'], $usedKeys[$area] ?? [], true)) { + $usedKeys[$area][] = $matches->first()['configuration_key']; + $replacements[$item['configuration_key']] = $matches->first()['configuration_key']; + $snapshot[$area][$index]['configuration_key'] = $matches->first()['configuration_key']; + } + } + } + foreach ($snapshot['ticket_statuses'] ?? [] as $index => $status) { + $snapshot['ticket_statuses'][$index]['board_column_key'] = $replacements[$status['board_column_key'] ?? ''] ?? ($status['board_column_key'] ?? null); + } + foreach ($snapshot['status_transitions'] ?? [] as $index => $transition) { + $snapshot['status_transitions'][$index] = [ + 'status_key' => $replacements[$transition['status_key']] ?? $transition['status_key'], + 'target_keys' => array_map(static fn (string $key): string => $replacements[$key] ?? $key, $transition['target_keys']), + ]; + } + } + if ($source !== null) { + $statusKeys = $source->ticketStatuses()->pluck('configuration_key', 'id')->all(); + foreach ($snapshot['notification_actions'] ?? [] as $index => $action) { + $snapshot['notification_actions'][$index]['filters'] = $configuration->mapNotificationFilters($action['filters'] ?? [], $statusKeys); + } + } + $template->forceFill(['configuration_snapshot' => $snapshot])->saveQuietly(); + }); + + Project::query()->with('projectTemplate')->eachById(function (Project $project) use ($identity): void { + $snapshot = $project->projectTemplate?->configuration_snapshot ?? []; + foreach (ProjectConfigurationIdentity::relations() as $area => $relation) { + $models = $project->{$relation}()->get(); + $usedIds = []; + $columnKeys = $area === 'ticket_statuses' ? $project->boardColumns()->pluck('configuration_key', 'id')->all() : []; + foreach ($snapshot[$area] ?? [] as $item) { + $matches = $models->filter(fn ($model): bool => $identity->signature($area, $model->attributesToArray()) === $identity->signature($area, $item)); + $matches = $matches->reject(static fn ($model): bool => in_array($model->id, $usedIds, true)); + if ($matches->count() > 1) { + $matches = $matches->filter(static fn ($model): bool => $model->position === ($item['position'] ?? null)); + if ($area === 'ticket_statuses') { + $matches = $matches->filter(static fn ($model): bool => ($columnKeys[$model->board_column_id] ?? null) === ($item['board_column_key'] ?? null)); + } + } + if ($matches->count() === 1) { + $model = $matches->first(); + $usedIds[] = $model->id; + DB::table($model->getTable())->where('id', $model->id)->update(['configuration_key' => $item['configuration_key']]); + } + } + } + }); + foreach (self::TABLES as $name) { + Schema::table($name, function (Blueprint $table): void { + $table->unique(['project_id', 'configuration_key']); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $configuration = app(DuplicateProjectConfiguration::class); + ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void { + $snapshot = $configuration->normalizeConfiguration($template->configuration_snapshot ?? []); + $columnIndexes = array_flip(array_column($snapshot['board_columns'] ?? [], 'configuration_key')); + $statusIndexes = array_flip(array_column($snapshot['ticket_statuses'] ?? [], 'configuration_key')); + if (isset($snapshot['status_transitions'])) { + $transitions = array_fill(0, count($statusIndexes), []); + foreach ($snapshot['status_transitions'] as $transition) { + $index = $statusIndexes[$transition['status_key']] ?? null; + if ($index !== null) { + $transitions[$index] = array_values(array_filter(array_map( + static fn (string $key): ?int => $statusIndexes[$key] ?? null, + $transition['target_keys'], + ), static fn (?int $index): bool => $index !== null)); + } + } + $snapshot['status_transitions'] = $transitions; + } + foreach ($snapshot['ticket_statuses'] ?? [] as $index => $status) { + $status['board_column_index'] = $columnIndexes[$status['board_column_key'] ?? ''] ?? null; + unset($status['board_column_key']); + $snapshot['ticket_statuses'][$index] = $status; + } + $sourceStatusIds = $template->sourceProject?->ticketStatuses()->pluck('id', 'configuration_key')->all() ?? []; + foreach ($snapshot['notification_actions'] ?? [] as $index => $action) { + $snapshot['notification_actions'][$index]['filters'] = $configuration->mapNotificationFilters($action['filters'] ?? [], $sourceStatusIds); + } + foreach (ProjectConfigurationIdentity::relations() as $area => $relation) { + foreach ($snapshot[$area] ?? [] as $index => $item) { + unset($item['configuration_key']); + $snapshot[$area][$index] = $item; + } + } + $template->forceFill(['configuration_snapshot' => $snapshot])->saveQuietly(); + }); + foreach (self::TABLES as $name) { + Schema::table($name, function (Blueprint $table): void { + $table->dropUnique(['project_id', 'configuration_key']); + $table->dropIndex(['configuration_key']); + $table->dropColumn('configuration_key'); + }); + } + } +}; diff --git a/resources/js/components/projects/ProjectTemplateForm.vue b/resources/js/components/projects/ProjectTemplateForm.vue index f0bb3f0..61b445d 100644 --- a/resources/js/components/projects/ProjectTemplateForm.vue +++ b/resources/js/components/projects/ProjectTemplateForm.vue @@ -3,7 +3,6 @@ import { Link, useForm } from '@inertiajs/vue3'; import { Layers3, LockKeyhole } from 'lucide-vue-next'; import { computed } from 'vue'; import FormFeedback from '@/components/FormFeedback.vue'; -import FeedbackState from '@/components/FeedbackState.vue'; import InputError from '@/components/InputError.vue'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -12,7 +11,13 @@ import { Label } from '@/components/ui/label'; import { useFormProtection } from '@/composables/useFormProtection'; import { useLocalization } from '@/composables/useLocalization'; import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas'; -import { index, update } from '@/routes/project-templates'; +import { + index, + update, + store as storeStandalone, +} from '@/routes/project-templates'; +import TemplateConfigurationEditor from '@/components/projects/TemplateConfigurationEditor.vue'; +import { createTemplateConfiguration } from '@/lib/project-template-configuration'; import { show } from '@/routes/projects'; import { store } from '@/routes/projects/templates'; import type { ProjectDuplicateCopyAreaOption } from '@/types'; @@ -22,11 +27,18 @@ import type { ProjectTemplateModel } from '@/types/models/project-template-model const props = defineProps<{ copyAreaOptions: ProjectDuplicateCopyAreaOption[]; - project?: ProjectModel; + project?: ProjectModel | null; + initialConfiguration?: Record; template?: ProjectTemplateModel; }>(); const { t } = useLocalization(); const { areaText } = useProjectTemplateAreas(); +const publishingBase = computed(() => + props.project?.project_template && + !props.project.project_template.is_published + ? props.project.project_template + : null, +); const source = computed(() => props.template?.source_project ?? props.project); const linkedCount = computed(() => props.template?.linked_project_count ?? 0); const form = useForm( @@ -34,14 +46,23 @@ const form = useForm( { name: props.template?.name ?? - t('templates.defaultName', { project: props.project?.name ?? '' }), + (props.project + ? t('templates.defaultName', { project: props.project.name }) + : ''), slug: props.template?.slug ?? - `${props.project?.key.toLowerCase()}-template`, + publishingBase.value?.slug ?? + (props.project + ? `${props.project.key.toLowerCase()}-template` + : ''), copy_areas: props.template ? [...props.template.copy_areas] : props.copyAreaOptions.map((area) => area.value), confirm_linked_projects_update: false, + configuration_snapshot: createTemplateConfiguration( + props.template?.configuration_snapshot ?? + props.initialConfiguration, + ), }, ); const protection = useFormProtection(form); @@ -53,13 +74,10 @@ const copyAreasError = computed( )?.[1], ); const cancelTarget = computed(() => - props.template ? index() : show(props.project!.id), + props.project ? show(props.project.id) : index(), ); function submit(): void { - if ( - !source.value || - (linkedCount.value > 0 && !form.confirm_linked_projects_update) - ) { + if (linkedCount.value > 0 && !form.confirm_linked_projects_update) { return; } form.transform((data) => @@ -67,6 +85,7 @@ function submit(): void { ? { name: data.name, copy_areas: data.copy_areas, + configuration_snapshot: data.configuration_snapshot, ...(linkedCount.value > 0 ? { confirm_linked_projects_update: @@ -74,10 +93,21 @@ function submit(): void { } : {}), } - : { name: data.name, slug: data.slug, copy_areas: data.copy_areas }, + : { + name: data.name, + slug: data.slug, + copy_areas: data.copy_areas, + ...(!props.project + ? { configuration_snapshot: data.configuration_snapshot } + : {}), + }, ); protection.submit( - props.template ? update(props.template.id) : store(props.project!.id), + props.template + ? update(props.template.id) + : props.project + ? store(props.project.id) + : storeStandalone(), { preserveScroll: true }, ); } @@ -90,12 +120,6 @@ function submit(): void { @submit.prevent="submit" > -
@@ -115,7 +139,7 @@ function submit(): void { name="name" required maxlength="255" - :disabled="form.processing || !source" + :disabled="form.processing" :placeholder="t('templates.namePlaceholder')" :invalid="!!form.errors.name" aria-describedby="name-error" @@ -139,7 +163,11 @@ function submit(): void { name="slug" required maxlength="255" - :disabled="!!template || form.processing || !source" + :disabled=" + !!template || + !!publishingBase || + form.processing + " :placeholder="t('templates.slugPlaceholder')" :invalid="!!form.errors.slug" aria-describedby="slug-help slug-error" @@ -152,7 +180,7 @@ function submit(): void { > {{ t( - template + template || publishingBase ? 'templates.slugLocked' : 'templates.slugHelp', ) @@ -166,7 +194,7 @@ function submit(): void {
@@ -231,6 +259,14 @@ function submit(): void { :message="copyAreasError" />
+