SprintController.php 44 KB

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