TaskRepository.php 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Repositories;
  4. use App\Domain\Task;
  5. use PDO;
  6. use RuntimeException;
  7. final class TaskRepository
  8. {
  9. /** Whitelisted updatable columns. */
  10. private const UPDATABLE = ['title', 'owner_worker_id', 'priority'];
  11. public function __construct(private readonly PDO $pdo)
  12. {
  13. }
  14. /** @return list<Task> ordered by sort_order ASC */
  15. public function allForSprint(int $sprintId): array
  16. {
  17. $stmt = $this->pdo->prepare(
  18. 'SELECT * FROM tasks WHERE sprint_id = ? ORDER BY sort_order ASC'
  19. );
  20. $stmt->execute([$sprintId]);
  21. $out = [];
  22. foreach ($stmt as $row) {
  23. $out[] = self::hydrate($row);
  24. }
  25. return $out;
  26. }
  27. public function find(int $id): ?Task
  28. {
  29. $stmt = $this->pdo->prepare('SELECT * FROM tasks WHERE id = ?');
  30. $stmt->execute([$id]);
  31. $row = $stmt->fetch();
  32. return is_array($row) ? self::hydrate($row) : null;
  33. }
  34. public function create(
  35. int $sprintId,
  36. string $title,
  37. ?int $ownerWorkerId,
  38. int $priority,
  39. ): Task {
  40. $now = gmdate('Y-m-d\TH:i:s\Z');
  41. $max = (int) $this->pdo
  42. ->query('SELECT COALESCE(MAX(sort_order), 0) FROM tasks WHERE sprint_id = ' . $sprintId)
  43. ->fetchColumn();
  44. $stmt = $this->pdo->prepare(
  45. 'INSERT INTO tasks (sprint_id, title, owner_worker_id, priority, sort_order, created_at, updated_at)
  46. VALUES (?, ?, ?, ?, ?, ?, ?)'
  47. );
  48. $stmt->execute([$sprintId, $title, $ownerWorkerId, $priority, $max + 1, $now, $now]);
  49. $id = (int) $this->pdo->lastInsertId();
  50. $task = $this->find($id);
  51. if ($task === null) {
  52. throw new RuntimeException('Inserted task not found');
  53. }
  54. return $task;
  55. }
  56. /**
  57. * @param array<string,mixed> $changes
  58. * @return array{before: Task, after: Task}
  59. */
  60. public function update(int $id, array $changes): array
  61. {
  62. $before = $this->find($id);
  63. if ($before === null) {
  64. throw new RuntimeException("Task {$id} not found");
  65. }
  66. $changes = array_intersect_key($changes, array_flip(self::UPDATABLE));
  67. if ($changes === []) {
  68. return ['before' => $before, 'after' => $before];
  69. }
  70. $sets = [];
  71. $vals = [];
  72. foreach ($changes as $col => $v) {
  73. $sets[] = "{$col} = ?";
  74. $vals[] = match ($col) {
  75. 'title' => (string) $v,
  76. 'owner_worker_id' => $v === null ? null : (int) $v,
  77. 'priority' => (int) $v,
  78. default => $v,
  79. };
  80. }
  81. $sets[] = 'updated_at = ?';
  82. $vals[] = gmdate('Y-m-d\TH:i:s\Z');
  83. $vals[] = $id;
  84. $stmt = $this->pdo->prepare(
  85. 'UPDATE tasks SET ' . implode(', ', $sets) . ' WHERE id = ?'
  86. );
  87. $stmt->execute($vals);
  88. $after = $this->find($id) ?? $before;
  89. return ['before' => $before, 'after' => $after];
  90. }
  91. /**
  92. * Delete a task. Does NOT read cascaded assignment rows; the controller
  93. * is responsible for auditing those BEFORE calling this method.
  94. * Returns the pre-deletion row for auditing.
  95. */
  96. public function delete(int $id): ?Task
  97. {
  98. $before = $this->find($id);
  99. if ($before === null) {
  100. return null;
  101. }
  102. $this->pdo
  103. ->prepare('DELETE FROM tasks WHERE id = ?')
  104. ->execute([$id]);
  105. return $before;
  106. }
  107. /**
  108. * Apply an ordering of tasks within a sprint. Same two-phase negate-then-
  109. * apply pattern as SprintWorkerRepository::reorder.
  110. *
  111. * @param list<array{task_id:int, sort_order:int}> $ordering
  112. * @return list<array{before: Task, after: Task}>
  113. */
  114. public function reorder(int $sprintId, array $ordering): array
  115. {
  116. if ($ordering === []) {
  117. return [];
  118. }
  119. $current = [];
  120. foreach ($this->allForSprint($sprintId) as $t) {
  121. $current[$t->id] = $t;
  122. }
  123. $stage = $this->pdo->prepare(
  124. 'UPDATE tasks SET sort_order = -? WHERE id = ? AND sprint_id = ?'
  125. );
  126. foreach ($ordering as $row) {
  127. $stage->execute([$row['sort_order'], $row['task_id'], $sprintId]);
  128. }
  129. $apply = $this->pdo->prepare(
  130. 'UPDATE tasks SET sort_order = ?, updated_at = ? WHERE id = ? AND sprint_id = ?'
  131. );
  132. $now = gmdate('Y-m-d\TH:i:s\Z');
  133. foreach ($ordering as $row) {
  134. $apply->execute([$row['sort_order'], $now, $row['task_id'], $sprintId]);
  135. }
  136. $out = [];
  137. foreach ($ordering as $row) {
  138. $tid = (int) $row['task_id'];
  139. $before = $current[$tid] ?? null;
  140. if ($before === null) {
  141. continue;
  142. }
  143. if ($before->sortOrder === (int) $row['sort_order']) {
  144. continue;
  145. }
  146. $after = $this->find($tid);
  147. if ($after !== null) {
  148. $out[] = ['before' => $before, 'after' => $after];
  149. }
  150. }
  151. return $out;
  152. }
  153. /**
  154. * @param array<string,mixed> $row
  155. */
  156. private static function hydrate(array $row): Task
  157. {
  158. return new Task(
  159. id: (int) $row['id'],
  160. sprintId: (int) $row['sprint_id'],
  161. title: (string) $row['title'],
  162. ownerWorkerId: isset($row['owner_worker_id']) && $row['owner_worker_id'] !== null
  163. ? (int) $row['owner_worker_id']
  164. : null,
  165. priority: (int) $row['priority'],
  166. sortOrder: (int) $row['sort_order'],
  167. createdAt: (string) $row['created_at'],
  168. updatedAt: (string) $row['updated_at'],
  169. );
  170. }
  171. }