TaskController.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683
  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\TaskAssignment;
  14. use App\Http\Request;
  15. use App\Http\Response;
  16. use App\Repositories\AppSettingsRepository;
  17. use App\Repositories\SprintRepository;
  18. use App\Repositories\SprintWorkerDayRepository;
  19. use App\Repositories\SprintWorkerRepository;
  20. use App\Repositories\TaskAssignmentRepository;
  21. use App\Repositories\TaskRepository;
  22. use App\Repositories\UserRepository;
  23. use App\Repositories\WorkerRepository;
  24. use App\Services\AuditLogger;
  25. use App\Services\CapacityCalculator;
  26. use InvalidArgumentException;
  27. use PDO;
  28. use Throwable;
  29. /**
  30. * Task CRUD + assignments.
  31. *
  32. * Tasks live per-sprint; assignments are per (task, sprint_worker) cell.
  33. * All endpoints are admin-only JSON. Every mutation writes per-row audit
  34. * entries inside the same transaction as the DB change.
  35. */
  36. final class TaskController
  37. {
  38. /**
  39. * R01-N24: see SprintController::MAX_BATCH_ITEMS. Same rationale, same
  40. * cap; reorder / assignments / status share the cell-style payload
  41. * shape and need the same defence-in-depth bound.
  42. */
  43. public const MAX_BATCH_ITEMS = 5000;
  44. public function __construct(
  45. private readonly PDO $pdo,
  46. private readonly UserRepository $users,
  47. private readonly SprintRepository $sprints,
  48. private readonly SprintWorkerRepository $sprintWorkers,
  49. private readonly SprintWorkerDayRepository $days,
  50. private readonly TaskRepository $tasks,
  51. private readonly TaskAssignmentRepository $assignments,
  52. private readonly WorkerRepository $workers,
  53. private readonly AuditLogger $audit,
  54. private readonly AppSettingsRepository $appSettings,
  55. ) {
  56. }
  57. /** POST /sprints/{id}/tasks — create a task (rows append at sort_order MAX+1). */
  58. public function create(Request $req, array $params): Response
  59. {
  60. $actor = SessionGuard::requireAdminJson($req, $this->users);
  61. if ($actor instanceof Response) {
  62. return $actor;
  63. }
  64. $sprintId = (int) $params['id'];
  65. $sprint = $this->sprints->find($sprintId);
  66. if ($sprint === null) {
  67. return Response::err('not_found', 'Sprint not found', 404);
  68. }
  69. $body = $req->json() ?? [];
  70. $title = isset($body['title']) && is_string($body['title']) ? trim($body['title']) : '';
  71. if ($title === '') {
  72. $title = '(Untitled task)';
  73. }
  74. $priority = isset($body['priority']) ? (int) $body['priority'] : 1;
  75. if ($priority !== 1 && $priority !== 2) {
  76. return Response::err('validation', 'priority must be 1 or 2', 422);
  77. }
  78. $ownerWorkerId = null;
  79. if (isset($body['owner_worker_id']) && $body['owner_worker_id'] !== null && $body['owner_worker_id'] !== '') {
  80. $ownerWorkerId = (int) $body['owner_worker_id'];
  81. if ($this->workers->find($ownerWorkerId) === null) {
  82. return Response::err('validation', 'Unknown owner_worker_id', 422);
  83. }
  84. }
  85. $this->pdo->beginTransaction();
  86. try {
  87. $task = $this->tasks->create($sprintId, $title, $ownerWorkerId, $priority);
  88. $this->audit->recordForRequest(
  89. 'CREATE', 'task', $task->id,
  90. null, $task->toAuditSnapshot(),
  91. $req, $actor,
  92. );
  93. $this->pdo->commit();
  94. } catch (Throwable) {
  95. $this->pdo->rollBack();
  96. return Response::err('db_error', 'Could not create task', 500);
  97. }
  98. return Response::ok([
  99. 'task' => $task->toAuditSnapshot(),
  100. 'assignments' => (object) [], // new task, no assignments yet
  101. ]);
  102. }
  103. /** PATCH /tasks/{id} — edit title / owner / priority. */
  104. public function update(Request $req, array $params): Response
  105. {
  106. $actor = SessionGuard::requireAdminJson($req, $this->users);
  107. if ($actor instanceof Response) {
  108. return $actor;
  109. }
  110. $id = (int) $params['id'];
  111. $task = $this->tasks->find($id);
  112. if ($task === null) {
  113. return Response::err('not_found', 'Task not found', 404);
  114. }
  115. $body = $req->json() ?? [];
  116. $changes = [];
  117. if (array_key_exists('title', $body)) {
  118. $title = is_string($body['title']) ? trim($body['title']) : '';
  119. if ($title === '') {
  120. return Response::err('validation', 'title cannot be empty', 422);
  121. }
  122. $changes['title'] = $title;
  123. }
  124. if (array_key_exists('priority', $body)) {
  125. $p = (int) $body['priority'];
  126. if ($p !== 1 && $p !== 2) {
  127. return Response::err('validation', 'priority must be 1 or 2', 422);
  128. }
  129. $changes['priority'] = $p;
  130. }
  131. if (array_key_exists('owner_worker_id', $body)) {
  132. $v = $body['owner_worker_id'];
  133. if ($v === null || $v === '') {
  134. $changes['owner_worker_id'] = null;
  135. } else {
  136. $ow = (int) $v;
  137. if ($this->workers->find($ow) === null) {
  138. return Response::err('validation', 'Unknown owner_worker_id', 422);
  139. }
  140. $changes['owner_worker_id'] = $ow;
  141. }
  142. }
  143. if (array_key_exists('description', $body)) {
  144. $desc = is_string($body['description']) ? $body['description'] : '';
  145. if (strlen($desc) > 8000) {
  146. return Response::err('validation', 'description too long', 422);
  147. }
  148. $changes['description'] = $desc;
  149. }
  150. if (array_key_exists('url', $body)) {
  151. $u = is_string($body['url']) ? trim($body['url']) : '';
  152. if ($u !== '') {
  153. if (strlen($u) > 2048) {
  154. return Response::err('validation', 'url too long', 422);
  155. }
  156. if (!preg_match('#^https?://#i', $u)) {
  157. return Response::err('validation', 'url must start with http:// or https://', 422);
  158. }
  159. }
  160. $changes['url'] = $u;
  161. }
  162. if ($changes === []) {
  163. return Response::ok(['task' => $task->toAuditSnapshot()]);
  164. }
  165. $this->pdo->beginTransaction();
  166. try {
  167. $result = $this->tasks->update($id, $changes);
  168. $this->audit->recordForRequest(
  169. 'UPDATE', 'task', $id,
  170. $result['before']->toAuditSnapshot(),
  171. $result['after']->toAuditSnapshot(),
  172. $req, $actor,
  173. );
  174. $responsePayload = [
  175. 'task' => $result['after']->toAuditSnapshot(),
  176. ];
  177. // If priority changed, touched workers' Available depends on prio-1
  178. // commitments. Recompute capacity for every sprint worker so the
  179. // client can paint the updated summary in one go.
  180. if (array_key_exists('priority', $changes) && $changes['priority'] !== $result['before']->priority) {
  181. $responsePayload['per_worker'] = $this->computeCapacity($result['after']->sprintId);
  182. }
  183. $this->pdo->commit();
  184. } catch (Throwable) {
  185. $this->pdo->rollBack();
  186. return Response::err('db_error', 'Could not update task', 500);
  187. }
  188. return Response::ok($responsePayload);
  189. }
  190. /** DELETE /tasks/{id} — delete a task; audits each assignment before cascade. */
  191. public function delete(Request $req, array $params): Response
  192. {
  193. $actor = SessionGuard::requireAdminJson($req, $this->users);
  194. if ($actor instanceof Response) {
  195. return $actor;
  196. }
  197. $id = (int) $params['id'];
  198. $task = $this->tasks->find($id);
  199. if ($task === null) {
  200. return Response::err('not_found', 'Task not found', 404);
  201. }
  202. // Snapshot every assignment before the FK cascade wipes them.
  203. $assignments = $this->assignments->allForTask($id);
  204. $this->pdo->beginTransaction();
  205. try {
  206. foreach ($assignments as $a) {
  207. $this->audit->recordForRequest(
  208. 'DELETE', 'task_assignment', $a->id,
  209. $a->toAuditSnapshot(), null,
  210. $req, $actor,
  211. );
  212. }
  213. $this->tasks->delete($id);
  214. $this->audit->recordForRequest(
  215. 'DELETE', 'task', $id,
  216. $task->toAuditSnapshot(), null,
  217. $req, $actor,
  218. );
  219. $responsePayload = ['removed_id' => $id];
  220. // If it was a prio-1 task, available changes on every worker that
  221. // had an assignment.
  222. if ($task->priority === 1 && $assignments !== []) {
  223. $responsePayload['per_worker'] = $this->computeCapacity($task->sprintId);
  224. }
  225. $this->pdo->commit();
  226. } catch (Throwable) {
  227. $this->pdo->rollBack();
  228. return Response::err('db_error', 'Could not delete task', 500);
  229. }
  230. return Response::ok($responsePayload);
  231. }
  232. /** POST /sprints/{id}/tasks/reorder — apply an ordering. */
  233. public function reorder(Request $req, array $params): Response
  234. {
  235. $actor = SessionGuard::requireAdminJson($req, $this->users);
  236. if ($actor instanceof Response) {
  237. return $actor;
  238. }
  239. $sprintId = (int) $params['id'];
  240. if ($this->sprints->find($sprintId) === null) {
  241. return Response::err('not_found', 'Sprint not found', 404);
  242. }
  243. $body = $req->json();
  244. if (!is_array($body) || !array_is_list($body)) {
  245. return Response::err('validation', 'body must be a list of {task_id, sort_order}', 422);
  246. }
  247. if (count($body) > self::MAX_BATCH_ITEMS) {
  248. return Response::err(
  249. 'too_many_items',
  250. 'reorder list exceeds ' . self::MAX_BATCH_ITEMS . '-item cap',
  251. 413,
  252. );
  253. }
  254. $ordering = [];
  255. $seenOrder = [];
  256. foreach ($body as $row) {
  257. if (!is_array($row) || !isset($row['task_id'], $row['sort_order'])) {
  258. return Response::err('validation', 'each entry needs task_id and sort_order', 422);
  259. }
  260. $tid = (int) $row['task_id'];
  261. $order = (int) $row['sort_order'];
  262. if ($tid <= 0 || $order < 1) {
  263. return Response::err('validation', 'ids/orders must be positive', 422);
  264. }
  265. if (isset($seenOrder[$order])) {
  266. return Response::err('validation', 'duplicate sort_order', 422);
  267. }
  268. $seenOrder[$order] = true;
  269. $ordering[] = ['task_id' => $tid, 'sort_order' => $order];
  270. }
  271. $this->pdo->beginTransaction();
  272. try {
  273. $diffs = $this->tasks->reorder($sprintId, $ordering);
  274. foreach ($diffs as $d) {
  275. $this->audit->recordForRequest(
  276. 'UPDATE', 'task', $d['after']->id,
  277. $d['before']->toAuditSnapshot(),
  278. $d['after']->toAuditSnapshot(),
  279. $req, $actor,
  280. );
  281. }
  282. $this->pdo->commit();
  283. } catch (Throwable) {
  284. $this->pdo->rollBack();
  285. return Response::err('db_error', 'Could not reorder', 500);
  286. }
  287. return Response::ok(['moved' => count($diffs)]);
  288. }
  289. /** PATCH /tasks/{id}/assignments — batch upsert of task_assignments. */
  290. public function updateAssignments(Request $req, array $params): Response
  291. {
  292. $actor = SessionGuard::requireAdminJson($req, $this->users);
  293. if ($actor instanceof Response) {
  294. return $actor;
  295. }
  296. $taskId = (int) $params['id'];
  297. $task = $this->tasks->find($taskId);
  298. if ($task === null) {
  299. return Response::err('not_found', 'Task not found', 404);
  300. }
  301. $body = $req->json();
  302. if (!is_array($body) || !array_is_list($body)) {
  303. return Response::err('validation', 'body must be a list of {sprint_worker_id, days}', 422);
  304. }
  305. if ($body === []) {
  306. return Response::ok(['applied' => 0, 'noop' => 0]);
  307. }
  308. if (count($body) > self::MAX_BATCH_ITEMS) {
  309. return Response::err(
  310. 'too_many_items',
  311. 'assignment list exceeds ' . self::MAX_BATCH_ITEMS . '-item cap',
  312. 413,
  313. );
  314. }
  315. // Cross-check sprint worker IDs belong to the task's sprint.
  316. $validSw = [];
  317. foreach ($this->sprintWorkers->allForSprint($task->sprintId) as $sw) {
  318. $validSw[$sw->id] = true;
  319. }
  320. $cells = [];
  321. foreach ($body as $i => $row) {
  322. if (!is_array($row) || !isset($row['sprint_worker_id'], $row['days'])) {
  323. return Response::err('validation', "cell[{$i}] needs sprint_worker_id, days", 422);
  324. }
  325. $swId = (int) $row['sprint_worker_id'];
  326. $daysN = $row['days'];
  327. if (!is_numeric($daysN)) {
  328. return Response::err('validation', "cell[{$i}] days must be numeric", 422);
  329. }
  330. $days = (float) $daysN;
  331. if (!isset($validSw[$swId])) {
  332. return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422);
  333. }
  334. if ($days < 0) {
  335. return Response::err('validation', "cell[{$i}] days cannot be negative", 422);
  336. }
  337. // Assignments step by 0.5 but have no hard upper bound per spec §3.
  338. $doubled = $days * 2;
  339. if (abs($doubled - round($doubled)) > 1e-9) {
  340. return Response::err('validation', "cell[{$i}] days must step by 0.5", 422);
  341. }
  342. $cells[] = ['sw_id' => $swId, 'days' => $days];
  343. }
  344. $applied = 0;
  345. $noop = 0;
  346. $this->pdo->beginTransaction();
  347. try {
  348. foreach ($cells as $c) {
  349. $result = $this->assignments->upsert($taskId, $c['sw_id'], $c['days']);
  350. if ($result['action'] === 'NOOP') {
  351. $noop++;
  352. continue;
  353. }
  354. $applied++;
  355. $this->audit->recordForRequest(
  356. action: $result['action'],
  357. entityType: 'task_assignment',
  358. entityId: $result['after']?->id ?? $result['before']?->id,
  359. before: $result['before']?->toAuditSnapshot(),
  360. after: $result['after']?->toAuditSnapshot(),
  361. req: $req,
  362. actor: $actor,
  363. );
  364. }
  365. $this->pdo->commit();
  366. } catch (Throwable) {
  367. $this->pdo->rollBack();
  368. return Response::err('db_error', 'Could not save assignments', 500);
  369. }
  370. $data = [
  371. 'applied' => $applied,
  372. 'noop' => $noop,
  373. 'task_id' => $taskId,
  374. ];
  375. if ($applied > 0 && $task->priority === 1) {
  376. $data['per_worker'] = $this->computeCapacity($task->sprintId);
  377. }
  378. return Response::ok($data);
  379. }
  380. /**
  381. * PATCH /tasks/{id}/assignments/status — set per-cell workflow status.
  382. *
  383. * Open to any signed-in user (Phase 18 — first non-admin write surface),
  384. * gated by the global app_settings.task_status_enabled flag. CSRF still
  385. * required. Days are NOT modified by this endpoint.
  386. */
  387. public function updateAssignmentsStatus(Request $req, array $params): Response
  388. {
  389. $actor = SessionGuard::requireAuthJson($req, $this->users);
  390. if ($actor instanceof Response) {
  391. return $actor;
  392. }
  393. if (!$this->appSettings->getBool('task_status_enabled', false)) {
  394. return Response::err('feature_disabled', 'Task status colors are disabled', 403);
  395. }
  396. $taskId = (int) $params['id'];
  397. $task = $this->tasks->find($taskId);
  398. if ($task === null) {
  399. return Response::err('not_found', 'Task not found', 404);
  400. }
  401. $body = $req->json();
  402. if (!is_array($body) || !array_is_list($body)) {
  403. return Response::err('validation', 'body must be a list of {sprint_worker_id, status}', 422);
  404. }
  405. if ($body === []) {
  406. return Response::ok(['applied' => 0, 'noop' => 0]);
  407. }
  408. if (count($body) > self::MAX_BATCH_ITEMS) {
  409. return Response::err(
  410. 'too_many_items',
  411. 'status list exceeds ' . self::MAX_BATCH_ITEMS . '-item cap',
  412. 413,
  413. );
  414. }
  415. $validSw = [];
  416. foreach ($this->sprintWorkers->allForSprint($task->sprintId) as $sw) {
  417. $validSw[$sw->id] = true;
  418. }
  419. $cells = [];
  420. foreach ($body as $i => $row) {
  421. if (!is_array($row) || !isset($row['sprint_worker_id'], $row['status'])) {
  422. return Response::err('validation', "cell[{$i}] needs sprint_worker_id, status", 422);
  423. }
  424. $swId = (int) $row['sprint_worker_id'];
  425. $status = is_string($row['status']) ? $row['status'] : '';
  426. if (!isset($validSw[$swId])) {
  427. return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422);
  428. }
  429. if (!TaskAssignment::isValidStatus($status)) {
  430. return Response::err('validation', "cell[{$i}] invalid status", 422);
  431. }
  432. $cells[] = ['sw_id' => $swId, 'status' => $status];
  433. }
  434. $applied = 0;
  435. $noop = 0;
  436. $this->pdo->beginTransaction();
  437. try {
  438. foreach ($cells as $c) {
  439. try {
  440. $result = $this->assignments->upsertStatus(
  441. $taskId, $c['sw_id'], $c['status'],
  442. );
  443. } catch (InvalidArgumentException) {
  444. // Already validated above, but guard the repo invariant.
  445. $this->pdo->rollBack();
  446. return Response::err('validation', 'invalid status', 422);
  447. }
  448. if ($result['action'] === 'NOOP') {
  449. $noop++;
  450. continue;
  451. }
  452. $applied++;
  453. $this->audit->recordForRequest(
  454. action: $result['action'],
  455. entityType: 'task_assignment',
  456. entityId: $result['after']?->id ?? $result['before']?->id,
  457. before: $result['before']?->toAuditSnapshot(),
  458. after: $result['after']?->toAuditSnapshot(),
  459. req: $req,
  460. actor: $actor,
  461. );
  462. }
  463. $this->pdo->commit();
  464. } catch (Throwable) {
  465. $this->pdo->rollBack();
  466. return Response::err('db_error', 'Could not save statuses', 500);
  467. }
  468. return Response::ok([
  469. 'applied' => $applied,
  470. 'noop' => $noop,
  471. 'task_id' => $taskId,
  472. ]);
  473. }
  474. /**
  475. * POST /tasks/{id}/move — reassign a task to another sprint.
  476. *
  477. * All task_assignments are dropped (audited per-cell before the wipe);
  478. * task lands at the bottom of the destination sprint's list. Capacity is
  479. * affected on both sides for prio-1 tasks, but the client just reloads
  480. * the page after a successful move so we don't need to send fresh
  481. * per-worker numbers in the response.
  482. */
  483. public function moveToSprint(Request $req, array $params): Response
  484. {
  485. $actor = SessionGuard::requireAdminJson($req, $this->users);
  486. if ($actor instanceof Response) {
  487. return $actor;
  488. }
  489. $id = (int) $params['id'];
  490. $task = $this->tasks->find($id);
  491. if ($task === null) {
  492. return Response::err('not_found', 'Task not found', 404);
  493. }
  494. $body = $req->json() ?? [];
  495. $destSprintId = isset($body['sprint_id']) ? (int) $body['sprint_id'] : 0;
  496. if ($destSprintId <= 0) {
  497. return Response::err('validation', 'sprint_id required', 422);
  498. }
  499. if ($destSprintId === $task->sprintId) {
  500. return Response::err('validation', 'task already in this sprint', 422);
  501. }
  502. if ($this->sprints->find($destSprintId) === null) {
  503. return Response::err('not_found', 'Destination sprint not found', 404);
  504. }
  505. $oldAssignments = $this->assignments->allForTask($id);
  506. $this->pdo->beginTransaction();
  507. try {
  508. // Audit each assignment before they vanish.
  509. foreach ($oldAssignments as $a) {
  510. $this->audit->recordForRequest(
  511. 'DELETE', 'task_assignment', $a->id,
  512. $a->toAuditSnapshot(), null,
  513. $req, $actor,
  514. );
  515. }
  516. $result = $this->tasks->moveToSprint($id, $destSprintId);
  517. $this->audit->recordForRequest(
  518. 'UPDATE', 'task', $id,
  519. $result['before']->toAuditSnapshot(),
  520. $result['after']->toAuditSnapshot(),
  521. $req, $actor,
  522. );
  523. $this->pdo->commit();
  524. } catch (Throwable) {
  525. $this->pdo->rollBack();
  526. return Response::err('db_error', 'Could not move task', 500);
  527. }
  528. return Response::ok([
  529. 'task' => $result['after']->toAuditSnapshot(),
  530. ]);
  531. }
  532. /**
  533. * POST /tasks/{id}/copy — clone a task into another sprint.
  534. *
  535. * Carries title / owner / priority / description / url; assignments are
  536. * NOT carried (fresh slate per the design call). The new task records
  537. * `linked_task_id = source.id`; the bidirectional UI link is rendered
  538. * by the sprint view via TaskRepository::linkedSummariesForTasks.
  539. */
  540. public function copyToSprint(Request $req, array $params): Response
  541. {
  542. $actor = SessionGuard::requireAdminJson($req, $this->users);
  543. if ($actor instanceof Response) {
  544. return $actor;
  545. }
  546. $id = (int) $params['id'];
  547. $task = $this->tasks->find($id);
  548. if ($task === null) {
  549. return Response::err('not_found', 'Task not found', 404);
  550. }
  551. $body = $req->json() ?? [];
  552. $destSprintId = isset($body['sprint_id']) ? (int) $body['sprint_id'] : 0;
  553. if ($destSprintId <= 0) {
  554. return Response::err('validation', 'sprint_id required', 422);
  555. }
  556. if ($this->sprints->find($destSprintId) === null) {
  557. return Response::err('not_found', 'Destination sprint not found', 404);
  558. }
  559. $this->pdo->beginTransaction();
  560. try {
  561. $copy = $this->tasks->create(
  562. sprintId: $destSprintId,
  563. title: $task->title,
  564. ownerWorkerId: $task->ownerWorkerId,
  565. priority: $task->priority,
  566. description: $task->description,
  567. url: $task->url,
  568. linkedTaskId: $task->id,
  569. );
  570. $this->audit->recordForRequest(
  571. 'CREATE', 'task', $copy->id,
  572. null, $copy->toAuditSnapshot(),
  573. $req, $actor,
  574. );
  575. $this->pdo->commit();
  576. } catch (Throwable) {
  577. $this->pdo->rollBack();
  578. return Response::err('db_error', 'Could not copy task', 500);
  579. }
  580. return Response::ok([
  581. 'task' => $copy->toAuditSnapshot(),
  582. ]);
  583. }
  584. /**
  585. * Full per-worker capacity recompute for a sprint. Used to keep the
  586. * client-side capacity strip in sync when changes cascade across rows.
  587. *
  588. * @return array<string, array{ressourcen:float, after_reserves:float, committed_prio1:float, available:float}>
  589. */
  590. private function computeCapacity(int $sprintId): array
  591. {
  592. $sprint = $this->sprints->find($sprintId);
  593. if ($sprint === null) {
  594. return [];
  595. }
  596. $dayGrid = $this->days->grid($sprintId);
  597. $committed = $this->assignments->committedPrio1BySprint($sprintId);
  598. $out = [];
  599. foreach ($this->sprintWorkers->allForSprint($sprintId) as $sw) {
  600. $ressourcen = array_sum($dayGrid[$sw->id] ?? []);
  601. $out[(string) $sw->id] = CapacityCalculator::forWorker(
  602. $ressourcen,
  603. $sprint->reserveFraction,
  604. $committed[$sw->id] ?? 0.0,
  605. );
  606. }
  607. return $out;
  608. }
  609. }