fix(KAN-14): preserve template inheritance and project data
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Concerns;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Enums\NotificationFilterAttribute;
|
||||
use App\Enums\NotificationFilterOperator;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
trait ValidatesProjectTemplateConfiguration
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
protected function configurationRules(): array
|
||||
{
|
||||
$rules = ['configuration_snapshot' => ['sometimes', 'array:'.implode(',', DuplicateProjectConfiguration::copyableAreas())]];
|
||||
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
|
||||
$path = 'configuration_snapshot.'.$area;
|
||||
$rules[$path] = ['sometimes', 'array', 'max:200'];
|
||||
$rules[$path.'.*.configuration_key'] = ['required', 'uuid', 'distinct:strict'];
|
||||
}
|
||||
$rules['configuration_snapshot.project_settings'] = ['sometimes', 'array:show_author,show_story_points'];
|
||||
foreach (['show_author', 'show_story_points'] as $field) {
|
||||
$rules['configuration_snapshot.project_settings.'.$field] = ['required_with:configuration_snapshot.project_settings', 'boolean'];
|
||||
}
|
||||
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities'] as $area) {
|
||||
$rules["configuration_snapshot.{$area}.*.name"] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_fields', 'ticket_link_types'] as $area) {
|
||||
$rules["configuration_snapshot.{$area}.*.position"] = ['required', 'integer', 'min:1', 'max:10000'];
|
||||
if ($area !== 'ticket_statuses') {
|
||||
$rules["configuration_snapshot.{$area}.*.position"][] = 'distinct:strict';
|
||||
}
|
||||
}
|
||||
$fields = [
|
||||
'board_columns' => 'configuration_key,name,position',
|
||||
'ticket_statuses' => 'configuration_key,name,position,board_column_key',
|
||||
'ticket_types' => 'configuration_key,name,position,is_parent',
|
||||
'ticket_priorities' => 'configuration_key,name,position',
|
||||
'ticket_fields' => 'configuration_key,label,position,field_type',
|
||||
'ticket_link_types' => 'configuration_key,label,counterpart_label,position',
|
||||
'repository_connections' => 'configuration_key,provider,base_url,provider_repository_id,owner_name,repository_name,repository_full_name,repository_url',
|
||||
'notification_actions' => 'configuration_key,name,enabled,event_type,channel,channel_configuration,message_template,delivery_url,filters',
|
||||
];
|
||||
foreach ($fields as $area => $allowed) {
|
||||
$rules["configuration_snapshot.{$area}.*"] = ['array:'.$allowed];
|
||||
}
|
||||
$rules['configuration_snapshot.ticket_statuses.*.board_column_key'] = ['nullable', 'uuid'];
|
||||
$rules['configuration_snapshot.ticket_types.*.is_parent'] = ['required', 'boolean'];
|
||||
$rules['configuration_snapshot.ticket_fields.*.field_type'] = ['required', Rule::in(['number', 'user'])];
|
||||
$rules['configuration_snapshot.ticket_fields.*.label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.ticket_link_types.*.label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.ticket_link_types.*.counterpart_label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.status_transitions'] = ['sometimes', 'array', 'max:200'];
|
||||
$rules['configuration_snapshot.status_transitions.*'] = ['array:status_key,target_keys'];
|
||||
$rules['configuration_snapshot.status_transitions.*.status_key'] = ['required', 'uuid', 'distinct:strict'];
|
||||
$rules['configuration_snapshot.status_transitions.*.target_keys'] = ['present', 'array', 'max:200'];
|
||||
$rules['configuration_snapshot.status_transitions.*.target_keys.*'] = ['uuid'];
|
||||
foreach (['provider_repository_id', 'owner_name', 'repository_name', 'repository_full_name'] as $field) {
|
||||
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
$rules['configuration_snapshot.repository_connections.*.provider'] = ['required', Rule::in(['gitea'])];
|
||||
foreach (['base_url', 'repository_url'] as $field) {
|
||||
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'url:http,https', 'max:255'];
|
||||
}
|
||||
$prefix = 'configuration_snapshot.notification_actions.*.';
|
||||
$rules[$prefix.'name'] = ['required', 'string', 'max:255'];
|
||||
$rules[$prefix.'enabled'] = ['required', 'boolean'];
|
||||
$rules[$prefix.'event_type'] = ['required', Rule::in(NotificationEventType::values())];
|
||||
$rules[$prefix.'channel'] = ['required', Rule::in(NotificationChannel::values())];
|
||||
$rules[$prefix.'delivery_url'] = ['required', 'url:http,https', 'max:2048'];
|
||||
$rules[$prefix.'message_template'] = ['nullable', 'string', 'max:2000'];
|
||||
$rules[$prefix.'channel_configuration'] = ['sometimes', 'array:allowed_mentions'];
|
||||
$rules[$prefix.'channel_configuration.allowed_mentions'] = ['sometimes', 'array'];
|
||||
$rules[$prefix.'channel_configuration.allowed_mentions.*'] = [Rule::in(['everyone', 'roles', 'users'])];
|
||||
$rules[$prefix.'filters'] = ['sometimes', 'array', 'max:25'];
|
||||
$rules[$prefix.'filters.*'] = ['array:attribute,operator,value'];
|
||||
$rules[$prefix.'filters.*.attribute'] = ['required', Rule::in(NotificationFilterAttribute::values())];
|
||||
$rules[$prefix.'filters.*.operator'] = ['required', Rule::in(NotificationFilterOperator::values())];
|
||||
$rules[$prefix.'filters.*.value'] = ['nullable'];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/** @return array<int, callable(Validator): void> */
|
||||
public function after(): array
|
||||
{
|
||||
return [function (Validator $validator): void {
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
$validated = $validator->validated();
|
||||
$configuration = Arr::get($validated, 'configuration_snapshot', []);
|
||||
$areas = Arr::get($validated, 'copy_areas', []);
|
||||
$columns = array_column(Arr::get($configuration, 'board_columns', []), 'configuration_key');
|
||||
$statuses = array_column(Arr::get($configuration, 'ticket_statuses', []), 'configuration_key');
|
||||
foreach (Arr::get($configuration, 'ticket_statuses', []) as $index => $status) {
|
||||
$key = Arr::get($status, 'board_column_key');
|
||||
if ($key !== null && in_array('board_columns', $areas, true) && ! in_array($key, $columns, true)) {
|
||||
$validator->errors()->add("configuration_snapshot.ticket_statuses.{$index}.board_column_key", 'Choose a column from this template.');
|
||||
}
|
||||
}
|
||||
foreach (Arr::get($configuration, 'status_transitions', []) as $index => $transition) {
|
||||
if (! in_array(Arr::get($transition, 'status_key'), $statuses, true)
|
||||
|| array_diff(Arr::get($transition, 'target_keys', []), $statuses) !== []) {
|
||||
$validator->errors()->add("configuration_snapshot.status_transitions.{$index}", 'Choose statuses from this template.');
|
||||
}
|
||||
if (in_array('status_transitions', $areas, true) && ! in_array('ticket_statuses', $areas, true)
|
||||
&& Arr::get($transition, 'target_keys', []) !== []) {
|
||||
$validator->errors()->add('copy_areas', 'Include ticket statuses when defining template transitions.');
|
||||
}
|
||||
}
|
||||
$repositoryIdentifiers = [];
|
||||
foreach (Arr::get($configuration, 'repository_connections', []) as $index => $connection) {
|
||||
$identifier = serialize([$connection['provider'], $connection['base_url'], $connection['provider_repository_id']]);
|
||||
if (isset($repositoryIdentifiers[$identifier])) {
|
||||
$validator->errors()->add("configuration_snapshot.repository_connections.{$index}.provider_repository_id", 'This repository is already included in the template.');
|
||||
}
|
||||
$repositoryIdentifiers[$identifier] = true;
|
||||
}
|
||||
foreach (Arr::get($configuration, 'notification_actions', []) as $index => $action) {
|
||||
if (Arr::get($action, 'channel') === 'discord' && blank(Arr::get($action, 'message_template'))) {
|
||||
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.message_template", 'A Discord message is required.');
|
||||
}
|
||||
foreach (Arr::get($action, 'filters', []) as $filterIndex => $filter) {
|
||||
$operator = Arr::get($filter, 'operator');
|
||||
$value = Arr::get($filter, 'value');
|
||||
$valid = is_string($value) || $value === null || (is_array($value) && collect($value)->every(static fn ($item): bool => is_string($item)));
|
||||
if (! $valid || (in_array($operator, ['equals', 'not_equals', 'in'], true) && blank($value))
|
||||
|| (in_array($operator, ['exists', 'not_exists'], true) && filled($value))) {
|
||||
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.filters.{$filterIndex}.value", 'Choose a value that matches the filter operator.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,7 @@ class ResetProjectTemplateRequest extends FormRequest
|
||||
Rule::in([$project->key]),
|
||||
],
|
||||
'status_replacements' => ['sometimes', 'array'],
|
||||
'status_replacements.*' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
],
|
||||
'status_replacements.*' => ['required'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -54,40 +50,34 @@ class ResetProjectTemplateRequest extends FormRequest
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$validated = $validator->validated();
|
||||
$areas = array_values(Arr::get(
|
||||
$validated,
|
||||
'areas',
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
));
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []);
|
||||
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||
foreach ($areas as $area) {
|
||||
if (! array_key_exists($area, $configuration)) {
|
||||
$validator->errors()->add('areas', 'This template does not define the selected settings area.');
|
||||
}
|
||||
}
|
||||
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');
|
||||
$templateKeys = array_column($configuration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [], 'configuration_key');
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
|
||||
$allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all();
|
||||
$replacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
foreach ($statuses->slice(count($templateStatuses)) as $status) {
|
||||
if ($status->tickets_count === 0) {
|
||||
foreach ($replacements as $id => $replacement) {
|
||||
if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement)
|
||||
|| ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
|
||||
$validator->errors()->add("status_replacements.{$id}", 'Choose a replacement from this template for a status in this project.');
|
||||
}
|
||||
}
|
||||
foreach ($statuses as $status) {
|
||||
if (in_array($status->configuration_key, $templateKeys, true) || $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}.",
|
||||
);
|
||||
$replacement = Arr::get($replacements, (string) $status->id);
|
||||
if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
|
||||
$validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}.");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,7 +12,8 @@ class StoreProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('create-project') ?? false;
|
||||
return ($this->user()?->can('create-project') ?? false)
|
||||
&& ($this->route('project') === null || $this->user()->can('admin-project', $this->route('project')));
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
@@ -29,10 +30,14 @@ class StoreProjectTemplateRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$project = $this->route('project');
|
||||
$baseTemplate = $project?->projectTemplate;
|
||||
$publishingBase = $baseTemplate !== null && ! $baseTemplate->is_published;
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')->ignore($publishingBase ? $baseTemplate->id : null), Rule::when($publishingBase, Rule::in([$baseTemplate?->slug]))],
|
||||
'copy_areas' => ['present', 'array'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
|
||||
|
||||
class StoreStandaloneProjectTemplateRequest extends StoreProjectTemplateRequest
|
||||
{
|
||||
use ValidatesProjectTemplateConfiguration;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [...parent::rules(), ...$this->configurationRules()];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -10,6 +11,8 @@ use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
use ValidatesProjectTemplateConfiguration;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('create-project') ?? false;
|
||||
@@ -25,8 +28,9 @@ class UpdateProjectTemplateRequest extends FormRequest
|
||||
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
|
||||
|
||||
return [
|
||||
...$this->configurationRules(),
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'copy_areas' => ['present', 'array'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
'confirm_linked_projects_update' => [
|
||||
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||
|
||||
Reference in New Issue
Block a user