SprintController.php 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Controllers;
  4. use App\Auth\SessionGuard;
  5. use App\Domain\User;
  6. use App\Http\Request;
  7. use App\Http\Response;
  8. use App\Http\View;
  9. use App\Repositories\SprintRepository;
  10. use App\Repositories\SprintWeekRepository;
  11. use App\Repositories\SprintWorkerDayRepository;
  12. use App\Repositories\SprintWorkerRepository;
  13. use App\Repositories\UserRepository;
  14. use App\Repositories\WorkerRepository;
  15. use App\Services\AuditLogger;
  16. use App\Services\CapacityCalculator;
  17. use DateTimeImmutable;
  18. use PDO;
  19. use PDOException;
  20. use Throwable;
  21. final class SprintController
  22. {
  23. public function __construct(
  24. private readonly PDO $pdo,
  25. private readonly UserRepository $users,
  26. private readonly SprintRepository $sprints,
  27. private readonly SprintWeekRepository $weeks,
  28. private readonly SprintWorkerRepository $sprintWorkers,
  29. private readonly SprintWorkerDayRepository $days,
  30. private readonly WorkerRepository $workers,
  31. private readonly AuditLogger $audit,
  32. private readonly View $view,
  33. ) {
  34. }
  35. /** GET /sprints/new — admin-only form. */
  36. public function newForm(Request $req): Response
  37. {
  38. $actor = SessionGuard::requireAdmin($this->users);
  39. if ($actor instanceof Response) {
  40. return $actor;
  41. }
  42. return Response::html($this->view->render('sprints/new', [
  43. 'title' => 'New sprint',
  44. 'currentUser' => $actor,
  45. 'csrfToken' => SessionGuard::csrfToken(),
  46. 'error' => $req->queryString('error'),
  47. 'form' => [
  48. 'name' => '',
  49. 'start_date' => '',
  50. 'end_date' => '',
  51. 'reserve_fraction' => '20',
  52. 'n_weeks' => '4',
  53. ],
  54. ]));
  55. }
  56. /** POST /sprints — create sprint + materialise weeks in one tx. */
  57. public function create(Request $req): Response
  58. {
  59. $actor = SessionGuard::requireAdmin($this->users);
  60. if ($actor instanceof Response) {
  61. return $actor;
  62. }
  63. if (!SessionGuard::verifyCsrf($req)) {
  64. return Response::text('CSRF token invalid', 403);
  65. }
  66. $name = trim($req->postString('name'));
  67. $start = $req->postString('start_date');
  68. $end = $req->postString('end_date');
  69. // reserve_fraction submitted as a percentage (0..100) from the form.
  70. $reservePct = $req->postString('reserve_fraction');
  71. $nWeeksStr = $req->postString('n_weeks');
  72. if ($name === '') {
  73. return Response::redirect('/sprints/new?error=name_required');
  74. }
  75. $startD = DateTimeImmutable::createFromFormat('Y-m-d', $start);
  76. $endD = DateTimeImmutable::createFromFormat('Y-m-d', $end);
  77. if ($startD === false || $endD === false) {
  78. return Response::redirect('/sprints/new?error=dates_invalid');
  79. }
  80. if ($endD < $startD) {
  81. return Response::redirect('/sprints/new?error=dates_order');
  82. }
  83. if (!is_numeric($reservePct)) {
  84. return Response::redirect('/sprints/new?error=reserve_invalid');
  85. }
  86. $reserve = ((float) $reservePct) / 100.0;
  87. if ($reserve < 0.0 || $reserve > 1.0) {
  88. return Response::redirect('/sprints/new?error=reserve_out_of_range');
  89. }
  90. if (!ctype_digit($nWeeksStr)) {
  91. return Response::redirect('/sprints/new?error=n_weeks_invalid');
  92. }
  93. $nWeeks = (int) $nWeeksStr;
  94. if ($nWeeks < 1 || $nWeeks > 26) {
  95. return Response::redirect('/sprints/new?error=n_weeks_range');
  96. }
  97. $this->pdo->beginTransaction();
  98. try {
  99. $sprint = $this->sprints->create(
  100. name: $name,
  101. startDate: $startD->format('Y-m-d'),
  102. endDate: $endD->format('Y-m-d'),
  103. reserveFraction: $reserve,
  104. );
  105. $this->audit->recordForRequest(
  106. action: 'CREATE',
  107. entityType: 'sprint',
  108. entityId: $sprint->id,
  109. before: null,
  110. after: $sprint->toAuditSnapshot(),
  111. req: $req,
  112. actor: $actor,
  113. );
  114. $weeks = $this->sprints->materializeWeeks(
  115. $sprint->id,
  116. $startD->format('Y-m-d'),
  117. $nWeeks,
  118. );
  119. foreach ($weeks as $w) {
  120. $this->audit->recordForRequest(
  121. action: 'CREATE',
  122. entityType: 'sprint_week',
  123. entityId: $w['id'],
  124. before: null,
  125. after: ['sprint_id' => $sprint->id] + $w,
  126. req: $req,
  127. actor: $actor,
  128. );
  129. }
  130. $this->pdo->commit();
  131. } catch (Throwable) {
  132. $this->pdo->rollBack();
  133. return Response::redirect('/sprints/new?error=db_error');
  134. }
  135. return Response::redirect('/sprints/' . $sprint->id);
  136. }
  137. /** GET /sprints/{id} — main planning view (Section A Arbeitstage; tasks land in Phase 6). */
  138. public function show(Request $req, array $params): Response
  139. {
  140. $actor = SessionGuard::requireAuth($this->users);
  141. if ($actor instanceof Response) {
  142. return $actor;
  143. }
  144. $id = (int) $params['id'];
  145. $sprint = $this->sprints->find($id);
  146. if ($sprint === null) {
  147. return Response::text('Not Found', 404);
  148. }
  149. $weeks = $this->weeks->allForSprint($id);
  150. $sprintWorkers = $this->sprintWorkers->allForSprint($id);
  151. $grid = $this->days->grid($id);
  152. // Seed initial capacity server-side so the page is meaningful without JS
  153. // and the JS has the same numbers to compare against.
  154. $capacity = [];
  155. foreach ($sprintWorkers as $sw) {
  156. $wkDays = $grid[$sw->id] ?? [];
  157. $ressourcen = array_sum($wkDays);
  158. $capacity[$sw->id] = CapacityCalculator::forWorker(
  159. $ressourcen,
  160. $sprint->reserveFraction,
  161. 0.0, // prio-1 commitments come with Phase 6
  162. );
  163. }
  164. return Response::html($this->view->render('sprints/show', [
  165. 'title' => $sprint->name,
  166. 'currentUser' => $actor,
  167. 'csrfToken' => SessionGuard::csrfToken(),
  168. 'sprint' => $sprint,
  169. 'weeks' => $weeks,
  170. 'sprintWorkers' => $sprintWorkers,
  171. 'grid' => $grid,
  172. 'capacity' => $capacity,
  173. ]));
  174. }
  175. // -----------------------------------------------------------------------
  176. // Phase 4 — settings page + JSON mutation endpoints.
  177. // -----------------------------------------------------------------------
  178. /** GET /sprints/{id}/settings — admin-only. */
  179. public function settings(Request $req, array $params): Response
  180. {
  181. $actor = SessionGuard::requireAdmin($this->users);
  182. if ($actor instanceof Response) {
  183. return $actor;
  184. }
  185. $id = (int) $params['id'];
  186. $sprint = $this->sprints->find($id);
  187. if ($sprint === null) {
  188. return Response::text('Not Found', 404);
  189. }
  190. return Response::html($this->view->render('sprints/settings', [
  191. 'title' => "Settings — {$sprint->name}",
  192. 'currentUser' => $actor,
  193. 'csrfToken' => SessionGuard::csrfToken(),
  194. 'sprint' => $sprint,
  195. 'weeks' => $this->weeks->allForSprint($id),
  196. 'sprintWorkers' => $this->sprintWorkers->allForSprint($id),
  197. 'availableWorkers' => $this->workers->activeNotInSprint($id),
  198. ]));
  199. }
  200. /** PATCH /sprints/{id} — JSON — update name / dates / reserve_fraction. */
  201. public function updateMeta(Request $req, array $params): Response
  202. {
  203. $gate = $this->gateJsonAdmin($req);
  204. if ($gate instanceof Response) {
  205. return $gate;
  206. }
  207. $actor = $gate;
  208. $id = (int) $params['id'];
  209. $sprint = $this->sprints->find($id);
  210. if ($sprint === null) {
  211. return Response::err('not_found', 'Sprint not found', 404);
  212. }
  213. $body = $req->json() ?? [];
  214. $changes = [];
  215. if (array_key_exists('name', $body)) {
  216. $name = is_string($body['name']) ? trim($body['name']) : '';
  217. if ($name === '') {
  218. return Response::err('validation', 'Name cannot be empty', 422, ['field' => 'name']);
  219. }
  220. $changes['name'] = $name;
  221. }
  222. if (array_key_exists('start_date', $body)) {
  223. if (!is_string($body['start_date']) || !self::isIsoDate($body['start_date'])) {
  224. return Response::err('validation', 'Invalid start_date', 422, ['field' => 'start_date']);
  225. }
  226. $changes['start_date'] = $body['start_date'];
  227. }
  228. if (array_key_exists('end_date', $body)) {
  229. if (!is_string($body['end_date']) || !self::isIsoDate($body['end_date'])) {
  230. return Response::err('validation', 'Invalid end_date', 422, ['field' => 'end_date']);
  231. }
  232. $changes['end_date'] = $body['end_date'];
  233. }
  234. if (array_key_exists('reserve_fraction', $body)) {
  235. if (!is_numeric($body['reserve_fraction'])) {
  236. return Response::err('validation', 'reserve_fraction must be numeric', 422);
  237. }
  238. $rf = (float) $body['reserve_fraction'];
  239. if ($rf < 0.0 || $rf > 1.0) {
  240. return Response::err('validation', 'reserve_fraction must be 0..1', 422);
  241. }
  242. $changes['reserve_fraction'] = $rf;
  243. }
  244. $effectiveStart = $changes['start_date'] ?? $sprint->startDate;
  245. $effectiveEnd = $changes['end_date'] ?? $sprint->endDate;
  246. if ($effectiveEnd < $effectiveStart) {
  247. return Response::err('validation', 'end_date must be on or after start_date', 422);
  248. }
  249. if ($changes === []) {
  250. return Response::ok(['sprint' => $sprint->toAuditSnapshot()]);
  251. }
  252. $this->pdo->beginTransaction();
  253. try {
  254. $result = $this->sprints->update($id, $changes);
  255. $this->audit->recordForRequest(
  256. 'UPDATE', 'sprint', $id,
  257. $result['before']->toAuditSnapshot(),
  258. $result['after']->toAuditSnapshot(),
  259. $req, $actor,
  260. );
  261. $this->pdo->commit();
  262. } catch (Throwable) {
  263. $this->pdo->rollBack();
  264. return Response::err('db_error', 'Could not save sprint', 500);
  265. }
  266. return Response::ok(['sprint' => $result['after']->toAuditSnapshot()]);
  267. }
  268. /** POST /sprints/{id}/weeks — JSON — resize the week set. */
  269. public function replaceWeeks(Request $req, array $params): Response
  270. {
  271. $gate = $this->gateJsonAdmin($req);
  272. if ($gate instanceof Response) {
  273. return $gate;
  274. }
  275. $actor = $gate;
  276. $id = (int) $params['id'];
  277. $sprint = $this->sprints->find($id);
  278. if ($sprint === null) {
  279. return Response::err('not_found', 'Sprint not found', 404);
  280. }
  281. $body = $req->json() ?? [];
  282. if (!isset($body['n_weeks']) || !is_int($body['n_weeks']) || $body['n_weeks'] < 1 || $body['n_weeks'] > 26) {
  283. return Response::err('validation', 'n_weeks must be an integer in 1..26', 422);
  284. }
  285. $this->pdo->beginTransaction();
  286. try {
  287. $diff = $this->weeks->syncCount($id, $sprint->startDate, (int) $body['n_weeks']);
  288. foreach ($diff['added'] as $w) {
  289. $this->audit->recordForRequest(
  290. 'CREATE', 'sprint_week', $w->id,
  291. null, $w->toAuditSnapshot(),
  292. $req, $actor,
  293. );
  294. }
  295. foreach ($diff['removed'] as $w) {
  296. $this->audit->recordForRequest(
  297. 'DELETE', 'sprint_week', $w->id,
  298. $w->toAuditSnapshot(), null,
  299. $req, $actor,
  300. );
  301. }
  302. $this->pdo->commit();
  303. } catch (Throwable) {
  304. $this->pdo->rollBack();
  305. return Response::err('db_error', 'Could not update weeks', 500);
  306. }
  307. return Response::ok([
  308. 'weeks' => array_map(
  309. fn($w) => $w->toAuditSnapshot(),
  310. $this->weeks->allForSprint($id)
  311. ),
  312. 'added' => count($diff['added']),
  313. 'removed' => count($diff['removed']),
  314. ]);
  315. }
  316. /** POST /sprints/{id}/workers — JSON — add a worker to the sprint. */
  317. public function addWorker(Request $req, array $params): Response
  318. {
  319. $gate = $this->gateJsonAdmin($req);
  320. if ($gate instanceof Response) {
  321. return $gate;
  322. }
  323. $actor = $gate;
  324. $sprintId = (int) $params['id'];
  325. if ($this->sprints->find($sprintId) === null) {
  326. return Response::err('not_found', 'Sprint not found', 404);
  327. }
  328. $body = $req->json() ?? [];
  329. if (!isset($body['worker_id']) || !is_int($body['worker_id'])) {
  330. return Response::err('validation', 'worker_id required', 422);
  331. }
  332. $workerId = (int) $body['worker_id'];
  333. $worker = $this->workers->find($workerId);
  334. if ($worker === null) {
  335. return Response::err('validation', 'Unknown worker', 422, ['field' => 'worker_id']);
  336. }
  337. if (!$worker->isActive) {
  338. return Response::err('validation', 'Worker is inactive', 422, ['field' => 'worker_id']);
  339. }
  340. $rtb = $worker->defaultRtb;
  341. if (isset($body['rtb'])) {
  342. if (!is_numeric($body['rtb']) || (float) $body['rtb'] < 0.0 || (float) $body['rtb'] > 1.0) {
  343. return Response::err('validation', 'rtb must be 0..1', 422);
  344. }
  345. $rtb = (float) $body['rtb'];
  346. }
  347. $this->pdo->beginTransaction();
  348. try {
  349. $sw = $this->sprintWorkers->add($sprintId, $workerId, $rtb);
  350. $this->audit->recordForRequest(
  351. 'CREATE', 'sprint_worker', $sw->id,
  352. null, $sw->toAuditSnapshot(),
  353. $req, $actor,
  354. );
  355. $this->pdo->commit();
  356. } catch (PDOException $e) {
  357. $this->pdo->rollBack();
  358. if (str_contains(strtolower($e->getMessage()), 'unique')) {
  359. return Response::err('conflict', 'Worker already in sprint', 409);
  360. }
  361. return Response::err('db_error', 'Could not add worker', 500);
  362. } catch (Throwable) {
  363. $this->pdo->rollBack();
  364. return Response::err('db_error', 'Could not add worker', 500);
  365. }
  366. return Response::ok([
  367. 'sprint_worker' => $sw->toAuditSnapshot() + ['worker_name' => $sw->workerName],
  368. ]);
  369. }
  370. /** DELETE /sprints/{id}/workers/{sw_id} — JSON — remove a worker from the sprint. */
  371. public function removeWorker(Request $req, array $params): Response
  372. {
  373. $gate = $this->gateJsonAdmin($req);
  374. if ($gate instanceof Response) {
  375. return $gate;
  376. }
  377. $actor = $gate;
  378. $sprintId = (int) $params['id'];
  379. $swId = (int) $params['sw_id'];
  380. $existing = $this->sprintWorkers->find($swId);
  381. if ($existing === null || $existing->sprintId !== $sprintId) {
  382. return Response::err('not_found', 'sprint_worker not found in this sprint', 404);
  383. }
  384. $this->pdo->beginTransaction();
  385. try {
  386. $removed = $this->sprintWorkers->remove($swId);
  387. if ($removed !== null) {
  388. $this->audit->recordForRequest(
  389. 'DELETE', 'sprint_worker', $removed->id,
  390. $removed->toAuditSnapshot(), null,
  391. $req, $actor,
  392. );
  393. }
  394. $this->pdo->commit();
  395. } catch (Throwable) {
  396. $this->pdo->rollBack();
  397. return Response::err('db_error', 'Could not remove worker', 500);
  398. }
  399. return Response::ok(['removed_id' => $swId]);
  400. }
  401. /** POST /sprints/{id}/workers/reorder — JSON — apply an ordering. */
  402. public function reorderWorkers(Request $req, array $params): Response
  403. {
  404. $gate = $this->gateJsonAdmin($req);
  405. if ($gate instanceof Response) {
  406. return $gate;
  407. }
  408. $actor = $gate;
  409. $sprintId = (int) $params['id'];
  410. if ($this->sprints->find($sprintId) === null) {
  411. return Response::err('not_found', 'Sprint not found', 404);
  412. }
  413. $body = $req->json();
  414. if (!is_array($body) || !array_is_list($body)) {
  415. return Response::err('validation', 'body must be a list of {sprint_worker_id, sort_order}', 422);
  416. }
  417. $ordering = [];
  418. $seenOrder = [];
  419. foreach ($body as $row) {
  420. if (!is_array($row) || !isset($row['sprint_worker_id'], $row['sort_order'])) {
  421. return Response::err('validation', 'each entry needs sprint_worker_id and sort_order', 422);
  422. }
  423. $sw = (int) $row['sprint_worker_id'];
  424. $order = (int) $row['sort_order'];
  425. if ($sw <= 0 || $order < 1) {
  426. return Response::err('validation', 'ids/orders must be positive', 422);
  427. }
  428. if (isset($seenOrder[$order])) {
  429. return Response::err('validation', 'duplicate sort_order', 422);
  430. }
  431. $seenOrder[$order] = true;
  432. $ordering[] = ['sprint_worker_id' => $sw, 'sort_order' => $order];
  433. }
  434. $this->pdo->beginTransaction();
  435. try {
  436. $diffs = $this->sprintWorkers->reorder($sprintId, $ordering);
  437. foreach ($diffs as $d) {
  438. $this->audit->recordForRequest(
  439. 'UPDATE', 'sprint_worker', $d['after']->id,
  440. $d['before']->toAuditSnapshot(),
  441. $d['after']->toAuditSnapshot(),
  442. $req, $actor,
  443. );
  444. }
  445. $this->pdo->commit();
  446. } catch (Throwable) {
  447. $this->pdo->rollBack();
  448. return Response::err('db_error', 'Could not reorder', 500);
  449. }
  450. return Response::ok(['moved' => count($diffs)]);
  451. }
  452. /** PATCH /sprints/{id}/workers/{sw_id} — JSON — edit RTB. */
  453. public function updateWorker(Request $req, array $params): Response
  454. {
  455. $gate = $this->gateJsonAdmin($req);
  456. if ($gate instanceof Response) {
  457. return $gate;
  458. }
  459. $actor = $gate;
  460. $sprintId = (int) $params['id'];
  461. $swId = (int) $params['sw_id'];
  462. $existing = $this->sprintWorkers->find($swId);
  463. if ($existing === null || $existing->sprintId !== $sprintId) {
  464. return Response::err('not_found', 'sprint_worker not found in this sprint', 404);
  465. }
  466. $body = $req->json() ?? [];
  467. if (!isset($body['rtb']) || !is_numeric($body['rtb'])) {
  468. return Response::err('validation', 'rtb required', 422);
  469. }
  470. $rtb = (float) $body['rtb'];
  471. if ($rtb < 0.0 || $rtb > 1.0) {
  472. return Response::err('validation', 'rtb must be 0..1', 422);
  473. }
  474. $this->pdo->beginTransaction();
  475. try {
  476. $result = $this->sprintWorkers->setRtb($swId, $rtb);
  477. $this->audit->recordForRequest(
  478. 'UPDATE', 'sprint_worker', $swId,
  479. $result['before']->toAuditSnapshot(),
  480. $result['after']->toAuditSnapshot(),
  481. $req, $actor,
  482. );
  483. $this->pdo->commit();
  484. } catch (Throwable) {
  485. $this->pdo->rollBack();
  486. return Response::err('db_error', 'Could not update worker', 500);
  487. }
  488. return Response::ok(['sprint_worker' => $result['after']->toAuditSnapshot()]);
  489. }
  490. /** PATCH /sprints/{id}/week-cells — JSON — batch upsert of sprint_worker_days. */
  491. public function updateWeekCells(Request $req, array $params): Response
  492. {
  493. $gate = $this->gateJsonAdmin($req);
  494. if ($gate instanceof Response) {
  495. return $gate;
  496. }
  497. $actor = $gate;
  498. $sprintId = (int) $params['id'];
  499. $sprint = $this->sprints->find($sprintId);
  500. if ($sprint === null) {
  501. return Response::err('not_found', 'Sprint not found', 404);
  502. }
  503. $body = $req->json();
  504. if (!is_array($body) || !array_is_list($body)) {
  505. return Response::err('validation', 'body must be a list of {sprint_worker_id, sprint_week_id, days}', 422);
  506. }
  507. if ($body === []) {
  508. return Response::ok(['applied' => 0, 'noop' => 0, 'per_worker' => new \stdClass()]);
  509. }
  510. // Cross-check every cell belongs to this sprint.
  511. $validSw = array_column(
  512. array_map(fn($sw) => ['id' => $sw->id], $this->sprintWorkers->allForSprint($sprintId)),
  513. 'id',
  514. );
  515. $validSw = array_flip($validSw);
  516. $validWk = array_column(
  517. array_map(fn($w) => ['id' => $w->id], $this->weeks->allForSprint($sprintId)),
  518. 'id',
  519. );
  520. $validWk = array_flip($validWk);
  521. $cells = [];
  522. foreach ($body as $i => $row) {
  523. if (!is_array($row) || !isset($row['sprint_worker_id'], $row['sprint_week_id'], $row['days'])) {
  524. return Response::err('validation', "cell[{$i}] needs sprint_worker_id, sprint_week_id, days", 422);
  525. }
  526. $swId = (int) $row['sprint_worker_id'];
  527. $wkId = (int) $row['sprint_week_id'];
  528. $daysN = $row['days'];
  529. if (!is_numeric($daysN)) {
  530. return Response::err('validation', "cell[{$i}] days must be numeric", 422);
  531. }
  532. $days = (float) $daysN;
  533. if (!isset($validSw[$swId])) {
  534. return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422);
  535. }
  536. if (!isset($validWk[$wkId])) {
  537. return Response::err('validation', "cell[{$i}] sprint_week {$wkId} not in sprint", 422);
  538. }
  539. if (!CapacityCalculator::isHalfStep($days, 0.0, 5.0)) {
  540. return Response::err('validation', "cell[{$i}] days must be 0..5 in 0.5 steps", 422);
  541. }
  542. $cells[] = ['sw_id' => $swId, 'week_id' => $wkId, 'days' => $days];
  543. }
  544. $applied = 0;
  545. $noop = 0;
  546. $touchedWorkers = [];
  547. $this->pdo->beginTransaction();
  548. try {
  549. foreach ($cells as $c) {
  550. $result = $this->days->upsert($c['sw_id'], $c['week_id'], $c['days']);
  551. if ($result['action'] === 'NOOP') {
  552. $noop++;
  553. continue;
  554. }
  555. $applied++;
  556. $touchedWorkers[$c['sw_id']] = true;
  557. $this->audit->recordForRequest(
  558. action: $result['action'],
  559. entityType: 'sprint_worker_days',
  560. entityId: $result['after']?->id ?? $result['before']?->id,
  561. before: $result['before']?->toAuditSnapshot(),
  562. after: $result['after']?->toAuditSnapshot(),
  563. req: $req,
  564. actor: $actor,
  565. );
  566. }
  567. $this->pdo->commit();
  568. } catch (Throwable) {
  569. $this->pdo->rollBack();
  570. return Response::err('db_error', 'Could not save cells', 500);
  571. }
  572. // Recompute capacity for every worker whose row changed.
  573. $grid = $this->days->grid($sprintId);
  574. $perWorker = [];
  575. foreach (array_keys($touchedWorkers) as $swId) {
  576. $ressourcen = array_sum($grid[$swId] ?? []);
  577. $perWorker[(string) $swId] = CapacityCalculator::forWorker(
  578. $ressourcen,
  579. $sprint->reserveFraction,
  580. 0.0,
  581. );
  582. }
  583. return Response::ok([
  584. 'applied' => $applied,
  585. 'noop' => $noop,
  586. 'per_worker' => $perWorker === [] ? new \stdClass() : $perWorker,
  587. ]);
  588. }
  589. /** PATCH /sprints/{id}/week/{week_id} — JSON — edit max_working_days for one week. */
  590. public function updateWeekMax(Request $req, array $params): Response
  591. {
  592. $gate = $this->gateJsonAdmin($req);
  593. if ($gate instanceof Response) {
  594. return $gate;
  595. }
  596. $actor = $gate;
  597. $sprintId = (int) $params['id'];
  598. $weekId = (int) $params['week_id'];
  599. $week = $this->weeks->find($weekId);
  600. if ($week === null || $week->sprintId !== $sprintId) {
  601. return Response::err('not_found', 'sprint_week not found in this sprint', 404);
  602. }
  603. $body = $req->json() ?? [];
  604. if (!isset($body['max_working_days']) || !is_numeric($body['max_working_days'])) {
  605. return Response::err('validation', 'max_working_days required', 422);
  606. }
  607. $maxDays = (float) $body['max_working_days'];
  608. if (!CapacityCalculator::isHalfStep($maxDays, 0.0, 5.0)) {
  609. return Response::err('validation', 'max_working_days must be 0..5 in 0.5 steps', 422);
  610. }
  611. if (abs($week->maxWorkingDays - $maxDays) < 1e-9) {
  612. return Response::ok(['sprint_week' => $week->toAuditSnapshot()]);
  613. }
  614. $this->pdo->beginTransaction();
  615. try {
  616. $result = $this->weeks->setMaxWorkingDays($weekId, $maxDays);
  617. $this->audit->recordForRequest(
  618. 'UPDATE', 'sprint_week', $weekId,
  619. $result['before']->toAuditSnapshot(),
  620. $result['after']->toAuditSnapshot(),
  621. $req, $actor,
  622. );
  623. $this->pdo->commit();
  624. } catch (Throwable) {
  625. $this->pdo->rollBack();
  626. return Response::err('db_error', 'Could not update week', 500);
  627. }
  628. return Response::ok(['sprint_week' => $result['after']->toAuditSnapshot()]);
  629. }
  630. // ------------------------------------------------------------------
  631. // Shared helpers
  632. // ------------------------------------------------------------------
  633. /**
  634. * Admin gate for JSON endpoints. Returns the signed-in User on success,
  635. * or an `Response::err(...)` JSON envelope on failure. Also enforces CSRF.
  636. */
  637. private function gateJsonAdmin(Request $req): User|Response
  638. {
  639. $user = SessionGuard::currentUser($this->users);
  640. if ($user === null) {
  641. return Response::err('unauthenticated', 'Sign in required', 401);
  642. }
  643. if (!$user->isAdmin) {
  644. return Response::err('forbidden', 'Admin access required', 403);
  645. }
  646. if (!SessionGuard::verifyCsrf($req)) {
  647. return Response::err('csrf', 'CSRF token invalid', 403);
  648. }
  649. return $user;
  650. }
  651. private static function isIsoDate(string $s): bool
  652. {
  653. $d = DateTimeImmutable::createFromFormat('Y-m-d', $s);
  654. return $d !== false && $d->format('Y-m-d') === $s;
  655. }
  656. }