* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Controllers; use App\Auth\SessionGuard; use App\Domain\TaskAssignment; use App\Http\Request; use App\Http\Response; use App\Repositories\AppSettingsRepository; use App\Repositories\SprintRepository; use App\Repositories\SprintWorkerDayRepository; use App\Repositories\SprintWorkerRepository; use App\Repositories\TaskAssignmentRepository; use App\Repositories\TaskRepository; use App\Repositories\UserRepository; use App\Repositories\WorkerRepository; use App\Services\AuditLogger; use App\Services\CapacityCalculator; use InvalidArgumentException; use PDO; use Throwable; /** * Task CRUD + assignments. * * Tasks live per-sprint; assignments are per (task, sprint_worker) cell. * All endpoints are admin-only JSON. Every mutation writes per-row audit * entries inside the same transaction as the DB change. */ final class TaskController { /** * R01-N24: see SprintController::MAX_BATCH_ITEMS. Same rationale, same * cap; reorder / assignments / status share the cell-style payload * shape and need the same defence-in-depth bound. */ public const MAX_BATCH_ITEMS = 5000; public function __construct( private readonly PDO $pdo, private readonly UserRepository $users, private readonly SprintRepository $sprints, private readonly SprintWorkerRepository $sprintWorkers, private readonly SprintWorkerDayRepository $days, private readonly TaskRepository $tasks, private readonly TaskAssignmentRepository $assignments, private readonly WorkerRepository $workers, private readonly AuditLogger $audit, private readonly AppSettingsRepository $appSettings, ) { } /** POST /sprints/{id}/tasks — create a task (rows append at sort_order MAX+1). */ public function create(Request $req, array $params): Response { $actor = SessionGuard::requireAdminJson($req, $this->users); if ($actor instanceof Response) { return $actor; } $sprintId = (int) $params['id']; $sprint = $this->sprints->find($sprintId); if ($sprint === null) { return Response::err('not_found', 'Sprint not found', 404); } $body = $req->json() ?? []; $title = isset($body['title']) && is_string($body['title']) ? trim($body['title']) : ''; if ($title === '') { $title = '(Untitled task)'; } $priority = isset($body['priority']) ? (int) $body['priority'] : 1; if ($priority !== 1 && $priority !== 2) { return Response::err('validation', 'priority must be 1 or 2', 422); } $ownerWorkerId = null; if (isset($body['owner_worker_id']) && $body['owner_worker_id'] !== null && $body['owner_worker_id'] !== '') { $ownerWorkerId = (int) $body['owner_worker_id']; if ($this->workers->find($ownerWorkerId) === null) { return Response::err('validation', 'Unknown owner_worker_id', 422); } } $this->pdo->beginTransaction(); try { $task = $this->tasks->create($sprintId, $title, $ownerWorkerId, $priority); $this->audit->recordForRequest( 'CREATE', 'task', $task->id, null, $task->toAuditSnapshot(), $req, $actor, ); $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not create task', 500); } return Response::ok([ 'task' => $task->toAuditSnapshot(), 'assignments' => (object) [], // new task, no assignments yet ]); } /** PATCH /tasks/{id} — edit title / owner / priority. */ public function update(Request $req, array $params): Response { $actor = SessionGuard::requireAdminJson($req, $this->users); if ($actor instanceof Response) { return $actor; } $id = (int) $params['id']; $task = $this->tasks->find($id); if ($task === null) { return Response::err('not_found', 'Task not found', 404); } $body = $req->json() ?? []; $changes = []; if (array_key_exists('title', $body)) { $title = is_string($body['title']) ? trim($body['title']) : ''; if ($title === '') { return Response::err('validation', 'title cannot be empty', 422); } $changes['title'] = $title; } if (array_key_exists('priority', $body)) { $p = (int) $body['priority']; if ($p !== 1 && $p !== 2) { return Response::err('validation', 'priority must be 1 or 2', 422); } $changes['priority'] = $p; } if (array_key_exists('owner_worker_id', $body)) { $v = $body['owner_worker_id']; if ($v === null || $v === '') { $changes['owner_worker_id'] = null; } else { $ow = (int) $v; if ($this->workers->find($ow) === null) { return Response::err('validation', 'Unknown owner_worker_id', 422); } $changes['owner_worker_id'] = $ow; } } if (array_key_exists('description', $body)) { $desc = is_string($body['description']) ? $body['description'] : ''; if (strlen($desc) > 8000) { return Response::err('validation', 'description too long', 422); } $changes['description'] = $desc; } if (array_key_exists('url', $body)) { $u = is_string($body['url']) ? trim($body['url']) : ''; if ($u !== '') { if (strlen($u) > 2048) { return Response::err('validation', 'url too long', 422); } if (!preg_match('#^https?://#i', $u)) { return Response::err('validation', 'url must start with http:// or https://', 422); } } $changes['url'] = $u; } if ($changes === []) { return Response::ok(['task' => $task->toAuditSnapshot()]); } $this->pdo->beginTransaction(); try { $result = $this->tasks->update($id, $changes); $this->audit->recordForRequest( 'UPDATE', 'task', $id, $result['before']->toAuditSnapshot(), $result['after']->toAuditSnapshot(), $req, $actor, ); $responsePayload = [ 'task' => $result['after']->toAuditSnapshot(), ]; // If priority changed, touched workers' Available depends on prio-1 // commitments. Recompute capacity for every sprint worker so the // client can paint the updated summary in one go. if (array_key_exists('priority', $changes) && $changes['priority'] !== $result['before']->priority) { $responsePayload['per_worker'] = $this->computeCapacity($result['after']->sprintId); } $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not update task', 500); } return Response::ok($responsePayload); } /** DELETE /tasks/{id} — delete a task; audits each assignment before cascade. */ public function delete(Request $req, array $params): Response { $actor = SessionGuard::requireAdminJson($req, $this->users); if ($actor instanceof Response) { return $actor; } $id = (int) $params['id']; $task = $this->tasks->find($id); if ($task === null) { return Response::err('not_found', 'Task not found', 404); } // Snapshot every assignment before the FK cascade wipes them. $assignments = $this->assignments->allForTask($id); $this->pdo->beginTransaction(); try { foreach ($assignments as $a) { $this->audit->recordForRequest( 'DELETE', 'task_assignment', $a->id, $a->toAuditSnapshot(), null, $req, $actor, ); } $this->tasks->delete($id); $this->audit->recordForRequest( 'DELETE', 'task', $id, $task->toAuditSnapshot(), null, $req, $actor, ); $responsePayload = ['removed_id' => $id]; // If it was a prio-1 task, available changes on every worker that // had an assignment. if ($task->priority === 1 && $assignments !== []) { $responsePayload['per_worker'] = $this->computeCapacity($task->sprintId); } $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not delete task', 500); } return Response::ok($responsePayload); } /** POST /sprints/{id}/tasks/reorder — apply an ordering. */ public function reorder(Request $req, array $params): Response { $actor = SessionGuard::requireAdminJson($req, $this->users); if ($actor instanceof Response) { return $actor; } $sprintId = (int) $params['id']; if ($this->sprints->find($sprintId) === null) { return Response::err('not_found', 'Sprint not found', 404); } $body = $req->json(); if (!is_array($body) || !array_is_list($body)) { return Response::err('validation', 'body must be a list of {task_id, sort_order}', 422); } if (count($body) > self::MAX_BATCH_ITEMS) { return Response::err( 'too_many_items', 'reorder list exceeds ' . self::MAX_BATCH_ITEMS . '-item cap', 413, ); } $ordering = []; $seenOrder = []; foreach ($body as $row) { if (!is_array($row) || !isset($row['task_id'], $row['sort_order'])) { return Response::err('validation', 'each entry needs task_id and sort_order', 422); } $tid = (int) $row['task_id']; $order = (int) $row['sort_order']; if ($tid <= 0 || $order < 1) { return Response::err('validation', 'ids/orders must be positive', 422); } if (isset($seenOrder[$order])) { return Response::err('validation', 'duplicate sort_order', 422); } $seenOrder[$order] = true; $ordering[] = ['task_id' => $tid, 'sort_order' => $order]; } $this->pdo->beginTransaction(); try { $diffs = $this->tasks->reorder($sprintId, $ordering); foreach ($diffs as $d) { $this->audit->recordForRequest( 'UPDATE', 'task', $d['after']->id, $d['before']->toAuditSnapshot(), $d['after']->toAuditSnapshot(), $req, $actor, ); } $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not reorder', 500); } return Response::ok(['moved' => count($diffs)]); } /** PATCH /tasks/{id}/assignments — batch upsert of task_assignments. */ public function updateAssignments(Request $req, array $params): Response { $actor = SessionGuard::requireAdminJson($req, $this->users); if ($actor instanceof Response) { return $actor; } $taskId = (int) $params['id']; $task = $this->tasks->find($taskId); if ($task === null) { return Response::err('not_found', 'Task not found', 404); } $body = $req->json(); if (!is_array($body) || !array_is_list($body)) { return Response::err('validation', 'body must be a list of {sprint_worker_id, days}', 422); } if ($body === []) { return Response::ok(['applied' => 0, 'noop' => 0]); } if (count($body) > self::MAX_BATCH_ITEMS) { return Response::err( 'too_many_items', 'assignment list exceeds ' . self::MAX_BATCH_ITEMS . '-item cap', 413, ); } // Cross-check sprint worker IDs belong to the task's sprint. $validSw = []; foreach ($this->sprintWorkers->allForSprint($task->sprintId) as $sw) { $validSw[$sw->id] = true; } $cells = []; foreach ($body as $i => $row) { if (!is_array($row) || !isset($row['sprint_worker_id'], $row['days'])) { return Response::err('validation', "cell[{$i}] needs sprint_worker_id, days", 422); } $swId = (int) $row['sprint_worker_id']; $daysN = $row['days']; if (!is_numeric($daysN)) { return Response::err('validation', "cell[{$i}] days must be numeric", 422); } $days = (float) $daysN; if (!isset($validSw[$swId])) { return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422); } if ($days < 0) { return Response::err('validation', "cell[{$i}] days cannot be negative", 422); } // Assignments step by 0.5 but have no hard upper bound per spec §3. $doubled = $days * 2; if (abs($doubled - round($doubled)) > 1e-9) { return Response::err('validation', "cell[{$i}] days must step by 0.5", 422); } $cells[] = ['sw_id' => $swId, 'days' => $days]; } $applied = 0; $noop = 0; $this->pdo->beginTransaction(); try { foreach ($cells as $c) { $result = $this->assignments->upsert($taskId, $c['sw_id'], $c['days']); if ($result['action'] === 'NOOP') { $noop++; continue; } $applied++; $this->audit->recordForRequest( action: $result['action'], entityType: 'task_assignment', entityId: $result['after']?->id ?? $result['before']?->id, before: $result['before']?->toAuditSnapshot(), after: $result['after']?->toAuditSnapshot(), req: $req, actor: $actor, ); } $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not save assignments', 500); } $data = [ 'applied' => $applied, 'noop' => $noop, 'task_id' => $taskId, ]; if ($applied > 0 && $task->priority === 1) { $data['per_worker'] = $this->computeCapacity($task->sprintId); } return Response::ok($data); } /** * PATCH /tasks/{id}/assignments/status — set per-cell workflow status. * * Open to any signed-in user (Phase 18 — first non-admin write surface), * gated by the global app_settings.task_status_enabled flag. CSRF still * required. Days are NOT modified by this endpoint. */ public function updateAssignmentsStatus(Request $req, array $params): Response { $actor = SessionGuard::requireAuthJson($req, $this->users); if ($actor instanceof Response) { return $actor; } if (!$this->appSettings->getBool('task_status_enabled', false)) { return Response::err('feature_disabled', 'Task status colors are disabled', 403); } $taskId = (int) $params['id']; $task = $this->tasks->find($taskId); if ($task === null) { return Response::err('not_found', 'Task not found', 404); } $body = $req->json(); if (!is_array($body) || !array_is_list($body)) { return Response::err('validation', 'body must be a list of {sprint_worker_id, status}', 422); } if ($body === []) { return Response::ok(['applied' => 0, 'noop' => 0]); } if (count($body) > self::MAX_BATCH_ITEMS) { return Response::err( 'too_many_items', 'status list exceeds ' . self::MAX_BATCH_ITEMS . '-item cap', 413, ); } $validSw = []; foreach ($this->sprintWorkers->allForSprint($task->sprintId) as $sw) { $validSw[$sw->id] = true; } $cells = []; foreach ($body as $i => $row) { if (!is_array($row) || !isset($row['sprint_worker_id'], $row['status'])) { return Response::err('validation', "cell[{$i}] needs sprint_worker_id, status", 422); } $swId = (int) $row['sprint_worker_id']; $status = is_string($row['status']) ? $row['status'] : ''; if (!isset($validSw[$swId])) { return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422); } if (!TaskAssignment::isValidStatus($status)) { return Response::err('validation', "cell[{$i}] invalid status", 422); } $cells[] = ['sw_id' => $swId, 'status' => $status]; } $applied = 0; $noop = 0; $this->pdo->beginTransaction(); try { foreach ($cells as $c) { try { $result = $this->assignments->upsertStatus( $taskId, $c['sw_id'], $c['status'], ); } catch (InvalidArgumentException) { // Already validated above, but guard the repo invariant. $this->pdo->rollBack(); return Response::err('validation', 'invalid status', 422); } if ($result['action'] === 'NOOP') { $noop++; continue; } $applied++; $this->audit->recordForRequest( action: $result['action'], entityType: 'task_assignment', entityId: $result['after']?->id ?? $result['before']?->id, before: $result['before']?->toAuditSnapshot(), after: $result['after']?->toAuditSnapshot(), req: $req, actor: $actor, ); } $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not save statuses', 500); } return Response::ok([ 'applied' => $applied, 'noop' => $noop, 'task_id' => $taskId, ]); } /** * POST /tasks/{id}/move — reassign a task to another sprint. * * All task_assignments are dropped (audited per-cell before the wipe); * task lands at the bottom of the destination sprint's list. Capacity is * affected on both sides for prio-1 tasks, but the client just reloads * the page after a successful move so we don't need to send fresh * per-worker numbers in the response. */ public function moveToSprint(Request $req, array $params): Response { $actor = SessionGuard::requireAdminJson($req, $this->users); if ($actor instanceof Response) { return $actor; } $id = (int) $params['id']; $task = $this->tasks->find($id); if ($task === null) { return Response::err('not_found', 'Task not found', 404); } $body = $req->json() ?? []; $destSprintId = isset($body['sprint_id']) ? (int) $body['sprint_id'] : 0; if ($destSprintId <= 0) { return Response::err('validation', 'sprint_id required', 422); } if ($destSprintId === $task->sprintId) { return Response::err('validation', 'task already in this sprint', 422); } if ($this->sprints->find($destSprintId) === null) { return Response::err('not_found', 'Destination sprint not found', 404); } $oldAssignments = $this->assignments->allForTask($id); $this->pdo->beginTransaction(); try { // Audit each assignment before they vanish. foreach ($oldAssignments as $a) { $this->audit->recordForRequest( 'DELETE', 'task_assignment', $a->id, $a->toAuditSnapshot(), null, $req, $actor, ); } $result = $this->tasks->moveToSprint($id, $destSprintId); $this->audit->recordForRequest( 'UPDATE', 'task', $id, $result['before']->toAuditSnapshot(), $result['after']->toAuditSnapshot(), $req, $actor, ); $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not move task', 500); } return Response::ok([ 'task' => $result['after']->toAuditSnapshot(), ]); } /** * POST /tasks/{id}/copy — clone a task into another sprint. * * Carries title / owner / priority / description / url; assignments are * NOT carried (fresh slate per the design call). The new task records * `linked_task_id = source.id`; the bidirectional UI link is rendered * by the sprint view via TaskRepository::linkedSummariesForTasks. */ public function copyToSprint(Request $req, array $params): Response { $actor = SessionGuard::requireAdminJson($req, $this->users); if ($actor instanceof Response) { return $actor; } $id = (int) $params['id']; $task = $this->tasks->find($id); if ($task === null) { return Response::err('not_found', 'Task not found', 404); } $body = $req->json() ?? []; $destSprintId = isset($body['sprint_id']) ? (int) $body['sprint_id'] : 0; if ($destSprintId <= 0) { return Response::err('validation', 'sprint_id required', 422); } if ($this->sprints->find($destSprintId) === null) { return Response::err('not_found', 'Destination sprint not found', 404); } $this->pdo->beginTransaction(); try { $copy = $this->tasks->create( sprintId: $destSprintId, title: $task->title, ownerWorkerId: $task->ownerWorkerId, priority: $task->priority, description: $task->description, url: $task->url, linkedTaskId: $task->id, ); $this->audit->recordForRequest( 'CREATE', 'task', $copy->id, null, $copy->toAuditSnapshot(), $req, $actor, ); $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not copy task', 500); } return Response::ok([ 'task' => $copy->toAuditSnapshot(), ]); } /** * Full per-worker capacity recompute for a sprint. Used to keep the * client-side capacity strip in sync when changes cascade across rows. * * @return array */ private function computeCapacity(int $sprintId): array { $sprint = $this->sprints->find($sprintId); if ($sprint === null) { return []; } $dayGrid = $this->days->grid($sprintId); $committed = $this->assignments->committedPrio1BySprint($sprintId); $out = []; foreach ($this->sprintWorkers->allForSprint($sprintId) as $sw) { $ressourcen = array_sum($dayGrid[$sw->id] ?? []); $out[(string) $sw->id] = CapacityCalculator::forWorker( $ressourcen, $sprint->reserveFraction, $committed[$sw->id] ?? 0.0, ); } return $out; } }