fix: make project template inheritance granular and safe
CI/CD / test_and_check_app (push) Failing after 3m8s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Failing after 3m8s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
Nico Schröder
2026-09-08 10:25:52 +00:00
committed by Twyco
parent fed6a282a4
commit 9895fc36db
50 changed files with 1504 additions and 624 deletions
+20 -5
View File
@@ -37,6 +37,7 @@ class ProjectController extends Controller
private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload,
private readonly CreateProject $createProject,
private readonly DeleteProject $deleteProject,
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly ListVisibleProjects $listVisibleProjects,
private readonly PermissionService $permissionService,
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
@@ -135,9 +136,10 @@ class ProjectController extends Controller
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
{
$validated = $request->validated();
$template = ProjectTemplate::query()
->published()
->findOrFail($request->integer('source_template_id'));
->findOrFail((int) Arr::get($validated, 'source_template_id'));
$this->updateProjectTemplateLink->switch($project, $template);
@@ -147,16 +149,29 @@ class ProjectController extends Controller
public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse
{
$request->validated();
$validated = $request->validated();
$template = $project->projectTemplate;
abort_if($template === null, 422, 'This project has no linked template to reset.');
$this->updateProjectTemplateLink->reset($project->load('projectTemplate'));
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
);
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
$statusReplacements = Arr::get($validated, 'status_replacements', []);
return to_route('projects.settings.general', $project)
->with('status', "{$project->name} was reset to the {$template->name} template baseline.");
$this->updateProjectTemplateLink->reset(
$project->load('projectTemplate'),
$areas,
$statusReplacements,
);
$status = Arr::has($validated, 'areas')
? "Selected settings were reset to the {$template->name} template."
: "{$project->name} was reset to the {$template->name} template baseline.";
return back()->with('status', $status);
}
public function access(Request $request, Project $project): Response
@@ -24,16 +24,13 @@ 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(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
@@ -29,16 +29,13 @@ 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(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
@@ -29,16 +29,13 @@ 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(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
@@ -2,10 +2,13 @@
namespace App\Http\Requests;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class ResetProjectTemplateRequest extends FormRequest
{
@@ -23,11 +26,71 @@ class ResetProjectTemplateRequest extends FormRequest
$project = $this->route('project');
return [
'areas' => ['sometimes', 'array', 'min:1'],
'areas.*' => ['required', 'string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
'confirmation_key' => [
'required',
'required_without:areas',
'string',
Rule::in([$project->key]),
],
'status_replacements' => ['sometimes', 'array'],
'status_replacements.*' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
],
];
}
/** @return array<int, callable(Validator): void> */
public function after(): array
{
return [
function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
/** @var Project $project */
$project = $this->route('project');
$validated = $validator->validated();
$areas = array_values(Arr::get(
$validated,
'areas',
DuplicateProjectConfiguration::copyableAreas(),
));
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
return;
}
$project->loadMissing('projectTemplate');
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
$project->projectTemplate?->configuration_snapshot ?? [],
);
$templateStatuses = Arr::get(
$configuration,
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
[],
);
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
$replacements = Arr::get($validated, 'status_replacements', []);
foreach ($statuses->slice(count($templateStatuses)) as $status) {
if ($status->tickets_count === 0) {
continue;
}
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
if (! $allowedReplacementIds->contains($replacementId)) {
$validator->errors()->add(
"status_replacements.{$status->id}",
"Choose a replacement status for {$status->name}.",
);
}
}
},
];
}
@@ -38,6 +101,7 @@ class ResetProjectTemplateRequest extends FormRequest
{
return [
'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.',
'areas.*.in' => 'Choose a valid template area to reset.',
];
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ class StoreProjectRequest extends FormRequest
'copy_areas' => ['nullable', 'array'],
'copy_areas.*' => [
'string',
Rule::in(DuplicateProjectConfiguration::copyableAreas()),
Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas()),
],
];
}
@@ -33,7 +33,7 @@ class StoreProjectTemplateRequest extends FormRequest
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
];
}
}
@@ -26,9 +26,6 @@ class StoreProjectTicketBranchRequest 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 [
'branch_name' => ['required', 'string', 'max:255'],
@@ -36,7 +33,7 @@ class StoreProjectTicketBranchRequest extends FormRequest
'required',
'integer',
Rule::exists('repository_connections', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
@@ -48,9 +48,6 @@ 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'],
@@ -70,21 +67,21 @@ class StoreProjectTicketRequest extends FormRequest
'required',
'integer',
Rule::exists('ticket_priorities', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'ticket_status_id' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'ticket_type_id' => [
'required',
'integer',
Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'story_points' => ['nullable', 'integer', 'min:0'],
+1 -4
View File
@@ -28,9 +28,6 @@ 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' => [
@@ -42,7 +39,7 @@ class StoreTicketLinkRequest extends FormRequest
'required',
'integer',
Rule::exists('ticket_link_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
@@ -26,16 +26,13 @@ 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(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'name' => ['required', 'string', 'max:255'],
@@ -27,7 +27,7 @@ class UpdateProjectTemplateRequest extends FormRequest
return [
'name' => ['required', 'string', 'max:255'],
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [
Rule::requiredIf($requiresImpactConfirmation),
'accepted',
@@ -51,9 +51,6 @@ 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'],
@@ -72,7 +69,7 @@ class UpdateProjectTicketRequest extends FormRequest
'required',
'integer',
Rule::exists('ticket_field_definitions', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'field_values.*.value_number' => ['nullable', 'numeric'],
@@ -87,14 +84,14 @@ class UpdateProjectTicketRequest extends FormRequest
'required',
'integer',
Rule::exists('ticket_priorities', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'ticket_type_id' => [
'required',
'integer',
Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'title' => ['required', 'string', 'max:255'],
@@ -150,7 +147,7 @@ class UpdateProjectTicketRequest extends FormRequest
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
$requestedTicketType = TicketType::query()
->where($project->project_template_id === null ? 'project_id' : 'project_template_id', $project->project_template_id ?? $project->id)
->where('project_id', $project->id)
->find($requestedTicketTypeId);
if (
@@ -29,16 +29,13 @@ 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(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
@@ -26,9 +26,6 @@ class ValidateProjectTicketBranchRequest 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 [
'branch_name' => ['required', 'string', 'max:255'],
@@ -36,7 +33,7 @@ class ValidateProjectTicketBranchRequest extends FormRequest
'required',
'integer',
Rule::exists('repository_connections', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
+1
View File
@@ -37,6 +37,7 @@ class ProjectResource extends JsonResource
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []),
'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []),
'reset_requirements' => Arr::get($templateState, 'resetRequirements', []),
'template_configuration' => Arr::get($templateState, 'templateConfiguration', []),
'template_id' => Arr::get($templateState, 'templateId'),
'template_name' => Arr::get($templateState, 'templateName'),
@@ -2,6 +2,7 @@
namespace App\Http\Resources;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\ProjectTemplate;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -17,6 +18,8 @@ class ProjectTemplateResource extends JsonResource
public function toArray(Request $request): array
{
if ($this->resource instanceof ProjectTemplate) {
$configuration = app(DuplicateProjectConfiguration::class);
return [
'id' => $this->id,
'project_id' => $this->project_id,
@@ -27,8 +30,10 @@ class ProjectTemplateResource extends JsonResource
'name' => $this->name,
'slug' => $this->slug,
'is_published' => $this->is_published,
'copy_areas' => $this->copy_areas ?? [],
'configuration_snapshot' => $this->configuration_snapshot ?? [],
'copy_areas' => $configuration->normalizeCopyAreas($this->copy_areas ?? []),
'configuration_snapshot' => $configuration->normalizeConfiguration(
$this->configuration_snapshot ?? [],
),
'linked_project_count' => $this->whenCounted('linkedProjects'),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),