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/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..98b278f 100644 --- a/app/Actions/Projects/CreateProject.php +++ b/app/Actions/Projects/CreateProject.php @@ -6,25 +6,65 @@ 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, ) {} /** - * @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'])); - $this->initializeProjectTicketMetadata->handle($project); - $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); + $this->initializeProjectTicketMetadata->handle($project); + $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); - return $project; + $creationMode = $attributes['creation_mode'] ?? 'scratch'; + + if ($creationMode === 'duplicate' && isset($attributes['source_project_id'])) { + $sourceProject = Project::query()->findOrFail((int) $attributes['source_project_id']); + + $this->duplicateProjectConfiguration->handle( + $sourceProject, + $project, + array_values($attributes['copy_areas'] ?? []), + ); + } + + if ($creationMode === 'template' && isset($attributes['source_template_id'])) { + $template = ProjectTemplate::query()->findOrFail((int) $attributes['source_template_id']); + + $this->duplicateProjectConfiguration->apply( + $project, + $template->configuration_snapshot ?? [], + ); + + $project->forceFill([ + 'project_template_id' => $template->id, + ])->save(); + } + + if ($project->project_template_id === null) { + $baseTemplate = $this->createProjectBaseTemplate->handle($project->fresh() ?? $project); + + $project->forceFill([ + 'project_template_id' => $baseTemplate->id, + ])->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..01e2eed --- /dev/null +++ b/app/Actions/Projects/DuplicateProjectConfiguration.php @@ -0,0 +1,400 @@ + + */ + public static function copyableAreas(): array + { + return [ + self::COPY_PROJECT_SETTINGS, + self::COPY_BOARD_WORKFLOW, + 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 copyAreaOptions(): array + { + return [ + [ + 'value' => self::COPY_PROJECT_SETTINGS, + 'label' => 'Project settings', + 'description' => 'Copy general project preferences like story points and author visibility.', + ], + [ + 'value' => self::COPY_BOARD_WORKFLOW, + 'label' => 'Board workflow', + 'description' => 'Copy board columns, ticket statuses, and allowed status transitions.', + ], + [ + 'value' => self::COPY_TICKET_TYPES, + 'label' => 'Ticket types', + 'description' => 'Copy available ticket types, including parent-ticket types.', + ], + [ + 'value' => self::COPY_TICKET_PRIORITIES, + 'label' => 'Ticket priorities', + 'description' => 'Copy the project-specific ticket priorities.', + ], + [ + 'value' => self::COPY_TICKET_FIELDS, + 'label' => 'Custom ticket fields', + 'description' => 'Copy custom ticket field definitions without any ticket values.', + ], + [ + 'value' => self::COPY_TICKET_LINK_TYPES, + 'label' => 'Ticket link types', + 'description' => 'Copy available relationship labels between tickets.', + ], + [ + 'value' => self::COPY_REPOSITORY_CONNECTIONS, + 'label' => 'Repository connections', + 'description' => 'Copy linked repositories without changing the new project identity.', + ], + [ + 'value' => self::COPY_NOTIFICATION_ACTIONS, + 'label' => 'Notification actions', + 'description' => 'Copy notification rules and delivery settings.', + ], + ]; + } + + /** + * @param list $copyAreas + * @return list + */ + public function normalizeCopyAreas(array $copyAreas): array + { + return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas))); + } + + /** + * @param list $copyAreas + * @return array + */ + public function export(Project $sourceProject, array $copyAreas): array + { + $selectedAreas = $this->normalizeCopyAreas($copyAreas); + + if ($selectedAreas === []) { + return []; + } + + $sourceProject->loadMissing([ + '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, + ]; + } + + if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) { + $boardColumnIndexes = []; + $boardColumns = []; + + foreach ($sourceProject->boardColumns as $index => $boardColumn) { + $boardColumnIndexes[$boardColumn->id] = $index; + $boardColumns[] = [ + 'name' => $boardColumn->name, + 'position' => $boardColumn->position, + ]; + } + + $ticketStatusIndexes = []; + $ticketStatuses = []; + + foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) { + $ticketStatusIndexes[$ticketStatus->id] = $index; + $ticketStatuses[] = [ + 'board_column_index' => $ticketStatus->board_column_id === null + ? null + : ($boardColumnIndexes[$ticketStatus->board_column_id] ?? null), + 'name' => $ticketStatus->name, + 'position' => $ticketStatus->position, + 'allowed_target_indexes' => [], + ]; + } + + foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) { + $ticketStatuses[$index]['allowed_target_indexes'] = $ticketStatus->allowedTargetStatuses + ->pluck('id') + ->map(fn (int $statusId): ?int => $ticketStatusIndexes[$statusId] ?? null) + ->filter(static fn (?int $statusIndex): bool => $statusIndex !== null) + ->values() + ->all(); + } + + $configuration[self::COPY_BOARD_WORKFLOW] = [ + 'board_columns' => $boardColumns, + 'ticket_statuses' => $ticketStatuses, + ]; + } + + if (in_array(self::COPY_TICKET_TYPES, $selectedAreas, true)) { + $configuration[self::COPY_TICKET_TYPES] = $sourceProject->ticketTypes + ->map(fn ($ticketType): array => [ + 'name' => $ticketType->name, + 'position' => $ticketType->position, + 'is_parent' => $ticketType->is_parent, + ]) + ->values() + ->all(); + } + + if (in_array(self::COPY_TICKET_PRIORITIES, $selectedAreas, true)) { + $configuration[self::COPY_TICKET_PRIORITIES] = $sourceProject->ticketPriorities + ->map(fn ($ticketPriority): array => [ + 'name' => $ticketPriority->name, + 'position' => $ticketPriority->position, + ]) + ->values() + ->all(); + } + + if (in_array(self::COPY_TICKET_FIELDS, $selectedAreas, true)) { + $configuration[self::COPY_TICKET_FIELDS] = $sourceProject->ticketFieldDefinitions + ->map(fn ($ticketFieldDefinition): array => [ + 'field_type' => $ticketFieldDefinition->field_type, + 'label' => $ticketFieldDefinition->label, + 'position' => $ticketFieldDefinition->position, + ]) + ->values() + ->all(); + } + + if (in_array(self::COPY_TICKET_LINK_TYPES, $selectedAreas, true)) { + $configuration[self::COPY_TICKET_LINK_TYPES] = $sourceProject->ticketLinkTypes + ->map(fn ($ticketLinkType): array => [ + 'label' => $ticketLinkType->label, + 'counterpart_label' => $ticketLinkType->counterpart_label, + 'position' => $ticketLinkType->position, + ]) + ->values() + ->all(); + } + + if (in_array(self::COPY_REPOSITORY_CONNECTIONS, $selectedAreas, true)) { + $configuration[self::COPY_REPOSITORY_CONNECTIONS] = $sourceProject->repositoryConnections + ->map(fn ($repositoryConnection): array => [ + 'provider' => $repositoryConnection->provider->value, + 'base_url' => $repositoryConnection->base_url, + 'provider_repository_id' => $repositoryConnection->provider_repository_id, + 'owner_name' => $repositoryConnection->owner_name, + 'repository_name' => $repositoryConnection->repository_name, + 'repository_full_name' => $repositoryConnection->repository_full_name, + 'repository_url' => $repositoryConnection->repository_url, + ]) + ->values() + ->all(); + } + + if (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) { + $configuration[self::COPY_NOTIFICATION_ACTIONS] = $sourceProject->notificationActions + ->map(fn ($notificationAction): array => [ + 'name' => $notificationAction->name, + 'enabled' => $notificationAction->enabled, + 'event_type' => $notificationAction->event_type->value, + 'channel' => $notificationAction->channel->value, + 'channel_configuration' => $notificationAction->channel_configuration ?? [], + 'message_template' => $notificationAction->message_template, + 'delivery_url' => $notificationAction->delivery_url, + 'filters' => $notificationAction->filters ?? [], + ]) + ->values() + ->all(); + } + + return $configuration; + } + + /** + * @param array $configuration + */ + public function apply(Project $targetProject, array $configuration): void + { + if (array_key_exists(self::COPY_PROJECT_SETTINGS, $configuration)) { + $projectSettings = $configuration[self::COPY_PROJECT_SETTINGS]; + + $targetProject->forceFill([ + 'show_author' => (bool) ($projectSettings['show_author'] ?? $targetProject->show_author), + 'show_story_points' => (bool) ($projectSettings['show_story_points'] ?? $targetProject->show_story_points), + ])->save(); + } + + if (array_key_exists(self::COPY_BOARD_WORKFLOW, $configuration)) { + $targetProject->ticketStatuses()->delete(); + $targetProject->boardColumns()->delete(); + + $boardColumnIds = []; + + foreach (($configuration[self::COPY_BOARD_WORKFLOW]['board_columns'] ?? []) as $boardColumn) { + $newBoardColumn = $targetProject->boardColumns()->create([ + 'name' => $boardColumn['name'], + 'position' => $boardColumn['position'], + ]); + + $boardColumnIds[] = $newBoardColumn->id; + } + + $ticketStatusIds = []; + + foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $ticketStatus) { + $boardColumnIndex = $ticketStatus['board_column_index'] ?? null; + $newTicketStatus = $targetProject->ticketStatuses()->create([ + 'board_column_id' => $boardColumnIndex === null ? null : ($boardColumnIds[$boardColumnIndex] ?? null), + 'name' => $ticketStatus['name'], + 'position' => $ticketStatus['position'], + ]); + + $ticketStatusIds[] = $newTicketStatus->id; + } + + foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $index => $ticketStatus) { + $newTicketStatusId = $ticketStatusIds[$index] ?? null; + + if ($newTicketStatusId === null) { + continue; + } + + $targetProject->ticketStatuses()->findOrFail($newTicketStatusId) + ->allowedTargetStatuses() + ->sync( + collect($ticketStatus['allowed_target_indexes'] ?? []) + ->map(fn (int $statusIndex): ?int => $ticketStatusIds[$statusIndex] ?? null) + ->filter(static fn (?int $statusId): bool => $statusId !== null) + ->values() + ->all(), + ); + } + } + + if (array_key_exists(self::COPY_TICKET_TYPES, $configuration)) { + $targetProject->ticketTypes()->delete(); + + foreach ($configuration[self::COPY_TICKET_TYPES] as $ticketType) { + $targetProject->ticketTypes()->create([ + 'name' => $ticketType['name'], + 'position' => $ticketType['position'], + 'is_parent' => $ticketType['is_parent'], + ]); + } + } + + if (array_key_exists(self::COPY_TICKET_PRIORITIES, $configuration)) { + $targetProject->ticketPriorities()->delete(); + + foreach ($configuration[self::COPY_TICKET_PRIORITIES] as $ticketPriority) { + $targetProject->ticketPriorities()->create([ + 'name' => $ticketPriority['name'], + 'position' => $ticketPriority['position'], + ]); + } + } + + if (array_key_exists(self::COPY_TICKET_FIELDS, $configuration)) { + $targetProject->ticketFieldDefinitions()->delete(); + + foreach ($configuration[self::COPY_TICKET_FIELDS] as $ticketFieldDefinition) { + $targetProject->ticketFieldDefinitions()->create([ + 'field_type' => $ticketFieldDefinition['field_type'], + 'label' => $ticketFieldDefinition['label'], + 'position' => $ticketFieldDefinition['position'], + ]); + } + } + + if (array_key_exists(self::COPY_TICKET_LINK_TYPES, $configuration)) { + $targetProject->ticketLinkTypes()->delete(); + + foreach ($configuration[self::COPY_TICKET_LINK_TYPES] as $ticketLinkType) { + $targetProject->ticketLinkTypes()->create([ + 'label' => $ticketLinkType['label'], + 'counterpart_label' => $ticketLinkType['counterpart_label'], + 'position' => $ticketLinkType['position'], + ]); + } + } + + if (array_key_exists(self::COPY_REPOSITORY_CONNECTIONS, $configuration)) { + $targetProject->repositoryConnections()->delete(); + + foreach ($configuration[self::COPY_REPOSITORY_CONNECTIONS] as $repositoryConnection) { + $targetProject->repositoryConnections()->create([ + 'provider' => $repositoryConnection['provider'], + 'base_url' => $repositoryConnection['base_url'], + 'provider_repository_id' => $repositoryConnection['provider_repository_id'], + 'owner_name' => $repositoryConnection['owner_name'], + 'repository_name' => $repositoryConnection['repository_name'], + 'repository_full_name' => $repositoryConnection['repository_full_name'], + 'repository_url' => $repositoryConnection['repository_url'], + ]); + } + } + + if (array_key_exists(self::COPY_NOTIFICATION_ACTIONS, $configuration)) { + $targetProject->notificationActions()->delete(); + + foreach ($configuration[self::COPY_NOTIFICATION_ACTIONS] as $notificationAction) { + $targetProject->notificationActions()->create([ + 'name' => $notificationAction['name'], + 'enabled' => $notificationAction['enabled'], + 'event_type' => $notificationAction['event_type'], + 'channel' => $notificationAction['channel'], + 'channel_configuration' => $notificationAction['channel_configuration'] ?? [], + 'message_template' => $notificationAction['message_template'] ?? null, + 'delivery_url' => $notificationAction['delivery_url'], + 'filters' => $notificationAction['filters'] ?? [], + ]); + } + } + } + + /** + * @param list $copyAreas + */ + public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void + { + $this->apply($targetProject, $this->export($sourceProject, $copyAreas)); + } +} diff --git a/app/Actions/Projects/ResolveProjectTemplateState.php b/app/Actions/Projects/ResolveProjectTemplateState.php new file mode 100644 index 0000000..40e8b0c --- /dev/null +++ b/app/Actions/Projects/ResolveProjectTemplateState.php @@ -0,0 +1,92 @@ +|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); + + $templateConfiguration = collect($projectTemplate->configuration_snapshot ?? []) + ->only($selectedAreas) + ->all(); + $currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas); + + $overriddenAreas = []; + $inheritedAreas = []; + + foreach ($selectedAreas as $area) { + $label = $this->labelForArea($area); + $hasTemplateValue = array_key_exists($area, $templateConfiguration); + $hasCurrentValue = array_key_exists($area, $currentConfiguration); + + if (! $hasTemplateValue && ! $hasCurrentValue) { + continue; + } + + if ($hasTemplateValue && $hasCurrentValue && $currentConfiguration[$area] === $templateConfiguration[$area]) { + $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' => true, + 'overriddenAreas' => $overriddenAreas, + 'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues), + 'templateConfiguration' => $templateConfiguration, + 'templateId' => $projectTemplate->id, + 'templateName' => $projectTemplate->name, + 'templateSlug' => $projectTemplate->slug, + ]; + } + + 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/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/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index d6405bb..235d746 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -6,13 +6,17 @@ 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\Enums\Permission; use App\Http\Requests\DeleteProjectRequest; use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\SyncProjectPermissionsRequest; use App\Http\Resources\PermissionOptionResource; use App\Http\Resources\ProjectResource; +use App\Http\Resources\ProjectTemplateResource; +use App\Models\ProjectTemplate; use App\Http\Resources\TeamResource; use App\Http\Resources\UserResource; use App\Models\Project; @@ -32,6 +36,7 @@ class ProjectController extends Controller private readonly DeleteProject $deleteProject, private readonly ListVisibleProjects $listVisibleProjects, private readonly PermissionService $permissionService, + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, ) {} public function index(Request $request): Response @@ -51,7 +56,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 +94,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), ]); } diff --git a/app/Http/Controllers/ProjectTemplateController.php b/app/Http/Controllers/ProjectTemplateController.php new file mode 100644 index 0000000..d47908f --- /dev/null +++ b/app/Http/Controllers/ProjectTemplateController.php @@ -0,0 +1,63 @@ + session('status'), + 'templates' => ProjectTemplateResource::collection( + ProjectTemplate::query() + ->published() + ->with('sourceProject') + ->orderBy('name') + ->get(), + )->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 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/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php index 6c7eeb0..2e28543 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::copyableAreas()), + ], + ]; + } + + /** + * @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..a210f53 --- /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::copyableAreas())], + ]; + } +} diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php index bc34640..a4bafea 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,32 @@ 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', []), + '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..3bcdcc9 --- /dev/null +++ b/app/Http/Resources/ProjectTemplateResource.php @@ -0,0 +1,51 @@ + + */ + public function toArray(Request $request): array + { + if ($this->resource instanceof ProjectTemplate) { + 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' => $this->copy_areas ?? [], + 'configuration_snapshot' => $this->configuration_snapshot ?? [], + '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', []), + 'created_at' => Arr::get($this->resource, 'created_at'), + 'updated_at' => Arr::get($this->resource, 'updated_at'), + ]; + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 51cd5f5..ec907cb 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'])] class Project extends Model { /** @use HasFactory */ @@ -44,6 +46,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) diff --git a/app/Models/ProjectTemplate.php b/app/Models/ProjectTemplate.php new file mode 100644 index 0000000..272e636 --- /dev/null +++ b/app/Models/ProjectTemplate.php @@ -0,0 +1,41 @@ + */ + 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'); + } +} 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/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index a612285..f15b3c7 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'; @@ -44,6 +45,14 @@ const mainNavItems = computed(() => { }, ]; + if (page.props.auth.permissions.projectCreate) { + items.push({ + title: 'Project templates', + href: '/project-templates', + icon: Layers3, + }); + } + if (page.props.auth.permissions.globalAdmin) { items.push({ title: 'Users', diff --git a/resources/js/pages/project-templates/Create.vue b/resources/js/pages/project-templates/Create.vue new file mode 100644 index 0000000..c5d50ca --- /dev/null +++ b/resources/js/pages/project-templates/Create.vue @@ -0,0 +1,142 @@ + + + diff --git a/resources/js/pages/project-templates/Index.vue b/resources/js/pages/project-templates/Index.vue new file mode 100644 index 0000000..dcba323 --- /dev/null +++ b/resources/js/pages/project-templates/Index.vue @@ -0,0 +1,113 @@ + + + diff --git a/resources/js/pages/projects/Create.vue b/resources/js/pages/projects/Create.vue index a6c8517..67985d2 100644 --- a/resources/js/pages/projects/Create.vue +++ b/resources/js/pages/projects/Create.vue @@ -1,5 +1,6 @@