ordered by sort_order ASC */ public function allForSprint(int $sprintId): array { $stmt = $this->pdo->prepare( 'SELECT * FROM tasks WHERE sprint_id = ? ORDER BY sort_order ASC' ); $stmt->execute([$sprintId]); $out = []; foreach ($stmt as $row) { $out[] = self::hydrate($row); } return $out; } public function find(int $id): ?Task { $stmt = $this->pdo->prepare('SELECT * FROM tasks WHERE id = ?'); $stmt->execute([$id]); $row = $stmt->fetch(); return is_array($row) ? self::hydrate($row) : null; } public function create( int $sprintId, string $title, ?int $ownerWorkerId, int $priority, ): Task { $now = gmdate('Y-m-d\TH:i:s\Z'); $max = (int) $this->pdo ->query('SELECT COALESCE(MAX(sort_order), 0) FROM tasks WHERE sprint_id = ' . $sprintId) ->fetchColumn(); $stmt = $this->pdo->prepare( 'INSERT INTO tasks (sprint_id, title, owner_worker_id, priority, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)' ); $stmt->execute([$sprintId, $title, $ownerWorkerId, $priority, $max + 1, $now, $now]); $id = (int) $this->pdo->lastInsertId(); $task = $this->find($id); if ($task === null) { throw new RuntimeException('Inserted task not found'); } return $task; } /** * @param array $changes * @return array{before: Task, after: Task} */ public function update(int $id, array $changes): array { $before = $this->find($id); if ($before === null) { throw new RuntimeException("Task {$id} not found"); } $changes = array_intersect_key($changes, array_flip(self::UPDATABLE)); if ($changes === []) { return ['before' => $before, 'after' => $before]; } $sets = []; $vals = []; foreach ($changes as $col => $v) { $sets[] = "{$col} = ?"; $vals[] = match ($col) { 'title' => (string) $v, 'owner_worker_id' => $v === null ? null : (int) $v, 'priority' => (int) $v, default => $v, }; } $sets[] = 'updated_at = ?'; $vals[] = gmdate('Y-m-d\TH:i:s\Z'); $vals[] = $id; $stmt = $this->pdo->prepare( 'UPDATE tasks SET ' . implode(', ', $sets) . ' WHERE id = ?' ); $stmt->execute($vals); $after = $this->find($id) ?? $before; return ['before' => $before, 'after' => $after]; } /** * Delete a task. Does NOT read cascaded assignment rows; the controller * is responsible for auditing those BEFORE calling this method. * Returns the pre-deletion row for auditing. */ public function delete(int $id): ?Task { $before = $this->find($id); if ($before === null) { return null; } $this->pdo ->prepare('DELETE FROM tasks WHERE id = ?') ->execute([$id]); return $before; } /** * Apply an ordering of tasks within a sprint. Same two-phase negate-then- * apply pattern as SprintWorkerRepository::reorder. * * @param list $ordering * @return list */ public function reorder(int $sprintId, array $ordering): array { if ($ordering === []) { return []; } $current = []; foreach ($this->allForSprint($sprintId) as $t) { $current[$t->id] = $t; } $stage = $this->pdo->prepare( 'UPDATE tasks SET sort_order = -? WHERE id = ? AND sprint_id = ?' ); foreach ($ordering as $row) { $stage->execute([$row['sort_order'], $row['task_id'], $sprintId]); } $apply = $this->pdo->prepare( 'UPDATE tasks SET sort_order = ?, updated_at = ? WHERE id = ? AND sprint_id = ?' ); $now = gmdate('Y-m-d\TH:i:s\Z'); foreach ($ordering as $row) { $apply->execute([$row['sort_order'], $now, $row['task_id'], $sprintId]); } $out = []; foreach ($ordering as $row) { $tid = (int) $row['task_id']; $before = $current[$tid] ?? null; if ($before === null) { continue; } if ($before->sortOrder === (int) $row['sort_order']) { continue; } $after = $this->find($tid); if ($after !== null) { $out[] = ['before' => $before, 'after' => $after]; } } return $out; } /** * @param array $row */ private static function hydrate(array $row): Task { return new Task( id: (int) $row['id'], sprintId: (int) $row['sprint_id'], title: (string) $row['title'], ownerWorkerId: isset($row['owner_worker_id']) && $row['owner_worker_id'] !== null ? (int) $row['owner_worker_id'] : null, priority: (int) $row['priority'], sortOrder: (int) $row['sort_order'], createdAt: (string) $row['created_at'], updatedAt: (string) $row['updated_at'], ); } }