diff --git a/app/Actions/Tickets/CreateTicketWorklogEntry.php b/app/Actions/Tickets/CreateTicketWorklogEntry.php new file mode 100644 index 0000000..8690f51 --- /dev/null +++ b/app/Actions/Tickets/CreateTicketWorklogEntry.php @@ -0,0 +1,28 @@ +|null $changes + */ + public function handle( + Ticket $ticket, + ?User $actor, + string $entryType, + string $summary, + ?array $changes = null, + ): TicketWorklogEntry { + return $ticket->worklogEntries()->create([ + 'actor_id' => $actor?->id, + 'changes' => $changes, + 'entry_type' => $entryType, + 'summary' => $summary, + ]); + } +} diff --git a/app/Http/Controllers/ProjectBacklogController.php b/app/Http/Controllers/ProjectBacklogController.php index 2322318..ae4869c 100644 --- a/app/Http/Controllers/ProjectBacklogController.php +++ b/app/Http/Controllers/ProjectBacklogController.php @@ -27,6 +27,8 @@ class ProjectBacklogController extends Controller ); return Inertia::render('projects/Backlog', [ + 'boardColumns' => $project->boardColumns()->get(), + 'canEditTickets' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'), 'canCreateTickets' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'create'), 'project' => $project, 'status' => session('status'), diff --git a/app/Http/Controllers/ProjectBoardController.php b/app/Http/Controllers/ProjectBoardController.php new file mode 100644 index 0000000..ca5a15b --- /dev/null +++ b/app/Http/Controllers/ProjectBoardController.php @@ -0,0 +1,88 @@ +projectPermissionService->userCanViewProject($request->user(), $project), + 403, + ); + + return Inertia::render('projects/Board', [ + 'boardColumns' => $project->boardColumns() + ->with([ + 'tickets' => fn ($query) => $query + ->with(['ticketPriority', 'ticketType']), + ]) + ->get(), + 'canEditTickets' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'), + 'project' => $project, + 'status' => session('status'), + ]); + } + + public function move( + MoveProjectTicketRequest $request, + Project $project, + Ticket $ticket, + ): RedirectResponse { + abort_unless( + $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'), + 403, + ); + + $destinationColumn = $project->boardColumns() + ->find($request->boardColumnId()); + + $originBoardColumnId = $ticket->board_column_id; + $originLabel = $ticket->boardColumn?->name ?? 'Backlog'; + $destinationLabel = $destinationColumn?->name ?? 'Backlog'; + + if ($ticket->board_column_id === $request->boardColumnId()) { + return to_route($request->redirectRouteName(), $project) + ->with('status', "{$ticket->ticket_key} already belongs to {$destinationLabel}."); + } + + $ticket->update([ + 'board_column_id' => $destinationColumn?->id, + ]); + + $this->createTicketWorklogEntry->handle( + $ticket->fresh(), + $request->user(), + 'status_changed', + "Moved from {$originLabel} to {$destinationLabel}.", + [ + 'from' => [ + 'board_column_id' => $originBoardColumnId, + 'label' => $originLabel, + ], + 'to' => [ + 'board_column_id' => $destinationColumn?->id, + 'label' => $destinationLabel, + ], + ], + ); + + return to_route($request->redirectRouteName(), $project) + ->with('status', "{$ticket->ticket_key} moved to {$destinationLabel}."); + } +} diff --git a/app/Http/Requests/MoveProjectTicketRequest.php b/app/Http/Requests/MoveProjectTicketRequest.php new file mode 100644 index 0000000..9aebb17 --- /dev/null +++ b/app/Http/Requests/MoveProjectTicketRequest.php @@ -0,0 +1,57 @@ +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 [ + 'board_column_id' => [ + 'nullable', + 'integer', + Rule::exists('board_columns', 'id')->where('project_id', $project->id), + ], + 'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])], + ]; + } + + public function boardColumnId(): ?int + { + $boardColumnId = $this->validated('board_column_id'); + + if ($boardColumnId === null || $boardColumnId === '') { + return null; + } + + return (int) $boardColumnId; + } + + public function redirectRouteName(): string + { + return $this->validated('redirect_to') === 'backlog' + ? 'projects.backlog.index' + : 'projects.board.index'; + } +} diff --git a/app/Models/BoardColumn.php b/app/Models/BoardColumn.php index 218d4c1..6598bdd 100644 --- a/app/Models/BoardColumn.php +++ b/app/Models/BoardColumn.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'])] class BoardColumn extends Model @@ -18,4 +19,10 @@ class BoardColumn extends Model { return $this->belongsTo(Project::class); } + + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class) + ->orderByDesc('sequence_number'); + } } diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 9453cc3..fe43817 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.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([ 'acceptance_criteria', @@ -43,4 +44,11 @@ class Ticket extends Model { return $this->belongsTo(TicketType::class); } + + public function worklogEntries(): HasMany + { + return $this->hasMany(TicketWorklogEntry::class) + ->orderByDesc('created_at') + ->orderByDesc('id'); + } } diff --git a/app/Models/TicketWorklogEntry.php b/app/Models/TicketWorklogEntry.php new file mode 100644 index 0000000..bc4a03d --- /dev/null +++ b/app/Models/TicketWorklogEntry.php @@ -0,0 +1,42 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'changes' => 'array', + ]; + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } +} diff --git a/database/factories/TicketWorklogEntryFactory.php b/database/factories/TicketWorklogEntryFactory.php new file mode 100644 index 0000000..093c732 --- /dev/null +++ b/database/factories/TicketWorklogEntryFactory.php @@ -0,0 +1,33 @@ + + */ +class TicketWorklogEntryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'actor_id' => User::factory(), + 'changes' => [ + 'from' => 'Backlog', + 'to' => 'In Progress', + ], + 'entry_type' => 'status_changed', + 'summary' => 'Moved from Backlog to In Progress.', + 'ticket_id' => Ticket::factory(), + ]; + } +} diff --git a/database/migrations/2026_03_25_065302_create_ticket_worklog_entries_table.php b/database/migrations/2026_03_25_065302_create_ticket_worklog_entries_table.php new file mode 100644 index 0000000..d7bb5a5 --- /dev/null +++ b/database/migrations/2026_03_25_065302_create_ticket_worklog_entries_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('ticket_id')->constrained()->cascadeOnDelete(); + $table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('entry_type'); + $table->text('summary'); + $table->json('changes')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_worklog_entries'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 5db2851..adc152e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,6 +18,7 @@ class DatabaseSeeder extends Seeder BoardColumnSeeder::class, ProjectTicketMetadataSeeder::class, TicketSeeder::class, + TicketWorklogEntrySeeder::class, TeamSeeder::class, ProjectAccessSeeder::class, ]); diff --git a/database/seeders/TicketWorklogEntrySeeder.php b/database/seeders/TicketWorklogEntrySeeder.php new file mode 100644 index 0000000..21fe5c7 --- /dev/null +++ b/database/seeders/TicketWorklogEntrySeeder.php @@ -0,0 +1,35 @@ +orderBy('id')->first(); + + Ticket::query() + ->orderBy('id') + ->get() + ->each(function (Ticket $ticket) use ($defaultActor): void { + TicketWorklogEntry::factory()->create([ + 'actor_id' => $defaultActor?->id, + 'changes' => [ + 'from' => null, + 'to' => 'Backlog', + ], + 'entry_type' => 'created', + 'summary' => 'Ticket was created in Backlog.', + 'ticket_id' => $ticket->id, + ]); + }); + } +} diff --git a/resources/js/pages/projects/Backlog.vue b/resources/js/pages/projects/Backlog.vue index 9787e14..9b5637c 100644 --- a/resources/js/pages/projects/Backlog.vue +++ b/resources/js/pages/projects/Backlog.vue @@ -1,6 +1,7 @@ + + diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue index 1bcb19e..15452a1 100644 --- a/resources/js/pages/projects/Show.vue +++ b/resources/js/pages/projects/Show.vue @@ -13,6 +13,7 @@ import { } from '@/components/ui/card'; import AppLayout from '@/layouts/AppLayout.vue'; import { access, index, show } from '@/routes/projects'; +import { index as boardIndex } from '@/routes/projects/board'; import { index as backlogIndex } from '@/routes/projects/backlog'; import { board as boardSettings, @@ -37,19 +38,19 @@ const projectAreas = [ { title: 'Backlog', description: - 'This area will receive new tickets first and act as the intake queue for project work.', + 'Create tickets, review incoming work, and move ready items into the board workflow.', icon: ListTodo, }, { title: 'Board', description: - 'This area will become the configurable Kanban board with project-specific columns.', + 'Track active work across the project-defined Kanban columns and update ticket status.', icon: LayoutGrid, }, { title: 'Project settings', description: - 'This area will host project-level configuration such as ticket metadata and workflow options.', + 'Manage board columns, ticket metadata, and the project-specific workflow configuration.', icon: Settings2, }, ]; @@ -74,6 +75,10 @@ const projectAreas = [
+ +
diff --git a/resources/js/types/models/board-column-model.ts b/resources/js/types/models/board-column-model.ts index 52b8f69..abd1e42 100644 --- a/resources/js/types/models/board-column-model.ts +++ b/resources/js/types/models/board-column-model.ts @@ -1,8 +1,11 @@ +import type { TicketModel } from '@/types/models/ticket-model'; + export type BoardColumnModel = { id: number; name: string; position: number; project_id: number; + tickets?: TicketModel[]; created_at: string; updated_at: string; }; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index a695516..c46d872 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -48,6 +48,13 @@ export type ProjectBoardSettingsPageProps = { status?: string | null; }; +export type ProjectBoardPageProps = { + boardColumns: BoardColumnModel[]; + canEditTickets: boolean; + project: ProjectModel; + status?: string | null; +}; + export type TicketFieldTypeOption = { label: string; value: string; @@ -63,7 +70,9 @@ export type ProjectTicketMetadataSettingsPageProps = { }; export type ProjectBacklogPageProps = { + boardColumns: BoardColumnModel[]; canCreateTickets: boolean; + canEditTickets: boolean; project: ProjectModel; status?: string | null; ticketPriorities: TicketPriorityModel[]; diff --git a/routes/web.php b/routes/web.php index 23d55ed..efa3564 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Admin\UserManagementController; use App\Http\Controllers\ProjectBacklogController; +use App\Http\Controllers\ProjectBoardController; use App\Http\Controllers\ProjectBoardSettingsController; use App\Http\Controllers\ProjectController; use App\Http\Controllers\ProjectTicketMetadataSettingsController; @@ -31,6 +32,10 @@ Route::middleware(['auth', 'verified'])->group(function () { ->name('projects.backlog.store'); Route::scopeBindings()->group(function (): void { + Route::get('projects/{project}/board', [ProjectBoardController::class, 'index']) + ->name('projects.board.index'); + Route::patch('projects/{project}/tickets/{ticket}/status', [ProjectBoardController::class, 'move']) + ->name('projects.tickets.status.update'); Route::get('projects/{project}/settings/board', [ProjectBoardSettingsController::class, 'show']) ->name('projects.settings.board'); Route::post('projects/{project}/settings/board/columns', [ProjectBoardSettingsController::class, 'store']) diff --git a/tests/Feature/Models/BoardColumnTest.php b/tests/Feature/Models/BoardColumnTest.php index 0304359..f852bbd 100644 --- a/tests/Feature/Models/BoardColumnTest.php +++ b/tests/Feature/Models/BoardColumnTest.php @@ -2,8 +2,11 @@ namespace Tests\Feature\Models; +use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Models\BoardColumn; use App\Models\Project; +use App\Models\Ticket; +use App\Models\TicketPriority; use Database\Seeders\BoardColumnSeeder; use Database\Seeders\ProjectSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -50,6 +53,52 @@ class BoardColumnTest extends TestCase ); } + /** + * Ensure board columns expose tickets ordered by descending sequence number. + */ + public function test_board_column_tickets_are_returned_in_sequence_order(): void + { + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $priority = TicketPriority::factory()->for($project)->create([ + 'name' => 'Medium', + 'position' => 1, + ]); + + $boardColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Doing', + 'position' => 1, + ]); + + Ticket::factory()->create([ + 'board_column_id' => $boardColumn->id, + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, + 'title' => 'First ticket', + ]); + Ticket::factory()->create([ + 'board_column_id' => $boardColumn->id, + 'project_id' => $project->id, + 'sequence_number' => 2, + 'ticket_key' => 'CORE-2', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, + 'title' => 'Second ticket', + ]); + + $this->assertSame( + ['CORE-2', 'CORE-1'], + $boardColumn->fresh()->tickets->pluck('ticket_key')->all(), + ); + } + /** * Ensure the board column seeder creates starter workflow columns for seeded projects. */ diff --git a/tests/Feature/Models/TicketTest.php b/tests/Feature/Models/TicketTest.php index aec3db6..322f242 100644 --- a/tests/Feature/Models/TicketTest.php +++ b/tests/Feature/Models/TicketTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Models; use App\Models\Project; use App\Models\Ticket; +use App\Models\TicketWorklogEntry; use Database\Seeders\BoardColumnSeeder; use Database\Seeders\ProjectSeeder; use Database\Seeders\ProjectTicketMetadataSeeder; @@ -55,4 +56,26 @@ class TicketTest extends TestCase 'board_column_id' => null, ]); } + + /** + * Ensure ticket worklog entries are exposed newest first for timeline rendering. + */ + public function test_ticket_worklog_entries_are_returned_newest_first(): void + { + $ticket = Ticket::factory()->create(); + + $olderEntry = TicketWorklogEntry::factory()->for($ticket)->create([ + 'created_at' => now()->subMinute(), + 'updated_at' => now()->subMinute(), + ]); + $newerEntry = TicketWorklogEntry::factory()->for($ticket)->create([ + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertSame( + [$newerEntry->id, $olderEntry->id], + $ticket->fresh()->worklogEntries->modelKeys(), + ); + } } diff --git a/tests/Feature/Models/TicketWorklogEntryTest.php b/tests/Feature/Models/TicketWorklogEntryTest.php new file mode 100644 index 0000000..bd69c06 --- /dev/null +++ b/tests/Feature/Models/TicketWorklogEntryTest.php @@ -0,0 +1,52 @@ +create(); + + $this->assertNotNull($entry->ticket); + $this->assertNotNull($entry->actor); + $this->assertSame('status_changed', $entry->entry_type); + } + + /** + * Ensure the worklog seeder creates an initial entry for each seeded ticket. + */ + public function test_ticket_worklog_entry_seeder_creates_entries_for_seeded_tickets(): void + { + $this->seed([ + UserSeeder::class, + ProjectSeeder::class, + BoardColumnSeeder::class, + ProjectTicketMetadataSeeder::class, + TicketSeeder::class, + TicketWorklogEntrySeeder::class, + ]); + + $this->assertDatabaseHas('ticket_worklog_entries', [ + 'entry_type' => 'created', + 'summary' => 'Ticket was created in Backlog.', + ]); + + $this->assertSame(6, TicketWorklogEntry::query()->count()); + } +} diff --git a/tests/Feature/Projects/ProjectBacklogControllerTest.php b/tests/Feature/Projects/ProjectBacklogControllerTest.php index 9faca0c..d63de31 100644 --- a/tests/Feature/Projects/ProjectBacklogControllerTest.php +++ b/tests/Feature/Projects/ProjectBacklogControllerTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Projects; use App\Actions\ProjectPermissions\ProjectPermissionService; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; +use App\Models\BoardColumn; use App\Models\Project; use App\Models\Ticket; use App\Models\TicketPriority; @@ -27,6 +28,10 @@ class ProjectBacklogControllerTest extends TestCase ]); app(InitializeProjectTicketMetadata::class)->handle($project); + BoardColumn::factory()->for($project)->create([ + 'name' => 'Todo', + 'position' => 1, + ]); $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'Medium', @@ -49,6 +54,8 @@ class ProjectBacklogControllerTest extends TestCase ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Backlog') + ->where('boardColumns.0.name', 'Todo') + ->where('canEditTickets', false) ->where('project.id', $project->id) ->where('tickets.0.ticket_key', $ticket->ticket_key) ->where('canCreateTickets', false), diff --git a/tests/Feature/Projects/ProjectBoardControllerTest.php b/tests/Feature/Projects/ProjectBoardControllerTest.php new file mode 100644 index 0000000..1f49ea0 --- /dev/null +++ b/tests/Feature/Projects/ProjectBoardControllerTest.php @@ -0,0 +1,301 @@ +create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $todoColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Todo', + 'position' => 1, + ]); + $doingColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Doing', + 'position' => 2, + ]); + + $priority = TicketPriority::factory()->for($project)->create([ + 'name' => 'High', + 'position' => 1, + ]); + + Ticket::factory()->create([ + 'board_column_id' => null, + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, + 'title' => 'Backlog ticket', + ]); + + $boardTicket = Ticket::factory()->create([ + 'board_column_id' => $doingColumn->id, + 'project_id' => $project->id, + 'sequence_number' => 2, + 'ticket_key' => 'CORE-2', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, + 'title' => 'Board ticket', + ]); + + app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']); + + $this->actingAs($user) + ->get(route('projects.board.index', $project)) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('projects/Board') + ->where('project.id', $project->id) + ->where('boardColumns.0.name', $todoColumn->name) + ->has('boardColumns.0.tickets', 0) + ->where('boardColumns.1.name', $doingColumn->name) + ->has('boardColumns.1.tickets', 1) + ->where('boardColumns.1.tickets.0.ticket_key', $boardTicket->ticket_key) + ->where('boardColumns.1.tickets.0.title', $boardTicket->title) + ->where('canEditTickets', false), + ); + } + + /** + * Ensure moving backlog tickets into a board column requires edit permission and logs the change. + */ + public function test_users_with_edit_permission_can_move_backlog_tickets_to_a_board_column(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $todoColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Todo', + 'position' => 1, + ]); + + $priority = TicketPriority::factory()->for($project)->create([ + 'name' => 'High', + 'position' => 1, + ]); + + $ticket = Ticket::factory()->create([ + 'board_column_id' => null, + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, + 'title' => 'Backlog ticket', + ]); + + app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); + + $this->actingAs($user) + ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ + 'board_column_id' => $todoColumn->id, + 'redirect_to' => 'backlog', + ]) + ->assertRedirect(route('projects.backlog.index', $project)) + ->assertSessionHas('status', 'CORE-1 moved to Todo.'); + + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'board_column_id' => $todoColumn->id, + ]); + + $this->assertDatabaseHas('ticket_worklog_entries', [ + 'ticket_id' => $ticket->id, + 'actor_id' => $user->id, + 'entry_type' => 'status_changed', + 'summary' => 'Moved from Backlog to Todo.', + ]); + } + + /** + * Ensure board tickets can move between columns. + */ + public function test_users_with_edit_permission_can_move_board_tickets_between_columns(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $todoColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Todo', + 'position' => 1, + ]); + $doneColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Done', + 'position' => 2, + ]); + + $priority = TicketPriority::factory()->for($project)->create([ + 'name' => 'High', + 'position' => 1, + ]); + + $ticket = Ticket::factory()->create([ + 'board_column_id' => $todoColumn->id, + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, + 'title' => 'Board ticket', + ]); + + app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); + + $this->actingAs($user) + ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ + 'board_column_id' => $doneColumn->id, + 'redirect_to' => 'board', + ]) + ->assertRedirect(route('projects.board.index', $project)) + ->assertSessionHas('status', 'CORE-1 moved to Done.'); + + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'board_column_id' => $doneColumn->id, + ]); + + $this->assertDatabaseHas('ticket_worklog_entries', [ + 'ticket_id' => $ticket->id, + 'actor_id' => $user->id, + 'entry_type' => 'status_changed', + 'summary' => 'Moved from Todo to Done.', + ]); + } + + /** + * Ensure moving a board ticket back to backlog removes it from the board columns. + */ + public function test_users_with_edit_permission_can_move_board_tickets_back_to_backlog(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $doingColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Doing', + 'position' => 1, + ]); + + $priority = TicketPriority::factory()->for($project)->create([ + 'name' => 'High', + 'position' => 1, + ]); + + $ticket = Ticket::factory()->create([ + 'board_column_id' => $doingColumn->id, + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, + 'title' => 'Board ticket', + ]); + + app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); + + $this->actingAs($user) + ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ + 'board_column_id' => null, + 'redirect_to' => 'board', + ]) + ->assertRedirect(route('projects.board.index', $project)) + ->assertSessionHas('status', 'CORE-1 moved to Backlog.'); + + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'board_column_id' => null, + ]); + + $this->actingAs($user) + ->get(route('projects.board.index', $project)) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('boardColumns.0.name', $doingColumn->name) + ->has('boardColumns.0.tickets', 0), + ); + } + + /** + * Ensure cross-project board column moves are rejected. + */ + public function test_status_moves_to_columns_from_other_projects_are_rejected(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + $otherProject = Project::factory()->create(); + + app(InitializeProjectTicketMetadata::class)->handle($project); + app(InitializeProjectTicketMetadata::class)->handle($otherProject); + + $otherProjectColumn = BoardColumn::factory()->for($otherProject)->create([ + 'name' => 'Foreign', + 'position' => 1, + ]); + + $priority = TicketPriority::factory()->for($project)->create([ + 'name' => 'High', + 'position' => 1, + ]); + + $ticket = Ticket::factory()->create([ + 'board_column_id' => null, + 'project_id' => $project->id, + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, + ]); + + app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']); + + $this->actingAs($user) + ->from(route('projects.backlog.index', $project)) + ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ + 'board_column_id' => $otherProjectColumn->id, + 'redirect_to' => 'backlog', + ]) + ->assertRedirect(route('projects.backlog.index', $project)) + ->assertSessionHasErrors('board_column_id'); + + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'board_column_id' => null, + ]); + + $this->assertDatabaseCount('ticket_worklog_entries', 0); + } +}