From f7a0543ea15a95da57311aec3619b04454d08189 Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 25 Mar 2026 01:33:35 +0100 Subject: [PATCH 1/2] Implement ticket creation and backlog --- app/Actions/Tickets/CreateProjectTicket.php | 40 +++ .../Controllers/ProjectBacklogController.php | 56 ++++ .../Requests/StoreProjectTicketRequest.php | 46 +++ app/Models/Project.php | 6 + app/Models/Ticket.php | 46 +++ database/factories/TicketFactory.php | 58 ++++ ...2026_03_25_004100_create_tickets_table.php | 38 +++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/TicketSeeder.php | 55 ++++ resources/js/pages/projects/Backlog.vue | 265 ++++++++++++++++++ resources/js/pages/projects/Show.vue | 15 +- resources/js/types/models/ticket-model.ts | 20 ++ resources/js/types/page-props.ts | 10 + routes/web.php | 5 + tests/Feature/Models/ProjectTest.php | 20 ++ tests/Feature/Models/TicketTest.php | 58 ++++ .../Projects/ProjectBacklogControllerTest.php | 137 +++++++++ 17 files changed, 873 insertions(+), 3 deletions(-) create mode 100644 app/Actions/Tickets/CreateProjectTicket.php create mode 100644 app/Http/Controllers/ProjectBacklogController.php create mode 100644 app/Http/Requests/StoreProjectTicketRequest.php create mode 100644 app/Models/Ticket.php create mode 100644 database/factories/TicketFactory.php create mode 100644 database/migrations/2026_03_25_004100_create_tickets_table.php create mode 100644 database/seeders/TicketSeeder.php create mode 100644 resources/js/pages/projects/Backlog.vue create mode 100644 resources/js/types/models/ticket-model.ts create mode 100644 tests/Feature/Models/TicketTest.php create mode 100644 tests/Feature/Projects/ProjectBacklogControllerTest.php diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php new file mode 100644 index 0000000..395fa49 --- /dev/null +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -0,0 +1,40 @@ +where('project_id', $project->id) + ->max('sequence_number') + 1; + + return Ticket::query()->create([ + 'acceptance_criteria' => $attributes['acceptance_criteria'], + 'board_column_id' => null, + 'description' => $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'], + ]); + }); + } +} diff --git a/app/Http/Controllers/ProjectBacklogController.php b/app/Http/Controllers/ProjectBacklogController.php new file mode 100644 index 0000000..2322318 --- /dev/null +++ b/app/Http/Controllers/ProjectBacklogController.php @@ -0,0 +1,56 @@ +projectPermissionService->userCanViewProject($request->user(), $project), + 403, + ); + + return Inertia::render('projects/Backlog', [ + 'canCreateTickets' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'create'), + 'project' => $project, + 'status' => session('status'), + 'ticketPriorities' => $project->ticketPriorities()->get(), + 'ticketTypes' => $project->ticketTypes()->get(), + 'tickets' => Ticket::query() + ->with(['ticketPriority', 'ticketType']) + ->where('project_id', $project->id) + ->whereNull('board_column_id') + ->orderByDesc('sequence_number') + ->get(), + ]); + } + + public function store(StoreProjectTicketRequest $request, Project $project): RedirectResponse + { + abort_unless( + $this->projectPermissionService->userHasPermission($request->user(), $project, 'create'), + 403, + ); + + $ticket = $this->createProjectTicket->handle($project, $request->validated()); + + return to_route('projects.backlog.index', $project) + ->with('status', "{$ticket->ticket_key} was added to the backlog."); + } +} diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php new file mode 100644 index 0000000..b45df86 --- /dev/null +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -0,0 +1,46 @@ +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 [ + 'acceptance_criteria' => ['nullable', 'string'], + 'description' => ['nullable', 'string'], + 'ticket_priority_id' => [ + 'required', + 'integer', + Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id), + ], + 'ticket_type_id' => [ + 'required', + 'integer', + Rule::exists('ticket_types', 'id')->where('project_id', $project->id), + ], + 'title' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index e71e301..d4fb8e2 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -46,4 +46,10 @@ class Project extends Model return $this->hasMany(TicketType::class) ->orderBy('position'); } + + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class) + ->orderByDesc('sequence_number'); + } } diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php new file mode 100644 index 0000000..9453cc3 --- /dev/null +++ b/app/Models/Ticket.php @@ -0,0 +1,46 @@ + */ + use HasFactory; + + public function boardColumn(): BelongsTo + { + return $this->belongsTo(BoardColumn::class); + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function ticketPriority(): BelongsTo + { + return $this->belongsTo(TicketPriority::class); + } + + public function ticketType(): BelongsTo + { + return $this->belongsTo(TicketType::class); + } +} diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php new file mode 100644 index 0000000..8d8789a --- /dev/null +++ b/database/factories/TicketFactory.php @@ -0,0 +1,58 @@ + + */ +class TicketFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'acceptance_criteria' => fake()->paragraph(), + 'board_column_id' => null, + 'description' => fake()->paragraphs(2, true), + 'sequence_number' => 1, + 'ticket_key' => 'TEST-1', + 'title' => fake()->sentence(4), + ]; + } + + public function configure(): static + { + return $this->afterMaking(function (Ticket $ticket): void { + if ($ticket->project_id !== null && $ticket->ticket_type_id !== null && $ticket->ticket_priority_id !== null) { + return; + } + + $project = Project::factory()->create([ + 'key' => fake()->unique()->lexify('????'), + ]); + + 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->sequence_number = 1; + $ticket->ticket_key = "{$project->key}-1"; + }); + } +} diff --git a/database/migrations/2026_03_25_004100_create_tickets_table.php b/database/migrations/2026_03_25_004100_create_tickets_table.php new file mode 100644 index 0000000..46da13f --- /dev/null +++ b/database/migrations/2026_03_25_004100_create_tickets_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->foreignId('ticket_type_id')->constrained()->cascadeOnDelete(); + $table->foreignId('ticket_priority_id')->constrained()->cascadeOnDelete(); + $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->timestamps(); + + $table->unique(['project_id', 'sequence_number']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tickets'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 725e6bf..5db2851 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,6 +17,7 @@ class DatabaseSeeder extends Seeder ProjectSeeder::class, BoardColumnSeeder::class, ProjectTicketMetadataSeeder::class, + TicketSeeder::class, TeamSeeder::class, ProjectAccessSeeder::class, ]); diff --git a/database/seeders/TicketSeeder.php b/database/seeders/TicketSeeder.php new file mode 100644 index 0000000..d24e23a --- /dev/null +++ b/database/seeders/TicketSeeder.php @@ -0,0 +1,55 @@ +orderBy('id') + ->get() + ->each(function (Project $project) use ($createProjectTicket, $initializeProjectTicketMetadata): void { + $initializeProjectTicketMetadata->handle($project); + + $priority = $project->ticketPriorities()->first(); + + if ($priority === null) { + $priority = TicketPriority::factory()->for($project)->create([ + 'name' => 'Medium', + 'position' => 1, + ]); + } + + $defaultType = $project->ticketTypes()->firstOrFail(); + + $createProjectTicket->handle($project, [ + 'acceptance_criteria' => 'The seeded ticket should be visible in the backlog list.', + 'description' => 'Seeded backlog item for demonstrating the first ticket workflow.', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $defaultType->id, + 'title' => 'Seed backlog item', + ]); + + $createProjectTicket->handle($project, [ + 'acceptance_criteria' => 'Duplicate titles must be allowed for project-local ticket keys.', + 'description' => 'Second seeded backlog item using the same title on purpose.', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $defaultType->id, + 'title' => 'Seed backlog item', + ]); + }); + } +} diff --git a/resources/js/pages/projects/Backlog.vue b/resources/js/pages/projects/Backlog.vue new file mode 100644 index 0000000..9787e14 --- /dev/null +++ b/resources/js/pages/projects/Backlog.vue @@ -0,0 +1,265 @@ + + +