SprintController.php 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Controllers;
  12. use App\Auth\SessionGuard;
  13. use App\Domain\SprintWeek;
  14. use App\Domain\User;
  15. use App\Http\Request;
  16. use App\Http\Response;
  17. use App\Http\View;
  18. use InvalidArgumentException;
  19. use App\Repositories\AppSettingsRepository;
  20. use App\Repositories\SprintRepository;
  21. use App\Repositories\SprintWeekRepository;
  22. use App\Repositories\SprintWorkerDayRepository;
  23. use App\Repositories\SprintWorkerRepository;
  24. use App\Repositories\TaskAssignmentRepository;
  25. use App\Repositories\TaskRepository;
  26. use App\Repositories\UserRepository;
  27. use App\Repositories\WorkerRepository;
  28. use App\Services\AuditLogger;
  29. use App\Services\CapacityCalculator;
  30. use DateTimeImmutable;
  31. use PDO;
  32. use PDOException;
  33. use Throwable;
  34. final class SprintController
  35. {
  36. /**
  37. * R01-N24: defence-in-depth cap on the batch JSON endpoints. The 1 MiB
  38. * `Request::MAX_BODY_BYTES` already gates the wire format; this cap
  39. * stops an attacker (or a buggy client) from packing tens of thousands
  40. * of small cells inside a still-under-1MiB body and pinning the DB on
  41. * a long upsert loop. Real workloads are nowhere near this limit:
  42. * `n_weeks` is capped at 26 in the schema and a sprint never carries
  43. * tens of admins-on-its-back, so 5000 cells in one PATCH is already
  44. * comfortably more than any genuine UI flow needs.
  45. */
  46. public const MAX_BATCH_ITEMS = 5000;
  47. public function __construct(
  48. private readonly PDO $pdo,
  49. private readonly UserRepository $users,
  50. private readonly SprintRepository $sprints,
  51. private readonly SprintWeekRepository $weeks,
  52. private readonly SprintWorkerRepository $sprintWorkers,
  53. private readonly SprintWorkerDayRepository $days,
  54. private readonly TaskRepository $tasks,
  55. private readonly TaskAssignmentRepository $assignments,
  56. private readonly WorkerRepository $workers,
  57. private readonly AuditLogger $audit,
  58. private readonly View $view,
  59. private readonly AppSettingsRepository $appSettings,
  60. ) {
  61. }
  62. /** GET /sprints/new — admin-only form. */
  63. public function newForm(Request $req): Response
  64. {
  65. $actor = SessionGuard::requireAdmin($this->users);
  66. if ($actor instanceof Response) {
  67. return $actor;
  68. }
  69. return Response::html($this->view->render('sprints/new', [
  70. 'title' => 'New sprint',
  71. 'currentUser' => $actor,
  72. 'csrfToken' => SessionGuard::csrfToken(),
  73. 'error' => $req->queryString('error'),
  74. 'form' => [
  75. 'name' => '',
  76. 'start_date' => '',
  77. 'end_date' => '',
  78. 'reserve_fraction' => '20',
  79. ],
  80. ]));
  81. }
  82. /** POST /sprints — create sprint + materialise weeks in one tx. */
  83. public function create(Request $req): Response
  84. {
  85. $actor = SessionGuard::requireAdmin($this->users);
  86. if ($actor instanceof Response) {
  87. return $actor;
  88. }
  89. if (!SessionGuard::verifyCsrf($req)) {
  90. return Response::text('CSRF token invalid', 403);
  91. }
  92. $name = trim($req->postString('name'));
  93. $start = $req->postString('start_date');
  94. $end = $req->postString('end_date');
  95. // reserve_fraction submitted as a percentage (0..100) from the form.
  96. $reservePct = $req->postString('reserve_fraction');
  97. if ($name === '') {
  98. return Response::redirect('/sprints/new?error=name_required');
  99. }
  100. $startD = DateTimeImmutable::createFromFormat('Y-m-d', $start);
  101. $endD = DateTimeImmutable::createFromFormat('Y-m-d', $end);
  102. if ($startD === false || $endD === false) {
  103. return Response::redirect('/sprints/new?error=dates_invalid');
  104. }
  105. if ($endD < $startD) {
  106. return Response::redirect('/sprints/new?error=dates_order');
  107. }
  108. if (!is_numeric($reservePct)) {
  109. return Response::redirect('/sprints/new?error=reserve_invalid');
  110. }
  111. $reserve = ((float) $reservePct) / 100.0;
  112. if ($reserve < 0.0 || $reserve > 1.0) {
  113. return Response::redirect('/sprints/new?error=reserve_out_of_range');
  114. }
  115. // Week count derives from the date range — same rule as PATCH /sprints/{id}.
  116. $nWeeks = self::weeksBetween($startD->format('Y-m-d'), $endD->format('Y-m-d'));
  117. if ($nWeeks > 26) {
  118. return Response::redirect('/sprints/new?error=dates_too_long');
  119. }
  120. $this->pdo->beginTransaction();
  121. try {
  122. $sprint = $this->sprints->create(
  123. name: $name,
  124. startDate: $startD->format('Y-m-d'),
  125. endDate: $endD->format('Y-m-d'),
  126. reserveFraction: $reserve,
  127. );
  128. $this->audit->recordForRequest(
  129. action: 'CREATE',
  130. entityType: 'sprint',
  131. entityId: $sprint->id,
  132. before: null,
  133. after: $sprint->toAuditSnapshot(),
  134. req: $req,
  135. actor: $actor,
  136. );
  137. $weeks = $this->sprints->materializeWeeks(
  138. $sprint->id,
  139. $startD->format('Y-m-d'),
  140. $nWeeks,
  141. );
  142. foreach ($weeks as $w) {
  143. $this->audit->recordForRequest(
  144. action: 'CREATE',
  145. entityType: 'sprint_week',
  146. entityId: $w['id'],
  147. before: null,
  148. after: ['sprint_id' => $sprint->id] + $w,
  149. req: $req,
  150. actor: $actor,
  151. );
  152. }
  153. $this->pdo->commit();
  154. } catch (Throwable) {
  155. $this->pdo->rollBack();
  156. return Response::redirect('/sprints/new?error=db_error');
  157. }
  158. return Response::redirect('/sprints/' . $sprint->id);
  159. }
  160. /** GET /sprints/{id} — main planning view (Section A Arbeitstage; tasks land in Phase 6). */
  161. public function show(Request $req, array $params): Response
  162. {
  163. $actor = SessionGuard::requireAuth($this->users);
  164. if ($actor instanceof Response) {
  165. return $actor;
  166. }
  167. $id = (int) $params['id'];
  168. $data = $this->loadSprintPage($id);
  169. if ($data === null) {
  170. return Response::text('Not Found', 404);
  171. }
  172. return Response::html($this->view->render('sprints/show', [
  173. 'title' => $data['sprint']->name,
  174. 'currentUser' => $actor,
  175. 'csrfToken' => SessionGuard::csrfToken(),
  176. ] + $data));
  177. }
  178. /**
  179. * GET /sprints/{id}/present — big-screen / beamer presentation view.
  180. * Strips chrome + Arbeitstage + capacity summary; renders only the
  181. * task-list toolbar + table full-viewport.
  182. */
  183. public function present(Request $req, array $params): Response
  184. {
  185. $actor = SessionGuard::requireAuth($this->users);
  186. if ($actor instanceof Response) {
  187. return $actor;
  188. }
  189. $id = (int) $params['id'];
  190. $data = $this->loadSprintPage($id);
  191. if ($data === null) {
  192. return Response::text('Not Found', 404);
  193. }
  194. // Present view extends layout-bare.twig instead of the shared layout.twig.
  195. return Response::html($this->view->render('sprints/present', [
  196. 'title' => $data['sprint']->name . ' — present',
  197. 'currentUser' => $actor,
  198. 'csrfToken' => SessionGuard::csrfToken(),
  199. ] + $data));
  200. }
  201. /**
  202. * Shared data fan-out for show() and present(). Returns null if the
  203. * sprint is missing so each caller can render its own 404.
  204. *
  205. * @return array{
  206. * sprint: \App\Domain\Sprint,
  207. * weeks: list<\App\Domain\SprintWeek>,
  208. * sprintWorkers: list<\App\Domain\SprintWorker>,
  209. * grid: array<int, array<int, float>>,
  210. * capacity: array<int, array{ressourcen:float, after_reserves:float, committed_prio1:float, available:float}>,
  211. * tasks: list<\App\Domain\Task>,
  212. * taskGrid: array<int, array<int, float>>,
  213. * statusGrid: array<int, array<int, string>>,
  214. * ownerChoices: list<\App\Domain\Worker>,
  215. * taskStatusEnabled: bool,
  216. * assignmentSliderMax: int,
  217. * }|null
  218. */
  219. private function loadSprintPage(int $id): ?array
  220. {
  221. $sprint = $this->sprints->find($id);
  222. if ($sprint === null) {
  223. return null;
  224. }
  225. $weeks = $this->weeks->allForSprint($id);
  226. $sprintWorkers = $this->sprintWorkers->allForSprint($id);
  227. $grid = $this->days->grid($id);
  228. $tasks = $this->tasks->allForSprint($id);
  229. $taskGrid = $this->assignments->gridForSprint($id);
  230. $statusGrid = $this->assignments->statusGridForSprint($id);
  231. $committedP1 = $this->assignments->committedPrio1BySprint($id);
  232. // Seed initial capacity server-side so the page is meaningful without JS
  233. // and the JS has the same numbers to compare against.
  234. $capacity = [];
  235. foreach ($sprintWorkers as $sw) {
  236. $wkDays = $grid[$sw->id] ?? [];
  237. $ressourcen = array_sum($wkDays);
  238. $capacity[$sw->id] = CapacityCalculator::forWorker(
  239. $ressourcen,
  240. $sprint->reserveFraction,
  241. $committedP1[$sw->id] ?? 0.0,
  242. );
  243. }
  244. // Owner dropdown source: all active workers (not just sprint members,
  245. // since the Excel allows the owner to be any worker — but typically
  246. // they are one of the sprint workers. Keep it restrictive for the UI).
  247. $ownerChoices = $this->workers->all();
  248. // Phase 22: candidate destination sprints for Move/Copy (everything
  249. // except this one), plus bidirectional linked-task summaries for the
  250. // tasks on this sprint.
  251. $sprintChoices = [];
  252. foreach ($this->sprints->allWithCounts() as $row) {
  253. $s = $row['sprint'];
  254. if ($s->id === $id) {
  255. continue;
  256. }
  257. $sprintChoices[] = ['id' => $s->id, 'name' => $s->name];
  258. }
  259. $linkedMap = $this->tasks->linkedSummariesForTasks($tasks);
  260. return [
  261. 'sprint' => $sprint,
  262. 'weeks' => $weeks,
  263. 'sprintWorkers' => $sprintWorkers,
  264. 'grid' => $grid,
  265. 'capacity' => $capacity,
  266. 'tasks' => $tasks,
  267. 'taskGrid' => $taskGrid,
  268. 'statusGrid' => $statusGrid,
  269. 'ownerChoices' => $ownerChoices,
  270. 'sprintChoices' => $sprintChoices,
  271. 'linkedMap' => $linkedMap,
  272. 'taskStatusEnabled' => $this->appSettings->getBool('task_status_enabled', false),
  273. 'assignmentSliderMax' => max(
  274. 1,
  275. min(100, $this->appSettings->getInt('assignment_slider_max', 10)),
  276. ),
  277. ];
  278. }
  279. // -----------------------------------------------------------------------
  280. // Phase 4 — settings page + JSON mutation endpoints.
  281. // -----------------------------------------------------------------------
  282. /** GET /sprints/{id}/settings — admin-only. */
  283. public function settings(Request $req, array $params): Response
  284. {
  285. $actor = SessionGuard::requireAdmin($this->users);
  286. if ($actor instanceof Response) {
  287. return $actor;
  288. }
  289. $id = (int) $params['id'];
  290. $sprint = $this->sprints->find($id);
  291. if ($sprint === null) {
  292. return Response::text('Not Found', 404);
  293. }
  294. return Response::html($this->view->render('sprints/settings', [
  295. 'title' => "Settings — {$sprint->name}",
  296. 'currentUser' => $actor,
  297. 'csrfToken' => SessionGuard::csrfToken(),
  298. 'sprint' => $sprint,
  299. 'weeks' => $this->weeks->allForSprint($id),
  300. 'sprintWorkers' => $this->sprintWorkers->allForSprint($id),
  301. 'availableWorkers' => $this->workers->activeNotInSprint($id),
  302. 'error' => $req->queryString('error'),
  303. ]));
  304. }
  305. /** PATCH /sprints/{id} — JSON — update name / dates / reserve_fraction. */
  306. public function updateMeta(Request $req, array $params): Response
  307. {
  308. $gate = $this->gateJsonAdmin($req);
  309. if ($gate instanceof Response) {
  310. return $gate;
  311. }
  312. $actor = $gate;
  313. $id = (int) $params['id'];
  314. $sprint = $this->sprints->find($id);
  315. if ($sprint === null) {
  316. return Response::err('not_found', 'Sprint not found', 404);
  317. }
  318. $body = $req->json() ?? [];
  319. $changes = [];
  320. if (array_key_exists('name', $body)) {
  321. $name = is_string($body['name']) ? trim($body['name']) : '';
  322. if ($name === '') {
  323. return Response::err('validation', 'Name cannot be empty', 422, ['field' => 'name']);
  324. }
  325. $changes['name'] = $name;
  326. }
  327. if (array_key_exists('start_date', $body)) {
  328. if (!is_string($body['start_date']) || !self::isIsoDate($body['start_date'])) {
  329. return Response::err('validation', 'Invalid start_date', 422, ['field' => 'start_date']);
  330. }
  331. $changes['start_date'] = $body['start_date'];
  332. }
  333. if (array_key_exists('end_date', $body)) {
  334. if (!is_string($body['end_date']) || !self::isIsoDate($body['end_date'])) {
  335. return Response::err('validation', 'Invalid end_date', 422, ['field' => 'end_date']);
  336. }
  337. $changes['end_date'] = $body['end_date'];
  338. }
  339. if (array_key_exists('reserve_fraction', $body)) {
  340. if (!is_numeric($body['reserve_fraction'])) {
  341. return Response::err('validation', 'reserve_fraction must be numeric', 422);
  342. }
  343. $rf = (float) $body['reserve_fraction'];
  344. if ($rf < 0.0 || $rf > 1.0) {
  345. return Response::err('validation', 'reserve_fraction must be 0..1', 422);
  346. }
  347. $changes['reserve_fraction'] = $rf;
  348. }
  349. $effectiveStart = $changes['start_date'] ?? $sprint->startDate;
  350. $effectiveEnd = $changes['end_date'] ?? $sprint->endDate;
  351. if ($effectiveEnd < $effectiveStart) {
  352. return Response::err('validation', 'end_date must be on or after start_date', 422);
  353. }
  354. // When the date range moves, the week set is fully derived from it:
  355. // count = floor((end - start)/7) + 1, capped at 26.
  356. $datesChanged = isset($changes['start_date']) || isset($changes['end_date']);
  357. $targetWeeks = null;
  358. $cascadedDays = [];
  359. if ($datesChanged) {
  360. $targetWeeks = self::weeksBetween($effectiveStart, $effectiveEnd);
  361. if ($targetWeeks > 26) {
  362. return Response::err(
  363. 'validation',
  364. 'Date range spans more than 26 weeks',
  365. 422,
  366. );
  367. }
  368. // Snapshot cells that would be cascade-deleted by a shrink BEFORE
  369. // we open the transaction, so the audit has the rows it needs.
  370. $existing = $this->weeks->allForSprint($id);
  371. if ($targetWeeks < count($existing)) {
  372. foreach (array_slice($existing, $targetWeeks) as $w) {
  373. foreach ($this->days->allForSprintWeek($w->id) as $d) {
  374. $cascadedDays[] = $d;
  375. }
  376. }
  377. }
  378. }
  379. if ($changes === []) {
  380. return Response::ok(['sprint' => $sprint->toAuditSnapshot()]);
  381. }
  382. $this->pdo->beginTransaction();
  383. try {
  384. $result = $this->sprints->update($id, $changes);
  385. $this->audit->recordForRequest(
  386. 'UPDATE', 'sprint', $id,
  387. $result['before']->toAuditSnapshot(),
  388. $result['after']->toAuditSnapshot(),
  389. $req, $actor,
  390. );
  391. if ($datesChanged && $targetWeeks !== null) {
  392. // 1) Realign existing rows' start_date/iso_week to the new offset.
  393. foreach ($this->weeks->realignDates($id, $effectiveStart) as $d) {
  394. $this->audit->recordForRequest(
  395. 'UPDATE', 'sprint_week', $d['after']->id,
  396. $d['before']->toAuditSnapshot(),
  397. $d['after']->toAuditSnapshot(),
  398. $req, $actor,
  399. );
  400. }
  401. // 2) Audit cells that the upcoming shrink will cascade.
  402. foreach ($cascadedDays as $cell) {
  403. $this->audit->recordForRequest(
  404. 'DELETE', 'sprint_worker_days', $cell->id,
  405. $cell->toAuditSnapshot(), null,
  406. $req, $actor,
  407. );
  408. }
  409. // 3) Resize the week set.
  410. $diff = $this->weeks->syncCount($id, $effectiveStart, $targetWeeks);
  411. foreach ($diff['added'] as $w) {
  412. $this->audit->recordForRequest(
  413. 'CREATE', 'sprint_week', $w->id,
  414. null, $w->toAuditSnapshot(),
  415. $req, $actor,
  416. );
  417. }
  418. foreach ($diff['removed'] as $w) {
  419. $this->audit->recordForRequest(
  420. 'DELETE', 'sprint_week', $w->id,
  421. $w->toAuditSnapshot(), null,
  422. $req, $actor,
  423. );
  424. }
  425. }
  426. $this->pdo->commit();
  427. } catch (Throwable) {
  428. $this->pdo->rollBack();
  429. return Response::err('db_error', 'Could not save sprint', 500);
  430. }
  431. return Response::ok([
  432. 'sprint' => $result['after']->toAuditSnapshot(),
  433. 'weeks_synced' => $datesChanged,
  434. ]);
  435. }
  436. /**
  437. * Number of calendar weeks the inclusive range [start..end] spans.
  438. * Matches the "every 7-day block since start" model used by
  439. * {@see SprintWeekRepository::syncCount()}.
  440. */
  441. private static function weeksBetween(string $startYmd, string $endYmd): int
  442. {
  443. $start = DateTimeImmutable::createFromFormat('Y-m-d', $startYmd);
  444. $end = DateTimeImmutable::createFromFormat('Y-m-d', $endYmd);
  445. if ($start === false || $end === false) {
  446. return 1;
  447. }
  448. $days = (int) $start->diff($end)->format('%r%a');
  449. return max(1, intdiv($days, 7) + 1);
  450. }
  451. /** POST /sprints/{id}/weeks — JSON — resize the week set. */
  452. public function replaceWeeks(Request $req, array $params): Response
  453. {
  454. $gate = $this->gateJsonAdmin($req);
  455. if ($gate instanceof Response) {
  456. return $gate;
  457. }
  458. $actor = $gate;
  459. $id = (int) $params['id'];
  460. $sprint = $this->sprints->find($id);
  461. if ($sprint === null) {
  462. return Response::err('not_found', 'Sprint not found', 404);
  463. }
  464. $body = $req->json() ?? [];
  465. if (!isset($body['n_weeks']) || !is_int($body['n_weeks']) || $body['n_weeks'] < 1 || $body['n_weeks'] > 26) {
  466. return Response::err('validation', 'n_weeks must be an integer in 1..26', 422);
  467. }
  468. // Identify which weeks will be dropped so we can snapshot their
  469. // sprint_worker_days BEFORE syncCount runs the DELETE (cascade
  470. // would otherwise wipe them without audit).
  471. $targetCount = (int) $body['n_weeks'];
  472. $existing = $this->weeks->allForSprint($id);
  473. $toRemove = $targetCount < count($existing)
  474. ? array_slice($existing, $targetCount)
  475. : [];
  476. $cascadedDays = [];
  477. foreach ($toRemove as $w) {
  478. foreach ($this->days->allForSprintWeek($w->id) as $d) {
  479. $cascadedDays[] = $d;
  480. }
  481. }
  482. $this->pdo->beginTransaction();
  483. try {
  484. foreach ($cascadedDays as $d) {
  485. $this->audit->recordForRequest(
  486. 'DELETE', 'sprint_worker_days', $d->id,
  487. $d->toAuditSnapshot(), null,
  488. $req, $actor,
  489. );
  490. }
  491. $diff = $this->weeks->syncCount($id, $sprint->startDate, $targetCount);
  492. foreach ($diff['added'] as $w) {
  493. $this->audit->recordForRequest(
  494. 'CREATE', 'sprint_week', $w->id,
  495. null, $w->toAuditSnapshot(),
  496. $req, $actor,
  497. );
  498. }
  499. foreach ($diff['removed'] as $w) {
  500. $this->audit->recordForRequest(
  501. 'DELETE', 'sprint_week', $w->id,
  502. $w->toAuditSnapshot(), null,
  503. $req, $actor,
  504. );
  505. }
  506. $this->pdo->commit();
  507. } catch (Throwable) {
  508. $this->pdo->rollBack();
  509. return Response::err('db_error', 'Could not update weeks', 500);
  510. }
  511. return Response::ok([
  512. 'weeks' => array_map(
  513. fn($w) => $w->toAuditSnapshot(),
  514. $this->weeks->allForSprint($id)
  515. ),
  516. 'added' => count($diff['added']),
  517. 'removed' => count($diff['removed']),
  518. ]);
  519. }
  520. /** POST /sprints/{id}/workers — JSON — add a worker to the sprint. */
  521. public function addWorker(Request $req, array $params): Response
  522. {
  523. $gate = $this->gateJsonAdmin($req);
  524. if ($gate instanceof Response) {
  525. return $gate;
  526. }
  527. $actor = $gate;
  528. $sprintId = (int) $params['id'];
  529. if ($this->sprints->find($sprintId) === null) {
  530. return Response::err('not_found', 'Sprint not found', 404);
  531. }
  532. $body = $req->json() ?? [];
  533. if (!isset($body['worker_id']) || !is_int($body['worker_id'])) {
  534. return Response::err('validation', 'worker_id required', 422);
  535. }
  536. $workerId = (int) $body['worker_id'];
  537. $worker = $this->workers->find($workerId);
  538. if ($worker === null) {
  539. return Response::err('validation', 'Unknown worker', 422, ['field' => 'worker_id']);
  540. }
  541. if (!$worker->isActive) {
  542. return Response::err('validation', 'Worker is inactive', 422, ['field' => 'worker_id']);
  543. }
  544. $rtb = $worker->defaultRtb;
  545. if (isset($body['rtb'])) {
  546. if (!is_numeric($body['rtb']) || (float) $body['rtb'] < 0.0 || (float) $body['rtb'] > 1.0) {
  547. return Response::err('validation', 'rtb must be 0..1', 422);
  548. }
  549. $rtb = (float) $body['rtb'];
  550. }
  551. $this->pdo->beginTransaction();
  552. try {
  553. $sw = $this->sprintWorkers->add($sprintId, $workerId, $rtb);
  554. $this->audit->recordForRequest(
  555. 'CREATE', 'sprint_worker', $sw->id,
  556. null, $sw->toAuditSnapshot(),
  557. $req, $actor,
  558. );
  559. $this->pdo->commit();
  560. } catch (PDOException $e) {
  561. $this->pdo->rollBack();
  562. if (str_contains(strtolower($e->getMessage()), 'unique')) {
  563. return Response::err('conflict', 'Worker already in sprint', 409);
  564. }
  565. return Response::err('db_error', 'Could not add worker', 500);
  566. } catch (Throwable) {
  567. $this->pdo->rollBack();
  568. return Response::err('db_error', 'Could not add worker', 500);
  569. }
  570. return Response::ok([
  571. 'sprint_worker' => $sw->toAuditSnapshot() + ['worker_name' => $sw->workerName],
  572. ]);
  573. }
  574. /** DELETE /sprints/{id}/workers/{sw_id} — JSON — remove a worker from the sprint. */
  575. public function removeWorker(Request $req, array $params): Response
  576. {
  577. $gate = $this->gateJsonAdmin($req);
  578. if ($gate instanceof Response) {
  579. return $gate;
  580. }
  581. $actor = $gate;
  582. $sprintId = (int) $params['id'];
  583. $swId = (int) $params['sw_id'];
  584. $existing = $this->sprintWorkers->find($swId);
  585. if ($existing === null || $existing->sprintId !== $sprintId) {
  586. return Response::err('not_found', 'sprint_worker not found in this sprint', 404);
  587. }
  588. // Snapshot everything the FK cascade will wipe BEFORE deleting, so
  589. // each cascaded row gets its own DELETE audit entry (spec §5).
  590. $cascadedDays = $this->days->allForSprintWorker($swId);
  591. $cascadedAssignments = $this->assignments->allForSprintWorker($swId);
  592. $this->pdo->beginTransaction();
  593. try {
  594. foreach ($cascadedDays as $d) {
  595. $this->audit->recordForRequest(
  596. 'DELETE', 'sprint_worker_days', $d->id,
  597. $d->toAuditSnapshot(), null,
  598. $req, $actor,
  599. );
  600. }
  601. foreach ($cascadedAssignments as $a) {
  602. $this->audit->recordForRequest(
  603. 'DELETE', 'task_assignment', $a->id,
  604. $a->toAuditSnapshot(), null,
  605. $req, $actor,
  606. );
  607. }
  608. $removed = $this->sprintWorkers->remove($swId);
  609. if ($removed !== null) {
  610. $this->audit->recordForRequest(
  611. 'DELETE', 'sprint_worker', $removed->id,
  612. $removed->toAuditSnapshot(), null,
  613. $req, $actor,
  614. );
  615. }
  616. $this->pdo->commit();
  617. } catch (Throwable) {
  618. $this->pdo->rollBack();
  619. return Response::err('db_error', 'Could not remove worker', 500);
  620. }
  621. return Response::ok(['removed_id' => $swId]);
  622. }
  623. /** POST /sprints/{id}/workers/reorder — JSON — apply an ordering. */
  624. public function reorderWorkers(Request $req, array $params): Response
  625. {
  626. $gate = $this->gateJsonAdmin($req);
  627. if ($gate instanceof Response) {
  628. return $gate;
  629. }
  630. $actor = $gate;
  631. $sprintId = (int) $params['id'];
  632. if ($this->sprints->find($sprintId) === null) {
  633. return Response::err('not_found', 'Sprint not found', 404);
  634. }
  635. $body = $req->json();
  636. if (!is_array($body) || !array_is_list($body)) {
  637. return Response::err('validation', 'body must be a list of {sprint_worker_id, sort_order}', 422);
  638. }
  639. $ordering = [];
  640. $seenOrder = [];
  641. foreach ($body as $row) {
  642. if (!is_array($row) || !isset($row['sprint_worker_id'], $row['sort_order'])) {
  643. return Response::err('validation', 'each entry needs sprint_worker_id and sort_order', 422);
  644. }
  645. $sw = (int) $row['sprint_worker_id'];
  646. $order = (int) $row['sort_order'];
  647. if ($sw <= 0 || $order < 1) {
  648. return Response::err('validation', 'ids/orders must be positive', 422);
  649. }
  650. if (isset($seenOrder[$order])) {
  651. return Response::err('validation', 'duplicate sort_order', 422);
  652. }
  653. $seenOrder[$order] = true;
  654. $ordering[] = ['sprint_worker_id' => $sw, 'sort_order' => $order];
  655. }
  656. $this->pdo->beginTransaction();
  657. try {
  658. $diffs = $this->sprintWorkers->reorder($sprintId, $ordering);
  659. foreach ($diffs as $d) {
  660. $this->audit->recordForRequest(
  661. 'UPDATE', 'sprint_worker', $d['after']->id,
  662. $d['before']->toAuditSnapshot(),
  663. $d['after']->toAuditSnapshot(),
  664. $req, $actor,
  665. );
  666. }
  667. $this->pdo->commit();
  668. } catch (Throwable) {
  669. $this->pdo->rollBack();
  670. return Response::err('db_error', 'Could not reorder', 500);
  671. }
  672. return Response::ok(['moved' => count($diffs)]);
  673. }
  674. /** PATCH /sprints/{id}/workers/{sw_id} — JSON — edit RTB. */
  675. public function updateWorker(Request $req, array $params): Response
  676. {
  677. $gate = $this->gateJsonAdmin($req);
  678. if ($gate instanceof Response) {
  679. return $gate;
  680. }
  681. $actor = $gate;
  682. $sprintId = (int) $params['id'];
  683. $swId = (int) $params['sw_id'];
  684. $existing = $this->sprintWorkers->find($swId);
  685. if ($existing === null || $existing->sprintId !== $sprintId) {
  686. return Response::err('not_found', 'sprint_worker not found in this sprint', 404);
  687. }
  688. $body = $req->json() ?? [];
  689. if (!isset($body['rtb']) || !is_numeric($body['rtb'])) {
  690. return Response::err('validation', 'rtb required', 422);
  691. }
  692. $rtb = (float) $body['rtb'];
  693. if ($rtb < 0.0 || $rtb > 1.0) {
  694. return Response::err('validation', 'rtb must be 0..1', 422);
  695. }
  696. $this->pdo->beginTransaction();
  697. try {
  698. $result = $this->sprintWorkers->setRtb($swId, $rtb);
  699. $this->audit->recordForRequest(
  700. 'UPDATE', 'sprint_worker', $swId,
  701. $result['before']->toAuditSnapshot(),
  702. $result['after']->toAuditSnapshot(),
  703. $req, $actor,
  704. );
  705. $this->pdo->commit();
  706. } catch (Throwable) {
  707. $this->pdo->rollBack();
  708. return Response::err('db_error', 'Could not update worker', 500);
  709. }
  710. return Response::ok(['sprint_worker' => $result['after']->toAuditSnapshot()]);
  711. }
  712. /** PATCH /sprints/{id}/week-cells — JSON — batch upsert of sprint_worker_days. */
  713. public function updateWeekCells(Request $req, array $params): Response
  714. {
  715. $gate = $this->gateJsonAdmin($req);
  716. if ($gate instanceof Response) {
  717. return $gate;
  718. }
  719. $actor = $gate;
  720. $sprintId = (int) $params['id'];
  721. $sprint = $this->sprints->find($sprintId);
  722. if ($sprint === null) {
  723. return Response::err('not_found', 'Sprint not found', 404);
  724. }
  725. $body = $req->json();
  726. if (!is_array($body) || !array_is_list($body)) {
  727. return Response::err('validation', 'body must be a list of {sprint_worker_id, sprint_week_id, days}', 422);
  728. }
  729. if ($body === []) {
  730. return Response::ok(['applied' => 0, 'noop' => 0, 'per_worker' => new \stdClass()]);
  731. }
  732. if (count($body) > self::MAX_BATCH_ITEMS) {
  733. return Response::err(
  734. 'too_many_items',
  735. 'cell list exceeds ' . self::MAX_BATCH_ITEMS . '-item cap',
  736. 413,
  737. );
  738. }
  739. // Cross-check every cell belongs to this sprint.
  740. $validSw = array_column(
  741. array_map(fn($sw) => ['id' => $sw->id], $this->sprintWorkers->allForSprint($sprintId)),
  742. 'id',
  743. );
  744. $validSw = array_flip($validSw);
  745. $validWk = array_column(
  746. array_map(fn($w) => ['id' => $w->id], $this->weeks->allForSprint($sprintId)),
  747. 'id',
  748. );
  749. $validWk = array_flip($validWk);
  750. $cells = [];
  751. foreach ($body as $i => $row) {
  752. if (!is_array($row) || !isset($row['sprint_worker_id'], $row['sprint_week_id'], $row['days'])) {
  753. return Response::err('validation', "cell[{$i}] needs sprint_worker_id, sprint_week_id, days", 422);
  754. }
  755. $swId = (int) $row['sprint_worker_id'];
  756. $wkId = (int) $row['sprint_week_id'];
  757. $daysN = $row['days'];
  758. if (!is_numeric($daysN)) {
  759. return Response::err('validation', "cell[{$i}] days must be numeric", 422);
  760. }
  761. $days = (float) $daysN;
  762. if (!isset($validSw[$swId])) {
  763. return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422);
  764. }
  765. if (!isset($validWk[$wkId])) {
  766. return Response::err('validation', "cell[{$i}] sprint_week {$wkId} not in sprint", 422);
  767. }
  768. if (!CapacityCalculator::isHalfStep($days, 0.0, 5.0)) {
  769. return Response::err('validation', "cell[{$i}] days must be 0..5 in 0.5 steps", 422);
  770. }
  771. $cells[] = ['sw_id' => $swId, 'week_id' => $wkId, 'days' => $days];
  772. }
  773. $applied = 0;
  774. $noop = 0;
  775. $touchedWorkers = [];
  776. $this->pdo->beginTransaction();
  777. try {
  778. foreach ($cells as $c) {
  779. $result = $this->days->upsert($c['sw_id'], $c['week_id'], $c['days']);
  780. if ($result['action'] === 'NOOP') {
  781. $noop++;
  782. continue;
  783. }
  784. $applied++;
  785. $touchedWorkers[$c['sw_id']] = true;
  786. $this->audit->recordForRequest(
  787. action: $result['action'],
  788. entityType: 'sprint_worker_days',
  789. entityId: $result['after']?->id ?? $result['before']?->id,
  790. before: $result['before']?->toAuditSnapshot(),
  791. after: $result['after']?->toAuditSnapshot(),
  792. req: $req,
  793. actor: $actor,
  794. );
  795. }
  796. $this->pdo->commit();
  797. } catch (Throwable) {
  798. $this->pdo->rollBack();
  799. return Response::err('db_error', 'Could not save cells', 500);
  800. }
  801. // Recompute capacity for every worker whose row changed.
  802. $grid = $this->days->grid($sprintId);
  803. $perWorker = [];
  804. foreach (array_keys($touchedWorkers) as $swId) {
  805. $ressourcen = array_sum($grid[$swId] ?? []);
  806. $perWorker[(string) $swId] = CapacityCalculator::forWorker(
  807. $ressourcen,
  808. $sprint->reserveFraction,
  809. 0.0,
  810. );
  811. }
  812. return Response::ok([
  813. 'applied' => $applied,
  814. 'noop' => $noop,
  815. 'per_worker' => $perWorker === [] ? new \stdClass() : $perWorker,
  816. ]);
  817. }
  818. /**
  819. * PATCH /sprints/{id}/week/{week_id} — JSON — set the active weekdays for
  820. * one week. Accepts either `{"active_days_mask": 15}` or
  821. * `{"active_days": ["Mo", "Di", "Mi", "Do"]}`; max_working_days is
  822. * derived server-side as popcount(mask).
  823. */
  824. public function updateWeekDays(Request $req, array $params): Response
  825. {
  826. $gate = $this->gateJsonAdmin($req);
  827. if ($gate instanceof Response) {
  828. return $gate;
  829. }
  830. $actor = $gate;
  831. $sprintId = (int) $params['id'];
  832. $weekId = (int) $params['week_id'];
  833. $week = $this->weeks->find($weekId);
  834. if ($week === null || $week->sprintId !== $sprintId) {
  835. return Response::err('not_found', 'sprint_week not found in this sprint', 404);
  836. }
  837. $body = $req->json() ?? [];
  838. $mask = null;
  839. if (array_key_exists('active_days_mask', $body)) {
  840. $raw = $body['active_days_mask'];
  841. if (!is_int($raw) || $raw < 0 || $raw > SprintWeek::MASK_ALL) {
  842. return Response::err(
  843. 'validation',
  844. 'active_days_mask must be an integer 0..31',
  845. 422,
  846. ['field' => 'active_days_mask'],
  847. );
  848. }
  849. $mask = $raw;
  850. } elseif (array_key_exists('active_days', $body)) {
  851. if (!is_array($body['active_days']) || !array_is_list($body['active_days'])) {
  852. return Response::err(
  853. 'validation',
  854. 'active_days must be a list of Mo/Di/Mi/Do/Fr',
  855. 422,
  856. ['field' => 'active_days'],
  857. );
  858. }
  859. try {
  860. $mask = SprintWeek::daysToMask($body['active_days']);
  861. } catch (InvalidArgumentException $e) {
  862. return Response::err(
  863. 'validation',
  864. $e->getMessage(),
  865. 422,
  866. ['field' => 'active_days'],
  867. );
  868. }
  869. } else {
  870. return Response::err(
  871. 'validation',
  872. 'one of active_days_mask or active_days required',
  873. 422,
  874. );
  875. }
  876. if ($week->activeDaysMask === $mask) {
  877. return Response::ok(['sprint_week' => $week->toAuditSnapshot()]);
  878. }
  879. $this->pdo->beginTransaction();
  880. try {
  881. $result = $this->weeks->updateActiveDays($weekId, $mask);
  882. $this->audit->recordForRequest(
  883. 'UPDATE', 'sprint_week', $weekId,
  884. $result['before']->toAuditSnapshot(),
  885. $result['after']->toAuditSnapshot(),
  886. $req, $actor,
  887. );
  888. $this->pdo->commit();
  889. } catch (Throwable) {
  890. $this->pdo->rollBack();
  891. return Response::err('db_error', 'Could not update week', 500);
  892. }
  893. return Response::ok(['sprint_week' => $result['after']->toAuditSnapshot()]);
  894. }
  895. /**
  896. * POST /sprints/{id}/delete — destructive: removes the sprint and every
  897. * row attached to it.
  898. *
  899. * Form-post (not JSON): admin + CSRF token from the form, plus a
  900. * `confirm_name` field that must match the sprint's name verbatim. Each
  901. * cascaded child (task_assignments, sprint_worker_days, tasks,
  902. * sprint_workers, sprint_weeks) is snapshotted and audited DELETE
  903. * before the parent delete fires, per spec §7. Tasks in *other*
  904. * sprints whose `linked_task_id` points at one of this sprint's tasks
  905. * are silently SET NULL by the FK; we audit those as UPDATE rows so
  906. * the chain is reconstructable from the audit log.
  907. */
  908. public function delete(Request $req, array $params): Response
  909. {
  910. $actor = SessionGuard::requireAdmin($this->users);
  911. if ($actor instanceof Response) {
  912. return $actor;
  913. }
  914. if (!SessionGuard::verifyCsrf($req)) {
  915. return Response::text('CSRF token invalid', 403);
  916. }
  917. $sprintId = (int) $params['id'];
  918. $sprint = $this->sprints->find($sprintId);
  919. if ($sprint === null) {
  920. return Response::text('Not Found', 404);
  921. }
  922. // Type-the-name guard: defence in depth — the JS keeps the submit
  923. // button disabled until this matches, but a JS-bypass attempt still
  924. // hits this check.
  925. $confirm = trim($req->postString('confirm_name'));
  926. if ($confirm !== $sprint->name) {
  927. return Response::redirect('/sprints/' . $sprintId . '/settings?error=name_mismatch');
  928. }
  929. // Snapshot every cascaded child BEFORE the parent delete. Order
  930. // mirrors the FK dependency tree: leaves first.
  931. $sprintWorkers = $this->sprintWorkers->allForSprint($sprintId);
  932. $cascadedDays = [];
  933. $cascadedAssignments = [];
  934. foreach ($sprintWorkers as $sw) {
  935. foreach ($this->days->allForSprintWorker($sw->id) as $d) {
  936. $cascadedDays[] = $d;
  937. }
  938. foreach ($this->assignments->allForSprintWorker($sw->id) as $a) {
  939. $cascadedAssignments[] = $a;
  940. }
  941. }
  942. $tasksInSprint = $this->tasks->allForSprint($sprintId);
  943. $weeks = $this->weeks->allForSprint($sprintId);
  944. // Phase 22 SET NULL audit: tasks in OTHER sprints whose
  945. // linked_task_id points at any task in this sprint will be
  946. // silently nulled by the cascade. Capture them so the audit log
  947. // doesn't lose the link.
  948. $linkUpdates = [];
  949. if ($tasksInSprint !== []) {
  950. $taskIds = array_map(fn($t) => $t->id, $tasksInSprint);
  951. $place = implode(',', array_fill(0, count($taskIds), '?'));
  952. $stmt = $this->pdo->prepare(
  953. 'SELECT * FROM tasks WHERE linked_task_id IN (' . $place . ')'
  954. );
  955. $stmt->execute($taskIds);
  956. foreach ($stmt as $row) {
  957. $tid = (int) $row['id'];
  958. // Skip rows that are themselves in this sprint — they're
  959. // about to be deleted, no SET NULL audit needed.
  960. if ((int) $row['sprint_id'] === $sprintId) {
  961. continue;
  962. }
  963. $linkUpdates[] = $tid;
  964. }
  965. }
  966. $this->pdo->beginTransaction();
  967. try {
  968. // Audit cascaded leaves first.
  969. foreach ($cascadedAssignments as $a) {
  970. $this->audit->recordForRequest(
  971. 'DELETE', 'task_assignment', $a->id,
  972. $a->toAuditSnapshot(), null,
  973. $req, $actor,
  974. );
  975. }
  976. foreach ($cascadedDays as $d) {
  977. $this->audit->recordForRequest(
  978. 'DELETE', 'sprint_worker_days', $d->id,
  979. $d->toAuditSnapshot(), null,
  980. $req, $actor,
  981. );
  982. }
  983. foreach ($tasksInSprint as $t) {
  984. $this->audit->recordForRequest(
  985. 'DELETE', 'task', $t->id,
  986. $t->toAuditSnapshot(), null,
  987. $req, $actor,
  988. );
  989. }
  990. foreach ($sprintWorkers as $sw) {
  991. $this->audit->recordForRequest(
  992. 'DELETE', 'sprint_worker', $sw->id,
  993. $sw->toAuditSnapshot(), null,
  994. $req, $actor,
  995. );
  996. }
  997. foreach ($weeks as $w) {
  998. $this->audit->recordForRequest(
  999. 'DELETE', 'sprint_week', $w->id,
  1000. $w->toAuditSnapshot(), null,
  1001. $req, $actor,
  1002. );
  1003. }
  1004. // Audit the SET NULL on cross-sprint linked tasks. We refetch
  1005. // each row inside the transaction so the snapshot is current.
  1006. foreach ($linkUpdates as $tid) {
  1007. $linked = $this->tasks->find($tid);
  1008. if ($linked === null) {
  1009. continue;
  1010. }
  1011. $beforeSnap = $linked->toAuditSnapshot();
  1012. $afterSnap = $beforeSnap;
  1013. $afterSnap['linked_task_id'] = null;
  1014. $this->audit->recordForRequest(
  1015. 'UPDATE', 'task', $tid,
  1016. $beforeSnap, $afterSnap,
  1017. $req, $actor,
  1018. );
  1019. }
  1020. $this->sprints->delete($sprintId);
  1021. $this->audit->recordForRequest(
  1022. 'DELETE', 'sprint', $sprintId,
  1023. $sprint->toAuditSnapshot(), null,
  1024. $req, $actor,
  1025. );
  1026. $this->pdo->commit();
  1027. } catch (Throwable) {
  1028. $this->pdo->rollBack();
  1029. return Response::redirect('/sprints/' . $sprintId . '/settings?error=db_error');
  1030. }
  1031. // R01-N26: stash the deleted sprint's name in a one-shot session
  1032. // flash instead of leaking it via `?deleted=<name>`. The query-string
  1033. // form let anyone craft `/?deleted=foo` and see a green "Sprint foo
  1034. // was deleted" chip without an actual delete having happened.
  1035. // The home route reads this key and unsets it on render; expiry on
  1036. // first read makes it impossible to re-trigger with a refresh.
  1037. SessionGuard::start();
  1038. $_SESSION['flash_deleted_sprint_name'] = $sprint->name;
  1039. return Response::redirect('/');
  1040. }
  1041. // ------------------------------------------------------------------
  1042. // Shared helpers
  1043. // ------------------------------------------------------------------
  1044. /**
  1045. * Admin gate for JSON endpoints. Returns the signed-in User on success,
  1046. * or an `Response::err(...)` JSON envelope on failure. Also enforces CSRF.
  1047. */
  1048. private function gateJsonAdmin(Request $req): User|Response
  1049. {
  1050. $user = SessionGuard::currentUser($this->users);
  1051. if ($user === null) {
  1052. return Response::err('unauthenticated', 'Sign in required', 401);
  1053. }
  1054. if (!$user->isAdmin) {
  1055. return Response::err('forbidden', 'Admin access required', 403);
  1056. }
  1057. if (!SessionGuard::verifyCsrf($req)) {
  1058. return Response::err('csrf', 'CSRF token invalid', 403);
  1059. }
  1060. return $user;
  1061. }
  1062. private static function isIsoDate(string $s): bool
  1063. {
  1064. $d = DateTimeImmutable::createFromFormat('Y-m-d', $s);
  1065. return $d !== false && $d->format('Y-m-d') === $s;
  1066. }
  1067. }