diff --git a/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php b/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php index fc3470b..33bd60e 100644 --- a/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php +++ b/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php @@ -3,40 +3,29 @@ namespace App\Actions\ProjectTicketMetadata; use App\Models\Project; -use App\Models\TicketType; class InitializeProjectTicketMetadata { public function handle(Project $project): void { - $project->ticketTypes()->firstOrCreate( - ['system_key' => 'task'], - [ - 'is_builtin' => true, + if (! $project->ticketTypes()->exists()) { + $project->ticketTypes()->create([ 'name' => 'Task', 'position' => 1, - ], - ); + ]); - $project->ticketTypes()->firstOrCreate( - ['system_key' => 'parent'], - [ - 'is_builtin' => true, - 'name' => 'Parent', + $project->ticketTypes()->create([ + 'name' => 'Release', + 'is_parent' => true, 'position' => 2, - ], - ); + ]); + } - $project->ticketTypes() - ->whereIn('system_key', TicketType::BUILTIN_KEYS) - ->orderBy('position') - ->get() - ->values() - ->each(function (TicketType $ticketType, int $index): void { - $ticketType->forceFill([ - 'is_builtin' => true, - 'position' => $index + 1, - ])->saveQuietly(); - }); + if (! $project->ticketPriorities()->exists()) { + $project->ticketPriorities()->create([ + 'name' => 'Medium', + 'position' => 1, + ]); + } } } diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index 383f7f7..7a8ce30 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -5,6 +5,7 @@ namespace App\Actions\Tickets; use App\Models\Project; use App\Models\Ticket; use App\Models\User; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; class CreateProjectTicket @@ -16,7 +17,9 @@ class CreateProjectTicket /** * @param array{ * acceptance_criteria: string|null, + * assignee_user_id?: int|null, * description: string|null, + * story_points?: int|null, * ticket_priority_id: int, * ticket_type_id: int, * title: string @@ -30,15 +33,18 @@ class CreateProjectTicket ->max('sequence_number') + 1; $ticket = Ticket::query()->create([ - 'acceptance_criteria' => $attributes['acceptance_criteria'], + 'acceptance_criteria' => Arr::get($attributes, 'acceptance_criteria'), + 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), + 'author_user_id' => $actor?->id, 'board_column_id' => null, - 'description' => $attributes['description'], + 'description' => Arr::get($attributes, 'description'), 'project_id' => $project->id, 'sequence_number' => $nextSequenceNumber, 'ticket_key' => "{$project->key}-{$nextSequenceNumber}", - 'ticket_priority_id' => $attributes['ticket_priority_id'], - 'ticket_type_id' => $attributes['ticket_type_id'], - 'title' => $attributes['title'], + 'ticket_priority_id' => Arr::get($attributes, 'ticket_priority_id'), + 'ticket_type_id' => Arr::get($attributes, 'ticket_type_id'), + 'title' => Arr::get($attributes, 'title'), + 'story_points' => Arr::get($attributes, 'story_points'), ]); if ($actor !== null) { diff --git a/app/Actions/Tickets/UpdateProjectTicket.php b/app/Actions/Tickets/UpdateProjectTicket.php index df5032e..7f59412 100644 --- a/app/Actions/Tickets/UpdateProjectTicket.php +++ b/app/Actions/Tickets/UpdateProjectTicket.php @@ -18,9 +18,12 @@ class UpdateProjectTicket /** * @param array{ * acceptance_criteria: string|null, + * assignee_user_id: int|null, + * author_user_id: int|null, * blocked_comment: string|null, * description: string|null, * is_blocked: bool, + * story_points: int|null, * ticket_priority_id: int, * ticket_type_id: int, * parent_ticket_id: int|null, @@ -44,6 +47,8 @@ class UpdateProjectTicket } $ticket->loadMissing([ + 'assignee', + 'author', 'fieldValues.ticketFieldDefinition', 'fieldValues.userValue', 'parentTicket', @@ -103,6 +108,14 @@ class UpdateProjectTicket $ticket->blocked_comment, $ticketAttributes['blocked_comment'], ); + $this->trackNumberChange( + $changes, + $summaryLabels, + 'story_points', + 'story points', + $ticket->story_points, + $ticketAttributes['story_points'], + ); if ($ticket->ticket_type_id !== $ticketAttributes['ticket_type_id']) { $changes['ticket_type'] = [ @@ -140,6 +153,26 @@ class UpdateProjectTicket $summaryLabels[] = 'parent ticket'; } + if ($ticket->assignee_user_id !== $ticketAttributes['assignee_user_id']) { + $changes['assignee'] = [ + 'from' => $ticket->assignee?->name, + 'to' => $ticketAttributes['assignee_user_id'] === null + ? null + : User::query()->find($ticketAttributes['assignee_user_id'])?->name, + ]; + $summaryLabels[] = 'assignee'; + } + + if ($ticket->author_user_id !== $ticketAttributes['author_user_id']) { + $changes['author'] = [ + 'from' => $ticket->author?->name, + 'to' => $ticketAttributes['author_user_id'] === null + ? null + : User::query()->find($ticketAttributes['author_user_id'])?->name, + ]; + $summaryLabels[] = 'author'; + } + $ticket->fill($ticketAttributes); if ($ticket->isDirty()) { @@ -217,6 +250,29 @@ class UpdateProjectTicket $summaryLabels[] = $summaryLabel; } + /** + * @param array $changes + * @param array $summaryLabels + */ + private function trackNumberChange( + array &$changes, + array &$summaryLabels, + string $changeKey, + string $summaryLabel, + ?int $currentValue, + ?int $newValue, + ): void { + if ($currentValue === $newValue) { + return; + } + + $changes[$changeKey] = [ + 'from' => $currentValue, + 'to' => $newValue, + ]; + $summaryLabels[] = $summaryLabel; + } + /** * @param array $changes * @param array{value_user_id?: int|string|null} $fieldValueAttribute diff --git a/app/Console/Commands/CreateInitialAdminUser.php b/app/Console/Commands/CreateAdminUser.php similarity index 72% rename from app/Console/Commands/CreateInitialAdminUser.php rename to app/Console/Commands/CreateAdminUser.php index 337a37b..21d56ed 100644 --- a/app/Console/Commands/CreateInitialAdminUser.php +++ b/app/Console/Commands/CreateAdminUser.php @@ -9,9 +9,9 @@ use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; use Illuminate\Support\Facades\Hash; -#[Signature('app:create-initial-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')] +#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')] #[Description('Command description')] -class CreateInitialAdminUser extends Command +class CreateAdminUser extends Command { /** * Execute the console command. @@ -23,12 +23,13 @@ class CreateInitialAdminUser extends Command 'email' => $this->argument('email'), 'password' => Hash::make($this->argument('password')), ]); - $this->info('User created'); $user->permissionAssignments()->create([ 'permission' => Permission::GlobalAdmin, ]); + $this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password'))); + return self::SUCCESS; } } diff --git a/app/Http/Controllers/ProjectBacklogController.php b/app/Http/Controllers/ProjectBacklogController.php index 176b64d..c772e26 100644 --- a/app/Http/Controllers/ProjectBacklogController.php +++ b/app/Http/Controllers/ProjectBacklogController.php @@ -8,6 +8,7 @@ use App\Enums\Permission; use App\Http\Requests\StoreProjectTicketRequest; use App\Models\Project; use App\Models\Ticket; +use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -43,6 +44,9 @@ class ProjectBacklogController extends Controller ->whereNull('board_column_id') ->orderByDesc('sequence_number') ->get(), + 'users' => User::query() + ->orderBy('name') + ->get(), ]); } diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index aa28d0c..2ce6140 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -38,16 +38,34 @@ class ProjectTicketController extends Controller 403, ); + $canEditTicket = $this->permissionService->allows($user, Permission::ProjectWrite, $project); + $canManageProject = $this->permissionService->allows($user, Permission::ProjectAdmin, $project); $excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)]; + $displayTicket = $ticket->load([ + 'assignee', + 'author', + 'fieldValues.ticketFieldDefinition', + 'fieldValues.userValue', + 'parentTicket', + 'ticketPriority', + 'ticketType', + 'worklogEntries.actor', + ]); + + if (! $project->show_author && ! $canManageProject) { + $displayTicket->setRelation('author', null); + $displayTicket->author_user_id = null; + } return Inertia::render('projects/Ticket', [ - 'canEditTicket' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 'canEditTicket' => $canEditTicket, + 'canManageProject' => $canManageProject, 'childTicketTree' => $this->buildChildTicketTree($ticket), 'linkedTickets' => $this->linkedTickets($ticket), 'parentTicketOptions' => Ticket::query() ->where('project_id', $project->id) ->whereNotIn('id', $excludedParentIds) - ->whereHas('ticketType', fn ($query) => $query->where('system_key', 'parent')) + ->whereHas('ticketType', fn ($query) => $query->where('is_parent', true)) ->orderByDesc('sequence_number') ->get() ->map(fn (Ticket $parentTicket): array => [ @@ -68,14 +86,7 @@ class ProjectTicketController extends Controller ), 'status' => session('status'), 'suggestedBranchName' => $this->buildSuggestedTicketBranchName->handle($ticket), - 'ticket' => $ticket->load([ - 'fieldValues.ticketFieldDefinition', - 'fieldValues.userValue', - 'parentTicket', - 'ticketPriority', - 'ticketType', - 'worklogEntries.actor', - ]), + 'ticket' => $displayTicket, 'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(), 'ticketLinkOptions' => Ticket::query() ->where('project_id', $project->id) @@ -109,13 +120,22 @@ class ProjectTicketController extends Controller ); $validated = $request->validated(); - $fieldValues = Arr::get($validated, 'field_values'); + $fieldValues = Arr::get($validated, 'field_values', []); $ticketAttributes = [ 'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'), + 'assignee_user_id' => Arr::exists($validated, 'assignee_user_id') + ? Arr::get($validated, 'assignee_user_id') + : $ticket->assignee_user_id, + 'author_user_id' => Arr::exists($validated, 'author_user_id') + ? Arr::get($validated, 'author_user_id') + : $ticket->author_user_id, 'blocked_comment' => Arr::get($validated, 'blocked_comment'), 'description' => Arr::get($validated, 'description'), 'is_blocked' => Arr::get($validated, 'is_blocked', false), 'parent_ticket_id' => Arr::get($validated, 'parent_ticket_id'), + 'story_points' => Arr::exists($validated, 'story_points') + ? Arr::get($validated, 'story_points') + : $ticket->story_points, 'ticket_priority_id' => Arr::get($validated, 'ticket_priority_id'), 'ticket_type_id' => Arr::get($validated, 'ticket_type_id'), 'title' => Arr::get($validated, 'title'), diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php index 4913d8c..b563820 100644 --- a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -3,13 +3,18 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; -use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; +use App\Actions\Tickets\CreateTicketWorklogEntry; use App\Enums\Permission; +use App\Http\Requests\DestroyProjectTicketTypeRequest; use App\Http\Requests\StoreProjectNamedSettingRequest; +use App\Http\Requests\StoreProjectTicketTypeRequest; use App\Http\Requests\StoreTicketFieldDefinitionRequest; use App\Http\Requests\UpdateProjectNamedSettingRequest; +use App\Http\Requests\UpdateProjectTicketMetadataSettings; +use App\Http\Requests\UpdateProjectTicketTypeRequest; use App\Http\Requests\UpdateTicketFieldDefinitionRequest; use App\Models\Project; +use App\Models\Ticket; use App\Models\TicketFieldDefinition; use App\Models\TicketLinkType; use App\Models\TicketPriority; @@ -26,14 +31,13 @@ use Inertia\Response; class ProjectTicketMetadataSettingsController extends Controller { public function __construct( - private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, + private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, private readonly PermissionService $permissionService, ) {} public function show(Request $request, Project $project): Response { $this->abortUnlessCanManage($request, $project); - $this->initializeProjectTicketMetadata->handle($project); return Inertia::render('projects/settings/Metadata', [ 'fieldTypeOptions' => collect(TicketFieldDefinition::FIELD_TYPES) @@ -43,35 +47,47 @@ class ProjectTicketMetadataSettingsController extends Controller ]) ->all(), 'project' => $project, + 'error' => session('error'), 'status' => session('status'), 'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(), 'ticketLinkTypes' => $project->ticketLinkTypes()->get(), 'ticketPriorities' => $project->ticketPriorities()->get(), - 'ticketTypes' => $project->ticketTypes()->get(), + 'ticketTypes' => $project->ticketTypes()->withCount('tickets')->get(), ]); } - public function storeType(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse + public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $this->initializeProjectTicketMetadata->handle($project); $validated = $request->validated(); - $name = Arr::get($validated, 'name'); - $position = Arr::get($validated, 'position'); - $ticketType = DB::transaction(function () use ($project, $name, $position): TicketType { + $project->update([ + 'show_author' => Arr::get($validated, 'show_author', false), + 'show_story_points' => Arr::get($validated, 'show_story_points', false), + ]); + + return to_route('projects.settings.metadata', $project) + ->with('status', 'Settings have been updated.'); + } + + public function storeType(StoreProjectTicketTypeRequest $request, Project $project): RedirectResponse + { + $this->abortUnlessCanManage($request, $project); + + $validated = $request->validated(); + + $ticketType = DB::transaction(function () use ($project, $validated): TicketType { $ticketType = $project->ticketTypes()->create([ - 'is_builtin' => false, - 'name' => $name, + 'name' => Arr::get($validated, 'name'), 'position' => $project->ticketTypes()->count() + 1, - 'system_key' => null, + 'is_parent' => Arr::get($validated, 'is_parent', false), ]); $this->reorderModels( $project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(), $ticketType, - $position ?? $project->ticketTypes()->count(), + Arr::get($validated, 'position') ?? $project->ticketTypes()->count(), ); return $ticketType; @@ -82,26 +98,23 @@ class ProjectTicketMetadataSettingsController extends Controller } public function updateType( - UpdateProjectNamedSettingRequest $request, + UpdateProjectTicketTypeRequest $request, Project $project, TicketType $ticketType, ): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $this->initializeProjectTicketMetadata->handle($project); $validated = $request->validated(); - $name = Arr::get($validated, 'name'); - $position = Arr::get($validated, 'position'); - DB::transaction(function () use ($project, $name, $position, $ticketType): void { + DB::transaction(function () use ($project, $validated, $ticketType): void { $ticketType->update([ - 'name' => $name, + 'name' => Arr::get($validated, 'name'), ]); $this->reorderModels( $project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(), $ticketType, - $position, + Arr::get($validated, 'position'), ); }); @@ -109,15 +122,54 @@ class ProjectTicketMetadataSettingsController extends Controller ->with('status', "{$ticketType->fresh()->name} type was updated."); } - public function destroyType(Request $request, Project $project, TicketType $ticketType): RedirectResponse - { + public function destroyType( + DestroyProjectTicketTypeRequest $request, + Project $project, + TicketType $ticketType, + ): RedirectResponse { $this->abortUnlessCanManage($request, $project); - abort_if($ticketType->is_builtin, 403); + if ($project->ticketTypes()->count() === 1) { + return to_route('projects.settings.metadata', $project) + ->with('error', true) + ->with('status', 'The last ticket type cannot be deleted.'); + } $deletedName = $ticketType->name; + $validated = $request->validated(); + + DB::transaction(function () use ($deletedName, $project, $request, $ticketType, $validated): void { + $replacementTicketTypeId = Arr::get($validated, 'replacement_ticket_type_id'); + + if ($replacementTicketTypeId !== null) { + $replacementTicketType = $project->ticketTypes() + ->findOrFail($replacementTicketTypeId); + + Ticket::query() + ->where('project_id', $project->id) + ->where('ticket_type_id', $ticketType->id) + ->with(['ticketType']) + ->get() + ->each(function (Ticket $ticket) use ($deletedName, $replacementTicketType, $request): void { + $ticket->update([ + 'ticket_type_id' => $replacementTicketType->id, + ]); + + $this->createTicketWorklogEntry->handle( + $ticket->fresh(), + $request->user(), + 'updated', + "Updated type because {$deletedName} type was deleted.", + [ + 'ticket_type' => [ + 'from' => $deletedName, + 'to' => $replacementTicketType->name, + ], + ], + ); + }); + } - DB::transaction(function () use ($project, $ticketType): void { $ticketType->delete(); $this->persistOrderedModels($project->ticketTypes()->get()->values()); @@ -188,6 +240,12 @@ class ProjectTicketMetadataSettingsController extends Controller ): RedirectResponse { $this->abortUnlessCanManage($request, $project); + if ($project->ticketPriorities()->count() === 1) { + return to_route('projects.settings.metadata', $project) + ->with('error', true) + ->with('status', 'The last ticket priority cannot be deleted.'); + } + $deletedName = $ticketPriority->name; DB::transaction(function () use ($project, $ticketPriority): void { @@ -207,21 +265,18 @@ class ProjectTicketMetadataSettingsController extends Controller $this->abortUnlessCanManage($request, $project); $validated = $request->validated(); - $label = Arr::get($validated, 'label'); - $fieldType = Arr::get($validated, 'field_type'); - $position = Arr::get($validated, 'position'); - $ticketFieldDefinition = DB::transaction(function () use ($project, $fieldType, $label): TicketFieldDefinition { + $ticketFieldDefinition = DB::transaction(function () use ($project, $validated): TicketFieldDefinition { $ticketFieldDefinition = $project->ticketFieldDefinitions()->create([ - 'field_type' => $fieldType, - 'label' => $label, + 'field_type' => Arr::get($validated, 'field_type'), + 'label' => Arr::get($validated, 'label'), 'position' => $project->ticketFieldDefinitions()->count() + 1, ]); $this->reorderModels( $project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(), $ticketFieldDefinition, - $position ?? $project->ticketFieldDefinitions()->count(), + Arr::get($validated, 'position') ?? $project->ticketFieldDefinitions()->count(), ); return $ticketFieldDefinition; @@ -239,20 +294,17 @@ class ProjectTicketMetadataSettingsController extends Controller $this->abortUnlessCanManage($request, $project); $validated = $request->validated(); - $label = Arr::get($validated, 'label'); - $fieldType = Arr::get($validated, 'field_type'); - $position = Arr::get($validated, 'position'); - DB::transaction(function () use ($project, $fieldType, $label, $ticketFieldDefinition, $position): void { + DB::transaction(function () use ($project, $ticketFieldDefinition, $validated): void { $ticketFieldDefinition->update([ - 'field_type' => $fieldType, - 'label' => $label, + 'field_type' => Arr::get($validated, 'field_type'), + 'label' => Arr::get($validated, 'label'), ]); $this->reorderModels( $project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(), $ticketFieldDefinition, - $position, + Arr::get($validated, 'position'), ); }); diff --git a/app/Http/Requests/DestroyProjectTicketTypeRequest.php b/app/Http/Requests/DestroyProjectTicketTypeRequest.php new file mode 100644 index 0000000..97bc396 --- /dev/null +++ b/app/Http/Requests/DestroyProjectTicketTypeRequest.php @@ -0,0 +1,101 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + /** @var Project $project */ + $project = $this->route('project'); + + return [ + 'replacement_ticket_type_id' => [ + 'nullable', + 'integer', + Rule::exists('ticket_types', 'id')->where('project_id', $project->id), + ], + ]; + } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + /** @var Project $project */ + $project = $this->route('project'); + + /** @var TicketType $ticketType */ + $ticketType = $this->route('ticketType'); + + $hasReplacementOptions = $project->ticketTypes() + ->whereKeyNot($ticketType->id) + ->exists(); + + if (! $hasReplacementOptions || ! $ticketType->tickets()->exists()) { + return; + } + + /** @var array $validated */ + $validated = $this->safe()->all(); + $replacementTicketTypeId = Arr::get($validated, 'replacement_ticket_type_id'); + + if ($replacementTicketTypeId === null) { + $validator->errors()->add( + 'replacement_ticket_type_id', + 'Choose a replacement type before deleting a type that is assigned to tickets.', + ); + + return; + } + + if ((int) $replacementTicketTypeId === $ticketType->id) { + $validator->errors()->add( + 'replacement_ticket_type_id', + 'The replacement type must be different from the deleted type.', + ); + + return; + } + + $replacementTicketType = $project->ticketTypes() + ->find($replacementTicketTypeId); + + if ( + $replacementTicketType !== null + && ! $replacementTicketType->is_parent + && $ticketType->tickets()->has('childTickets')->exists() + ) { + $validator->errors()->add( + 'replacement_ticket_type_id', + 'Choose a parent-enabled replacement type because affected tickets already have child tickets.', + ); + } + }, + ]; + } +} diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php index b45df86..5a0072c 100644 --- a/app/Http/Requests/StoreProjectTicketRequest.php +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -29,6 +29,7 @@ class StoreProjectTicketRequest extends FormRequest return [ 'acceptance_criteria' => ['nullable', 'string'], + 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'description' => ['nullable', 'string'], 'ticket_priority_id' => [ 'required', @@ -40,6 +41,7 @@ class StoreProjectTicketRequest extends FormRequest 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id), ], + 'story_points' => ['nullable', 'integer', 'min:0'], 'title' => ['required', 'string', 'max:255'], ]; } diff --git a/app/Http/Requests/StoreProjectTicketTypeRequest.php b/app/Http/Requests/StoreProjectTicketTypeRequest.php new file mode 100644 index 0000000..2d8f8fd --- /dev/null +++ b/app/Http/Requests/StoreProjectTicketTypeRequest.php @@ -0,0 +1,31 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'position' => ['nullable', 'integer', 'min:1'], + 'is_parent' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/UpdateProjectTicketMetadataSettings.php b/app/Http/Requests/UpdateProjectTicketMetadataSettings.php new file mode 100644 index 0000000..669a3be --- /dev/null +++ b/app/Http/Requests/UpdateProjectTicketMetadataSettings.php @@ -0,0 +1,30 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'show_author' => ['nullable', 'boolean'], + 'show_story_points' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php index 1072471..fe705ff 100644 --- a/app/Http/Requests/UpdateProjectTicketRequest.php +++ b/app/Http/Requests/UpdateProjectTicketRequest.php @@ -2,6 +2,8 @@ namespace App\Http\Requests; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use App\Models\Project; use App\Models\Ticket; use App\Models\TicketType; @@ -33,6 +35,8 @@ class UpdateProjectTicketRequest extends FormRequest return [ 'acceptance_criteria' => ['nullable', 'string'], + 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], + 'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'blocked_comment' => ['nullable', 'string'], 'description' => ['nullable', 'string'], 'field_values' => ['array'], @@ -61,6 +65,7 @@ class UpdateProjectTicketRequest extends FormRequest Rule::exists('ticket_types', 'id')->where('project_id', $project->id), ], 'title' => ['required', 'string', 'max:255'], + 'story_points' => ['nullable', 'integer', 'min:0'], ]; } @@ -72,7 +77,7 @@ class UpdateProjectTicketRequest extends FormRequest return [ function (Validator $validator): void { /** @var array $validated */ - $validated = $this->validated(); + $validated = $this->safe()->all(); /** @var Project $project */ $project = $this->route('project'); @@ -80,6 +85,9 @@ class UpdateProjectTicketRequest extends FormRequest /** @var Ticket $ticket */ $ticket = $this->route('ticket'); + $canManageProject = app(PermissionService::class) + ->allows($this->user(), Permission::ProjectAdmin, $project); + $parentTicketId = Arr::get($validated, 'parent_ticket_id'); if ($parentTicketId !== null) { @@ -94,8 +102,8 @@ class UpdateProjectTicketRequest extends FormRequest ->with('ticketType') ->find($parentTicketId); - if ($parentTicket !== null && $parentTicket->ticketType?->system_key !== 'parent') { - $validator->errors()->add('parent_ticket_id', 'Only tickets of type Parent can be selected as a parent.'); + if ($parentTicket !== null && ! $parentTicket->ticketType?->is_parent) { + $validator->errors()->add('parent_ticket_id', 'Only tickets with a parent-enabled type can be selected as a parent.'); return; } @@ -113,15 +121,23 @@ class UpdateProjectTicketRequest extends FormRequest ->find($requestedTicketTypeId); if ( - $requestedTicketType?->system_key !== 'parent' + ! $requestedTicketType?->is_parent && $ticket->childTickets()->exists() ) { - $validator->errors()->add('ticket_type_id', 'Tickets with child tickets must keep a Parent type.'); + $validator->errors()->add('ticket_type_id', 'Tickets with child tickets must keep a parent-enabled type.'); } if (Arr::get($validated, 'is_blocked', false) && blank(Arr::get($validated, 'blocked_comment'))) { $validator->errors()->add('blocked_comment', 'Blocked tickets require a blocker comment.'); } + + if ( + Arr::exists($validated, 'author_user_id') + && (int) Arr::get($validated, 'author_user_id') !== (int) $ticket->author_user_id + && ! $canManageProject + ) { + $validator->errors()->add('author_user_id', 'Only project admins can change the author.'); + } }, ]; } diff --git a/app/Http/Requests/UpdateProjectTicketTypeRequest.php b/app/Http/Requests/UpdateProjectTicketTypeRequest.php new file mode 100644 index 0000000..d8fceb3 --- /dev/null +++ b/app/Http/Requests/UpdateProjectTicketTypeRequest.php @@ -0,0 +1,30 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'position' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index b7fec0d..38dbd9e 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -11,12 +11,20 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Str; -#[Fillable(['name', 'key'])] +#[Fillable(['name', 'key', 'show_author', 'show_story_points'])] class Project extends Model { /** @use HasFactory */ use HasFactory; + protected function casts(): array + { + return [ + 'show_author' => 'boolean', + 'show_story_points' => 'boolean', + ]; + } + protected static function booted(): void { static::deleting(function (self $project): void { diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 2d23953..ca3de17 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'acceptance_criteria', + 'assignee_user_id', + 'author_user_id', 'board_column_id', 'blocked_comment', 'description', @@ -22,6 +24,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'ticket_priority_id', 'ticket_type_id', 'title', + 'story_points', ])] class Ticket extends Model { @@ -43,6 +46,16 @@ class Ticket extends Model return $this->belongsTo(BoardColumn::class); } + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assignee_user_id'); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_user_id'); + } + public function childTickets(): HasMany { return $this->hasMany(Ticket::class, 'parent_ticket_id') diff --git a/app/Models/TicketType.php b/app/Models/TicketType.php index 6021023..22cff08 100644 --- a/app/Models/TicketType.php +++ b/app/Models/TicketType.php @@ -7,23 +7,28 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['is_builtin', 'name', 'position', 'project_id', 'system_key'])] +#[Fillable(['name', 'position', 'project_id', 'is_parent'])] class TicketType extends Model { - /** - * @var array - */ - public const BUILTIN_KEYS = [ - 'task', - 'parent', - ]; - /** @use HasFactory */ use HasFactory; + protected function casts(): array + { + return [ + 'is_parent' => 'boolean', + ]; + } + public function project(): BelongsTo { return $this->belongsTo(Project::class); } + + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 8b0576f..ef51f81 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -59,4 +59,14 @@ class User extends Authenticatable ->orderBy('provider') ->orderBy('base_url'); } + + public function assignedTickets(): HasMany + { + return $this->hasMany(Ticket::class, 'assignee_user_id'); + } + + public function authoredTickets(): HasMany + { + return $this->hasMany(Ticket::class, 'author_user_id'); + } } diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index 11c4246..9d0ea0e 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -21,6 +21,8 @@ class ProjectFactory extends Factory return [ 'name' => Str::title(fake()->unique()->words(asText: true)), 'key' => Str::upper(fake()->unique()->lexify('?????')), + 'show_author' => true, + 'show_story_points' => true, ]; } } diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index 9f11f69..bbfaba6 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -5,7 +5,7 @@ namespace Database\Factories; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Models\Project; use App\Models\Ticket; -use App\Models\TicketPriority; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -22,12 +22,15 @@ class TicketFactory extends Factory { return [ 'acceptance_criteria' => fake()->paragraph(), + 'assignee_user_id' => null, + 'author_user_id' => User::factory(), 'board_column_id' => null, 'blocked_comment' => null, 'description' => fake()->paragraphs(2, true), 'is_blocked' => false, 'parent_ticket_id' => null, 'sequence_number' => 1, + 'story_points' => null, 'ticket_key' => 'TEST-1', 'title' => fake()->sentence(4), ]; @@ -46,14 +49,9 @@ class TicketFactory extends Factory app(InitializeProjectTicketMetadata::class)->handle($project); - $priority = TicketPriority::factory()->for($project)->create([ - 'name' => 'Medium', - 'position' => 1, - ]); - $ticket->project_id = $project->id; $ticket->ticket_type_id = $project->ticketTypes()->firstOrFail()->id; - $ticket->ticket_priority_id = $priority->id; + $ticket->ticket_priority_id = $project->ticketPriorities()->firstOrFail()->id; $ticket->sequence_number = 1; $ticket->ticket_key = "{$project->key}-1"; }); diff --git a/database/factories/TicketFieldValueFactory.php b/database/factories/TicketFieldValueFactory.php index 4cf0cfc..3b2a265 100644 --- a/database/factories/TicketFieldValueFactory.php +++ b/database/factories/TicketFieldValueFactory.php @@ -39,7 +39,7 @@ class TicketFieldValueFactory extends Factory $ticketFieldDefinition = $ticketFieldValue->ticket_field_definition_id === null ? TicketFieldDefinition::factory()->for($ticket->project)->create([ 'field_type' => 'number', - 'label' => 'Story Points', + 'label' => 'Custom Number', 'position' => $ticket->project->ticketFieldDefinitions()->count() + 1, ]) : TicketFieldDefinition::query()->findOrFail($ticketFieldValue->ticket_field_definition_id); diff --git a/database/factories/TicketTypeFactory.php b/database/factories/TicketTypeFactory.php index 9c96b98..fff3e0d 100644 --- a/database/factories/TicketTypeFactory.php +++ b/database/factories/TicketTypeFactory.php @@ -19,11 +19,10 @@ class TicketTypeFactory extends Factory public function definition(): array { return [ - 'is_builtin' => false, + 'is_parent' => false, 'name' => fake()->unique()->jobTitle(), 'position' => 1, 'project_id' => Project::factory(), - 'system_key' => null, ]; } } diff --git a/database/migrations/2026_03_24_234007_create_projects_table.php b/database/migrations/2026_03_24_234007_create_projects_table.php index fe5a66e..f098bf1 100644 --- a/database/migrations/2026_03_24_234007_create_projects_table.php +++ b/database/migrations/2026_03_24_234007_create_projects_table.php @@ -15,6 +15,8 @@ return new class extends Migration $table->id(); $table->string('name'); $table->string('key', 5)->unique(); + $table->boolean('show_author')->default(true); + $table->boolean('show_story_points')->default(true); $table->timestamps(); }); } diff --git a/database/migrations/2026_03_25_003100_create_ticket_types_table.php b/database/migrations/2026_03_25_003100_create_ticket_types_table.php index 938050b..39eaf30 100644 --- a/database/migrations/2026_03_25_003100_create_ticket_types_table.php +++ b/database/migrations/2026_03_25_003100_create_ticket_types_table.php @@ -15,13 +15,11 @@ return new class extends Migration $table->id(); $table->foreignId('project_id')->constrained()->cascadeOnDelete(); $table->string('name'); - $table->string('system_key')->nullable(); - $table->boolean('is_builtin')->default(false); + $table->boolean('is_parent')->default(false); $table->unsignedInteger('position'); $table->timestamps(); $table->unique(['project_id', 'position']); - $table->unique(['project_id', 'system_key']); }); } diff --git a/database/migrations/2026_03_25_004100_create_tickets_table.php b/database/migrations/2026_03_25_004100_create_tickets_table.php index 46da13f..332f192 100644 --- a/database/migrations/2026_03_25_004100_create_tickets_table.php +++ b/database/migrations/2026_03_25_004100_create_tickets_table.php @@ -16,12 +16,15 @@ return new class extends Migration $table->foreignId('project_id')->constrained()->cascadeOnDelete(); $table->foreignId('ticket_type_id')->constrained()->cascadeOnDelete(); $table->foreignId('ticket_priority_id')->constrained()->cascadeOnDelete(); + $table->foreignId('assignee_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('author_user_id')->nullable()->constrained('users')->nullOnDelete(); $table->foreignId('board_column_id')->nullable()->constrained()->nullOnDelete(); $table->unsignedInteger('sequence_number'); $table->string('ticket_key')->unique(); $table->string('title'); $table->text('description')->nullable(); $table->text('acceptance_criteria')->nullable(); + $table->unsignedInteger('story_points')->nullable()->default(null); $table->timestamps(); $table->unique(['project_id', 'sequence_number']); diff --git a/database/seeders/ProjectTicketMetadataSeeder.php b/database/seeders/ProjectTicketMetadataSeeder.php index 45c6c26..ef29311 100644 --- a/database/seeders/ProjectTicketMetadataSeeder.php +++ b/database/seeders/ProjectTicketMetadataSeeder.php @@ -5,8 +5,6 @@ namespace Database\Seeders; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Models\Project; use App\Models\TicketFieldDefinition; -use App\Models\TicketPriority; -use App\Models\TicketType; use Illuminate\Database\Seeder; class ProjectTicketMetadataSeeder extends Seeder @@ -24,32 +22,30 @@ class ProjectTicketMetadataSeeder extends Seeder ->each(function (Project $project) use ($initializeProjectTicketMetadata): void { $initializeProjectTicketMetadata->handle($project); - TicketType::factory() - ->for($project) - ->create([ - 'name' => 'Bug', + $project->ticketTypes()->updateOrCreate( + ['name' => 'Bug'], + [ + 'is_parent' => false, 'position' => 3, - 'system_key' => null, - ]); + ], + ); - TicketPriority::factory() - ->count(3) - ->sequence( - ['name' => 'Low', 'position' => 1], - ['name' => 'Medium', 'position' => 2], - ['name' => 'High', 'position' => 3], - ) - ->for($project) - ->create(); + $project->ticketPriorities()->updateOrCreate( + ['name' => 'Medium'], + ['position' => 2], + ); + $project->ticketPriorities()->updateOrCreate( + ['name' => 'Low'], + ['position' => 1], + ); + $project->ticketPriorities()->updateOrCreate( + ['name' => 'High'], + ['position' => 3], + ); TicketFieldDefinition::factory() - ->count(2) - ->sequence( - ['label' => 'Assignee', 'field_type' => 'user', 'position' => 1], - ['label' => 'Story Points', 'field_type' => 'number', 'position' => 2], - ) ->for($project) - ->create(); + ->create(['label' => 'Driver', 'field_type' => 'user', 'position' => 1]); }); } } diff --git a/database/seeders/TicketSeeder.php b/database/seeders/TicketSeeder.php index d24e23a..fd8688d 100644 --- a/database/seeders/TicketSeeder.php +++ b/database/seeders/TicketSeeder.php @@ -6,6 +6,7 @@ use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\Tickets\CreateProjectTicket; use App\Models\Project; use App\Models\TicketPriority; +use App\Models\User; use Illuminate\Database\Seeder; class TicketSeeder extends Seeder @@ -17,11 +18,13 @@ class TicketSeeder extends Seeder { $createProjectTicket = app(CreateProjectTicket::class); $initializeProjectTicketMetadata = app(InitializeProjectTicketMetadata::class); + $author = User::query()->orderBy('id')->first(); + $assignee = User::query()->orderBy('id')->skip(1)->first() ?? $author; Project::query() ->orderBy('id') ->get() - ->each(function (Project $project) use ($createProjectTicket, $initializeProjectTicketMetadata): void { + ->each(function (Project $project) use ($assignee, $author, $createProjectTicket, $initializeProjectTicketMetadata): void { $initializeProjectTicketMetadata->handle($project); $priority = $project->ticketPriorities()->first(); @@ -37,19 +40,23 @@ class TicketSeeder extends Seeder $createProjectTicket->handle($project, [ 'acceptance_criteria' => 'The seeded ticket should be visible in the backlog list.', + 'assignee_user_id' => $assignee?->id, 'description' => 'Seeded backlog item for demonstrating the first ticket workflow.', + 'story_points' => 3, 'ticket_priority_id' => $priority->id, 'ticket_type_id' => $defaultType->id, 'title' => 'Seed backlog item', - ]); + ], $author); $createProjectTicket->handle($project, [ 'acceptance_criteria' => 'Duplicate titles must be allowed for project-local ticket keys.', + 'assignee_user_id' => null, 'description' => 'Second seeded backlog item using the same title on purpose.', + 'story_points' => 5, 'ticket_priority_id' => $priority->id, 'ticket_type_id' => $defaultType->id, 'title' => 'Seed backlog item', - ]); + ], $author); }); } } diff --git a/database/seeders/TicketWorklogEntrySeeder.php b/database/seeders/TicketWorklogEntrySeeder.php index 21fe5c7..6bb6afc 100644 --- a/database/seeders/TicketWorklogEntrySeeder.php +++ b/database/seeders/TicketWorklogEntrySeeder.php @@ -17,6 +17,7 @@ class TicketWorklogEntrySeeder extends Seeder $defaultActor = User::query()->orderBy('id')->first(); Ticket::query() + ->whereDoesntHave('worklogEntries') ->orderBy('id') ->get() ->each(function (Ticket $ticket) use ($defaultActor): void { diff --git a/docs/missing-features.md b/docs/missing-features.md index 712fb28..a987da1 100644 --- a/docs/missing-features.md +++ b/docs/missing-features.md @@ -8,19 +8,7 @@ - app/Http/Requests/UpdateProjectTicketRequest.php and other ProjectTicket only to Ticket... - mehrere actions/quereys in services auslagern -- 05 - - ticket type remove build in - - → add is parentBool to ticketType -> remove system_key (maybe add slug) - - auto generate one parent one task on new project as now - - disable delete last child ticket type - - initializeProjectTicketMetadata nur beim init benutzen - - every form request removed unnecessary methods: like position in StoreProjectNamedSettingRequest - - use Arr:get(\$validated, 'value'). instead of these messages or \$request->value - - Made ticket priority more static(change to has one) in ticket, but only show if there are more than one configured. - - made story points more static(change to table field in table), but only show if checked in project settings → new value on Project: hasStoryPoints - - more ticket field types (string, string array, boolean, precision (double)) - - abortUnlessCanManage(xxx) entfernen, auch gate benutzen (ProjectTicketMetadataSettingsController) - - ticket assignee fest einbauen in ticket + - 06 - add Ticket Status - ticketstatus can have one or more board columns @@ -33,13 +21,12 @@ - 09 - app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null + - later: - - if deleting ticket type select a new ticket type which all existing ticket should get or select delete all tickets of that type - repository_connections check base Url maybe unnessecary? - Git listBranches replace baseUrl, owner, rperoName with GItReprository - Git prs replace baseUrl, owner, rperoName with GItReprository - - git php docu replace @ reutrn with data models - replace Kanbangit with env App Name -- 08 - - worklog überarbeiten → type als worklog enum → json je nach enum type - - UpdateProjectTicket aufräumen + - 08 + - worklog überarbeiten → type als worklog enum → json je nach enum type + - UpdateProjectTicket aufräumen diff --git a/resources/js/components/ui/input/Input.vue b/resources/js/components/ui/input/Input.vue index e5135c1..e6486c7 100644 --- a/resources/js/components/ui/input/Input.vue +++ b/resources/js/components/ui/input/Input.vue @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils" const props = defineProps<{ defaultValue?: string | number - modelValue?: string | number + modelValue?: string | number | null class?: HTMLAttributes["class"] }>() diff --git a/resources/js/pages/projects/Backlog.vue b/resources/js/pages/projects/Backlog.vue index 8453b1c..699adc4 100644 --- a/resources/js/pages/projects/Backlog.vue +++ b/resources/js/pages/projects/Backlog.vue @@ -107,74 +107,123 @@ const hasTicketMetadata = -
-
- - - -
+
+
+
+ +