| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683 |
- <?php
- /*
- * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
- * 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<string, array{ressourcen:float, after_reserves:float, committed_prio1:float, available:float}>
- */
- 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;
- }
- }
|