From 2390daf2ded7ff17aaf305e9d5525233301f5ddd Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 5 Jun 2026 20:31:38 +0000 Subject: [PATCH] Move remaining project metadata ownership to templates --- app/Actions/Projects/CreateProject.php | 24 ++-- app/Actions/Tickets/CreateProjectTicket.php | 10 +- .../Requests/Api/MoveProjectTicketRequest.php | 7 +- .../DestroyProjectTicketTypeRequest.php | 7 +- .../Requests/MoveProjectTicketRequest.php | 7 +- .../StoreProjectTicketBranchRequest.php | 16 ++- .../Requests/StoreProjectTicketRequest.php | 15 ++- app/Http/Requests/StoreTicketLinkRequest.php | 7 +- .../Requests/StoreTicketStatusRequest.php | 7 +- .../Requests/UpdateProjectTicketRequest.php | 17 ++- .../UpdateTicketStatusTransitionsRequest.php | 7 +- .../ValidateProjectTicketBranchRequest.php | 16 ++- app/Models/BoardColumn.php | 11 +- .../SyncsProjectTemplateOwnership.php | 41 +++++++ app/Models/NotificationAction.php | 10 ++ app/Models/Project.php | 48 ++++++-- app/Models/RepositoryConnection.php | 10 ++ app/Models/TicketFieldDefinition.php | 11 +- app/Models/TicketLinkType.php | 11 +- app/Models/TicketPriority.php | 11 +- app/Models/TicketStatus.php | 11 +- app/Models/TicketType.php | 11 +- ..._project_metadata_to_project_templates.php | 116 ++++++++++++++++++ tests/Feature/Api/ProjectApiTest.php | 7 +- .../ProjectTemplateBaselineMigrationTest.php | 73 +++++++++++ .../Projects/ProjectControllerTest.php | 7 +- 26 files changed, 473 insertions(+), 45 deletions(-) create mode 100644 app/Models/Concerns/SyncsProjectTemplateOwnership.php create mode 100644 database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php diff --git a/app/Actions/Projects/CreateProject.php b/app/Actions/Projects/CreateProject.php index 98b278f..f0e1134 100644 --- a/app/Actions/Projects/CreateProject.php +++ b/app/Actions/Projects/CreateProject.php @@ -27,6 +27,13 @@ class CreateProject { return DB::transaction(function () use ($attributes, $actor): Project { $project = Project::query()->create(Arr::only($attributes, ['key', 'name'])); + $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); @@ -50,19 +57,14 @@ class CreateProject $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(); - } + $baseTemplate->forceFill([ + 'configuration_snapshot' => $this->duplicateProjectConfiguration->export( + $project->fresh() ?? $project, + DuplicateProjectConfiguration::copyableAreas(), + ), + ])->save(); return $project->fresh() ?? $project; }); 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/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/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/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/Models/BoardColumn.php b/app/Models/BoardColumn.php index 9a5c634..c1fb63b 100644 --- a/app/Models/BoardColumn.php +++ b/app/Models/BoardColumn.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\BoardColumnFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,11 +11,19 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; -#[Fillable(['name', 'position', 'project_id'])] +#[Fillable(['name', 'position', 'project_id', 'project_template_id'])] class BoardColumn extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; + + protected static function booted(): void + { + static::saving(function (self $boardColumn): void { + $boardColumn->syncProjectTemplateOwnership(); + }); + } public function project(): BelongsTo { diff --git a/app/Models/Concerns/SyncsProjectTemplateOwnership.php b/app/Models/Concerns/SyncsProjectTemplateOwnership.php new file mode 100644 index 0000000..cf02f1d --- /dev/null +++ b/app/Models/Concerns/SyncsProjectTemplateOwnership.php @@ -0,0 +1,41 @@ +getAttribute('project_template_id'); + $projectId = $this->getAttribute('project_id'); + + if ($projectTemplateId === null && $projectId !== null) { + $projectTemplateId = Project::query() + ->whereKey($projectId) + ->value('project_template_id'); + + if ($projectTemplateId !== null) { + $this->setAttribute('project_template_id', $projectTemplateId); + } + } + + if ($projectId === null && $projectTemplateId !== null) { + $projectId = ProjectTemplate::query() + ->whereKey($projectTemplateId) + ->value('project_id'); + + if ($projectId !== null) { + $this->setAttribute('project_id', $projectId); + } + } + } + + public function projectTemplate(): BelongsTo + { + return $this->belongsTo(ProjectTemplate::class); + } +} diff --git a/app/Models/NotificationAction.php b/app/Models/NotificationAction.php index e9e6cc2..d2217c9 100644 --- a/app/Models/NotificationAction.php +++ b/app/Models/NotificationAction.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Models\Concerns\SyncsProjectTemplateOwnership; use Database\Factories\NotificationActionFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -23,12 +24,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'message_template', 'name', 'project_id', + 'project_template_id', ])] #[Hidden(['delivery_url'])] class NotificationAction extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; /** * @return array @@ -45,6 +48,13 @@ class NotificationAction extends Model ]; } + protected static function booted(): void + { + static::saving(function (self $notificationAction): void { + $notificationAction->syncProjectTemplateOwnership(); + }); + } + public function scopeEnabled(Builder $query): Builder { return $query->where('enabled', true); diff --git a/app/Models/Project.php b/app/Models/Project.php index ec907cb..043cb8e 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -59,51 +59,83 @@ class Project extends Model public function boardColumns(): HasMany { - return $this->hasMany(BoardColumn::class) + return $this->hasMany( + BoardColumn::class, + $this->project_template_id === null ? 'project_id' : 'project_template_id', + $this->project_template_id === null ? 'id' : 'project_template_id', + ) ->orderBy('position'); } public function ticketFieldDefinitions(): HasMany { - return $this->hasMany(TicketFieldDefinition::class) + return $this->hasMany( + TicketFieldDefinition::class, + $this->project_template_id === null ? 'project_id' : 'project_template_id', + $this->project_template_id === null ? 'id' : 'project_template_id', + ) ->orderBy('position'); } public function ticketPriorities(): HasMany { - return $this->hasMany(TicketPriority::class) + return $this->hasMany( + TicketPriority::class, + $this->project_template_id === null ? 'project_id' : 'project_template_id', + $this->project_template_id === null ? 'id' : 'project_template_id', + ) ->orderBy('position'); } public function ticketLinkTypes(): HasMany { - return $this->hasMany(TicketLinkType::class) + return $this->hasMany( + TicketLinkType::class, + $this->project_template_id === null ? 'project_id' : 'project_template_id', + $this->project_template_id === null ? 'id' : 'project_template_id', + ) ->orderBy('position'); } public function ticketStatuses(): HasMany { - return $this->hasMany(TicketStatus::class) + return $this->hasMany( + TicketStatus::class, + $this->project_template_id === null ? 'project_id' : 'project_template_id', + $this->project_template_id === null ? 'id' : 'project_template_id', + ) ->orderBy('board_column_id') ->orderBy('position'); } public function ticketTypes(): HasMany { - return $this->hasMany(TicketType::class) + return $this->hasMany( + TicketType::class, + $this->project_template_id === null ? 'project_id' : 'project_template_id', + $this->project_template_id === null ? 'id' : 'project_template_id', + ) ->orderBy('position'); } public function repositoryConnections(): HasMany { - return $this->hasMany(RepositoryConnection::class) + return $this->hasMany( + RepositoryConnection::class, + $this->project_template_id === null ? 'project_id' : 'project_template_id', + $this->project_template_id === null ? 'id' : 'project_template_id', + ) ->orderBy('provider') ->orderBy('repository_full_name'); } public function notificationActions(): HasMany { - return $this->hasMany(NotificationAction::class) + return $this->hasMany( + NotificationAction::class, + $this->project_template_id === null ? 'project_id' : 'project_template_id', + $this->project_template_id === null ? 'id' : 'project_template_id', + ) ->orderBy('name') ->orderBy('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/tests/Feature/Api/ProjectApiTest.php b/tests/Feature/Api/ProjectApiTest.php index 4d2ec2e..fd05bcd 100644 --- a/tests/Feature/Api/ProjectApiTest.php +++ b/tests/Feature/Api/ProjectApiTest.php @@ -102,7 +102,12 @@ class ProjectApiTest extends TestCase $project->refresh(); - $this->assertSame($template->id, $project->project_template_id); + $this->assertNotSame($template->id, $project->project_template_id); + $this->assertDatabaseHas('project_templates', [ + 'id' => $project->project_template_id, + 'project_id' => $project->id, + 'is_published' => false, + ]); $this->assertFalse($project->show_author); $this->assertFalse($project->show_story_points); $this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all()); diff --git a/tests/Feature/Database/ProjectTemplateBaselineMigrationTest.php b/tests/Feature/Database/ProjectTemplateBaselineMigrationTest.php index 0722364..dfcdf21 100644 --- a/tests/Feature/Database/ProjectTemplateBaselineMigrationTest.php +++ b/tests/Feature/Database/ProjectTemplateBaselineMigrationTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Database; +use App\Actions\Projects\DuplicateProjectConfiguration; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -70,4 +71,76 @@ class ProjectTemplateBaselineMigrationTest extends TestCase $this->assertSame(0, $template->is_published); $this->assertSame('lgcy-base-template', $template->slug); } + + public function test_metadata_migration_moves_existing_configuration_to_the_project_template_and_preserves_snapshot(): void + { + Artisan::call('migrate:fresh', [ + '--path' => [ + 'database/migrations/0001_01_01_000000_create_users_table.php', + 'database/migrations/0001_01_01_000001_create_cache_table.php', + 'database/migrations/0001_01_01_000002_create_jobs_table.php', + 'database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php', + 'database/migrations/2026_03_24_234007_create_projects_table.php', + 'database/migrations/2026_03_24_234734_create_teams_table.php', + 'database/migrations/2026_03_24_234735_create_team_user_table.php', + 'database/migrations/2026_03_25_002100_create_board_columns_table.php', + 'database/migrations/2026_03_25_003100_create_ticket_types_table.php', + 'database/migrations/2026_03_25_003101_create_ticket_priorities_table.php', + 'database/migrations/2026_03_25_003102_create_ticket_field_definitions_table.php', + 'database/migrations/2026_03_25_004098_create_ticket_statuses_table.php', + 'database/migrations/2026_03_25_004099_create_ticket_statuses_transitionstable.php', + 'database/migrations/2026_03_25_004100_create_tickets_table.php', + 'database/migrations/2026_03_25_065302_create_ticket_worklog_entries_table.php', + 'database/migrations/2026_03_25_070706_create_ticket_field_values_table.php', + 'database/migrations/2026_03_25_073715_create_ticket_links_table.php', + 'database/migrations/2026_03_25_073715_create_ticket_link_types_table.php', + 'database/migrations/2026_03_26_074522_create_user_git_connections_table.php', + 'database/migrations/2026_03_26_075849_create_repository_connections_table.php', + 'database/migrations/2026_04_12_120000_create_permission_assignments_table.php', + 'database/migrations/2026_04_20_174556_create_user_applications_table.php', + 'database/migrations/2026_04_20_194646_create_notification_actions_table.php', + 'database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php', + 'database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php', + 'database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php', + 'database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php', + 'database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php', + 'database/migrations/2026_05_23_101900_add_counterpart_label_to_ticket_link_types_table.php', + 'database/migrations/2026_05_25_011500_create_project_templates_table.php', + 'database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php', + ], + ]); + + DB::table('projects')->insert([ + 'name' => 'Legacy Project', + 'key' => 'LGCY', + 'show_author' => 0, + 'show_story_points' => 1, + 'project_template_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $projectId = (int) DB::table('projects')->where('key', 'LGCY')->value('id'); + + DB::table('ticket_types')->insert([ + 'project_id' => $projectId, + 'name' => 'Bug', + 'position' => 1, + 'is_parent' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Artisan::call('migrate', [ + '--path' => 'database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php', + ]); + + $project = DB::table('projects')->where('id', $projectId)->first(); + $template = DB::table('project_templates')->where('id', $project->project_template_id)->first(); + $ticketType = DB::table('ticket_types')->where('project_id', $projectId)->first(); + $snapshot = json_decode((string) $template->configuration_snapshot, true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame($project->project_template_id, $ticketType->project_template_id); + $this->assertSame(['Bug'], array_column($snapshot[DuplicateProjectConfiguration::COPY_TICKET_TYPES], 'name')); + } } diff --git a/tests/Feature/Projects/ProjectControllerTest.php b/tests/Feature/Projects/ProjectControllerTest.php index cddbbd4..10d0c7b 100644 --- a/tests/Feature/Projects/ProjectControllerTest.php +++ b/tests/Feature/Projects/ProjectControllerTest.php @@ -285,7 +285,12 @@ class ProjectControllerTest extends TestCase $project->refresh(); - $this->assertSame($template->id, $project->project_template_id); + $this->assertNotSame($template->id, $project->project_template_id); + $this->assertDatabaseHas('project_templates', [ + 'id' => $project->project_template_id, + 'project_id' => $project->id, + 'is_published' => false, + ]); $this->assertSame(false, $project->show_author); $this->assertSame(false, $project->show_story_points); $this->assertDatabaseHas('ticket_types', [ -- 2.54.0