pdo->prepare( self::SELECT . ' WHERE c.project_id = :project ORDER BY c.status_id, c.position ASC, c.id ASC' ); $stmt->execute(['project' => $projectId]); return array_map($this->cast(...), $stmt->fetchAll()); } /** * The owner's global inbox, in position order. * * @return CardRow[] */ public function allInInbox(int $ownerId): array { return $this->columnCards($ownerId, null, null); } /** * A card owned by the caller, wherever it is (inbox or a project). * * @return CardRow|null */ public function findOwnedBy(int $id, int $ownerId): ?array { $stmt = $this->pdo->prepare(self::SELECT . ' WHERE c.id = :id AND c.owner_id = :owner'); $stmt->execute(['id' => $id, 'owner' => $ownerId]); $row = $stmt->fetch(); return $row === false ? null : $this->cast($row); } /** * Create a card directly in a project, at the end of the given status. * * @return CardRow */ public function createInProject( int $ownerId, int $projectId, int $statusId, string $text, bool $complete, ?int $position, ): array { $position ??= $this->nextPositionInColumn($ownerId, $projectId, $statusId); return $this->insert($ownerId, $projectId, $statusId, $text, $complete, $position); } /** * Create a card in the owner's inbox. * * @return CardRow */ public function createInInbox(int $ownerId, string $text, bool $complete, ?int $position): array { $position ??= $this->nextPositionInColumn($ownerId, null, null); return $this->insert($ownerId, null, null, $text, $complete, $position); } /** * Update text/complete. Moving a card between columns -- including in or * out of the inbox -- is done via orderColumn(), not here. * * @param array{text?: string, complete?: bool} $fields * @return CardRow */ public function update(int $id, array $fields): array { $sets = ['updated_at = ' . $this->nowExpr()]; $params = ['id' => $id]; if (array_key_exists('text', $fields)) { $sets[] = 'text = :text'; $params['text'] = $fields['text']; } if (array_key_exists('complete', $fields)) { $sets[] = 'complete = :complete'; $params['complete'] = $fields['complete'] ? 1 : 0; } $this->pdo->prepare('UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id')->execute($params); /** @var CardRow $card */ $card = $this->find($id); return $card; } public function delete(int $id): void { $this->pdo->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $id]); } /** * Every card id owned by this user, wherever it is. * * @return int[] */ public function idsOwnedBy(int $ownerId): array { $stmt = $this->pdo->prepare('SELECT id FROM cards WHERE owner_id = :owner'); $stmt->execute(['owner' => $ownerId]); return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN)); } /** * IDs of the cards currently in one column, in position order. * * @return int[] */ public function idsInColumn(int $ownerId, ?int $projectId, ?int $statusId): array { [$match, $params] = $this->columnMatch($projectId, $statusId); $stmt = $this->pdo->prepare( "SELECT id FROM cards WHERE owner_id = :owner AND {$match} ORDER BY position ASC, id ASC" ); $stmt->execute(['owner' => $ownerId] + $params); return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN)); } /** * Set the contents and order of one column -- the inbox (both null) or a * project's status (both set). Every id in $orderedIds is moved there at * positions 0..n-1; any other column those cards came from (another * project's status, the inbox, ...) is re-packed. One transaction. * * @param int[] $orderedIds * @return CardRow[] the column's cards, in their new order */ public function orderColumn(int $ownerId, ?int $projectId, ?int $statusId, array $orderedIds): array { $orderedIds = array_values($orderedIds); $this->pdo->beginTransaction(); try { $sources = $this->columnsOf($ownerId, $orderedIds); $place = $this->pdo->prepare( 'UPDATE cards SET project_id = :project, status_id = :status, position = :position, updated_at = ' . $this->nowExpr() . ' WHERE id = :id AND owner_id = :owner' ); foreach ($orderedIds as $position => $id) { $place->execute([ 'project' => $projectId, 'status' => $statusId, 'position' => $position, 'id' => $id, 'owner' => $ownerId, ]); } foreach ($sources as [$sourceProject, $sourceStatus]) { if ($sourceProject === $projectId && $sourceStatus === $statusId) { continue; } $this->repack($ownerId, $sourceProject, $sourceStatus); } $this->pdo->commit(); } catch (\Throwable $e) { $this->pdo->rollBack(); throw $e; } return $this->columnCards($ownerId, $projectId, $statusId); } /** * Move every card in one project status to another status in the same * project -- used when a status is deleted out from under them. Appended * after the destination's existing cards, so both columns stay a dense * 0..n-1 with no repack needed. */ public function reassignStatus(int $ownerId, int $projectId, int $fromStatusId, int $toStatusId): void { $ids = $this->idsInColumn($ownerId, $projectId, $fromStatusId); if ($ids === []) { return; } $next = $this->nextPositionInColumn($ownerId, $projectId, $toStatusId); $stmt = $this->pdo->prepare( 'UPDATE cards SET status_id = :status, position = :position, updated_at = ' . $this->nowExpr() . ' WHERE id = :id AND owner_id = :owner' ); foreach ($ids as $i => $id) { $stmt->execute(['status' => $toStatusId, 'position' => $next + $i, 'id' => $id, 'owner' => $ownerId]); } } private function insert( int $ownerId, ?int $projectId, ?int $statusId, string $text, bool $complete, int $position, ): array { $stmt = $this->pdo->prepare( 'INSERT INTO cards (owner_id, project_id, status_id, text, complete, position) VALUES (:owner, :project, :status, :text, :complete, :position)' ); $stmt->execute([ 'owner' => $ownerId, 'project' => $projectId, 'status' => $statusId, 'text' => $text, 'complete' => $complete ? 1 : 0, 'position' => $position, ]); /** @var CardRow $card */ $card = $this->find((int) $this->pdo->lastInsertId()); return $card; } /** * @return CardRow[] */ private function columnCards(int $ownerId, ?int $projectId, ?int $statusId): array { // Qualified with c. -- self::SELECT joins card_statuses, which also // has a project_id, so the bare column name is ambiguous here. [$match, $params] = $this->columnMatch($projectId, $statusId, 'c.'); $stmt = $this->pdo->prepare( self::SELECT . " WHERE c.owner_id = :owner AND {$match} ORDER BY c.position ASC, c.id ASC" ); $stmt->execute(['owner' => $ownerId] + $params); return array_map($this->cast(...), $stmt->fetchAll()); } /** * The distinct (project_id, status_id) columns the given cards currently * sit in. * * @param int[] $ids * @return list */ private function columnsOf(int $ownerId, array $ids): array { if ($ids === []) { return []; } $placeholders = implode(',', array_fill(0, count($ids), '?')); $stmt = $this->pdo->prepare( "SELECT DISTINCT project_id, status_id FROM cards WHERE owner_id = ? AND id IN ($placeholders)" ); $stmt->execute([$ownerId, ...$ids]); return array_map( static fn (array $row): array => [ $row['project_id'] === null ? null : (int) $row['project_id'], $row['status_id'] === null ? null : (int) $row['status_id'], ], $stmt->fetchAll(PDO::FETCH_ASSOC), ); } /** Rewrite one column's positions to a dense 0..n-1 in current order. */ private function repack(int $ownerId, ?int $projectId, ?int $statusId): void { $ids = $this->idsInColumn($ownerId, $projectId, $statusId); $update = $this->pdo->prepare('UPDATE cards SET position = :position WHERE id = :id'); foreach ($ids as $position => $id) { $update->execute(['position' => $position, 'id' => $id]); } } private function nextPositionInColumn(int $ownerId, ?int $projectId, ?int $statusId): int { [$match, $params] = $this->columnMatch($projectId, $statusId); $stmt = $this->pdo->prepare( "SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE owner_id = :owner AND {$match}" ); $stmt->execute(['owner' => $ownerId] + $params); return (int) $stmt->fetchColumn(); } /** * A WHERE fragment matching one column. The inbox is (project_id IS NULL * AND status_id IS NULL); a project column is an exact pair -- the CHECK * constraint guarantees they're never mismatched. $prefix qualifies the * column names (e.g. "c.") for queries that join card_statuses, which * also has a project_id. * * @return array{0: string, 1: array} */ private function columnMatch(?int $projectId, ?int $statusId, string $prefix = ''): array { return $projectId === null ? ["{$prefix}project_id IS NULL AND {$prefix}status_id IS NULL", []] : [ "{$prefix}project_id = :project AND {$prefix}status_id = :status", ['project' => $projectId, 'status' => $statusId], ]; } /** * @return CardRow|null */ private function find(int $id): ?array { $stmt = $this->pdo->prepare(self::SELECT . ' WHERE c.id = :id'); $stmt->execute(['id' => $id]); $row = $stmt->fetch(); return $row === false ? null : $this->cast($row); } private function nowExpr(): string { return "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"; } /** * @param array $row * @return CardRow */ private function cast(array $row): array { $row['id'] = (int) $row['id']; $row['owner_id'] = (int) $row['owner_id']; $row['project_id'] = $row['project_id'] === null ? null : (int) $row['project_id']; $row['complete'] = (bool) $row['complete']; $row['position'] = (int) $row['position']; $statusId = $row['status_id'] === null ? null : (int) $row['status_id']; $row['status_id'] = $statusId; $row['status'] = $statusId === null ? null : ['id' => $statusId, 'name' => (string) $row['status_name']]; unset($row['status_name']); /** @var CardRow $row */ return $row; } }