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 98b278f..221e3d9 100644 --- a/app/Actions/Projects/CreateProject.php +++ b/app/Actions/Projects/CreateProject.php @@ -27,40 +27,55 @@ class CreateProject { 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; + + if ($creationMode === 'template' && $sourceTemplateId !== null) { + $sourceTemplate = ProjectTemplate::query() + ->published() + ->findOrFail((int) $sourceTemplateId); + + $project->forceFill([ + 'project_template_id' => $sourceTemplate->id, + ])->save(); + } else { + $baseTemplate = $this->createProjectBaseTemplate->handle($project); + + $project->forceFill([ + 'project_template_id' => $baseTemplate->id, + ])->save(); + } + + $project = $project->fresh() ?? $project; $this->initializeProjectTicketMetadata->handle($project); $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); - $creationMode = $attributes['creation_mode'] ?? 'scratch'; - - if ($creationMode === 'duplicate' && isset($attributes['source_project_id'])) { - $sourceProject = Project::query()->findOrFail((int) $attributes['source_project_id']); + 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($attributes['copy_areas'] ?? []), + array_values(Arr::get($attributes, 'copy_areas', [])), ); } - if ($creationMode === 'template' && isset($attributes['source_template_id'])) { - $template = ProjectTemplate::query()->findOrFail((int) $attributes['source_template_id']); - + if ($creationMode === 'template' && $sourceTemplate !== null) { $this->duplicateProjectConfiguration->apply( - $project, - $template->configuration_snapshot ?? [], + $project->fresh() ?? $project, + $sourceTemplate->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, + if ($baseTemplate !== null) { + $baseTemplate->forceFill([ + 'configuration_snapshot' => $this->duplicateProjectConfiguration->export( + $project->fresh() ?? $project, + DuplicateProjectConfiguration::copyableAreas(), + ), ])->save(); } diff --git a/app/Actions/Projects/ResolveProjectTemplateState.php b/app/Actions/Projects/ResolveProjectTemplateState.php new file mode 100644 index 0000000..8b3c8a0 --- /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' => $projectTemplate->is_published, + '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/Projects/UpdateProjectTemplate.php b/app/Actions/Projects/UpdateProjectTemplate.php new file mode 100644 index 0000000..f0d5528 --- /dev/null +++ b/app/Actions/Projects/UpdateProjectTemplate.php @@ -0,0 +1,66 @@ +} $attributes + */ + public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate + { + $projectTemplate->loadMissing('sourceProject', 'linkedProjects.projectTemplate'); + + $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 = $projectTemplate->configuration_snapshot ?? []; + $nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas); + $affectedAreas = array_values(array_unique([ + ...array_keys($previousConfiguration), + ...array_keys($nextConfiguration), + ])); + + DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void { + $projectTemplate->forceFill([ + 'name' => $attributes['name'], + 'copy_areas' => $normalizedCopyAreas, + 'configuration_snapshot' => $nextConfiguration, + ])->save(); + + foreach ($projectTemplate->linkedProjects as $linkedProject) { + $currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject->fresh(), $affectedAreas); + $replacementConfiguration = []; + + foreach ($nextConfiguration as $area => $configuration) { + $isNewTemplateArea = ! array_key_exists($area, $previousConfiguration); + $matchesPreviousTemplate = array_key_exists($area, $previousConfiguration) + && array_key_exists($area, $currentConfiguration) + && $currentConfiguration[$area] === $previousConfiguration[$area]; + + if ($isNewTemplateArea || $matchesPreviousTemplate) { + $replacementConfiguration[$area] = $configuration; + } + } + + if ($replacementConfiguration === []) { + continue; + } + + $this->duplicateProjectConfiguration->apply($linkedProject->fresh(), $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..a7bcc39 --- /dev/null +++ b/app/Actions/Projects/UpdateProjectTemplateLink.php @@ -0,0 +1,44 @@ +forceFill([ + 'project_template_id' => $template->id, + ])->save(); + + $this->duplicateProjectConfiguration->apply( + $project->fresh(), + $template->configuration_snapshot ?? [], + ); + }); + } + + public function reset(Project $project): void + { + $template = $project->projectTemplate; + + if ($template === null) { + return; + } + + DB::transaction(function () use ($project, $template): void { + $this->duplicateProjectConfiguration->apply( + $project->fresh(), + $template->configuration_snapshot ?? [], + ); + }); + } +} 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..985a410 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -47,9 +47,13 @@ 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->project_template_id === null ? 'project_id' : 'project_template_id', + $project->project_template_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 1f8ccb3..898a9df 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -8,17 +8,21 @@ 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\Models\ProjectTemplate; 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; @@ -35,6 +39,8 @@ class ProjectController extends Controller private readonly DeleteProject $deleteProject, private readonly ListVisibleProjects $listVisibleProjects, private readonly PermissionService $permissionService, + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, + private readonly UpdateProjectTemplateLink $updateProjectTemplateLink, ) {} public function index(Request $request): Response @@ -92,6 +98,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), ]); } @@ -114,10 +121,44 @@ 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 + { + $template = ProjectTemplate::query() + ->published() + ->findOrFail($request->integer('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 + { + $request->validated(); + + $template = $project->projectTemplate; + + abort_if($template === null, 422, 'This project has no linked template to reset.'); + + $this->updateProjectTemplateLink->reset($project->load('projectTemplate')); + + return to_route('projects.settings.general', $project) + ->with('status', "{$project->name} was reset to the {$template->name} template baseline."); + } + 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 index d47908f..e9c1b6a 100644 --- a/app/Http/Controllers/ProjectTemplateController.php +++ b/app/Http/Controllers/ProjectTemplateController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers; use App\Actions\Projects\CreateProjectTemplate; use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\UpdateProjectTemplate; use App\Http\Requests\StoreProjectTemplateRequest; +use App\Http\Requests\UpdateProjectTemplateRequest; use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectTemplateResource; use App\Models\Project; @@ -18,6 +20,7 @@ class ProjectTemplateController extends Controller { public function __construct( private readonly CreateProjectTemplate $createProjectTemplate, + private readonly UpdateProjectTemplate $updateProjectTemplate, ) {} public function index(Request $request): Response @@ -28,12 +31,25 @@ class ProjectTemplateController extends Controller 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', [ @@ -50,6 +66,16 @@ class ProjectTemplateController extends Controller ->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); diff --git a/app/Http/Requests/Api/MoveProjectTicketRequest.php b/app/Http/Requests/Api/MoveProjectTicketRequest.php index 6f87219..1ddccfa 100644 --- a/app/Http/Requests/Api/MoveProjectTicketRequest.php +++ b/app/Http/Requests/Api/MoveProjectTicketRequest.php @@ -24,12 +24,17 @@ class MoveProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ '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($ownershipColumn, $ownershipValue), + ), ], ]; } diff --git a/app/Http/Requests/DestroyProjectTicketTypeRequest.php b/app/Http/Requests/DestroyProjectTicketTypeRequest.php index 97bc396..64a0617 100644 --- a/app/Http/Requests/DestroyProjectTicketTypeRequest.php +++ b/app/Http/Requests/DestroyProjectTicketTypeRequest.php @@ -29,12 +29,17 @@ class DestroyProjectTicketTypeRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ '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($ownershipColumn, $ownershipValue), + ), ], ]; } diff --git a/app/Http/Requests/MoveProjectTicketRequest.php b/app/Http/Requests/MoveProjectTicketRequest.php index 29d080a..5ed7e1f 100644 --- a/app/Http/Requests/MoveProjectTicketRequest.php +++ b/app/Http/Requests/MoveProjectTicketRequest.php @@ -29,12 +29,17 @@ class MoveProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ '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($ownershipColumn, $ownershipValue), + ), ], '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..25b0dda --- /dev/null +++ b/app/Http/Requests/ResetProjectTemplateRequest.php @@ -0,0 +1,43 @@ +|string> + */ + public function rules(): array + { + /** @var Project $project */ + $project = $this->route('project'); + + return [ + 'confirmation_key' => [ + 'required', + 'string', + Rule::in([$project->key]), + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.', + ]; + } +} diff --git a/app/Http/Requests/StoreProjectTicketBranchRequest.php b/app/Http/Requests/StoreProjectTicketBranchRequest.php index 2d3e10b..9e1f88f 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,21 @@ class StoreProjectTicketBranchRequest extends FormRequest */ public function rules(): array { + /** @var Project $project */ + $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; + 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($ownershipColumn, $ownershipValue), + ), + ], ]; } } diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php index 59036c9..2c90e92 100644 --- a/app/Http/Requests/StoreProjectTicketRequest.php +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -48,6 +48,9 @@ class StoreProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ 'acceptance_criteria' => ['nullable', 'array'], @@ -66,17 +69,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($ownershipColumn, $ownershipValue), + ), ], '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($ownershipColumn, $ownershipValue), + ), ], '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($ownershipColumn, $ownershipValue), + ), ], '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..65904c6 100644 --- a/app/Http/Requests/StoreTicketLinkRequest.php +++ b/app/Http/Requests/StoreTicketLinkRequest.php @@ -28,6 +28,9 @@ class StoreTicketLinkRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ 'linked_ticket_id' => [ @@ -38,7 +41,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($ownershipColumn, $ownershipValue), + ), ], ]; } diff --git a/app/Http/Requests/StoreTicketStatusRequest.php b/app/Http/Requests/StoreTicketStatusRequest.php index 43d4fb8..696c35f 100644 --- a/app/Http/Requests/StoreTicketStatusRequest.php +++ b/app/Http/Requests/StoreTicketStatusRequest.php @@ -26,12 +26,17 @@ class StoreTicketStatusRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ '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($ownershipColumn, $ownershipValue), + ), ], '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..85bfdfe --- /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::copyableAreas())], + '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..9b42395 100644 --- a/app/Http/Requests/UpdateProjectTicketRequest.php +++ b/app/Http/Requests/UpdateProjectTicketRequest.php @@ -51,6 +51,9 @@ class UpdateProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ 'acceptance_criteria' => ['nullable', 'array'], @@ -68,7 +71,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($ownershipColumn, $ownershipValue), + ), ], 'field_values.*.value_number' => ['nullable', 'numeric'], 'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], @@ -81,12 +86,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($ownershipColumn, $ownershipValue), + ), ], '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($ownershipColumn, $ownershipValue), + ), ], 'title' => ['required', 'string', 'max:255'], 'story_points' => ['nullable', 'integer', 'min:0'], @@ -141,7 +150,7 @@ class UpdateProjectTicketRequest extends FormRequest $requestedTicketTypeId = Arr::get($validated, 'ticket_type_id'); $requestedTicketType = TicketType::query() - ->where('project_id', $project->id) + ->where($project->project_template_id === null ? 'project_id' : 'project_template_id', $project->project_template_id ?? $project->id) ->find($requestedTicketTypeId); if ( diff --git a/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php b/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php index c6c8681..60372ef 100644 --- a/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php +++ b/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php @@ -29,12 +29,17 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; return [ '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($ownershipColumn, $ownershipValue), + ), ], ]; } diff --git a/app/Http/Requests/ValidateProjectTicketBranchRequest.php b/app/Http/Requests/ValidateProjectTicketBranchRequest.php index 92f4f2b..ebae66a 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,21 @@ class ValidateProjectTicketBranchRequest extends FormRequest */ public function rules(): array { + /** @var Project $project */ + $project = $this->route('project'); + $projectTemplateId = $project->project_template_id; + $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; + $ownershipValue = $projectTemplateId ?? $project->id; + 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($ownershipColumn, $ownershipValue), + ), + ], ]; } } diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php index a30fba5..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,11 +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 index bf49dc9..cdfbc4f 100644 --- a/app/Http/Resources/ProjectTemplateResource.php +++ b/app/Http/Resources/ProjectTemplateResource.php @@ -28,6 +28,8 @@ class ProjectTemplateResource extends JsonResource 'slug' => $this->slug, 'is_published' => $this->is_published, 'copy_areas' => $this->copy_areas ?? [], + 'configuration_snapshot' => $this->configuration_snapshot ?? [], + 'linked_project_count' => $this->whenCounted('linkedProjects'), 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), ]; @@ -42,6 +44,8 @@ class ProjectTemplateResource extends JsonResource '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/ProjectTemplate.php b/app/Models/ProjectTemplate.php index 272e636..726e775 100644 --- a/app/Models/ProjectTemplate.php +++ b/app/Models/ProjectTemplate.php @@ -4,10 +4,11 @@ namespace App\Models; use Database\Factories\ProjectTemplateFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable(['project_id', 'source_project_id', 'name', 'slug', 'is_published', 'copy_areas', 'configuration_snapshot'])] class ProjectTemplate extends Model @@ -38,4 +39,9 @@ class ProjectTemplate extends Model { 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..1f37f56 100644 --- a/app/Models/TicketPriority.php +++ b/app/Models/TicketPriority.php @@ -2,17 +2,26 @@ 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; -#[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 { 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/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..01838fc --- /dev/null +++ b/database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php @@ -0,0 +1,116 @@ + + */ + 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', + ]) + ->get(); + + foreach ($projects as $project) { + $projectTemplateId = $project->project_template_id; + + if ($projectTemplateId === null || (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) { + 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/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index f15b3c7..e3e470a 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -25,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'; @@ -48,7 +49,7 @@ const mainNavItems = computed(() => { if (page.props.auth.permissions.projectCreate) { items.push({ title: 'Project templates', - href: '/project-templates', + href: projectTemplatesIndex(), icon: Layers3, }); } diff --git a/resources/js/pages/admin/users/Edit.vue b/resources/js/pages/admin/users/Edit.vue index 7b170e9..7748351 100644 --- a/resources/js/pages/admin/users/Edit.vue +++ b/resources/js/pages/admin/users/Edit.vue @@ -40,7 +40,7 @@ const form = useForm({ }); const submit = (): void => { - form.patch(save(props.user.id), { + form.patch(save.url(props.user.id), { preserveScroll: true, }); }; diff --git a/resources/js/pages/project-templates/Create.vue b/resources/js/pages/project-templates/Create.vue index c5d50ca..4a7f98a 100644 --- a/resources/js/pages/project-templates/Create.vue +++ b/resources/js/pages/project-templates/Create.vue @@ -15,7 +15,11 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import AppLayout from '@/layouts/AppLayout.vue'; -import { show as projectShow } from '@/routes/projects/index'; +import { index as projectsIndex, show as projectShow } from '@/routes/projects/index'; +import { + create as projectTemplateCreate, + store as projectTemplateStore, +} from '@/routes/projects/templates'; import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types'; const props = defineProps(); @@ -23,7 +27,7 @@ const props = defineProps(); const breadcrumbs: BreadcrumbItem[] = [ { title: 'Projects', - href: '/projects', + href: projectsIndex(), }, { title: props.project.name, @@ -31,7 +35,7 @@ const breadcrumbs: BreadcrumbItem[] = [ }, { title: 'Save as new template', - href: `/projects/${props.project.id}/templates/create`, + href: projectTemplateCreate(props.project.id), }, ]; @@ -50,7 +54,7 @@ const copyAreasError = computed(() => { }); const submit = (): void => { - form.post(`/projects/${props.project.id}/templates`, { + form.post(projectTemplateStore.url(props.project.id), { preserveScroll: true, }); }; 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 index dcba323..2f5bf61 100644 --- a/resources/js/pages/project-templates/Index.vue +++ b/resources/js/pages/project-templates/Index.vue @@ -11,6 +11,11 @@ import { CardTitle, } from '@/components/ui/card'; import AppLayout from '@/layouts/AppLayout.vue'; +import { + destroy as projectTemplatesDestroy, + edit as projectTemplatesEdit, + index as projectTemplatesIndex, +} from '@/routes/project-templates'; import { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index'; import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types'; @@ -19,12 +24,12 @@ const props = defineProps(); const breadcrumbs: BreadcrumbItem[] = [ { title: 'Project templates', - href: '/project-templates', + href: projectTemplatesIndex(), }, ]; const removeTemplate = (templateId: number): void => { - router.delete(`/project-templates/${templateId}`, { + router.delete(projectTemplatesDestroy.url(templateId), { preserveScroll: true, }); }; @@ -69,6 +74,9 @@ const removeTemplate = (templateId: number): void => {
{{ template.name }} {{ template.slug }} + + {{ template.linked_project_count }} linked {{ template.linked_project_count === 1 ? 'project' : 'projects' }} +