diff --git a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php index ef2a338..65d1e02 100644 --- a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php +++ b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php @@ -2,6 +2,8 @@ namespace App\Actions\Notifications; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\ResolveProjectTemplateState; use App\Enums\DiscordAllowedMentionType; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; @@ -15,6 +17,7 @@ class BuildProjectNotificationSettingsPayload { public function __construct( private readonly ListProjectNotificationActions $listProjectNotificationActions, + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, ) {} /** @@ -33,6 +36,9 @@ class BuildProjectNotificationSettingsPayload )->resolve(), 'project' => ProjectResource::make($project)->resolve(), 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS, + ]), 'templateVariables' => $this->templateVariables(), ]; } diff --git a/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php b/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php index a17fef0..63416ff 100644 --- a/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php +++ b/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php @@ -2,11 +2,19 @@ namespace App\Actions\ProjectBoard; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\ProjectTemplateOverrideManager; +use App\Actions\Projects\RefreshProjectBaseTemplate; use App\Models\TicketStatus; use Illuminate\Support\Arr; class UpdateTicketStatusTransitions { + public function __construct( + private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager, + private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate, + ) {} + /** * @param array{allowed_ticket_status_ids?: array|null} $validated */ @@ -19,6 +27,14 @@ class UpdateTicketStatusTransitions ->all(); $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(); } diff --git a/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php index 5edc3ec..2a43375 100644 --- a/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php +++ b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php @@ -2,11 +2,19 @@ namespace App\Actions\ProjectTicketMetadata; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\ProjectTemplateOverrideManager; +use App\Actions\Projects\RefreshProjectBaseTemplate; use App\Models\Project; use Illuminate\Support\Arr; class UpdateProjectTicketMetadataSettings { + public function __construct( + private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager, + private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate, + ) {} + /** * @param array{show_author?: bool|null, show_story_points?: bool|null} $validated */ @@ -16,6 +24,14 @@ class UpdateProjectTicketMetadataSettings '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(); } diff --git a/app/Actions/Projects/BuildProjectBoardSettingsPayload.php b/app/Actions/Projects/BuildProjectBoardSettingsPayload.php index a1992e6..72a5449 100644 --- a/app/Actions/Projects/BuildProjectBoardSettingsPayload.php +++ b/app/Actions/Projects/BuildProjectBoardSettingsPayload.php @@ -9,6 +9,10 @@ use App\Models\Project; class BuildProjectBoardSettingsPayload { + public function __construct( + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, + ) {} + /** * @return array */ @@ -27,6 +31,9 @@ class BuildProjectBoardSettingsPayload )->resolve(), 'project' => ProjectResource::make($project)->resolve(), 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW, + ]), 'ticketStatuses' => TicketStatusResource::collection( $project->ticketStatuses() ->with(['allowedTargetStatuses.boardColumn', 'boardColumn']) diff --git a/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php b/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php index 1cc9c2c..3ab6fcf 100644 --- a/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php +++ b/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php @@ -13,6 +13,10 @@ use App\Models\TicketFieldDefinition; class BuildProjectTicketMetadataSettingsPayload { + public function __construct( + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, + ) {} + /** * @return array */ @@ -23,6 +27,13 @@ class BuildProjectTicketMetadataSettingsPayload 'project' => ProjectResource::make($project)->resolve(), 'error' => $error, 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES, + DuplicateProjectConfiguration::COPY_TICKET_FIELDS, + DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES, + ]), 'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(), 'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(), 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), diff --git a/app/Actions/Projects/CreateProject.php b/app/Actions/Projects/CreateProject.php index 4baf52a..8d86e7c 100644 --- a/app/Actions/Projects/CreateProject.php +++ b/app/Actions/Projects/CreateProject.php @@ -6,25 +6,85 @@ use App\Actions\Permissions\PermissionService; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Enums\Permission; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\User; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; class CreateProject { public function __construct( + private readonly CreateProjectBaseTemplate $createProjectBaseTemplate, + private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, private readonly PermissionService $permissionService, + private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager, ) {} /** - * @param array{key: string, name: string} $attributes + * @param array{key: string, name: string, creation_mode?: string, source_project_id?: int|string|null, source_template_id?: int|string|null, copy_areas?: array|null} $attributes */ public function handle(array $attributes, User $actor): Project { - $project = Project::query()->create($attributes); + return DB::transaction(function () use ($attributes, $actor): Project { + $project = Project::query()->create(Arr::only($attributes, ['key', 'name'])); + $creationMode = Arr::get($attributes, 'creation_mode', 'scratch'); + $sourceTemplateId = Arr::get($attributes, 'source_template_id'); + $baseTemplate = null; + $sourceTemplate = null; - $this->initializeProjectTicketMetadata->handle($project); - $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); + if ($creationMode === 'template' && $sourceTemplateId !== null) { + $sourceTemplate = ProjectTemplate::query() + ->published() + ->findOrFail((int) $sourceTemplateId); - return $project; + $project->forceFill([ + 'project_template_id' => $sourceTemplate->id, + 'template_overrides' => [], + ])->saveQuietly(); + } else { + $baseTemplate = $this->createProjectBaseTemplate->handle($project); + + $project->forceFill([ + 'project_template_id' => $baseTemplate->id, + 'template_overrides' => [], + ])->saveQuietly(); + } + + $project = $project->fresh() ?? $project; + + $this->projectTemplateOverrideManager->withoutRecording( + fn () => $this->initializeProjectTicketMetadata->handle($project), + ); + $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); + + if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) { + $sourceProject = Project::query()->findOrFail((int) Arr::get($attributes, 'source_project_id')); + + $this->duplicateProjectConfiguration->handle( + $sourceProject, + $project, + array_values(Arr::get($attributes, 'copy_areas', [])), + ); + } + + if ($creationMode === 'template' && $sourceTemplate !== null) { + $this->duplicateProjectConfiguration->apply( + $project->fresh() ?? $project, + $sourceTemplate->configuration_snapshot ?? [], + ); + } + + if ($baseTemplate !== null) { + $baseTemplate->forceFill([ + 'configuration_snapshot' => $this->duplicateProjectConfiguration->export( + $project->fresh() ?? $project, + DuplicateProjectConfiguration::copyableAreas(), + ), + ])->save(); + } + + return $project->fresh() ?? $project; + }); } } diff --git a/app/Actions/Projects/CreateProjectBaseTemplate.php b/app/Actions/Projects/CreateProjectBaseTemplate.php new file mode 100644 index 0000000..29623e3 --- /dev/null +++ b/app/Actions/Projects/CreateProjectBaseTemplate.php @@ -0,0 +1,28 @@ +create([ + 'project_id' => $project->id, + 'source_project_id' => $project->id, + 'name' => "{$project->name} base template", + 'slug' => strtolower($project->key).'-base-template', + '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 new file mode 100644 index 0000000..463267e --- /dev/null +++ b/app/Actions/Projects/CreateProjectTemplate.php @@ -0,0 +1,32 @@ +} $attributes + */ + public function handle(Project $sourceProject, array $attributes): ProjectTemplate + { + $copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); + + 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), + ]); + } +} diff --git a/app/Actions/Projects/DuplicateProjectConfiguration.php b/app/Actions/Projects/DuplicateProjectConfiguration.php new file mode 100644 index 0000000..2513340 --- /dev/null +++ b/app/Actions/Projects/DuplicateProjectConfiguration.php @@ -0,0 +1,339 @@ + */ + public static function copyableAreas(): array + { + return [ + self::COPY_PROJECT_SETTINGS, + self::COPY_BOARD_COLUMNS, + self::COPY_TICKET_STATUSES, + self::COPY_STATUS_TRANSITIONS, + self::COPY_TICKET_TYPES, + self::COPY_TICKET_PRIORITIES, + self::COPY_TICKET_FIELDS, + self::COPY_TICKET_LINK_TYPES, + self::COPY_REPOSITORY_CONNECTIONS, + self::COPY_NOTIFICATION_ACTIONS, + ]; + } + + /** @return list */ + public static function acceptedCopyAreas(): array + { + return [...self::copyableAreas(), self::COPY_BOARD_WORKFLOW]; + } + + /** @return list */ + public static function copyAreaOptions(): array + { + return [ + ['value' => self::COPY_PROJECT_SETTINGS, 'label' => 'Project settings', 'description' => 'General project preferences like story points and author visibility.'], + ['value' => self::COPY_BOARD_COLUMNS, 'label' => 'Board columns', 'description' => 'Columns and their order on the project board.'], + ['value' => self::COPY_TICKET_STATUSES, 'label' => 'Ticket statuses', 'description' => 'Statuses and their assignment to board columns.'], + ['value' => self::COPY_STATUS_TRANSITIONS, 'label' => 'Status transitions', 'description' => 'Allowed moves between ticket statuses.'], + ['value' => self::COPY_TICKET_TYPES, 'label' => 'Ticket types', 'description' => 'Available ticket types, including parent types.'], + ['value' => self::COPY_TICKET_PRIORITIES, 'label' => 'Ticket priorities', 'description' => 'Available ticket priorities.'], + ['value' => self::COPY_TICKET_FIELDS, 'label' => 'Custom ticket fields', 'description' => 'Custom field definitions without ticket values.'], + ['value' => self::COPY_TICKET_LINK_TYPES, 'label' => 'Ticket link types', 'description' => 'Relationship labels between tickets.'], + ['value' => self::COPY_REPOSITORY_CONNECTIONS, 'label' => 'Repository connections', 'description' => 'Repositories linked to the project.'], + ['value' => self::COPY_NOTIFICATION_ACTIONS, 'label' => 'Notification actions', 'description' => 'Notification rules and delivery settings.'], + ]; + } + + /** @param list $copyAreas @return list */ + public function normalizeCopyAreas(array $copyAreas): array + { + if (in_array(self::COPY_BOARD_WORKFLOW, $copyAreas, true)) { + $copyAreas = [ + ...array_diff($copyAreas, [self::COPY_BOARD_WORKFLOW]), + self::COPY_BOARD_COLUMNS, + self::COPY_TICKET_STATUSES, + self::COPY_STATUS_TRANSITIONS, + ]; + } + + return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas))); + } + + /** @param array $configuration @return array */ + public function normalizeConfiguration(array $configuration): array + { + $workflow = $configuration[self::COPY_BOARD_WORKFLOW] ?? null; + + if (is_array($workflow)) { + $statuses = $workflow['ticket_statuses'] ?? []; + $configuration[self::COPY_BOARD_COLUMNS] ??= $workflow['board_columns'] ?? []; + $configuration[self::COPY_TICKET_STATUSES] ??= array_map( + static fn (array $status): array => array_diff_key($status, ['allowed_target_indexes' => true]), + $statuses, + ); + $configuration[self::COPY_STATUS_TRANSITIONS] ??= array_map( + static fn (array $status): array => $status['allowed_target_indexes'] ?? [], + $statuses, + ); + unset($configuration[self::COPY_BOARD_WORKFLOW]); + } + + return $configuration; + } + + /** @param list $copyAreas @return array */ + public function export(Project $sourceProject, array $copyAreas): array + { + $selectedAreas = $this->normalizeCopyAreas($copyAreas); + + if ($selectedAreas === []) { + return []; + } + + $sourceProject->load([ + 'boardColumns', 'notificationActions', 'repositoryConnections', 'ticketFieldDefinitions', + 'ticketLinkTypes', 'ticketPriorities', 'ticketStatuses.allowedTargetStatuses', 'ticketTypes', + ]); + $configuration = []; + + if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) { + $configuration[self::COPY_PROJECT_SETTINGS] = [ + 'show_author' => $sourceProject->show_author, + 'show_story_points' => $sourceProject->show_story_points, + ]; + } + + $columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys( + static fn ($column, int $index): array => [$column->id => $index], + ); + $statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys( + static fn ($status, int $index): array => [$status->id => $index], + ); + + 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], + )->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), + 'name' => $status->name, + 'position' => $status->position, + ], + )->all(); + } + + 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(), + )->all(); + } + + $simpleAreas = [ + self::COPY_TICKET_TYPES => fn ($item): array => ['name' => $item->name, 'position' => $item->position, 'is_parent' => $item->is_parent], + self::COPY_TICKET_PRIORITIES => fn ($item): array => ['name' => $item->name, 'position' => $item->position], + self::COPY_TICKET_FIELDS => fn ($item): array => ['field_type' => $item->field_type, 'label' => $item->label, 'position' => $item->position], + self::COPY_TICKET_LINK_TYPES => fn ($item): array => ['label' => $item->label, 'counterpart_label' => $item->counterpart_label, 'position' => $item->position], + self::COPY_REPOSITORY_CONNECTIONS => fn ($item): array => [ + 'provider' => $item->provider->value, 'base_url' => $item->base_url, + 'provider_repository_id' => $item->provider_repository_id, 'owner_name' => $item->owner_name, + 'repository_name' => $item->repository_name, 'repository_full_name' => $item->repository_full_name, + 'repository_url' => $item->repository_url, + ], + self::COPY_NOTIFICATION_ACTIONS => fn ($item): array => [ + '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 ?? [], + ], + ]; + $relations = [ + self::COPY_TICKET_TYPES => 'ticketTypes', self::COPY_TICKET_PRIORITIES => 'ticketPriorities', + self::COPY_TICKET_FIELDS => 'ticketFieldDefinitions', self::COPY_TICKET_LINK_TYPES => 'ticketLinkTypes', + self::COPY_REPOSITORY_CONNECTIONS => 'repositoryConnections', self::COPY_NOTIFICATION_ACTIONS => 'notificationActions', + ]; + + foreach ($simpleAreas as $area => $mapper) { + if (in_array($area, $selectedAreas, true)) { + $configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all(); + } + } + + return $configuration; + } + + /** + * @param array $configuration + * @param array $statusReplacements + * @return list areas which retained referenced local values + */ + public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array + { + $configuration = $this->normalizeConfiguration($configuration); + + $blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): 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(), + ); + if ($blocked) { + $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']); + + 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) { + $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); + if ($status !== null) { + $status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->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], + ]; + + foreach ($relations as $area => [$relation, $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) { + $blockedAreas[] = $area; + } + } + + foreach ($targetProject->configurationRelations() as $relation) { + $targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]); + } + + return array_values(array_unique($blockedAreas)); + }); + + foreach ($blockedAreas as $area) { + $this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area); + } + + return $blockedAreas; + } + + /** @param list $copyAreas */ + public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void + { + $this->apply($targetProject, $this->export($sourceProject, $copyAreas)); + } + + /** + * @param EloquentCollection $models + * @param array> $items + */ + private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool + { + foreach ($items as $index => $item) { + $model = $models->get($index); + $model === null ? $create($item) : $update($model, $item); + } + + $blocked = false; + foreach ($models->slice(count($items)) as $model) { + if ($canDelete !== null && ! $canDelete($model)) { + $blocked = true; + + continue; + } + $model->deleteQuietly(); + } + + return $blocked; + } +} diff --git a/app/Actions/Projects/ProjectTemplateOverrideManager.php b/app/Actions/Projects/ProjectTemplateOverrideManager.php new file mode 100644 index 0000000..f01339e --- /dev/null +++ b/app/Actions/Projects/ProjectTemplateOverrideManager.php @@ -0,0 +1,55 @@ + 0) { + 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(); + } + + /** + * @param list $areas + */ + public function clear(Project $project, array $areas): void + { + $remaining = array_values(array_diff($project->template_overrides ?? [], $areas)); + $project->forceFill(['template_overrides' => $remaining])->saveQuietly(); + } + + public function withoutRecording(callable $callback): mixed + { + self::$suppressionDepth++; + + try { + return $callback(); + } finally { + self::$suppressionDepth--; + } + } + + public function isRecording(): bool + { + return self::$suppressionDepth === 0; + } +} diff --git a/app/Actions/Projects/RefreshProjectBaseTemplate.php b/app/Actions/Projects/RefreshProjectBaseTemplate.php new file mode 100644 index 0000000..095ab11 --- /dev/null +++ b/app/Actions/Projects/RefreshProjectBaseTemplate.php @@ -0,0 +1,32 @@ +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(); + } +} diff --git a/app/Actions/Projects/ResolveProjectTemplateState.php b/app/Actions/Projects/ResolveProjectTemplateState.php new file mode 100644 index 0000000..99d3519 --- /dev/null +++ b/app/Actions/Projects/ResolveProjectTemplateState.php @@ -0,0 +1,136 @@ +|null $areas + * @return array|null + */ + public function handle(Project $project, ?array $areas = null): ?array + { + $project->loadMissing('projectTemplate'); + + /** @var ProjectTemplate|null $projectTemplate */ + $projectTemplate = $project->projectTemplate; + + if ($projectTemplate === null) { + return null; + } + + $selectedAreas = $areas === null + ? DuplicateProjectConfiguration::copyableAreas() + : $this->duplicateProjectConfiguration->normalizeCopyAreas($areas); + + $normalizedTemplateConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration( + $projectTemplate->configuration_snapshot ?? [], + ); + $templateConfiguration = collect($normalizedTemplateConfiguration) + ->only($selectedAreas) + ->all(); + $currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas); + $persistedOverrides = $projectTemplate->is_published + ? array_values(array_intersect($project->template_overrides ?? [], $selectedAreas)) + : []; + + $overriddenAreas = []; + $inheritedAreas = []; + + foreach ($selectedAreas as $area) { + $label = $this->labelForArea($area); + $hasTemplateValue = array_key_exists($area, $templateConfiguration); + + if (! $hasTemplateValue) { + continue; + } + + if (! in_array($area, $persistedOverrides, true)) { + $inheritedAreas[] = [ + 'label' => $label, + 'value' => $area, + ]; + + continue; + } + + $overriddenAreas[] = [ + 'label' => $label, + 'value' => $area, + ]; + } + + $overriddenAreaValues = array_flip(array_column($overriddenAreas, 'value')); + $inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value')); + + return [ + 'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)), + 'inheritedAreas' => $inheritedAreas, + 'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues), + 'inheritsFromTemplate' => $projectTemplate->is_published, + 'overriddenAreas' => $overriddenAreas, + 'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues), + 'resetRequirements' => $this->resetRequirements($project, $templateConfiguration, $persistedOverrides), + 'templateConfiguration' => $templateConfiguration, + 'templateId' => $projectTemplate->id, + 'templateName' => $projectTemplate->name, + 'templateSlug' => $projectTemplate->slug, + ]; + } + + /** + * @param array $templateConfiguration + * @param list $persistedOverrides + * @return array + */ + private function resetRequirements(Project $project, array $templateConfiguration, array $persistedOverrides): array + { + if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $persistedOverrides, true)) { + return []; + } + + $templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? []; + 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], + )->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(); + + return $removedStatuses === [] ? [] : [ + DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [ + 'removedStatuses' => $removedStatuses, + 'replacementOptions' => $replacementOptions, + ], + ]; + } + + private function labelForArea(string $area): string + { + foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) { + if ($option['value'] === $area) { + return $option['label']; + } + } + + return $area; + } +} diff --git a/app/Actions/Projects/UpdateProjectTemplate.php b/app/Actions/Projects/UpdateProjectTemplate.php new file mode 100644 index 0000000..c8231e4 --- /dev/null +++ b/app/Actions/Projects/UpdateProjectTemplate.php @@ -0,0 +1,88 @@ +} $attributes + */ + 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(); + + $projectTemplate->forceFill([ + 'name' => $attributes['name'], + 'copy_areas' => $normalizedCopyAreas, + 'configuration_snapshot' => $nextConfiguration, + ])->save(); + + Project::query() + ->where('project_template_id', $projectTemplate->id) + ->orderBy('id') + ->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void { + foreach ($linkedProjects as $linkedProject) { + $linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail(); + $currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas); + $overrides = $linkedProject->template_overrides ?? []; + $replacementConfiguration = []; + + foreach ($affectedAreas as $area) { + if (in_array($area, $overrides, true)) { + continue; + } + + if (! array_key_exists($area, $nextConfiguration)) { + $this->projectTemplateOverrideManager->mark($linkedProject, $area); + + continue; + } + + if (! array_key_exists($area, $previousConfiguration) + && array_key_exists($area, $currentConfiguration) + && $currentConfiguration[$area] !== []) { + $this->projectTemplateOverrideManager->mark($linkedProject, $area); + + continue; + } + + $replacementConfiguration[$area] = $nextConfiguration[$area]; + } + + if ($replacementConfiguration !== []) { + $this->duplicateProjectConfiguration->apply($linkedProject, $replacementConfiguration); + } + } + }); + }); + + return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects'); + } +} diff --git a/app/Actions/Projects/UpdateProjectTemplateLink.php b/app/Actions/Projects/UpdateProjectTemplateLink.php new file mode 100644 index 0000000..c046b03 --- /dev/null +++ b/app/Actions/Projects/UpdateProjectTemplateLink.php @@ -0,0 +1,69 @@ +whereKey($project->id)->lockForUpdate()->firstOrFail(); + $configuration = $this->duplicateProjectConfiguration->normalizeConfiguration( + $template->configuration_snapshot ?? [], + ); + $localAreas = array_values(array_diff( + DuplicateProjectConfiguration::copyableAreas(), + array_keys($configuration), + )); + + $project->forceFill([ + 'project_template_id' => $template->id, + 'template_overrides' => $localAreas, + ])->saveQuietly(); + + $blockedAreas = $this->duplicateProjectConfiguration->apply($project, $configuration); + $this->projectTemplateOverrideManager->clear( + $project->fresh(), + array_values(array_diff(array_keys($configuration), $blockedAreas)), + ); + }); + } + + /** + * @param list $areas + * @param array $statusReplacements + */ + public function reset(Project $project, array $areas, array $statusReplacements = []): void + { + $template = $project->projectTemplate; + + if ($template === null) { + return; + } + + DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void { + $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); + $configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration( + $template->configuration_snapshot ?? [], + ))->only($areas)->all(); + $blockedAreas = $this->duplicateProjectConfiguration->apply( + $project, + $configuration, + $statusReplacements, + ); + $this->projectTemplateOverrideManager->clear( + $project->fresh(), + array_values(array_diff(array_keys($configuration), $blockedAreas)), + ); + }); + } +} diff --git a/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php b/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php index e49b10f..ce22d98 100644 --- a/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php +++ b/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php @@ -4,6 +4,8 @@ namespace App\Actions\Repositories; use App\Actions\Git\Data\GitRepository; use App\Actions\Git\GitProviderManager; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\ResolveProjectTemplateState; use App\Http\Resources\AvailableGitRepositorySourceResource; use App\Http\Resources\ProjectResource; use App\Http\Resources\RepositoryConnectionResource; @@ -18,6 +20,7 @@ class BuildProjectRepositorySettingsPayload { public function __construct( private readonly GitProviderManager $gitProviderManager, + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, ) {} /** @@ -41,6 +44,9 @@ class BuildProjectRepositorySettingsPayload 'project' => ProjectResource::make($project)->resolve(), 'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(), 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS, + ]), ]; } diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index b2be15a..56d89d6 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -47,9 +47,9 @@ class CreateProjectTicket $nextSequenceNumber = (int) Ticket::query() ->where('project_id', $project->id) ->max('sequence_number') + 1; - $ticketStatus = TicketStatus::query() - ->where('project_id', $project->id) - ->findOrFail(Arr::get($attributes, 'ticket_status_id')); + $ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id); + + $ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id')); $ticket = Ticket::query()->create([ 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index d6405bb..d895e0f 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -6,16 +6,23 @@ use App\Actions\Permissions\PermissionService; use App\Actions\Projects\BuildProjectBacklogPayload; use App\Actions\Projects\CreateProject; use App\Actions\Projects\DeleteProject; +use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\ListVisibleProjects; +use App\Actions\Projects\ResolveProjectTemplateState; +use App\Actions\Projects\UpdateProjectTemplateLink; use App\Enums\Permission; use App\Http\Requests\DeleteProjectRequest; +use App\Http\Requests\ResetProjectTemplateRequest; use App\Http\Requests\StoreProjectRequest; +use App\Http\Requests\SwitchProjectTemplateRequest; use App\Http\Requests\SyncProjectPermissionsRequest; use App\Http\Resources\PermissionOptionResource; use App\Http\Resources\ProjectResource; +use App\Http\Resources\ProjectTemplateResource; use App\Http\Resources\TeamResource; use App\Http\Resources\UserResource; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\Team; use App\Models\User; use Illuminate\Http\RedirectResponse; @@ -30,8 +37,11 @@ class ProjectController extends Controller private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload, private readonly CreateProject $createProject, private readonly DeleteProject $deleteProject, + private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, private readonly ListVisibleProjects $listVisibleProjects, private readonly PermissionService $permissionService, + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, + private readonly UpdateProjectTemplateLink $updateProjectTemplateLink, ) {} public function index(Request $request): Response @@ -51,7 +61,23 @@ class ProjectController extends Controller 403, ); - return Inertia::render('projects/Create'); + $sourceTemplates = ProjectTemplate::query() + ->published() + ->with('sourceProject') + ->orderBy('name') + ->get(); + $selectedTemplateId = $request->integer('template'); + + return Inertia::render('projects/Create', [ + 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), + 'sourceProjects' => ProjectResource::collection( + $this->listVisibleProjects->handle($request->user()), + )->resolve(), + 'sourceTemplates' => ProjectTemplateResource::collection($sourceTemplates)->resolve(), + 'selectedSourceTemplateId' => $sourceTemplates->contains('id', $selectedTemplateId) + ? $selectedTemplateId + : null, + ]); } public function store(StoreProjectRequest $request): RedirectResponse @@ -73,6 +99,7 @@ class ProjectController extends Controller return Inertia::render('projects/Show', [ 'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 'project' => ProjectResource::make($project)->resolve(), + 'templateState' => $this->resolveProjectTemplateState->handle($project), ]); } @@ -95,10 +122,58 @@ class ProjectController extends Controller { return Inertia::render('projects/settings/General', [ 'project' => ProjectResource::make($project)->resolve(), + 'sourceTemplates' => ProjectTemplateResource::collection( + ProjectTemplate::query() + ->published() + ->with('sourceProject') + ->orderBy('name') + ->get(), + )->resolve(), 'status' => session('status'), + 'templateState' => $this->resolveProjectTemplateState->handle($project), ]); } + public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse + { + $validated = $request->validated(); + $template = ProjectTemplate::query() + ->published() + ->findOrFail((int) Arr::get($validated, 'source_template_id')); + + $this->updateProjectTemplateLink->switch($project, $template); + + return to_route('projects.settings.general', $project) + ->with('status', "{$project->name} now uses the {$template->name} template."); + } + + public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse + { + $validated = $request->validated(); + + $template = $project->projectTemplate; + + abort_if($template === null, 422, 'This project has no linked template to reset.'); + + $configuration = $this->duplicateProjectConfiguration->normalizeConfiguration( + $template->configuration_snapshot ?? [], + ); + $areas = array_values(Arr::get($validated, 'areas', array_keys($configuration))); + $statusReplacements = Arr::get($validated, 'status_replacements', []); + + $this->updateProjectTemplateLink->reset( + $project->load('projectTemplate'), + $areas, + $statusReplacements, + ); + + $status = Arr::has($validated, 'areas') + ? "Selected settings were reset to the {$template->name} template." + : "{$project->name} was reset to the {$template->name} template baseline."; + + return back()->with('status', $status); + } + public function access(Request $request, Project $project): Response { $user = $request->user(); diff --git a/app/Http/Controllers/ProjectTemplateController.php b/app/Http/Controllers/ProjectTemplateController.php new file mode 100644 index 0000000..e9c1b6a --- /dev/null +++ b/app/Http/Controllers/ProjectTemplateController.php @@ -0,0 +1,89 @@ + session('status'), + 'templates' => ProjectTemplateResource::collection( + ProjectTemplate::query() + ->published() + ->with('sourceProject') + ->withCount('linkedProjects') + ->orderBy('name') + ->get(), + )->resolve(), + ]); + } + + public function edit(ProjectTemplate $projectTemplate): Response + { + abort_if(! $projectTemplate->is_published, 404); + + $projectTemplate->load('sourceProject')->loadCount('linkedProjects'); + + return Inertia::render('project-templates/Edit', [ + 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), + 'template' => ProjectTemplateResource::make($projectTemplate)->resolve(), + ]); + } + + public function create(Project $project): Response + { + return Inertia::render('project-templates/Create', [ + 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), + 'project' => ProjectResource::make($project)->resolve(), + ]); + } + + public function store(StoreProjectTemplateRequest $request, Project $project): RedirectResponse + { + $template = $this->createProjectTemplate->handle($project, $request->validated()); + + return to_route('project-templates.index') + ->with('status', "Template {$template->name} was created."); + } + + public function update(UpdateProjectTemplateRequest $request, ProjectTemplate $projectTemplate): RedirectResponse + { + abort_if(! $projectTemplate->is_published, 404); + + $template = $this->updateProjectTemplate->handle($projectTemplate, $request->validated()); + + return to_route('project-templates.index') + ->with('status', "Template {$template->name} was updated."); + } + + public function destroy(ProjectTemplate $projectTemplate): RedirectResponse + { + abort_if(! $projectTemplate->is_published, 404); + + $templateName = $projectTemplate->name; + $projectTemplate->delete(); + + return to_route('project-templates.index') + ->with('status', "Template {$templateName} was deleted."); + } +} diff --git a/app/Http/Requests/Api/MoveProjectTicketRequest.php b/app/Http/Requests/Api/MoveProjectTicketRequest.php index 6f87219..a5b96ff 100644 --- a/app/Http/Requests/Api/MoveProjectTicketRequest.php +++ b/app/Http/Requests/Api/MoveProjectTicketRequest.php @@ -29,7 +29,9 @@ class MoveProjectTicketRequest extends FormRequest 'ticket_status_id' => [ 'required', 'integer', - Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), + Rule::exists('ticket_statuses', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], ]; } diff --git a/app/Http/Requests/DestroyProjectTicketTypeRequest.php b/app/Http/Requests/DestroyProjectTicketTypeRequest.php index 97bc396..8950083 100644 --- a/app/Http/Requests/DestroyProjectTicketTypeRequest.php +++ b/app/Http/Requests/DestroyProjectTicketTypeRequest.php @@ -34,7 +34,9 @@ class DestroyProjectTicketTypeRequest extends FormRequest 'replacement_ticket_type_id' => [ 'nullable', 'integer', - Rule::exists('ticket_types', 'id')->where('project_id', $project->id), + Rule::exists('ticket_types', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], ]; } diff --git a/app/Http/Requests/MoveProjectTicketRequest.php b/app/Http/Requests/MoveProjectTicketRequest.php index 29d080a..76cd975 100644 --- a/app/Http/Requests/MoveProjectTicketRequest.php +++ b/app/Http/Requests/MoveProjectTicketRequest.php @@ -34,7 +34,9 @@ class MoveProjectTicketRequest extends FormRequest 'ticket_status_id' => [ 'required', 'integer', - Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), + Rule::exists('ticket_statuses', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], 'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])], ]; diff --git a/app/Http/Requests/ResetProjectTemplateRequest.php b/app/Http/Requests/ResetProjectTemplateRequest.php new file mode 100644 index 0000000..670193d --- /dev/null +++ b/app/Http/Requests/ResetProjectTemplateRequest.php @@ -0,0 +1,107 @@ +|string> + */ + public function rules(): array + { + /** @var Project $project */ + $project = $this->route('project'); + + return [ + 'areas' => ['sometimes', 'array', 'min:1'], + 'areas.*' => ['required', 'string', Rule::in(DuplicateProjectConfiguration::copyableAreas())], + 'confirmation_key' => [ + 'required_without:areas', + 'string', + Rule::in([$project->key]), + ], + 'status_replacements' => ['sometimes', 'array'], + 'status_replacements.*' => [ + 'required', + 'integer', + Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), + ], + ]; + } + + /** @return array */ + public function after(): array + { + return [ + function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + /** @var Project $project */ + $project = $this->route('project'); + $validated = $validator->validated(); + $areas = array_values(Arr::get( + $validated, + 'areas', + DuplicateProjectConfiguration::copyableAreas(), + )); + + 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'); + $replacements = Arr::get($validated, 'status_replacements', []); + + foreach ($statuses->slice(count($templateStatuses)) as $status) { + if ($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}.", + ); + } + } + }, + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.', + 'areas.*.in' => 'Choose a valid template area to reset.', + ]; + } +} diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php index 6c7eeb0..a9fff7f 100644 --- a/app/Http/Requests/StoreProjectRequest.php +++ b/app/Http/Requests/StoreProjectRequest.php @@ -2,6 +2,10 @@ namespace App\Http\Requests; +use App\Actions\Permissions\PermissionService; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Enums\Permission; +use App\Models\Project; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Arr; @@ -20,12 +24,21 @@ class StoreProjectRequest extends FormRequest protected function prepareForValidation(): void { $key = Arr::get($this->all(), 'key'); + $creationMode = Arr::get($this->all(), 'creation_mode', 'scratch'); + $copyAreas = Arr::get($this->all(), 'copy_areas', []); if ($key !== null) { $this->merge([ 'key' => strtoupper(trim((string) $key)), ]); } + + $this->merge([ + 'creation_mode' => is_string($creationMode) ? trim($creationMode) : 'scratch', + 'copy_areas' => is_array($copyAreas) + ? array_values($copyAreas) + : ($copyAreas === null || $copyAreas === '' ? [] : [(string) $copyAreas]), + ]); } /** @@ -36,6 +49,7 @@ class StoreProjectRequest extends FormRequest public function rules(): array { return [ + 'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate', 'template'])], 'key' => [ 'required', 'string', @@ -44,6 +58,51 @@ class StoreProjectRequest extends FormRequest Rule::unique('projects', 'key'), ], 'name' => ['required', 'string', 'max:255'], + 'source_project_id' => [ + Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'duplicate'), + 'nullable', + 'integer', + Rule::exists('projects', 'id'), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === null || $value === '') { + return; + } + + $project = Project::query()->find($value); + + if ($project === null) { + return; + } + + $permissionService = app(PermissionService::class); + + if (! $permissionService->allows($this->user(), Permission::ProjectRead, $project)) { + $fail('Select a project you can access as the duplication source.'); + } + }, + ], + 'source_template_id' => [ + Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'template'), + 'nullable', + 'integer', + Rule::exists('project_templates', 'id')->where(fn ($query) => $query->where('is_published', true)), + ], + 'copy_areas' => ['nullable', 'array'], + 'copy_areas.*' => [ + 'string', + Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas()), + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'source_project_id.required' => 'Select a source project to duplicate from.', + 'source_template_id.required' => 'Select a template to create the project from.', ]; } } diff --git a/app/Http/Requests/StoreProjectTemplateRequest.php b/app/Http/Requests/StoreProjectTemplateRequest.php new file mode 100644 index 0000000..fe4f99e --- /dev/null +++ b/app/Http/Requests/StoreProjectTemplateRequest.php @@ -0,0 +1,39 @@ +user()?->can('create-project') ?? false; + } + + protected function prepareForValidation(): void + { + if ($this->filled('slug')) { + $this->merge([ + 'slug' => Str::slug((string) $this->input('slug')), + ]); + } + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')], + 'copy_areas' => ['required', 'array', 'min:1'], + 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], + ]; + } +} diff --git a/app/Http/Requests/StoreProjectTicketBranchRequest.php b/app/Http/Requests/StoreProjectTicketBranchRequest.php index 2d3e10b..c49b4d9 100644 --- a/app/Http/Requests/StoreProjectTicketBranchRequest.php +++ b/app/Http/Requests/StoreProjectTicketBranchRequest.php @@ -2,8 +2,10 @@ namespace App\Http\Requests; +use App\Models\Project; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class StoreProjectTicketBranchRequest extends FormRequest { @@ -22,9 +24,18 @@ class StoreProjectTicketBranchRequest extends FormRequest */ public function rules(): array { + /** @var Project $project */ + $project = $this->route('project'); + return [ 'branch_name' => ['required', 'string', 'max:255'], - 'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'], + 'repository_connection_id' => [ + 'required', + 'integer', + Rule::exists('repository_connections', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), + ], ]; } } diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php index 59036c9..4cc8191 100644 --- a/app/Http/Requests/StoreProjectTicketRequest.php +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -66,17 +66,23 @@ class StoreProjectTicketRequest extends FormRequest 'ticket_priority_id' => [ 'required', 'integer', - Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id), + Rule::exists('ticket_priorities', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], 'ticket_status_id' => [ 'required', 'integer', - Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), + Rule::exists('ticket_statuses', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], 'ticket_type_id' => [ 'required', 'integer', - Rule::exists('ticket_types', 'id')->where('project_id', $project->id), + Rule::exists('ticket_types', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], 'story_points' => ['nullable', 'integer', 'min:0'], 'title' => ['required', 'string', 'max:255'], diff --git a/app/Http/Requests/StoreTicketLinkRequest.php b/app/Http/Requests/StoreTicketLinkRequest.php index 50a299a..a4f2915 100644 --- a/app/Http/Requests/StoreTicketLinkRequest.php +++ b/app/Http/Requests/StoreTicketLinkRequest.php @@ -38,7 +38,9 @@ class StoreTicketLinkRequest extends FormRequest 'ticket_link_type_id' => [ 'required', 'integer', - Rule::exists('ticket_link_types', 'id')->where('project_id', $project->id), + Rule::exists('ticket_link_types', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], ]; } diff --git a/app/Http/Requests/StoreTicketStatusRequest.php b/app/Http/Requests/StoreTicketStatusRequest.php index 43d4fb8..a1270e7 100644 --- a/app/Http/Requests/StoreTicketStatusRequest.php +++ b/app/Http/Requests/StoreTicketStatusRequest.php @@ -31,7 +31,9 @@ class StoreTicketStatusRequest extends FormRequest 'board_column_id' => [ 'nullable', 'integer', - Rule::exists('board_columns', 'id')->where('project_id', $project->id), + Rule::exists('board_columns', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], 'name' => ['required', 'string', 'max:255'], ]; diff --git a/app/Http/Requests/SwitchProjectTemplateRequest.php b/app/Http/Requests/SwitchProjectTemplateRequest.php new file mode 100644 index 0000000..4d898a9 --- /dev/null +++ b/app/Http/Requests/SwitchProjectTemplateRequest.php @@ -0,0 +1,51 @@ +|string> + */ + public function rules(): array + { + /** @var Project $project */ + $project = $this->route('project'); + + return [ + 'confirmation_key' => [ + 'required', + 'string', + Rule::in([$project->key]), + ], + 'source_template_id' => [ + 'required', + 'integer', + Rule::exists(ProjectTemplate::class, 'id')->where('is_published', true), + Rule::notIn(array_filter([$project->project_template_id])), + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'confirmation_key.in' => 'Enter the exact project key to confirm the template switch.', + 'source_template_id.not_in' => 'Choose a different published template to switch this project.', + ]; + } +} diff --git a/app/Http/Requests/UpdateProjectTemplateRequest.php b/app/Http/Requests/UpdateProjectTemplateRequest.php new file mode 100644 index 0000000..1007bdd --- /dev/null +++ b/app/Http/Requests/UpdateProjectTemplateRequest.php @@ -0,0 +1,47 @@ +user()?->can('create-project') ?? false; + } + + /** + * @return array|string> + */ + public function rules(): array + { + /** @var ProjectTemplate|null $projectTemplate */ + $projectTemplate = $this->route('projectTemplate'); + $requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'copy_areas' => ['required', 'array', 'min:1'], + 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], + 'confirm_linked_projects_update' => [ + Rule::requiredIf($requiresImpactConfirmation), + 'accepted', + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'confirm_linked_projects_update.accepted' => 'Acknowledge the linked project warning before saving template changes.', + ]; + } +} diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php index 1db69e5..a337941 100644 --- a/app/Http/Requests/UpdateProjectTicketRequest.php +++ b/app/Http/Requests/UpdateProjectTicketRequest.php @@ -68,7 +68,9 @@ class UpdateProjectTicketRequest extends FormRequest 'field_values.*.ticket_field_definition_id' => [ 'required', 'integer', - Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id), + Rule::exists('ticket_field_definitions', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], 'field_values.*.value_number' => ['nullable', 'numeric'], 'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], @@ -81,12 +83,16 @@ class UpdateProjectTicketRequest extends FormRequest 'ticket_priority_id' => [ 'required', 'integer', - Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id), + Rule::exists('ticket_priorities', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], 'ticket_type_id' => [ 'required', 'integer', - Rule::exists('ticket_types', 'id')->where('project_id', $project->id), + Rule::exists('ticket_types', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], 'title' => ['required', 'string', 'max:255'], 'story_points' => ['nullable', 'integer', 'min:0'], diff --git a/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php b/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php index c6c8681..9a4f770 100644 --- a/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php +++ b/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php @@ -34,7 +34,9 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest 'allowed_ticket_status_ids' => ['nullable', 'array'], 'allowed_ticket_status_ids.*' => [ 'integer', - Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), + Rule::exists('ticket_statuses', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), ], ]; } diff --git a/app/Http/Requests/ValidateProjectTicketBranchRequest.php b/app/Http/Requests/ValidateProjectTicketBranchRequest.php index 92f4f2b..56e52c0 100644 --- a/app/Http/Requests/ValidateProjectTicketBranchRequest.php +++ b/app/Http/Requests/ValidateProjectTicketBranchRequest.php @@ -2,8 +2,10 @@ namespace App\Http\Requests; +use App\Models\Project; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class ValidateProjectTicketBranchRequest extends FormRequest { @@ -22,9 +24,18 @@ class ValidateProjectTicketBranchRequest extends FormRequest */ public function rules(): array { + /** @var Project $project */ + $project = $this->route('project'); + return [ 'branch_name' => ['required', 'string', 'max:255'], - 'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'], + 'repository_connection_id' => [ + 'required', + 'integer', + Rule::exists('repository_connections', 'id')->where( + fn ($query) => $query->where('project_id', $project->id), + ), + ], ]; } } diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php index bc34640..12022c8 100644 --- a/app/Http/Resources/ProjectResource.php +++ b/app/Http/Resources/ProjectResource.php @@ -2,8 +2,10 @@ namespace App\Http\Resources; +use App\Actions\Projects\ResolveProjectTemplateState; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Arr; class ProjectResource extends JsonResource { @@ -14,10 +16,33 @@ class ProjectResource extends JsonResource */ public function toArray(Request $request): array { + $this->resource->loadMissing('projectTemplate'); + + $templateState = app(ResolveProjectTemplateState::class)->handle($this->resource); + return [ 'id' => $this->id, 'name' => $this->name, 'key' => $this->key, + 'project_template_id' => $this->project_template_id, + 'project_template' => $this->projectTemplate === null + ? null + : ProjectTemplateResource::make($this->projectTemplate)->resolve(), + 'template_state' => $templateState === null + ? null + : [ + 'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []), + 'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []), + 'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []), + 'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false), + 'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []), + 'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []), + 'reset_requirements' => Arr::get($templateState, 'resetRequirements', []), + 'template_configuration' => Arr::get($templateState, 'templateConfiguration', []), + 'template_id' => Arr::get($templateState, 'templateId'), + 'template_name' => Arr::get($templateState, 'templateName'), + 'template_slug' => Arr::get($templateState, 'templateSlug'), + ], 'show_author' => $this->show_author, 'show_story_points' => $this->show_story_points, 'created_at' => $this->created_at?->toISOString(), diff --git a/app/Http/Resources/ProjectTemplateResource.php b/app/Http/Resources/ProjectTemplateResource.php new file mode 100644 index 0000000..9146fb0 --- /dev/null +++ b/app/Http/Resources/ProjectTemplateResource.php @@ -0,0 +1,58 @@ + + */ + public function toArray(Request $request): array + { + if ($this->resource instanceof ProjectTemplate) { + $configuration = app(DuplicateProjectConfiguration::class); + + return [ + 'id' => $this->id, + 'project_id' => $this->project_id, + 'source_project_id' => $this->source_project_id, + 'source_project' => $this->relationLoaded('sourceProject') && $this->sourceProject !== null + ? ProjectResource::make($this->sourceProject)->resolve() + : null, + 'name' => $this->name, + 'slug' => $this->slug, + 'is_published' => $this->is_published, + 'copy_areas' => $configuration->normalizeCopyAreas($this->copy_areas ?? []), + 'configuration_snapshot' => $configuration->normalizeConfiguration( + $this->configuration_snapshot ?? [], + ), + 'linked_project_count' => $this->whenCounted('linkedProjects'), + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } + + return [ + 'id' => Arr::get($this->resource, 'id'), + 'project_id' => Arr::get($this->resource, 'project_id'), + 'source_project_id' => Arr::get($this->resource, 'source_project_id'), + 'source_project' => Arr::get($this->resource, 'source_project'), + 'name' => Arr::get($this->resource, 'name'), + 'slug' => Arr::get($this->resource, 'slug'), + 'is_published' => Arr::get($this->resource, 'is_published', true), + 'copy_areas' => Arr::get($this->resource, 'copy_areas', []), + 'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []), + 'linked_project_count' => Arr::get($this->resource, 'linked_project_count'), + 'created_at' => Arr::get($this->resource, 'created_at'), + 'updated_at' => Arr::get($this->resource, 'updated_at'), + ]; + } +} diff --git a/app/Models/BoardColumn.php b/app/Models/BoardColumn.php index 9a5c634..c1fb63b 100644 --- a/app/Models/BoardColumn.php +++ b/app/Models/BoardColumn.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\BoardColumnFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,11 +11,19 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; -#[Fillable(['name', 'position', 'project_id'])] +#[Fillable(['name', 'position', 'project_id', 'project_template_id'])] class BoardColumn extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; + + protected static function booted(): void + { + static::saving(function (self $boardColumn): void { + $boardColumn->syncProjectTemplateOwnership(); + }); + } public function project(): BelongsTo { diff --git a/app/Models/Concerns/SyncsProjectTemplateOwnership.php b/app/Models/Concerns/SyncsProjectTemplateOwnership.php new file mode 100644 index 0000000..cf02f1d --- /dev/null +++ b/app/Models/Concerns/SyncsProjectTemplateOwnership.php @@ -0,0 +1,41 @@ +getAttribute('project_template_id'); + $projectId = $this->getAttribute('project_id'); + + if ($projectTemplateId === null && $projectId !== null) { + $projectTemplateId = Project::query() + ->whereKey($projectId) + ->value('project_template_id'); + + if ($projectTemplateId !== null) { + $this->setAttribute('project_template_id', $projectTemplateId); + } + } + + if ($projectId === null && $projectTemplateId !== null) { + $projectId = ProjectTemplate::query() + ->whereKey($projectTemplateId) + ->value('project_id'); + + if ($projectId !== null) { + $this->setAttribute('project_id', $projectId); + } + } + } + + public function projectTemplate(): BelongsTo + { + return $this->belongsTo(ProjectTemplate::class); + } +} diff --git a/app/Models/NotificationAction.php b/app/Models/NotificationAction.php index e9e6cc2..d2217c9 100644 --- a/app/Models/NotificationAction.php +++ b/app/Models/NotificationAction.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\NotificationActionFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -23,12 +24,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'message_template', 'name', 'project_id', + 'project_template_id', ])] #[Hidden(['delivery_url'])] class NotificationAction extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; /** * @return array @@ -45,6 +48,13 @@ class NotificationAction extends Model ]; } + protected static function booted(): void + { + static::saving(function (self $notificationAction): void { + $notificationAction->syncProjectTemplateOwnership(); + }); + } + public function scopeEnabled(Builder $query): Builder { return $query->where('enabled', true); diff --git a/app/Models/Project.php b/app/Models/Project.php index 51cd5f5..57a9b89 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Str; -#[Fillable(['name', 'key', 'show_author', 'show_story_points'])] +#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points', 'template_overrides'])] class Project extends Model { /** @use HasFactory */ @@ -22,6 +24,7 @@ class Project extends Model return [ 'show_author' => 'boolean', 'show_story_points' => 'boolean', + 'template_overrides' => 'array', ]; } @@ -44,6 +47,17 @@ class Project extends Model return $this->morphMany(PermissionAssignment::class, 'scope'); } + public function projectTemplate(): BelongsTo + { + return $this->belongsTo(ProjectTemplate::class, 'project_template_id'); + } + + public function baseTemplate(): HasOne + { + return $this->hasOne(ProjectTemplate::class) + ->where('is_published', false); + } + public function boardColumns(): HasMany { return $this->hasMany(BoardColumn::class) @@ -95,6 +109,21 @@ class Project extends Model ->orderBy('id'); } + /** @return list */ + public function configurationRelations(): array + { + return [ + 'boardColumns', + 'notificationActions', + 'repositoryConnections', + 'ticketFieldDefinitions', + 'ticketLinkTypes', + 'ticketPriorities', + 'ticketStatuses', + 'ticketTypes', + ]; + } + public function tickets(): HasMany { return $this->hasMany(Ticket::class) diff --git a/app/Models/ProjectTemplate.php b/app/Models/ProjectTemplate.php new file mode 100644 index 0000000..726e775 --- /dev/null +++ b/app/Models/ProjectTemplate.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + protected function casts(): array + { + return [ + 'copy_areas' => 'array', + 'configuration_snapshot' => 'array', + 'is_published' => 'boolean', + ]; + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('is_published', true); + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function sourceProject(): BelongsTo + { + return $this->belongsTo(Project::class, 'source_project_id'); + } + + public function linkedProjects(): HasMany + { + return $this->hasMany(Project::class, 'project_template_id'); + } +} diff --git a/app/Models/RepositoryConnection.php b/app/Models/RepositoryConnection.php index 44414e5..9095d6d 100644 --- a/app/Models/RepositoryConnection.php +++ b/app/Models/RepositoryConnection.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Actions\Git\Data\GitRepository; use App\Actions\Git\GitProviderKey; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\RepositoryConnectionFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'base_url', 'owner_name', 'project_id', + 'project_template_id', 'provider', 'provider_repository_id', 'repository_full_name', @@ -24,6 +26,7 @@ class RepositoryConnection extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; /** * @return array @@ -35,6 +38,13 @@ class RepositoryConnection extends Model ]; } + protected static function booted(): void + { + static::saving(function (self $repositoryConnection): void { + $repositoryConnection->syncProjectTemplateOwnership(); + }); + } + public function project(): BelongsTo { return $this->belongsTo(Project::class); diff --git a/app/Models/TicketFieldDefinition.php b/app/Models/TicketFieldDefinition.php index 017a758..b5aaf27 100644 --- a/app/Models/TicketFieldDefinition.php +++ b/app/Models/TicketFieldDefinition.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\TicketFieldDefinitionFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,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'])] +#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])] class TicketFieldDefinition extends Model { /** @@ -22,6 +23,14 @@ class TicketFieldDefinition extends Model /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; + + protected static function booted(): void + { + static::saving(function (self $ticketFieldDefinition): void { + $ticketFieldDefinition->syncProjectTemplateOwnership(); + }); + } public function project(): BelongsTo { diff --git a/app/Models/TicketLinkType.php b/app/Models/TicketLinkType.php index f66f50e..d34c2f6 100644 --- a/app/Models/TicketLinkType.php +++ b/app/Models/TicketLinkType.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\TicketLinkTypeFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['label', 'counterpart_label', 'position', 'project_id'])] +#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])] class TicketLinkType extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; + + protected static function booted(): void + { + static::saving(function (self $ticketLinkType): void { + $ticketLinkType->syncProjectTemplateOwnership(); + }); + } public function counterpartLabel(): string { diff --git a/app/Models/TicketPriority.php b/app/Models/TicketPriority.php index 92b1f9c..94588f9 100644 --- a/app/Models/TicketPriority.php +++ b/app/Models/TicketPriority.php @@ -2,20 +2,36 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\TicketPriorityFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['name', 'position', 'project_id'])] +#[Fillable(['name', 'position', 'project_id', 'project_template_id'])] class TicketPriority extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; + + protected static function booted(): void + { + static::saving(function (self $ticketPriority): void { + $ticketPriority->syncProjectTemplateOwnership(); + }); + } + public function project(): BelongsTo { return $this->belongsTo(Project::class); } + + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class); + } } diff --git a/app/Models/TicketStatus.php b/app/Models/TicketStatus.php index e215340..0bfe7fe 100644 --- a/app/Models/TicketStatus.php +++ b/app/Models/TicketStatus.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\TicketStatusFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -11,15 +12,23 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Str; -#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'slug'])] +#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])] class TicketStatus extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; protected static function booted(): void { static::saving(function (self $ticketStatus): void { + if ($ticketStatus->project_template_id === null && $ticketStatus->board_column_id !== null) { + $ticketStatus->project_template_id = BoardColumn::query() + ->whereKey($ticketStatus->board_column_id) + ->value('project_template_id'); + } + + $ticketStatus->syncProjectTemplateOwnership(); $ticketStatus->slug = Str::slug($ticketStatus->name); }); } diff --git a/app/Models/TicketType.php b/app/Models/TicketType.php index 22cff08..0a38385 100644 --- a/app/Models/TicketType.php +++ b/app/Models/TicketType.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\TicketTypeFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['name', 'position', 'project_id', 'is_parent'])] +#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])] class TicketType extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; + + protected static function booted(): void + { + static::saving(function (self $ticketType): void { + $ticketType->syncProjectTemplateOwnership(); + }); + } protected function casts(): array { diff --git a/app/Observers/ProjectConfigurationObserver.php b/app/Observers/ProjectConfigurationObserver.php new file mode 100644 index 0000000..8e9bf21 --- /dev/null +++ b/app/Observers/ProjectConfigurationObserver.php @@ -0,0 +1,66 @@ +markOverride($model); + } + + public function deleted(Model $model): void + { + $this->markOverride($model); + } + + private function markOverride(Model $model): void + { + if (! $this->projectTemplateOverrideManager->isRecording()) { + return; + } + + $project = $model->project()->first(); + + if ($project === null) { + return; + } + + $area = match (true) { + $model instanceof BoardColumn => DuplicateProjectConfiguration::COPY_BOARD_COLUMNS, + $model instanceof TicketStatus => DuplicateProjectConfiguration::COPY_TICKET_STATUSES, + $model instanceof TicketType => DuplicateProjectConfiguration::COPY_TICKET_TYPES, + $model instanceof TicketPriority => DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES, + $model instanceof TicketFieldDefinition => DuplicateProjectConfiguration::COPY_TICKET_FIELDS, + $model instanceof TicketLinkType => DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES, + $model instanceof RepositoryConnection => DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS, + $model instanceof NotificationAction => DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS, + default => null, + }; + + if ($area === null) { + return; + } + + $this->refreshProjectBaseTemplate->handle($project, $area); + $this->projectTemplateOverrideManager->mark($project, $area); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e2489fa..f1daa5c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,9 +8,18 @@ use App\Actions\Notifications\TicketCreated; use App\Actions\Notifications\TicketStatusChanged; use App\Actions\Permissions\PermissionService; use App\Enums\Permission; +use App\Models\BoardColumn; +use App\Models\NotificationAction; use App\Models\Project; +use App\Models\RepositoryConnection; use App\Models\Team; +use App\Models\TicketFieldDefinition; +use App\Models\TicketLinkType; +use App\Models\TicketPriority; +use App\Models\TicketStatus; +use App\Models\TicketType; use App\Models\User; +use App\Observers\ProjectConfigurationObserver; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Date; @@ -36,6 +45,18 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + foreach ([ + BoardColumn::class, + NotificationAction::class, + RepositoryConnection::class, + TicketFieldDefinition::class, + TicketLinkType::class, + TicketPriority::class, + TicketStatus::class, + TicketType::class, + ] as $model) { + $model::observe(ProjectConfigurationObserver::class); + } if ($this->app->environment() !== 'local') { URL::forceScheme('https'); diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index 9d0ea0e..d9bffda 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -23,6 +23,7 @@ class ProjectFactory extends Factory 'key' => Str::upper(fake()->unique()->lexify('?????')), 'show_author' => true, 'show_story_points' => true, + 'template_overrides' => [], ]; } } diff --git a/database/factories/ProjectTemplateFactory.php b/database/factories/ProjectTemplateFactory.php new file mode 100644 index 0000000..2751f96 --- /dev/null +++ b/database/factories/ProjectTemplateFactory.php @@ -0,0 +1,36 @@ + + */ +class ProjectTemplateFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'source_project_id' => Project::factory(), + 'name' => Str::title(fake()->unique()->words(3, true)), + 'slug' => fake()->unique()->slug(), + 'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], + 'configuration_snapshot' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ + 'show_author' => true, + 'show_story_points' => true, + ], + ], + ]; + } +} diff --git a/database/migrations/2026_05_25_011500_create_project_templates_table.php b/database/migrations/2026_05_25_011500_create_project_templates_table.php new file mode 100644 index 0000000..3147e8a --- /dev/null +++ b/database/migrations/2026_05_25_011500_create_project_templates_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('source_project_id')->nullable()->constrained('projects')->nullOnDelete(); + $table->string('name'); + $table->string('slug')->unique(); + $table->json('copy_areas'); + $table->json('configuration_snapshot'); + $table->timestamps(); + + $table->index(['source_project_id', 'name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('project_templates'); + } +}; diff --git a/database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php b/database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php new file mode 100644 index 0000000..c0c5489 --- /dev/null +++ b/database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php @@ -0,0 +1,54 @@ +foreignId('project_id')->nullable()->after('id')->constrained('projects')->nullOnDelete(); + $table->boolean('is_published')->default(true)->after('slug'); + }); + + Schema::table('projects', function (Blueprint $table): void { + $table->foreignId('project_template_id')->nullable()->after('key')->constrained('project_templates')->nullOnDelete(); + }); + + $projects = Project::query()->whereNull('project_template_id')->get(); + $createProjectBaseTemplate = app(CreateProjectBaseTemplate::class); + + foreach ($projects as $project) { + $baseTemplate = $createProjectBaseTemplate->handle($project); + + $project->forceFill([ + 'project_template_id' => $baseTemplate->id, + ])->save(); + } + + ProjectTemplate::query() + ->whereNull('project_id') + ->update(['is_published' => true]); + } + + public function down(): void + { + Project::query()->update(['project_template_id' => null]); + + ProjectTemplate::query()->where('is_published', false)->delete(); + + Schema::table('projects', function (Blueprint $table): void { + $table->dropConstrainedForeignId('project_template_id'); + }); + + Schema::table('project_templates', function (Blueprint $table): void { + $table->dropConstrainedForeignId('project_id'); + $table->dropColumn('is_published'); + }); + } +}; diff --git a/database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php b/database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php new file mode 100644 index 0000000..3105437 --- /dev/null +++ b/database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php @@ -0,0 +1,120 @@ + + */ + private array $tables = [ + 'board_columns', + 'ticket_field_definitions', + 'ticket_link_types', + 'ticket_priorities', + 'ticket_statuses', + 'ticket_types', + 'repository_connections', + 'notification_actions', + ]; + + public function up(): void + { + foreach ($this->tables as $table) { + Schema::table($table, function (Blueprint $table): void { + $table->foreignId('project_template_id') + ->nullable() + ->after('project_id') + ->constrained('project_templates') + ->nullOnDelete(); + }); + } + + $projects = DB::table('projects') + ->leftJoin('project_templates', 'project_templates.id', '=', 'projects.project_template_id') + ->select([ + 'projects.id', + 'projects.key', + 'projects.name', + 'projects.project_template_id', + 'project_templates.project_id as template_project_id', + 'project_templates.is_published as template_is_published', + ]) + ->get(); + + foreach ($projects as $project) { + $projectTemplateId = $project->project_template_id; + + if ($projectTemplateId === null || ( + ! (bool) $project->template_is_published + && (int) ($project->template_project_id ?? 0) !== (int) $project->id + )) { + $projectTemplateId = DB::table('project_templates')->insertGetId([ + 'project_id' => $project->id, + 'source_project_id' => $project->id, + 'name' => $project->name.' base template', + 'slug' => Str::lower($project->key).'-base-template', + 'is_published' => false, + 'copy_areas' => json_encode([ + 'project_settings', + 'board_workflow', + 'ticket_types', + 'ticket_priorities', + 'ticket_fields', + 'ticket_link_types', + 'repository_connections', + 'notification_actions', + ], JSON_THROW_ON_ERROR), + 'configuration_snapshot' => json_encode([], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('projects') + ->where('id', $project->id) + ->update([ + 'project_template_id' => $projectTemplateId, + 'updated_at' => now(), + ]); + } + + foreach ($this->tables as $table) { + DB::table($table) + ->where('project_id', $project->id) + ->update(['project_template_id' => $projectTemplateId]); + } + + $projectModel = Project::query()->find($project->id); + + if ($projectModel !== null && ! (bool) $project->template_is_published) { + DB::table('project_templates') + ->where('id', $projectTemplateId) + ->update([ + 'configuration_snapshot' => json_encode( + app(DuplicateProjectConfiguration::class)->export( + $projectModel, + DuplicateProjectConfiguration::copyableAreas(), + ), + JSON_THROW_ON_ERROR, + ), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + foreach (array_reverse($this->tables) as $table) { + Schema::table($table, function (Blueprint $table): void { + $table->dropConstrainedForeignId('project_template_id'); + }); + } + } +}; diff --git a/database/migrations/2026_09_08_091101_add_template_override_tracking_to_projects_table.php b/database/migrations/2026_09_08_091101_add_template_override_tracking_to_projects_table.php new file mode 100644 index 0000000..4f6c601 --- /dev/null +++ b/database/migrations/2026_09_08_091101_add_template_override_tracking_to_projects_table.php @@ -0,0 +1,63 @@ +json('template_overrides')->default('[]')->after('project_template_id'); + }); + + $configuration = app(DuplicateProjectConfiguration::class); + + ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void { + $template->forceFill([ + 'copy_areas' => $configuration->normalizeCopyAreas($template->copy_areas ?? []), + 'configuration_snapshot' => $configuration->normalizeConfiguration( + $template->configuration_snapshot ?? [], + ), + ])->saveQuietly(); + }); + + Project::query() + ->whereHas('projectTemplate', fn ($query) => $query->where('is_published', true)) + ->with('projectTemplate') + ->eachById(function (Project $project) use ($configuration): void { + $templateConfiguration = $configuration->normalizeConfiguration( + $project->projectTemplate?->configuration_snapshot ?? [], + ); + $areas = array_values(array_unique([ + ...array_keys($templateConfiguration), + ...DuplicateProjectConfiguration::copyableAreas(), + ])); + $currentConfiguration = $configuration->export($project, $areas); + $overrides = collect($areas) + ->filter(fn (string $area): bool => ! array_key_exists($area, $templateConfiguration) + || ($currentConfiguration[$area] ?? null) !== $templateConfiguration[$area]) + ->values() + ->all(); + + $project->forceFill(['template_overrides' => $overrides])->saveQuietly(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('projects', function (Blueprint $table): void { + $table->dropColumn('template_overrides'); + }); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index a612285..e3e470a 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -5,6 +5,7 @@ import { Folder, FolderGit2, LayoutGrid, + Layers3, Shield, Users, } from 'lucide-vue-next'; @@ -24,6 +25,7 @@ import { } from '@/components/ui/sidebar'; import { dashboard } from '@/routes'; import { index as adminUsers } from '@/routes/admin/users'; +import { index as projectTemplatesIndex } from '@/routes/project-templates'; import { index as projectsIndex } from '@/routes/projects'; import { index as teamsIndex } from '@/routes/teams'; import type { NavItem } from '@/types'; @@ -44,6 +46,14 @@ const mainNavItems = computed(() => { }, ]; + if (page.props.auth.permissions.projectCreate) { + items.push({ + title: 'Project templates', + href: projectTemplatesIndex(), + icon: Layers3, + }); + } + if (page.props.auth.permissions.globalAdmin) { items.push({ title: 'Users', diff --git a/resources/js/components/projects/TemplateInheritancePanel.vue b/resources/js/components/projects/TemplateInheritancePanel.vue new file mode 100644 index 0000000..cc87e57 --- /dev/null +++ b/resources/js/components/projects/TemplateInheritancePanel.vue @@ -0,0 +1,177 @@ + + + diff --git a/resources/js/pages/project-templates/Create.vue b/resources/js/pages/project-templates/Create.vue new file mode 100644 index 0000000..4a7f98a --- /dev/null +++ b/resources/js/pages/project-templates/Create.vue @@ -0,0 +1,146 @@ + + + diff --git a/resources/js/pages/project-templates/Edit.vue b/resources/js/pages/project-templates/Edit.vue new file mode 100644 index 0000000..fccbf70 --- /dev/null +++ b/resources/js/pages/project-templates/Edit.vue @@ -0,0 +1,175 @@ + + + diff --git a/resources/js/pages/project-templates/Index.vue b/resources/js/pages/project-templates/Index.vue new file mode 100644 index 0000000..2f5bf61 --- /dev/null +++ b/resources/js/pages/project-templates/Index.vue @@ -0,0 +1,127 @@ + + + diff --git a/resources/js/pages/projects/Create.vue b/resources/js/pages/projects/Create.vue index a6c8517..c46c12b 100644 --- a/resources/js/pages/projects/Create.vue +++ b/resources/js/pages/projects/Create.vue @@ -1,5 +1,6 @@