fix: make project template inheritance granular and safe
This commit is contained in:
@@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user