diff --git a/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php b/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php index 9f29d33..33bd60e 100644 --- a/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php +++ b/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php @@ -8,26 +8,24 @@ class InitializeProjectTicketMetadata { public function handle(Project $project): void { - $project->ticketTypes()->create( - [ + if (! $project->ticketTypes()->exists()) { + $project->ticketTypes()->create([ 'name' => 'Task', 'position' => 1, - ], - ); + ]); - $project->ticketTypes()->create( - [ + $project->ticketTypes()->create([ 'name' => 'Release', 'is_parent' => true, 'position' => 2, - ], - ); + ]); + } - $project->ticketPriorities()->create( - [ + 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 5b73bd0..7a8ce30 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -5,7 +5,7 @@ namespace App\Actions\Tickets; use App\Models\Project; use App\Models\Ticket; use App\Models\User; -use Arr; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; class CreateProjectTicket @@ -17,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 @@ -31,15 +33,17 @@ 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'), ]); 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/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 70ac994..2ce6140 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -38,10 +38,28 @@ 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() @@ -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,17 +120,25 @@ 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'), - 'story_points' => Arr::get($validated, 'story_points'), ]; $wasUpdated = $this->updateProjectTicket->handle( diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php index 8db2172..b563820 100644 --- a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -3,14 +3,18 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; +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; @@ -27,6 +31,7 @@ use Inertia\Response; class ProjectTicketMetadataSettingsController extends Controller { public function __construct( + private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, private readonly PermissionService $permissionService, ) {} @@ -47,15 +52,20 @@ class ProjectTicketMetadataSettingsController extends Controller 'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(), 'ticketLinkTypes' => $project->ticketLinkTypes()->get(), 'ticketPriorities' => $project->ticketPriorities()->get(), - 'ticketTypes' => $project->ticketTypes()->get(), + 'ticketTypes' => $project->ticketTypes()->withCount('tickets')->get(), ]); } public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse { + $this->abortUnlessCanManage($request, $project); + $validated = $request->validated(); - $project->update(['show_story_points' => Arr::get($validated, 'show_story_points', false)]); + $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.'); @@ -88,7 +98,7 @@ class ProjectTicketMetadataSettingsController extends Controller } public function updateType( - UpdateProjectNamedSettingRequest $request, + UpdateProjectTicketTypeRequest $request, Project $project, TicketType $ticketType, ): RedirectResponse { @@ -99,7 +109,6 @@ class ProjectTicketMetadataSettingsController extends Controller DB::transaction(function () use ($project, $validated, $ticketType): void { $ticketType->update([ 'name' => Arr::get($validated, 'name'), - 'is_parent' => Arr::get($validated, 'is_parent', false), ]); $this->reorderModels( @@ -113,8 +122,11 @@ 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); if ($project->ticketTypes()->count() === 1) { @@ -124,8 +136,40 @@ class ProjectTicketMetadataSettingsController extends Controller } $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()); 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 bd46303..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', diff --git a/app/Http/Requests/UpdateProjectNamedSettingRequest.php b/app/Http/Requests/UpdateProjectNamedSettingRequest.php index 4cbed99..cb3f60c 100644 --- a/app/Http/Requests/UpdateProjectNamedSettingRequest.php +++ b/app/Http/Requests/UpdateProjectNamedSettingRequest.php @@ -25,7 +25,6 @@ class UpdateProjectNamedSettingRequest extends FormRequest return [ 'name' => ['required', 'string', 'max:255'], 'position' => ['required', 'integer', 'min:1'], - 'is_parent' => ['nullable', 'boolean'], ]; } } diff --git a/app/Http/Requests/UpdateProjectTicketMetadataSettings.php b/app/Http/Requests/UpdateProjectTicketMetadataSettings.php index 227f399..669a3be 100644 --- a/app/Http/Requests/UpdateProjectTicketMetadataSettings.php +++ b/app/Http/Requests/UpdateProjectTicketMetadataSettings.php @@ -23,7 +23,8 @@ class UpdateProjectTicketMetadataSettings extends FormRequest public function rules(): array { return [ - 'show_story_points' => 'nullable|boolean', + 'show_author' => ['nullable', 'boolean'], + 'show_story_points' => ['nullable', 'boolean'], ]; } } diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php index 8acc8e0..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,7 +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', 'gt:0'], + 'story_points' => ['nullable', 'integer', 'min:0'], ]; } @@ -73,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'); @@ -81,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) { @@ -95,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; } @@ -114,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 3afe27b..38dbd9e 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Str; -#[Fillable(['name', 'key', 'show_story_points'])] +#[Fillable(['name', 'key', 'show_author', 'show_story_points'])] class Project extends Model { /** @use HasFactory */ @@ -20,6 +20,7 @@ class Project extends Model protected function casts(): array { return [ + 'show_author' => 'boolean', 'show_story_points' => 'boolean', ]; } diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 4a4fa29..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', @@ -44,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 cb6d401..22cff08 100644 --- a/app/Models/TicketType.php +++ b/app/Models/TicketType.php @@ -7,6 +7,7 @@ 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(['name', 'position', 'project_id', 'is_parent'])] class TicketType extends Model @@ -25,4 +26,9 @@ class TicketType extends Model { 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 52e763a..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,7 @@ 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_004100_create_tickets_table.php b/database/migrations/2026_03_25_004100_create_tickets_table.php index aa4c293..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,6 +16,8 @@ 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(); diff --git a/database/seeders/ProjectTicketMetadataSeeder.php b/database/seeders/ProjectTicketMetadataSeeder.php index 45c6c26..a448128 100644 --- a/database/seeders/ProjectTicketMetadataSeeder.php +++ b/database/seeders/ProjectTicketMetadataSeeder.php @@ -4,9 +4,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 +21,26 @@ 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(); - - TicketFieldDefinition::factory() - ->count(2) - ->sequence( - ['label' => 'Assignee', 'field_type' => 'user', 'position' => 1], - ['label' => 'Story Points', 'field_type' => 'number', 'position' => 2], - ) - ->for($project) - ->create(); + $project->ticketPriorities()->updateOrCreate( + ['name' => 'Medium'], + ['position' => 2], + ); + $project->ticketPriorities()->updateOrCreate( + ['name' => 'Low'], + ['position' => 1], + ); + $project->ticketPriorities()->updateOrCreate( + ['name' => 'High'], + ['position' => 3], + ); }); } } 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/resources/js/pages/projects/Backlog.vue b/resources/js/pages/projects/Backlog.vue index b3b90ad..699adc4 100644 --- a/resources/js/pages/projects/Backlog.vue +++ b/resources/js/pages/projects/Backlog.vue @@ -182,6 +182,29 @@ const hasTicketMetadata = /> +
+ + + +
+
(() => const canCreateBranches = computed( () => props.canEditTicket && linkedRepositoryConnections.value.length > 0, ); +const canEditAuthor = computed( + () => props.canEditTicket && props.canManageProject, +); +const canViewAuthor = computed( + () => props.project.show_author || props.canManageProject, +); const defaultBranchRepositoryConnectionId = computed(() => String( @@ -97,6 +103,8 @@ const breadcrumbs: BreadcrumbItem[] = [ const form = useForm({ acceptance_criteria: props.ticket.acceptance_criteria ?? '', + assignee_user_id: props.ticket.assignee_user_id ?? '', + author_user_id: props.ticket.author_user_id ?? '', blocked_comment: props.ticket.blocked_comment ?? '', description: props.ticket.description ?? '', field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => { @@ -461,6 +469,58 @@ const formatDateTime = (value: string): string => {
+
+
+ + + +
+ +
+ + + +
+
+
+ props.ticketTypes.filter((ticketType) => ticketType.id !== ticketTypeId);