SprintRepository.php 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Repositories;
  4. use App\Domain\Sprint;
  5. use App\Domain\SprintWeek;
  6. use DateTimeImmutable;
  7. use PDO;
  8. use RuntimeException;
  9. final class SprintRepository
  10. {
  11. public function __construct(private readonly PDO $pdo)
  12. {
  13. }
  14. /**
  15. * List sprints with worker + task counts. Newest start first.
  16. *
  17. * @return list<array{sprint: Sprint, nWorkers: int, nTasks: int}>
  18. */
  19. public function allWithCounts(): array
  20. {
  21. $stmt = $this->pdo->query(
  22. 'SELECT s.*,
  23. (SELECT COUNT(*) FROM sprint_workers WHERE sprint_id = s.id) AS n_workers,
  24. (SELECT COUNT(*) FROM tasks WHERE sprint_id = s.id) AS n_tasks
  25. FROM sprints s
  26. ORDER BY s.start_date DESC, s.id DESC'
  27. );
  28. $out = [];
  29. foreach ($stmt as $row) {
  30. $out[] = [
  31. 'sprint' => self::hydrate($row),
  32. 'nWorkers' => (int) $row['n_workers'],
  33. 'nTasks' => (int) $row['n_tasks'],
  34. ];
  35. }
  36. return $out;
  37. }
  38. public function find(int $id): ?Sprint
  39. {
  40. $stmt = $this->pdo->prepare('SELECT * FROM sprints WHERE id = ?');
  41. $stmt->execute([$id]);
  42. $row = $stmt->fetch();
  43. return is_array($row) ? self::hydrate($row) : null;
  44. }
  45. /**
  46. * Create a sprint row. Caller is responsible for the enclosing transaction
  47. * (e.g. to also audit the change and materialise week rows atomically).
  48. */
  49. public function create(
  50. string $name,
  51. string $startDate,
  52. string $endDate,
  53. float $reserveFraction,
  54. ): Sprint {
  55. $now = gmdate('Y-m-d\TH:i:s\Z');
  56. $stmt = $this->pdo->prepare(
  57. 'INSERT INTO sprints (name, start_date, end_date, reserve_fraction, is_archived, created_at, updated_at)
  58. VALUES (?, ?, ?, ?, 0, ?, ?)'
  59. );
  60. $stmt->execute([$name, $startDate, $endDate, $reserveFraction, $now, $now]);
  61. $id = (int) $this->pdo->lastInsertId();
  62. $sprint = $this->find($id);
  63. if ($sprint === null) {
  64. throw new RuntimeException('Inserted sprint not found');
  65. }
  66. return $sprint;
  67. }
  68. /** Whitelisted updatable columns on `sprints` for admin edits. */
  69. private const UPDATABLE = ['name', 'start_date', 'end_date', 'reserve_fraction', 'is_archived'];
  70. /**
  71. * Apply the given field changes. Returns before/after for auditing.
  72. *
  73. * @param array<string,mixed> $changes
  74. * @return array{before: Sprint, after: Sprint}
  75. */
  76. public function update(int $id, array $changes): array
  77. {
  78. $before = $this->find($id);
  79. if ($before === null) {
  80. throw new RuntimeException("Sprint {$id} not found");
  81. }
  82. $changes = array_intersect_key($changes, array_flip(self::UPDATABLE));
  83. if ($changes === []) {
  84. return ['before' => $before, 'after' => $before];
  85. }
  86. $sets = [];
  87. $vals = [];
  88. foreach ($changes as $col => $v) {
  89. $sets[] = "{$col} = ?";
  90. $vals[] = match ($col) {
  91. 'is_archived' => ((bool) $v) ? 1 : 0,
  92. 'reserve_fraction' => (float) $v,
  93. default => (string) $v,
  94. };
  95. }
  96. $sets[] = 'updated_at = ?';
  97. $vals[] = gmdate('Y-m-d\TH:i:s\Z');
  98. $vals[] = $id;
  99. $stmt = $this->pdo->prepare(
  100. 'UPDATE sprints SET ' . implode(', ', $sets) . ' WHERE id = ?'
  101. );
  102. $stmt->execute($vals);
  103. $after = $this->find($id) ?? $before;
  104. return ['before' => $before, 'after' => $after];
  105. }
  106. /**
  107. * Delete a sprint. Does NOT read cascaded child rows; the controller is
  108. * responsible for auditing those (sprint_weeks / sprint_workers /
  109. * sprint_worker_days / tasks / task_assignments) BEFORE calling this
  110. * method, per spec §7. Returns the pre-deletion row for the parent
  111. * audit entry.
  112. */
  113. public function delete(int $id): ?Sprint
  114. {
  115. $before = $this->find($id);
  116. if ($before === null) {
  117. return null;
  118. }
  119. $this->pdo
  120. ->prepare('DELETE FROM sprints WHERE id = ?')
  121. ->execute([$id]);
  122. return $before;
  123. }
  124. /**
  125. * Materialise N week rows for a sprint with sensible defaults.
  126. *
  127. * Returns the inserted rows (before=null, after=row-snapshot) so the caller
  128. * can audit each CREATE.
  129. *
  130. * @return list<array{id:int, sort_order:int, iso_week:int, start_date:string, max_working_days:float, active_days_mask:int}>
  131. */
  132. public function materializeWeeks(int $sprintId, string $startDate, int $nWeeks): array
  133. {
  134. if ($nWeeks < 1) {
  135. return [];
  136. }
  137. $d0 = DateTimeImmutable::createFromFormat('Y-m-d', $startDate);
  138. if ($d0 === false) {
  139. throw new RuntimeException("Invalid start_date: {$startDate}");
  140. }
  141. $insert = $this->pdo->prepare(
  142. 'INSERT INTO sprint_weeks
  143. (sprint_id, sort_order, iso_week, start_date, max_working_days, active_days_mask)
  144. VALUES (?, ?, ?, ?, ?, ?)'
  145. );
  146. $out = [];
  147. for ($i = 1; $i <= $nWeeks; $i++) {
  148. $weekStart = $d0->modify('+' . ($i - 1) . ' weeks');
  149. $iso = (int) $weekStart->format('W');
  150. $ymd = $weekStart->format('Y-m-d');
  151. $insert->execute([$sprintId, $i, $iso, $ymd, 5.0, SprintWeek::MASK_ALL]);
  152. $out[] = [
  153. 'id' => (int) $this->pdo->lastInsertId(),
  154. 'sort_order' => $i,
  155. 'iso_week' => $iso,
  156. 'start_date' => $ymd,
  157. 'max_working_days' => 5.0,
  158. 'active_days_mask' => SprintWeek::MASK_ALL,
  159. ];
  160. }
  161. return $out;
  162. }
  163. /**
  164. * @param array<string,mixed> $row
  165. */
  166. private static function hydrate(array $row): Sprint
  167. {
  168. return new Sprint(
  169. id: (int) $row['id'],
  170. name: (string) $row['name'],
  171. startDate: (string) $row['start_date'],
  172. endDate: (string) $row['end_date'],
  173. reserveFraction: (float) $row['reserve_fraction'],
  174. isArchived: ((int) $row['is_archived']) === 1,
  175. createdAt: (string) $row['created_at'],
  176. updatedAt: (string) $row['updated_at'],
  177. );
  178. }
  179. }