diff --git a/README.md b/README.md index 8e4e71d..8355f11 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ Each user owns **projects**, and each project holds ordered **cards**. | 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done | | 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done | | 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done | -| 11 | Persistent left sidebar (Dashboard link + project list/new-project form); dashboard = grid of projects with their "New" inbox cards | ✅ done | +| 11 | Persistent left sidebar (Dashboard link + project list/new-project form); dashboard = grid of project tiles | ✅ done | | 12 | Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed | ✅ done | +| 13 | Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns | ✅ done | There is no password. Signing in is entering an email address and opening the magic link sent to it — the same step creates the account the first time. See @@ -258,41 +259,44 @@ Creating a project also seeds it with three **statuses** — "To do", "Doing", ### Cards -Scoped to a project; the parent project's ownership is checked first -(`404` otherwise). +A card either sits in its owner's **inbox** (`project_id` and `status_id` both +`null`) or belongs to exactly one of their projects with a status in it (both +set) — enforced by a database CHECK constraint, never one without the other. +The inbox is global to the user, not per-project. Because a card may have no +project, single-card and ordering routes are addressed globally, by the card's +own id, rather than nested under a project: | Method | Path | Purpose | |--------|------|---------| -| `GET` | `/api/projects/{id}/cards` | every card, grouped by column (inbox first) then `position` | -| `POST` | `/api/projects/{id}/cards` | add a card | -| `PUT` | `/api/projects/{id}/cards/order` | set the order/contents of one status column | -| `GET` | `/api/projects/{id}/cards/{cardId}` | one card | -| `PATCH` | `/api/projects/{id}/cards/{cardId}` | update `text`, `complete`, and/or `status_id` | -| `DELETE` | `/api/projects/{id}/cards/{cardId}` | delete the card (`204`) | +| `GET` | `/api/projects/{id}/cards` | a project's cards, grouped by status then `position` | +| `POST` | `/api/projects/{id}/cards` | add a card directly to the project (its first status) | +| `GET` | `/api/inbox/cards` | the caller's inbox | +| `POST` | `/api/inbox/cards` | add a card to the inbox | +| `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) | +| `PUT` | `/api/cards/order` | set the order/contents of one column | -Create body: `text` (required, 1–1000 chars), `complete` (optional bool, -default `false`). `PATCH` needs at least one field. +Create body (either creation route): `text` (required, 1–1000 chars), +`complete` (optional bool, default `false`). `PATCH` accepts `text` and/or +`complete` only — moving a card is done via the order route below, not PATCH. **Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards -that share a `(project_id, status_id)`. The inbox (`status_id IS NULL`) is its -own column. New cards go to the end of the inbox. There is no project-wide order. +that share an `(owner, project, status)`. The inbox is its own column, per +owner. `PUT /api/cards/order` sets one column's contents and order: -A new card has **no** status (`status_id: null`) — it sits in the project -"inbox" until the user gives it one. Two ways to move it: +```json +{ "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] } +``` -- `PATCH …/cards/{cardId}` with `status_id` (a status id in this project, or - `null` for the inbox) — appends the card to the end of the destination column - and re-packs the one it left. `422` for an unknown or foreign status. -- `PUT …/cards/order` with `{ "status_id": , "card_ids": [3, 1, 2] }` — - makes those cards the exact contents of that column, in that order (positions - rewritten to `0..n-1`). Any card dragged in from another column is re-parented - and its old column re-packed, all in one transaction. `card_ids` must be - distinct cards of this project and must include every card already in the - target column (`422` otherwise). Returns `{ "cards": [ … ] }` for the whole - project. This is what the kanban board calls on every drop. - -A status row that is deleted clears itself from its cards rather than deleting -them. +`project_id`/`status_id` are both `null` for the inbox, or both set to a +project owned by the caller and one of its statuses (`404`/`422` otherwise). +`card_ids` must be distinct cards owned by the caller and must include every +card already in the target column (`422` otherwise); it rewrites positions to +`0..n-1`. Any card in the list that wasn't already in that column is +re-parented into it — moving it from another project's status, or the inbox, +or vice versa — and the column it left is re-packed, all in one transaction. +Returns `{ "cards": [ … ] }` for the new column. This is what dragging a card +in the kanban board (or the sidebar's inbox) calls on every drop; moving a +card from one project to another is just two calls, via the inbox in between. Card representation: @@ -304,23 +308,25 @@ Card representation: "text": "Design homepage", "complete": false, "position": 0, - "status_id": null, - "status": null, + "status_id": 2, + "status": { "id": 2, "name": "Doing" }, "created_at": "2026-09-03T12:00:00Z", "updated_at": "2026-09-03T12:00:00Z" } } ``` -`status` is the embedded `{ id, name }` of the linked status, or `null` when the -card has none. `GET …/cards` returns `{ "cards": [ … ] }`. +`project_id` and `status_id` are `null` together for an inbox card. `status` is +the embedded `{ id, name }` of the linked status, or `null`. `GET …/cards` +returns `{ "cards": [ … ] }`. ### Statuses Every project has an ordered set of card statuses, created with the project: "To do", "Doing", "Done". They are project-specific — each project owns its own -rows. There is no create/update/delete for the statuses themselves yet; a card -is moved between them (or to the inbox) via `PATCH …/cards/{cardId}`. +rows. There is no create/update/delete for the statuses themselves yet, and (as +a project card must always have one) a referenced status can't be deleted at +the database level either. | Method | Path | Purpose | |--------|------|---------| diff --git a/migrations/009_global_inbox.sql b/migrations/009_global_inbox.sql new file mode 100644 index 0000000..a7ad1d4 --- /dev/null +++ b/migrations/009_global_inbox.sql @@ -0,0 +1,58 @@ +-- The inbox becomes global to a user rather than per-project: a card now +-- either belongs to nobody's project (project_id AND status_id both NULL -- +-- sitting in that user's inbox) or to exactly one project with a status in it +-- (both set). Cards also gain a direct owner_id, since inbox cards have no +-- project to derive ownership from. +-- +-- SQLite can't relax an existing NOT NULL / add a CHECK to a live column, so +-- the table is rebuilt. + +CREATE TABLE cards_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + project_id INTEGER NULL REFERENCES projects (id) ON DELETE CASCADE, + -- Was ON DELETE SET NULL; now that a project card must have a status + -- (the CHECK below), silently nulling status_id on a deleted status would + -- leave project_id orphaned without it. There is no status-delete + -- endpoint yet, so this is only a safety net. + status_id INTEGER NULL REFERENCES card_statuses (id) ON DELETE RESTRICT, + text TEXT NOT NULL, + complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)), + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + CHECK ((project_id IS NULL) = (status_id IS NULL)) +); + +-- Existing cards with no status were sitting in their project's old +-- per-project inbox column; that concept is gone, so they move to the (now +-- global, per-owner) inbox -- project_id cleared alongside status_id. +INSERT INTO cards_new (id, owner_id, project_id, status_id, text, complete, position, created_at, updated_at) +SELECT c.id, + p.owner_id, + CASE WHEN c.status_id IS NULL THEN NULL ELSE c.project_id END, + c.status_id, + c.text, c.complete, c.position, c.created_at, c.updated_at +FROM cards c +JOIN projects p ON p.id = c.project_id; + +DROP TABLE cards; +ALTER TABLE cards_new RENAME TO cards; + +CREATE INDEX idx_cards_owner_project_status_position + ON cards (owner_id, project_id, status_id, position); +CREATE INDEX idx_cards_status ON cards (status_id); + +-- Re-pack every column -- including each owner's inbox -- to a dense 0..n-1, +-- preserving relative order. Safe despite writing the table it reads: the CTE +-- is evaluated against the pre-update snapshot. +WITH ranked (id, rk) AS ( + SELECT id, + row_number() OVER ( + PARTITION BY owner_id, project_id, status_id + ORDER BY position, id + ) - 1 + FROM cards +) +UPDATE cards +SET position = (SELECT rk FROM ranked WHERE ranked.id = cards.id); diff --git a/src/Http/Controllers/CardController.php b/src/Http/Controllers/CardController.php index 31f5e3a..66931e5 100644 --- a/src/Http/Controllers/CardController.php +++ b/src/Http/Controllers/CardController.php @@ -13,8 +13,10 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; /** - * CRUD for the cards within one project. Every route first checks that the - * parent project is owned by the authenticated user; otherwise it responds 404. + * CRUD for cards. A card either sits in the caller's inbox (no project) or + * belongs to one of their projects with a status in it; single-card and + * ordering routes are addressed globally (by card id, or by an explicit + * project_id/status_id column) since a card need not have a project. */ final class CardController extends Controller { @@ -41,6 +43,9 @@ final class CardController extends Controller /** * POST /api/projects/{projectId}/cards + * + * Creates the card directly in the project, in its first status (a + * project card always has one -- see the invariant on the `cards` table). */ public function store(Request $request, Response $response, array $args): Response { @@ -52,29 +57,62 @@ final class CardController extends Controller $position = $validator->optionalInt('position', 0); $validator->assert(); - $card = $this->cards->create($projectId, $text, $complete, $position); + $card = $this->cards->createInProject( + $this->user($request)['id'], + $projectId, + $this->firstStatusId($projectId), + $text, + $complete, + $position, + ); $this->projects->touch($projectId); return $this->json($response, ['card' => $this->present($card)], 201); } /** - * GET /api/projects/{projectId}/cards/{cardId} + * GET /api/inbox/cards */ - public function show(Request $request, Response $response, array $args): Response + public function inboxIndex(Request $request, Response $response): Response { - $projectId = $this->requireOwnedProjectId($request, $args); + $cards = $this->cards->allInInbox($this->user($request)['id']); - return $this->json($response, ['card' => $this->present($this->requireCard($projectId, $args))]); + return $this->json($response, ['cards' => array_map($this->present(...), $cards)]); } /** - * PATCH /api/projects/{projectId}/cards/{cardId} + * POST /api/inbox/cards + */ + public function inboxStore(Request $request, Response $response): Response + { + $validator = new Validator($this->body($request)); + $text = $validator->requiredString('text', self::TEXT_MAX); + $complete = $validator->optionalBool('complete') ?? false; + $position = $validator->optionalInt('position', 0); + $validator->assert(); + + $card = $this->cards->createInInbox($this->user($request)['id'], $text, $complete, $position); + + return $this->json($response, ['card' => $this->present($card)], 201); + } + + /** + * GET /api/cards/{cardId} + */ + public function show(Request $request, Response $response, array $args): Response + { + return $this->json($response, ['card' => $this->present($this->requireCard($request, $args))]); + } + + /** + * PATCH /api/cards/{cardId} + * + * Text and completion only. Moving a card -- into/out of the inbox, or + * between projects -- goes through PUT /api/cards/order. */ public function update(Request $request, Response $response, array $args): Response { - $projectId = $this->requireOwnedProjectId($request, $args); - $card = $this->requireCard($projectId, $args); + $card = $this->requireCard($request, $args); $validator = new Validator($this->body($request)); $fields = []; @@ -84,63 +122,51 @@ final class CardController extends Controller if ($validator->has('complete')) { $fields['complete'] = $validator->optionalBool('complete'); } - if ($validator->has('status_id')) { - $statusId = $validator->nullableInt('status_id', 1); - if ($statusId !== null && !$validator->failed() - && $this->statuses->findInProject($statusId, $projectId) === null - ) { - $validator->add('status_id', 'That status does not belong to this project.'); - } - $fields['status_id'] = $statusId; - } if ($fields === [] && !$validator->failed()) { - $validator->add('text', 'Provide at least one of: text, complete, status_id.'); + $validator->add('text', 'Provide at least one of: text, complete.'); } $validator->assert(); - $updated = $this->cards->update($card['id'], $projectId, $fields); - $this->projects->touch($projectId); + $updated = $this->cards->update($card['id'], $fields); + if ($card['project_id'] !== null) { + $this->projects->touch($card['project_id']); + } return $this->json($response, ['card' => $this->present($updated)]); } /** - * DELETE /api/projects/{projectId}/cards/{cardId} + * DELETE /api/cards/{cardId} */ public function destroy(Request $request, Response $response, array $args): Response { - $projectId = $this->requireOwnedProjectId($request, $args); - $this->cards->delete($this->requireCard($projectId, $args)['id']); - $this->projects->touch($projectId); + $card = $this->requireCard($request, $args); + $this->cards->delete($card['id']); + if ($card['project_id'] !== null) { + $this->projects->touch($card['project_id']); + } return $response->withStatus(204); } /** - * PUT /api/projects/{projectId}/cards/order + * PUT /api/cards/order * - * Sets the contents and order of one status column. + * Sets the contents and order of one column. * - * Body: { "status_id": 2 | null, "card_ids": [3, 1, 2] } — the cards that - * should make up that column, in order. Positions are rewritten to 0..n-1; - * any card moved in from another column is re-parented and its old column - * re-packed. `status_id` is omitted or null for the inbox. + * Body: { "project_id": 5 | null, "status_id": 2 | null, "card_ids": [3, 1, 2] } + * -- both null for the inbox, or both set to a project owned by the + * caller and one of its statuses. `card_ids` are the cards that should + * make up that column, in order; any card moved in from elsewhere + * (another project, the inbox) is re-parented and its old column + * re-packed. */ - public function reorder(Request $request, Response $response, array $args): Response + public function reorder(Request $request, Response $response): Response { - $projectId = $this->requireOwnedProjectId($request, $args); + $ownerId = $this->user($request)['id']; $body = $this->body($request); - $statusId = null; - if (array_key_exists('status_id', $body) && $body['status_id'] !== null) { - if (!is_int($body['status_id'])) { - throw new ApiException('status_id must be a status ID or null.', 422); - } - $statusId = $body['status_id']; - if ($this->statuses->findInProject($statusId, $projectId) === null) { - throw new ApiException('That status does not belong to this project.', 422); - } - } + [$projectId, $statusId] = $this->targetColumn($ownerId, $body); $order = $body['card_ids'] ?? null; if (!is_array($order) || array_filter($order, static fn ($id) => !is_int($id)) !== []) { @@ -150,19 +176,64 @@ final class CardController extends Controller if (count($order) !== count(array_unique($order))) { throw new ApiException('card_ids must not contain duplicates.', 422); } - if (array_diff($order, $this->cards->idsForProject($projectId)) !== []) { - throw new ApiException('Every card_id must be a card in this project.', 422); + if (array_diff($order, $this->cards->idsOwnedBy($ownerId)) !== []) { + throw new ApiException('Every card_id must be a card you own.', 422); } - if (array_diff($this->cards->idsInColumn($projectId, $statusId), $order) !== []) { + if (array_diff($this->cards->idsInColumn($ownerId, $projectId, $statusId), $order) !== []) { throw new ApiException('card_ids must include every card already in this column.', 422); } - $cards = $this->cards->orderColumn($projectId, $statusId, $order); - $this->projects->touch($projectId); + $cards = $this->cards->orderColumn($ownerId, $projectId, $statusId, $order); + + if ($projectId !== null) { + $this->projects->touch($projectId); + } return $this->json($response, ['cards' => array_map($this->present(...), $cards)]); } + /** + * Validate and resolve the { project_id, status_id } target column from + * the request body. + * + * @param array $body + * @return array{0: int|null, 1: int|null} + */ + private function targetColumn(int $ownerId, array $body): array + { + $projectId = $body['project_id'] ?? null; + $statusId = $body['status_id'] ?? null; + + if ($projectId === null) { + if ($statusId !== null) { + throw new ApiException('status_id must be null when project_id is null.', 422); + } + + return [null, null]; + } + + if (!is_int($projectId)) { + throw new ApiException('project_id must be an integer or null.', 422); + } + if ($this->projects->findOwnedBy($projectId, $ownerId) === null) { + throw new ApiException('Project not found.', 404); + } + if (!is_int($statusId)) { + throw new ApiException('status_id is required when project_id is set.', 422); + } + if ($this->statuses->findInProject($statusId, $projectId) === null) { + throw new ApiException('That status does not belong to this project.', 422); + } + + return [$projectId, $statusId]; + } + + /** Every project is seeded with statuses on creation; this is never empty. */ + private function firstStatusId(int $projectId): int + { + return $this->statuses->allForProject($projectId)[0]['id']; + } + /** * @param array $args */ @@ -179,11 +250,11 @@ final class CardController extends Controller /** * @param array $args - * @return array{id: int, project_id: int, text: string, complete: bool, position: int, status_id: int|null, status: array{id: int, name: string}|null, created_at: string, updated_at: string} + * @return array{id: int, owner_id: int, project_id: int|null, text: string, complete: bool, position: int, status_id: int|null, status: array{id: int, name: string}|null, created_at: string, updated_at: string} */ - private function requireCard(int $projectId, array $args): array + private function requireCard(Request $request, array $args): array { - $card = $this->cards->findInProject((int) $args['cardId'], $projectId); + $card = $this->cards->findOwnedBy((int) $args['cardId'], $this->user($request)['id']); if ($card === null) { throw new ApiException('Card not found.', 404); @@ -193,7 +264,7 @@ final class CardController extends Controller } /** - * @param array{id: int, project_id: int, text: string, complete: bool, position: int, status_id: int|null, status: array{id: int, name: string}|null, created_at: string, updated_at: string} $card + * @param array{id: int, owner_id: int, project_id: int|null, text: string, complete: bool, position: int, status_id: int|null, status: array{id: int, name: string}|null, created_at: string, updated_at: string} $card * @return array */ private function present(array $card): array diff --git a/src/Repository/CardRepository.php b/src/Repository/CardRepository.php index b921e89..118762e 100644 --- a/src/Repository/CardRepository.php +++ b/src/Repository/CardRepository.php @@ -9,12 +9,14 @@ use PDO; /** * Data access for the `cards` table. * - * `position` is a dense 0..n-1 rank *within a column* — the cards sharing a - * (project_id, status_id). The inbox is the column where status_id IS NULL. + * A card either sits in its owner's inbox (project_id AND status_id both + * NULL) or belongs to exactly one project with a status in it (both set) -- + * enforced by a CHECK constraint. `position` is a dense 0..n-1 rank within a + * "column": the cards sharing an (owner_id, project_id, status_id). * * @phpstan-type CardStatus array{id: int, name: string} * @phpstan-type CardRow array{ - * id: int, project_id: int, text: string, complete: bool, position: int, + * id: int, owner_id: int, project_id: int|null, text: string, complete: bool, position: int, * status_id: int|null, status: CardStatus|null, * created_at: string, updated_at: string * } @@ -22,7 +24,7 @@ use PDO; final class CardRepository { private const SELECT = <<<'SQL' - SELECT c.id, c.project_id, c.text, c.complete, c.position, c.status_id, + SELECT c.id, c.owner_id, c.project_id, c.text, c.complete, c.position, c.status_id, c.created_at, c.updated_at, s.name AS status_name FROM cards c @@ -34,10 +36,6 @@ final class CardRepository } /** - * Every card in the project, grouped by column (inbox first) and ordered by - * position within each. Consumers that want a different order (e.g. the - * alphabetical "all tasks" list) re-sort client-side. - * * @return CardRow[] */ public function allForProject(int $projectId): array @@ -51,12 +49,24 @@ final class CardRepository } /** + * 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 findInProject(int $id, int $projectId): ?array + public function findOwnedBy(int $id, int $ownerId): ?array { - $stmt = $this->pdo->prepare(self::SELECT . ' WHERE c.id = :id AND c.project_id = :project'); - $stmt->execute(['id' => $id, 'project' => $projectId]); + $stmt = $this->pdo->prepare(self::SELECT . ' WHERE c.id = :id AND c.owner_id = :owner'); + $stmt->execute(['id' => $id, 'owner' => $ownerId]); $row = $stmt->fetch(); @@ -64,46 +74,44 @@ final class CardRepository } /** + * Create a card directly in a project, at the end of the given status. + * * @return CardRow */ - public function create(int $projectId, string $text, bool $complete, ?int $position): array - { - // A new card has no status — it goes to the end of the inbox column. - $position ??= $this->nextPositionInColumn($projectId, null); + public function createInProject( + int $ownerId, + int $projectId, + int $statusId, + string $text, + bool $complete, + ?int $position, + ): array { + $position ??= $this->nextPositionInColumn($ownerId, $projectId, $statusId); - $stmt = $this->pdo->prepare( - 'INSERT INTO cards (project_id, text, complete, position) - VALUES (:project, :text, :complete, :position)' - ); - $stmt->execute([ - 'project' => $projectId, - 'text' => $text, - 'complete' => $complete ? 1 : 0, - 'position' => $position, - ]); - - /** @var CardRow $card */ - $card = $this->findInProject((int) $this->pdo->lastInsertId(), $projectId); - - return $card; + return $this->insert($ownerId, $projectId, $statusId, $text, $complete, $position); } /** - * Update simple fields, and/or move the card to another column. A status - * change drops the card at the end of the destination column and re-packs - * the one it left. Precise slotting within a column is done via orderColumn(). + * Create a card in the owner's inbox. * - * @param array{text?: string, complete?: bool, status_id?: int|null} $fields * @return CardRow */ - public function update(int $id, int $projectId, array $fields): array + public function createInInbox(int $ownerId, string $text, bool $complete, ?int $position): array { - /** @var CardRow $card */ - $card = $this->findInProject($id, $projectId); + $position ??= $this->nextPositionInColumn($ownerId, null, null); - $movesColumn = array_key_exists('status_id', $fields) && $fields['status_id'] !== $card['status_id']; - $sourceColumn = $card['status_id']; + 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]; @@ -115,40 +123,13 @@ final class CardRepository $sets[] = 'complete = :complete'; $params['complete'] = $fields['complete'] ? 1 : 0; } - if (array_key_exists('status_id', $fields)) { - $sets[] = 'status_id = :status_id'; - $params['status_id'] = $fields['status_id']; - } - if ($movesColumn) { - $sets[] = 'position = :position'; - $params['position'] = $this->nextPositionInColumn($projectId, $fields['status_id']); - } - $sql = 'UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id'; + $this->pdo->prepare('UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id')->execute($params); - if (!$movesColumn) { - $this->pdo->prepare($sql)->execute($params); + /** @var CardRow $card */ + $card = $this->find($id); - /** @var CardRow $updated */ - $updated = $this->findInProject($id, $projectId); - - return $updated; - } - - $this->pdo->beginTransaction(); - try { - $this->pdo->prepare($sql)->execute($params); - $this->repack($projectId, $sourceColumn); - $this->pdo->commit(); - } catch (\Throwable $e) { - $this->pdo->rollBack(); - throw $e; - } - - /** @var CardRow $updated */ - $updated = $this->findInProject($id, $projectId); - - return $updated; + return $card; } public function delete(int $id): void @@ -157,14 +138,14 @@ final class CardRepository } /** - * IDs of every card in the project. + * Every card id owned by this user, wherever it is. * * @return int[] */ - public function idsForProject(int $projectId): array + public function idsOwnedBy(int $ownerId): array { - $stmt = $this->pdo->prepare('SELECT id FROM cards WHERE project_id = :project'); - $stmt->execute(['project' => $projectId]); + $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)); } @@ -174,50 +155,54 @@ final class CardRepository * * @return int[] */ - public function idsInColumn(int $projectId, ?int $statusId): array + public function idsInColumn(int $ownerId, ?int $projectId, ?int $statusId): array { - [$match, $params] = $this->columnMatch($statusId); + [$match, $params] = $this->columnMatch($projectId, $statusId); $stmt = $this->pdo->prepare( - "SELECT id FROM cards WHERE project_id = :project AND {$match} ORDER BY position ASC, id ASC" + "SELECT id FROM cards WHERE owner_id = :owner AND {$match} ORDER BY position ASC, id ASC" ); - $stmt->execute(['project' => $projectId] + $params); + $stmt->execute(['owner' => $ownerId] + $params); return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN)); } /** - * Set the contents and order of one column. Every id in $orderedIds is moved - * into $statusId at positions 0..n-1; any other column those cards came from - * is re-packed. One transaction. + * 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 project's cards, grouped by column + * @return CardRow[] the column's cards, in their new order */ - public function orderColumn(int $projectId, ?int $statusId, array $orderedIds): array + public function orderColumn(int $ownerId, ?int $projectId, ?int $statusId, array $orderedIds): array { $orderedIds = array_values($orderedIds); $this->pdo->beginTransaction(); try { - $sourceColumns = $this->columnsOf($projectId, $orderedIds); + $sources = $this->columnsOf($ownerId, $orderedIds); $place = $this->pdo->prepare( - 'UPDATE cards SET status_id = :status, position = :position, updated_at = ' . $this->nowExpr() . ' - WHERE id = :id AND project_id = :project' + '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, - 'project' => $projectId, + 'owner' => $ownerId, ]); } - foreach ($sourceColumns as $source) { - if ($source !== $statusId) { - $this->repack($projectId, $source); + foreach ($sources as [$sourceProject, $sourceStatus]) { + if ($sourceProject === $projectId && $sourceStatus === $statusId) { + continue; } + $this->repack($ownerId, $sourceProject, $sourceStatus); } $this->pdo->commit(); @@ -226,16 +211,60 @@ final class CardRepository throw $e; } - return $this->allForProject($projectId); + return $this->columnCards($ownerId, $projectId, $statusId); + } + + 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; } /** - * The distinct status_id values (columns) the given cards currently sit in. + * @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 array + * @return list */ - private function columnsOf(int $projectId, array $ids): array + private function columnsOf(int $ownerId, array $ids): array { if ($ids === []) { return []; @@ -243,20 +272,23 @@ final class CardRepository $placeholders = implode(',', array_fill(0, count($ids), '?')); $stmt = $this->pdo->prepare( - "SELECT DISTINCT status_id FROM cards WHERE project_id = ? AND id IN ($placeholders)" + "SELECT DISTINCT project_id, status_id FROM cards WHERE owner_id = ? AND id IN ($placeholders)" ); - $stmt->execute([$projectId, ...$ids]); + $stmt->execute([$ownerId, ...$ids]); return array_map( - static fn ($value) => $value === null ? null : (int) $value, - $stmt->fetchAll(PDO::FETCH_COLUMN), + 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 $projectId, ?int $statusId): void + private function repack(int $ownerId, ?int $projectId, ?int $statusId): void { - $ids = $this->idsInColumn($projectId, $statusId); + $ids = $this->idsInColumn($ownerId, $projectId, $statusId); $update = $this->pdo->prepare('UPDATE cards SET position = :position WHERE id = :id'); foreach ($ids as $position => $id) { @@ -264,28 +296,47 @@ final class CardRepository } } - private function nextPositionInColumn(int $projectId, ?int $statusId): int + private function nextPositionInColumn(int $ownerId, ?int $projectId, ?int $statusId): int { - [$match, $params] = $this->columnMatch($statusId); + [$match, $params] = $this->columnMatch($projectId, $statusId); $stmt = $this->pdo->prepare( - "SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE project_id = :project AND {$match}" + "SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE owner_id = :owner AND {$match}" ); - $stmt->execute(['project' => $projectId] + $params); + $stmt->execute(['owner' => $ownerId] + $params); return (int) $stmt->fetchColumn(); } /** - * A WHERE fragment matching one column, since SQLite needs `IS NULL` (not - * `= NULL`) for the inbox. + * 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 $statusId): array + private function columnMatch(?int $projectId, ?int $statusId, string $prefix = ''): array { - return $statusId === null - ? ['status_id IS NULL', []] - : ['status_id = :status', ['status' => $statusId]]; + 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 @@ -300,7 +351,8 @@ final class CardRepository private function cast(array $row): array { $row['id'] = (int) $row['id']; - $row['project_id'] = (int) $row['project_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']; diff --git a/src/Support/Validator.php b/src/Support/Validator.php index 2c41e25..0dee993 100644 --- a/src/Support/Validator.php +++ b/src/Support/Validator.php @@ -90,27 +90,6 @@ final class Validator return $value; } - /** - * An integer (>= $min) or an explicit null. Only meaningful once has() - * has confirmed the field is present; a null return is a valid value. - */ - public function nullableInt(string $field, int $min): ?int - { - if (!$this->has($field) || $this->data[$field] === null) { - return null; - } - - $value = $this->data[$field]; - - if (!is_int($value) || $value < $min) { - $this->errors[$field][] = ucfirst($field) . " must be an integer of at least {$min}, or null."; - - return null; - } - - return $value; - } - public function add(string $field, string $message): void { $this->errors[$field][] = $message; diff --git a/src/bootstrap.php b/src/bootstrap.php index d22e7ed..23d0c5f 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -99,10 +99,21 @@ $app->group('/api', function (RouteCollectorProxy $group) use ( $projects->get('/{projectId:[0-9]+}/cards', [$cardController, 'index']); $projects->post('/{projectId:[0-9]+}/cards', [$cardController, 'store']); - $projects->put('/{projectId:[0-9]+}/cards/order', [$cardController, 'reorder']); - $projects->get('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'show']); - $projects->patch('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'update']); - $projects->delete('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'destroy']); + })->add($authMiddleware); + + // The inbox has no project of its own -- a user's unfiled cards. + $group->group('/inbox', function (RouteCollectorProxy $inbox) use ($cardController) { + $inbox->get('/cards', [$cardController, 'inboxIndex']); + $inbox->post('/cards', [$cardController, 'inboxStore']); + })->add($authMiddleware); + + // Single-card and ordering routes are global: a card may not have a + // project to nest them under. + $group->group('/cards', function (RouteCollectorProxy $cards) use ($cardController) { + $cards->put('/order', [$cardController, 'reorder']); + $cards->get('/{cardId:[0-9]+}', [$cardController, 'show']); + $cards->patch('/{cardId:[0-9]+}', [$cardController, 'update']); + $cards->delete('/{cardId:[0-9]+}', [$cardController, 'destroy']); })->add($authMiddleware); }); diff --git a/tests/CardOrderTest.php b/tests/CardOrderTest.php index 90bcdc3..7a028ce 100644 --- a/tests/CardOrderTest.php +++ b/tests/CardOrderTest.php @@ -4,10 +4,13 @@ declare(strict_types=1); namespace Tests; +use Psr\Http\Message\ResponseInterface; + /** - * `position` is a dense rank within a column — the cards sharing a - * (project, status). PUT /api/projects/{id}/cards/order sets one column's - * contents and order. + * `position` is a dense rank within a "column" -- the cards sharing an + * (owner, project, status). The inbox is the column where project and status + * are both null, and it's global to the owner rather than per-project. + * PUT /api/cards/order sets one column's contents and order. */ final class CardOrderTest extends ApiTestCase { @@ -26,231 +29,347 @@ final class CardOrderTest extends ApiTestCase } /** @param array $auth */ - private function addCard(int $projectId, string $text, array $auth): int + private function addToInbox(string $text, array $auth): int + { + return $this->decode( + $this->request('POST', '/api/inbox/cards', ['text' => $text], $auth), + )['card']['id']; + } + + /** @param array $auth */ + private function addToProject(int $projectId, string $text, array $auth): int { return $this->decode( $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth), )['card']['id']; } - /** @param array $auth @return list */ - private function cards(int $projectId, array $auth): array + /** @param int[] $cardIds @param array $auth */ + private function reorder(?int $projectId, ?int $statusId, array $cardIds, array $auth): ResponseInterface + { + return $this->request('PUT', '/api/cards/order', [ + 'project_id' => $projectId, + 'status_id' => $statusId, + 'card_ids' => $cardIds, + ], $auth); + } + + /** @param array $auth @return list */ + private function inbox(array $auth): array { return array_map( static fn (array $c): array => [ - 'text' => $c['text'], - 'status_id' => $c['status_id'], - 'position' => $c['position'], + 'text' => $c['text'], 'project_id' => $c['project_id'], + 'status_id' => $c['status_id'], 'position' => $c['position'], ], - $this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'], + $this->decode($this->request('GET', '/api/inbox/cards', null, $auth))['cards'], ); } - public function test_new_cards_are_ranked_densely_within_the_inbox(): void + // --- inbox CRUD ----------------------------------------------------- + + public function test_inbox_requires_authentication(): void + { + self::assertSame(401, $this->request('GET', '/api/inbox/cards')->getStatusCode()); + self::assertSame(401, $this->request('POST', '/api/inbox/cards', ['text' => 'x'])->getStatusCode()); + } + + public function test_creating_an_inbox_card(): void { $auth = $this->authHeader(); - $projectId = $this->newProject($auth); + $response = $this->request('POST', '/api/inbox/cards', ['text' => 'Buy milk'], $auth); + + self::assertSame(201, $response->getStatusCode()); + $card = $this->decode($response)['card']; + self::assertSame('Buy milk', $card['text']); + self::assertNull($card['project_id']); + self::assertNull($card['status_id']); + self::assertNull($card['status']); + self::assertSame(0, $card['position']); + } + + public function test_inbox_card_creation_validates_text(): void + { + $response = $this->request('POST', '/api/inbox/cards', ['text' => ' '], $this->authHeader()); + + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('text', $this->decode($response)['error']['details']); + } + + public function test_inbox_is_isolated_per_owner(): void + { + $mine = $this->authHeader('mine@example.com'); + $theirs = $this->authHeader('theirs@example.com'); + $this->addToInbox('mine', $mine); + $this->addToInbox('theirs', $theirs); + + self::assertSame(['mine'], array_column($this->inbox($mine), 'text')); + self::assertSame(['theirs'], array_column($this->inbox($theirs), 'text')); + } + + public function test_new_inbox_cards_are_ranked_densely(): void + { + $auth = $this->authHeader(); foreach (['A', 'B', 'C'] as $text) { - $this->addCard($projectId, $text, $auth); + $this->addToInbox($text, $auth); } - self::assertSame( - [['text' => 'A', 'status_id' => null, 'position' => 0], - ['text' => 'B', 'status_id' => null, 'position' => 1], - ['text' => 'C', 'status_id' => null, 'position' => 2]], - $this->cards($projectId, $auth), - ); + self::assertSame([0, 1, 2], array_column($this->inbox($auth), 'position')); + self::assertSame([null, null, null], array_column($this->inbox($auth), 'project_id')); } - public function test_reorder_within_the_inbox_column(): void + // --- reordering within a column -------------------------------------- + + public function test_reorder_within_the_inbox(): void { $auth = $this->authHeader(); - $projectId = $this->newProject($auth); $ids = []; foreach (['A', 'B', 'C'] as $text) { - $ids[$text] = $this->addCard($projectId, $text, $auth); + $ids[$text] = $this->addToInbox($text, $auth); } - $response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ - 'status_id' => null, - 'card_ids' => [$ids['C'], $ids['A'], $ids['B']], - ], $auth); + $response = $this->reorder(null, null, [$ids['C'], $ids['A'], $ids['B']], $auth); self::assertSame(200, $response->getStatusCode()); self::assertSame(['C', 'A', 'B'], array_column($this->decode($response)['cards'], 'text')); - self::assertSame([0, 1, 2], array_column($this->cards($projectId, $auth), 'position')); + self::assertSame(['C', 'A', 'B'], array_column($this->inbox($auth), 'text')); } - public function test_ordering_a_status_column_moves_cards_into_it_and_repacks_the_inbox(): void + public function test_reorder_within_a_project_status(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); $todo = $this->statuses($projectId, $auth)[0]['id']; $ids = []; foreach (['A', 'B', 'C'] as $text) { - $ids[$text] = $this->addCard($projectId, $text, $auth); + $ids[$text] = $this->addToProject($projectId, $text, $auth); } - // Move B and C into "To do" (C first), leaving A alone in the inbox. - $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ - 'status_id' => $todo, - 'card_ids' => [$ids['C'], $ids['B']], - ], $auth); + $response = $this->reorder($projectId, $todo, [$ids['C'], $ids['A'], $ids['B']], $auth); - $byText = []; - foreach ($this->cards($projectId, $auth) as $c) { - $byText[$c['text']] = $c; - } - - self::assertSame(['status_id' => null, 'position' => 0], ['status_id' => $byText['A']['status_id'], 'position' => $byText['A']['position']]); - self::assertSame(['status_id' => $todo, 'position' => 0], ['status_id' => $byText['C']['status_id'], 'position' => $byText['C']['position']]); - self::assertSame(['status_id' => $todo, 'position' => 1], ['status_id' => $byText['B']['status_id'], 'position' => $byText['B']['position']]); + self::assertSame(200, $response->getStatusCode()); + self::assertSame(['C', 'A', 'B'], array_column($this->decode($response)['cards'], 'text')); } - public function test_moving_a_card_out_of_the_inbox_repacks_the_survivors(): void + // --- moving between the inbox and a project -------------------------- + + public function test_moving_a_card_from_the_inbox_into_a_project_status(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); - $todo = $this->statuses($projectId, $auth)[0]['id']; + $doing = $this->statuses($projectId, $auth)[1]['id']; + $cardId = $this->addToInbox('Triage me', $auth); + + $response = $this->reorder($projectId, $doing, [$cardId], $auth); + + self::assertSame(200, $response->getStatusCode()); + $card = $this->decode($response)['cards'][0]; + self::assertSame($projectId, $card['project_id']); + self::assertSame($doing, $card['status_id']); + self::assertSame('Doing', $card['status']['name']); + self::assertSame(0, $card['position']); + self::assertSame([], $this->inbox($auth)); + } + + public function test_moving_a_card_out_of_a_project_back_to_the_inbox(): void + { + $auth = $this->authHeader(); + $projectId = $this->newProject($auth); + $cardId = $this->addToProject($projectId, 'Rethink this', $auth); + + $response = $this->reorder(null, null, [$cardId], $auth); + + self::assertSame(200, $response->getStatusCode()); + $card = $this->decode($response)['cards'][0]; + self::assertNull($card['project_id']); + self::assertNull($card['status_id']); + self::assertSame(['Rethink this'], array_column($this->inbox($auth), 'text')); + } + + public function test_a_card_moves_from_one_project_to_another_via_the_inbox(): void + { + $auth = $this->authHeader(); + $projectA = $this->newProject($auth, 'A'); + $projectB = $this->newProject($auth, 'B'); + $bStatus = $this->statuses($projectB, $auth)[2]['id']; + $cardId = $this->addToProject($projectA, 'Reassign me', $auth); + + // A -> inbox + $this->reorder(null, null, [$cardId], $auth); + self::assertSame(['Reassign me'], array_column($this->inbox($auth), 'text')); + + // inbox -> B + $response = $this->reorder($projectB, $bStatus, [$cardId], $auth); + + self::assertSame(200, $response->getStatusCode()); + $card = $this->decode($response)['cards'][0]; + self::assertSame($projectB, $card['project_id']); + self::assertSame($bStatus, $card['status_id']); + self::assertSame([], $this->inbox($auth)); + + $projectACards = $this->decode($this->request('GET', "/api/projects/{$projectA}/cards", null, $auth))['cards']; + self::assertSame([], $projectACards); + } + + public function test_moving_a_card_repacks_the_column_it_left(): void + { + $auth = $this->authHeader(); $ids = []; foreach (['A', 'B', 'C'] as $text) { - $ids[$text] = $this->addCard($projectId, $text, $auth); // inbox 0,1,2 + $ids[$text] = $this->addToInbox($text, $auth); // inbox: A@0, B@1, C@2 } + $projectId = $this->newProject($auth); + $todo = $this->statuses($projectId, $auth)[0]['id']; - // Pull the middle card into "To do". - $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ - 'status_id' => $todo, - 'card_ids' => [$ids['B']], - ], $auth); + $this->reorder($projectId, $todo, [$ids['B']], $auth); // pull B out - $inbox = array_values(array_filter($this->cards($projectId, $auth), static fn ($c) => $c['status_id'] === null)); self::assertSame( - [['text' => 'A', 'status_id' => null, 'position' => 0], - ['text' => 'C', 'status_id' => null, 'position' => 1]], - $inbox, + [['text' => 'A', 'position' => 0], ['text' => 'C', 'position' => 1]], + array_map( + static fn (array $c) => ['text' => $c['text'], 'position' => $c['position']], + $this->inbox($auth), + ), ); } public function test_a_new_inbox_card_lands_after_the_repacked_survivors(): void { $auth = $this->authHeader(); + $a = $this->addToInbox('A', $auth); + $this->addToInbox('B', $auth); // inbox: A@0, B@1 $projectId = $this->newProject($auth); $todo = $this->statuses($projectId, $auth)[0]['id']; - $a = $this->addCard($projectId, 'A', $auth); - $b = $this->addCard($projectId, 'B', $auth); // inbox: A@0, B@1 - $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ - 'status_id' => $todo, - 'card_ids' => [$a], - ], $auth); // inbox now: B@0 + $this->reorder($projectId, $todo, [$a], $auth); // inbox now: B@0 - $newId = $this->addCard($projectId, 'C', $auth); + $newId = $this->addToInbox('C', $auth); $byId = []; - foreach ($this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'] as $c) { - $byId[$c['id']] = $c; + foreach ($this->inbox($auth) as $c) { + $byId[$c['text']] = $c; } - self::assertSame(0, $byId[$b]['position']); - self::assertSame(1, $byId[$newId]['position']); - self::assertNull($byId[$newId]['status_id']); + self::assertSame(0, $byId['B']['position']); + self::assertSame(1, $byId['C']['position']); + self::assertNotSame($newId, null); // sanity: card was actually created } - public function test_patch_status_appends_the_card_to_the_destination_column(): void + // --- validation -------------------------------------------------------- + + public function test_order_rejects_status_id_without_project_id(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); - $todo = $this->statuses($projectId, $auth)[0]['id']; - $first = $this->addCard($projectId, 'first', $auth); - $second = $this->addCard($projectId, 'second', $auth); + $status = $this->statuses($projectId, $auth)[0]['id']; + $cardId = $this->addToInbox('x', $auth); - $this->request('PATCH', "/api/projects/{$projectId}/cards/{$first}", ['status_id' => $todo], $auth); - $moved = $this->decode( - $this->request('PATCH', "/api/projects/{$projectId}/cards/{$second}", ['status_id' => $todo], $auth), - )['card']; + $response = $this->reorder(null, $status, [$cardId], $auth); - self::assertSame($todo, $moved['status_id']); - self::assertSame(1, $moved['position']); // after `first`, which took slot 0 + self::assertSame(422, $response->getStatusCode()); } - public function test_reorder_rejects_a_foreign_status(): void + public function test_order_requires_status_id_when_project_id_is_set(): void + { + $auth = $this->authHeader(); + $projectId = $this->newProject($auth); + $cardId = $this->addToInbox('x', $auth); + + $response = $this->reorder($projectId, null, [$cardId], $auth); + + self::assertSame(422, $response->getStatusCode()); + } + + public function test_order_rejects_a_status_from_another_project(): void { $auth = $this->authHeader(); $mine = $this->newProject($auth, 'Mine'); $other = $this->newProject($auth, 'Other'); $foreignStatus = $this->statuses($other, $auth)[0]['id']; - $card = $this->addCard($mine, 'x', $auth); + $cardId = $this->addToProject($mine, 'x', $auth); - $response = $this->request('PUT', "/api/projects/{$mine}/cards/order", [ - 'status_id' => $foreignStatus, - 'card_ids' => [$card], - ], $auth); + $response = $this->reorder($mine, $foreignStatus, [$cardId], $auth); self::assertSame(422, $response->getStatusCode()); } - public function test_reorder_rejects_a_card_from_another_project(): void - { - $auth = $this->authHeader(); - $mine = $this->newProject($auth, 'Mine'); - $other = $this->newProject($auth, 'Other'); - $foreignCard = $this->addCard($other, 'x', $auth); - - $response = $this->request('PUT', "/api/projects/{$mine}/cards/order", [ - 'status_id' => null, - 'card_ids' => [$foreignCard], - ], $auth); - - self::assertSame(422, $response->getStatusCode()); - } - - public function test_reorder_must_list_every_card_already_in_the_target_column(): void - { - $auth = $this->authHeader(); - $projectId = $this->newProject($auth); - $todo = $this->statuses($projectId, $auth)[0]['id']; - $a = $this->addCard($projectId, 'A', $auth); - $b = $this->addCard($projectId, 'B', $auth); - - // Put both in "To do". - $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ - 'status_id' => $todo, - 'card_ids' => [$a, $b], - ], $auth); - - // Now try to reorder "To do" mentioning only one of them. - $response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ - 'status_id' => $todo, - 'card_ids' => [$b], - ], $auth); - - self::assertSame(422, $response->getStatusCode()); - } - - public function test_reorder_rejects_duplicate_ids(): void - { - $auth = $this->authHeader(); - $projectId = $this->newProject($auth); - $a = $this->addCard($projectId, 'A', $auth); - - $response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ - 'status_id' => null, - 'card_ids' => [$a, $a], - ], $auth); - - self::assertSame(422, $response->getStatusCode()); - } - - public function test_reorder_is_scoped_to_the_owner(): void + public function test_order_rejects_a_project_owned_by_someone_else(): void { $owner = $this->authHeader('owner@example.com'); $other = $this->authHeader('other@example.com'); $projectId = $this->newProject($owner); - $card = $this->addCard($projectId, 'x', $owner); + $status = $this->statuses($projectId, $owner)[0]['id']; + $cardId = $this->addToInbox('x', $other); - self::assertSame(404, $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ - 'status_id' => null, - 'card_ids' => [$card], - ], $other)->getStatusCode()); + $response = $this->reorder($projectId, $status, [$cardId], $other); + + self::assertSame(404, $response->getStatusCode()); + } + + public function test_order_rejects_a_card_owned_by_someone_else(): void + { + $owner = $this->authHeader('owner@example.com'); + $other = $this->authHeader('other@example.com'); + $foreignCardId = $this->addToInbox('not yours', $owner); + + $response = $this->reorder(null, null, [$foreignCardId], $other); + + self::assertSame(422, $response->getStatusCode()); + } + + public function test_order_must_list_every_card_already_in_the_target_column(): void + { + $auth = $this->authHeader(); + $a = $this->addToInbox('A', $auth); + $this->addToInbox('B', $auth); + + $response = $this->reorder(null, null, [$a], $auth); + + self::assertSame(422, $response->getStatusCode()); + } + + public function test_order_rejects_duplicate_ids(): void + { + $auth = $this->authHeader(); + $a = $this->addToInbox('A', $auth); + + $response = $this->reorder(null, null, [$a, $a], $auth); + + self::assertSame(422, $response->getStatusCode()); + } + + // --- single-card routes are global ------------------------------------- + + public function test_a_card_is_only_reachable_by_its_owner(): void + { + $owner = $this->authHeader('owner@example.com'); + $other = $this->authHeader('other@example.com'); + $cardId = $this->addToInbox('mine', $owner); + + self::assertSame(200, $this->request('GET', "/api/cards/{$cardId}", null, $owner)->getStatusCode()); + self::assertSame(404, $this->request('GET', "/api/cards/{$cardId}", null, $other)->getStatusCode()); + self::assertSame(404, $this->request('PATCH', "/api/cards/{$cardId}", ['text' => 'x'], $other)->getStatusCode()); + self::assertSame(404, $this->request('DELETE', "/api/cards/{$cardId}", null, $other)->getStatusCode()); + } + + public function test_patch_requires_a_recognised_field(): void + { + $auth = $this->authHeader(); + $projectId = $this->newProject($auth); + $status = $this->statuses($projectId, $auth)[1]['id']; + $cardId = $this->addToProject($projectId, 'x', $auth); + + // status_id is no longer a PATCH field -- moves go through /cards/order. + $response = $this->request('PATCH', "/api/cards/{$cardId}", ['status_id' => $status], $auth); + + self::assertSame(422, $response->getStatusCode()); + } + + public function test_deleting_an_inbox_card(): void + { + $auth = $this->authHeader(); + $cardId = $this->addToInbox('gone soon', $auth); + + self::assertSame(204, $this->request('DELETE', "/api/cards/{$cardId}", null, $auth)->getStatusCode()); + self::assertSame([], $this->inbox($auth)); } } diff --git a/tests/CardStatusTest.php b/tests/CardStatusTest.php index a51f41b..451f49d 100644 --- a/tests/CardStatusTest.php +++ b/tests/CardStatusTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Tests; +use PDOException; + final class CardStatusTest extends ApiTestCase { /** Create a project and return its id. */ @@ -63,93 +65,34 @@ final class CardStatusTest extends ApiTestCase self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}/statuses", null, $other)->getStatusCode()); } - public function test_a_new_card_has_no_status(): void + public function test_a_card_created_directly_in_a_project_starts_in_its_first_status(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); + $firstStatus = $this->statuses($projectId, $auth)[0]; $card = $this->decode( $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'First'], $auth), )['card']; - // New cards sit in the "inbox" — no status until the user assigns one. - self::assertArrayHasKey('status_id', $card); - self::assertNull($card['status_id']); - self::assertNull($card['status']); + self::assertSame($firstStatus['id'], $card['status_id']); + self::assertSame('To do', $card['status']['name']); + self::assertSame($projectId, $card['project_id']); } - public function test_a_card_can_be_moved_between_statuses_and_back_to_the_inbox(): void + public function test_a_referenced_status_cannot_be_deleted(): void { + // There's no delete-status endpoint; this exercises the FK directly. + // A card with a project must have a status (the CHECK constraint), so + // the FK is ON DELETE RESTRICT rather than SET NULL. $auth = $this->authHeader(); $projectId = $this->newProject($auth); - $statuses = $this->statuses($projectId, $auth); - $cardId = $this->decode( - $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'Move me'], $auth), - )['card']['id']; - - $doing = $this->decode( - $this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => $statuses[1]['id']], $auth), - )['card']; - self::assertSame($statuses[1]['id'], $doing['status_id']); - self::assertSame('Doing', $doing['status']['name']); - - $backToInbox = $this->decode( - $this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => null], $auth), - )['card']; - self::assertNull($backToInbox['status_id']); - self::assertNull($backToInbox['status']); - } - - public function test_a_card_rejects_a_status_from_another_project(): void - { - $auth = $this->authHeader(); - $mine = $this->newProject($auth, 'Mine'); - $other = $this->newProject($auth, 'Other'); - $foreignStatusId = $this->statuses($other, $auth)[0]['id']; - $cardId = $this->decode( - $this->request('POST', "/api/projects/{$mine}/cards", ['text' => 'x'], $auth), - )['card']['id']; - - $response = $this->request('PATCH', "/api/projects/{$mine}/cards/{$cardId}", ['status_id' => $foreignStatusId], $auth); - - self::assertSame(422, $response->getStatusCode()); - self::assertArrayHasKey('status_id', $this->decode($response)['error']['details']); - } - - public function test_a_card_rejects_an_unknown_status(): void - { - $auth = $this->authHeader(); - $projectId = $this->newProject($auth); - $cardId = $this->decode( - $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth), - )['card']['id']; - - $response = $this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => 999999], $auth); - - self::assertSame(422, $response->getStatusCode()); - } - - public function test_deleting_a_status_clears_it_from_its_cards(): void - { - $auth = $this->authHeader(); - $projectId = $this->newProject($auth); - $cardId = $this->decode( - $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'Orphan me'], $auth), - )['card']['id']; $statusId = $this->statuses($projectId, $auth)[0]['id']; + $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth); - $this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => $statusId], $auth); - - // No delete endpoint for statuses yet — remove the row directly to - // exercise ON DELETE SET NULL. $this->db()->exec('PRAGMA foreign_keys = ON'); + $this->expectException(PDOException::class); $this->db()->prepare('DELETE FROM card_statuses WHERE id = ?')->execute([$statusId]); - - $reread = $this->decode( - $this->request('GET', "/api/projects/{$projectId}/cards/{$cardId}", null, $auth), - )['card']; - self::assertNull($reread['status_id']); - self::assertNull($reread['status']); } public function test_deleting_a_project_cascades_to_its_statuses(): void diff --git a/tests/ProjectTest.php b/tests/ProjectTest.php index 3b5014f..7e41a36 100644 --- a/tests/ProjectTest.php +++ b/tests/ProjectTest.php @@ -100,12 +100,15 @@ final class ProjectTest extends ApiTestCase self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}", null, $auth)->getStatusCode()); } - public function test_cards_append_in_order_and_track_completion(): void + public function test_cards_land_in_the_first_status_and_track_completion(): void { $auth = $this->authHeader(); $projectId = $this->decode( $this->request('POST', '/api/projects', ['title' => 'Chores'], $auth), )['project']['id']; + $firstStatus = $this->decode( + $this->request('GET', "/api/projects/{$projectId}/statuses", null, $auth), + )['statuses'][0]; foreach (['Wash up', 'Hoover', 'Bins'] as $text) { $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth); @@ -114,10 +117,11 @@ final class ProjectTest extends ApiTestCase $cards = $this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards']; self::assertSame(['Wash up', 'Hoover', 'Bins'], array_column($cards, 'text')); self::assertSame([0, 1, 2], array_column($cards, 'position')); + self::assertSame([$firstStatus['id'], $firstStatus['id'], $firstStatus['id']], array_column($cards, 'status_id')); self::assertFalse($cards[0]['complete']); $done = $this->decode( - $this->request('PATCH', "/api/projects/{$projectId}/cards/{$cards[0]['id']}", ['complete' => true], $auth), + $this->request('PATCH', "/api/cards/{$cards[0]['id']}", ['complete' => true], $auth), )['card']; self::assertTrue($done['complete']); @@ -143,68 +147,6 @@ final class ProjectTest extends ApiTestCase self::assertArrayHasKey('text', $this->decode($bad)['error']['details']); } - public function test_cards_can_be_reordered_in_bulk(): void - { - $auth = $this->authHeader(); - $projectId = $this->decode( - $this->request('POST', '/api/projects', ['title' => 'Reorder'], $auth), - )['project']['id']; - - $ids = []; - foreach (['A', 'B', 'C'] as $text) { - $ids[$text] = $this->decode( - $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth), - )['card']['id']; - } - - $response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ - 'card_ids' => [$ids['C'], $ids['A'], $ids['B']], - ], $auth); - - self::assertSame(200, $response->getStatusCode()); - $cards = $this->decode($response)['cards']; - self::assertSame(['C', 'A', 'B'], array_column($cards, 'text')); - self::assertSame([0, 1, 2], array_column($cards, 'position')); - - // Order persists on a fresh read. - $reread = $this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards']; - self::assertSame(['C', 'A', 'B'], array_column($reread, 'text')); - } - - public function test_reorder_rejects_an_incomplete_id_set(): void - { - $auth = $this->authHeader(); - $projectId = $this->decode( - $this->request('POST', '/api/projects', ['title' => 'Reorder'], $auth), - )['project']['id']; - $first = $this->decode( - $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'one'], $auth), - )['card']['id']; - $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'two'], $auth); - - $response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ - 'card_ids' => [$first], - ], $auth); - - self::assertSame(422, $response->getStatusCode()); - } - - public function test_reorder_is_scoped_to_the_owner(): void - { - $owner = $this->authHeader('ro@example.com'); - $other = $this->authHeader('rx@example.com'); - $projectId = $this->decode( - $this->request('POST', '/api/projects', ['title' => 'Mine'], $owner), - )['project']['id']; - $cardId = $this->decode( - $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $owner), - )['card']['id']; - - self::assertSame(404, $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ - 'card_ids' => [$cardId], - ], $other)->getStatusCode()); - } - public function test_deleting_a_project_cascades_to_its_cards(): void { $auth = $this->authHeader(); @@ -217,11 +159,8 @@ final class ProjectTest extends ApiTestCase $this->request('DELETE', "/api/projects/{$projectId}", null, $auth); - // The parent project is gone, so the card route 404s on the project check. - self::assertSame( - 404, - $this->request('GET', "/api/projects/{$projectId}/cards/{$cardId}", null, $auth)->getStatusCode(), - ); + // The card went with its project. + self::assertSame(404, $this->request('GET', "/api/cards/{$cardId}", null, $auth)->getStatusCode()); } public function test_cards_under_another_users_project_are_not_reachable(): void diff --git a/web/README.md b/web/README.md index 881f43f..74ac82c 100644 --- a/web/README.md +++ b/web/README.md @@ -33,11 +33,13 @@ src/main.ts App bootstrap; resolves the stored session before mount src/router/index.ts Routes + guard (redirects to /login when unauthenticated) src/stores/auth.ts Pinia store: token in localStorage, magic-link + fetchMe src/stores/projects.ts Pinia store: the user's projects (fetch + create) -src/stores/cards.ts Pinia store: one project's cards (CRUD + reorderColumn) +src/stores/cards.ts Pinia store: one project's cards (CRUD; no reordering -- see lib/cardOrder.ts) +src/stores/inbox.ts Pinia store: the caller's global inbox (fetch + create) src/lib/api.ts fetch wrapper, bearer token, typed ApiError -src/components/AppSidebar.vue left nav: Dashboard link, divider, project list + new-project form +src/lib/cardOrder.ts reorderColumn() -- PUT /api/cards/order, shared by the sidebar and kanban board +src/components/AppSidebar.vue left nav: Dashboard link, project list + form, Inbox + form src/components/CardRow.vue editable text + status chip + delete, one card -src/components/KanbanCard.vue small draggable card for the board columns +src/components/KanbanCard.vue small draggable card for the board columns and the inbox src/views/ DashboardView, ProjectView, LoginView, ProfileView, VerifyEmailView ``` @@ -45,15 +47,27 @@ src/views/ DashboardView, ProjectView, LoginView, ProfileView, Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell: the top bar, then a left **sidebar** (`AppSidebar.vue`) beside the routed view. The sidebar stays mounted across navigation — it holds a **Dashboard** link, a -divider, the project list, and a compact new-project form (creating one jumps to -it). The current page is highlighted via RouterLink's `active-class`. -`` remounts the view on every path change so -sidebar → project → project navigation always does a fresh load. +divider, the project list + new-project form, another divider, then the +**Inbox** (see below). The current page is highlighted via RouterLink's +`active-class`. `` remounts the view on every +path change so sidebar → project → project navigation always does a fresh load. -`/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid of -project cards — each shows the project name and, under a **New** heading, its -inbox cards (`status_id === null`), fetched per project. Signed-out routes -(`/login`, `/verify-email`) render without the sidebar. +`/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid linking +to each project, showing its title and card count. Signed-out routes (`/login`, +`/verify-email`) render without the sidebar. + +## Inbox + +A card with no project lives in the caller's inbox (`useInboxStore`), rendered +in the sidebar under the project list -- not per-project, and not tied to +whatever page is open. It's a `vuedraggable` list in the same `"kanban"` drag +group as every project's kanban columns (below), so a card can be dragged +straight out of the sidebar into any status column of whichever project is +currently open, or the other way. `AppSidebar`'s `onInboxChange` persists a +drop via `reorderColumn(null, null, ids)`, then reloads the inbox and, if a +project view is currently mounted (`route.name === 'project'`), that project's +cards too -- either side of a drag could have been the inbox. A small form +under the list adds a card straight to the inbox. ## Project detail @@ -76,18 +90,18 @@ delete button. ### Kanban -Columns, left to right: **Inbox** (cards with no status) then each project -status in `position` order. `board` is derived from `cards.cards` + the -project's statuses and rebuilt by a `watch` whenever either changes. +One column per project status, in `position` order -- the inbox is *not* a +column here; it's in the sidebar (see above), though it's still a valid drag +source/target. `board` is derived from `cards.cards` + the project's statuses +and rebuilt by a `watch` whenever either changes. Every drop — whether reordering within a column (`moved`) or dragging in from -another (`added`) — calls `cards.reorderColumn(column.statusId, ids)` → -`PUT /api/projects/:id/cards/order` with `{ status_id, card_ids }`. The server -re-parents any moved-in card, re-packs the source column, and returns the whole -project's cards, which replaces local state; on failure the board reloads. - -The Inbox column has a small name + **Add** form at the bottom (`cards.add`); -new cards have no status, so they land straight in it. +another column or the sidebar's inbox (`added`) — calls +`reorderColumn(projectId, column.statusId, ids)` from `lib/cardOrder.ts` (shared +with the sidebar) → `PUT /api/cards/order`. The server re-parents any moved-in +card and re-packs whatever column it left; afterwards the view always reloads +both `inbox` and this project's `cards`, since either could have been the other +side of the move. ## Auth flow diff --git a/web/src/App.vue b/web/src/App.vue index 50a1a0b..569f81e 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -4,11 +4,13 @@ import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router' import AppSidebar from './components/AppSidebar.vue' import { useAuthStore } from './stores/auth' import { useCardsStore } from './stores/cards' +import { useInboxStore } from './stores/inbox' import { useProjectsStore } from './stores/projects' const auth = useAuthStore() const projects = useProjectsStore() const cards = useCardsStore() +const inbox = useInboxStore() const router = useRouter() const route = useRoute() @@ -20,6 +22,7 @@ async function onLogout() { auth.logout() projects.reset() cards.reset() + inbox.reset() await router.push({ name: 'login' }) } diff --git a/web/src/components/AppSidebar.vue b/web/src/components/AppSidebar.vue index bacf1f6..87754d7 100644 --- a/web/src/components/AppSidebar.vue +++ b/web/src/components/AppSidebar.vue @@ -1,11 +1,20 @@ diff --git a/web/src/lib/cardOrder.ts b/web/src/lib/cardOrder.ts new file mode 100644 index 0000000..e1d1e10 --- /dev/null +++ b/web/src/lib/cardOrder.ts @@ -0,0 +1,21 @@ +import { apiRequest } from './api' +import type { Card } from '../types' + +/** + * Set the contents and order of one column -- the inbox (both null) or a + * project's status (both set). Shared by the sidebar's inbox list and a + * project's kanban columns, since either can be a drag source or target for + * the other (dragging a card in re-parents it; its old column is re-packed + * server-side). + */ +export async function reorderColumn( + projectId: number | null, + statusId: number | null, + cardIds: number[], +): Promise<{ cards: Card[] }> { + return apiRequest<{ cards: Card[] }>('/cards/order', { + method: 'PUT', + auth: true, + body: { project_id: projectId, status_id: statusId, card_ids: cardIds }, + }) +} diff --git a/web/src/stores/cards.ts b/web/src/stores/cards.ts index 4c685a8..2c433be 100644 --- a/web/src/stores/cards.ts +++ b/web/src/stores/cards.ts @@ -6,8 +6,10 @@ import type { Card } from '../types' type CardPatch = Partial> export const useCardsStore = defineStore('cards', () => { - // Every card in the project, grouped by column (inbox first) then position. - // Views re-sort as needed (the "all tasks" list is alphabetical). + // One project's cards, grouped by status then position. Views re-sort as + // needed (the "all tasks" list is alphabetical). Moving a card in or out of + // this project (including via the inbox) goes through lib/cardOrder.ts, + // not this store -- callers re-load() afterwards. const cards = ref([]) const projectId = ref(null) const loading = ref(false) @@ -41,10 +43,11 @@ export const useCardsStore = defineStore('cards', () => { } async function patch(card: Card, fields: CardPatch): Promise { - const { card: updated } = await apiRequest<{ card: Card }>( - `/projects/${projectId.value}/cards/${card.id}`, - { method: 'PATCH', auth: true, body: fields }, - ) + const { card: updated } = await apiRequest<{ card: Card }>(`/cards/${card.id}`, { + method: 'PATCH', + auth: true, + body: fields, + }) const i = cards.value.findIndex((x) => x.id === updated.id) if (i !== -1) cards.value[i] = updated } @@ -53,23 +56,10 @@ export const useCardsStore = defineStore('cards', () => { const setText = (card: Card, text: string) => patch(card, { text }) async function remove(card: Card): Promise { - await apiRequest(`/projects/${projectId.value}/cards/${card.id}`, { method: 'DELETE', auth: true }) + await apiRequest(`/cards/${card.id}`, { method: 'DELETE', auth: true }) cards.value = cards.value.filter((c) => c.id !== card.id) } - /** - * Set the contents and order of one status column (`null` = inbox). Cards - * dragged in from another column are re-parented server-side; the response is - * the whole project's cards, which replaces local state. - */ - async function reorderColumn(statusId: number | null, cardIds: number[]): Promise { - const { cards: fresh } = await apiRequest<{ cards: Card[] }>( - `/projects/${projectId.value}/cards/order`, - { method: 'PUT', auth: true, body: { status_id: statusId, card_ids: cardIds } }, - ) - cards.value = fresh - } - function reset(): void { cards.value = [] projectId.value = null @@ -87,7 +77,6 @@ export const useCardsStore = defineStore('cards', () => { setComplete, setText, remove, - reorderColumn, reset, } }) diff --git a/web/src/stores/inbox.ts b/web/src/stores/inbox.ts new file mode 100644 index 0000000..f6670f6 --- /dev/null +++ b/web/src/stores/inbox.ts @@ -0,0 +1,43 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiRequest } from '../lib/api' +import type { Card } from '../types' + +/** + * The signed-in user's global inbox: cards with no project. Loaded once and + * kept mounted in the sidebar for the whole session, so it stays visible (and + * a drag target/source) while navigating between the dashboard and projects. + */ +export const useInboxStore = defineStore('inbox', () => { + const cards = ref([]) + const loaded = ref(false) + const loading = ref(false) + + async function load(): Promise { + loading.value = true + try { + const { cards: fetched } = await apiRequest<{ cards: Card[] }>('/inbox/cards', { auth: true }) + cards.value = fetched + loaded.value = true + } finally { + loading.value = false + } + } + + async function add(text: string): Promise { + const { card } = await apiRequest<{ card: Card }>('/inbox/cards', { + method: 'POST', + auth: true, + body: { text }, + }) + // The API appends the card, so the end of the array is its correct place. + cards.value.push(card) + } + + function reset(): void { + cards.value = [] + loaded.value = false + } + + return { cards, loaded, loading, load, add, reset } +}) diff --git a/web/src/style.css b/web/src/style.css index a04d2bc..4fc2b5f 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -163,6 +163,12 @@ body { overflow-y: auto; } +.sidebar__inbox-cards { + max-height: 40vh; + overflow-y: auto; + padding: 0 0.1rem; /* room for the ghost card's outline while dragging */ +} + .sidebar__note { padding: 0 0.6rem; font-size: 0.85rem; @@ -219,12 +225,12 @@ h1 { font-size: 0.9rem; } -/* --- dashboard: grid of projects with their inbox ------------------- */ +/* --- dashboard: grid of projects --------------------------------------- */ .dashboard__grid { margin-top: 1rem; display: grid; - grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: 1rem; align-items: start; } @@ -237,50 +243,21 @@ h1 { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; + color: inherit; + text-decoration: none; +} + +.project-card:hover { + border-color: var(--accent); } .project-card__title { font-weight: 600; font-size: 1.05rem; - color: inherit; - text-decoration: none; word-break: break-word; } -.project-card__title:hover { - color: var(--accent); -} - -.project-card__heading { - margin: 0.3rem 0 0; - font-size: 0.72rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--muted); -} - -.project-card__cards { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.3rem; - max-height: 16rem; - overflow-y: auto; -} - -.project-card__cards li { - font-size: 0.9rem; - padding: 0.35rem 0.5rem; - background: var(--bg); - border: 1px solid var(--border); - border-radius: 6px; -} - -.project-card__empty { - margin: 0; +.project-card__meta { font-size: 0.85rem; } diff --git a/web/src/types.ts b/web/src/types.ts index 949b8fe..cedb2db 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -31,9 +31,13 @@ export interface CardStatus { name: string } +/** + * A card either sits in its owner's global inbox (project_id and status_id + * both null) or belongs to one project with a status in it (both set). + */ export interface Card { id: number - project_id: number + project_id: number | null text: string complete: boolean position: number diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 68d65ef..99537a9 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -1,37 +1,21 @@ @@ -39,27 +23,24 @@ async function load() { diff --git a/web/src/views/ProjectView.vue b/web/src/views/ProjectView.vue index 921cd96..71e3391 100644 --- a/web/src/views/ProjectView.vue +++ b/web/src/views/ProjectView.vue @@ -5,13 +5,16 @@ import draggable from 'vuedraggable' import CardRow from '../components/CardRow.vue' import KanbanCard from '../components/KanbanCard.vue' import { ApiError, apiRequest } from '../lib/api' +import { reorderColumn } from '../lib/cardOrder' import { useCardsStore } from '../stores/cards' +import { useInboxStore } from '../stores/inbox' import { useProjectsStore } from '../stores/projects' import type { Card, CardStatus, Project } from '../types' const route = useRoute() const router = useRouter() const cards = useCardsStore() +const inbox = useInboxStore() const projects = useProjectsStore() const projectId = Number(route.params.id) @@ -38,9 +41,6 @@ const cancelButton = ref() const newText = ref('') const submitting = ref(false) -const newInboxText = ref('') -const addingToInbox = ref(false) - const summary = computed(() => { const total = cards.cards.length if (total === 0) return 'No cards yet.' @@ -53,10 +53,12 @@ const sortedCards = computed(() => ) // --- Kanban board -------------------------------------------------------- +// One column per project status -- the inbox lives in the sidebar now, not +// here, though it's still a valid drag source/target (shared "kanban" group). interface Column { key: string title: string - statusId: number | null + statusId: number cards: Card[] } type ColumnChange = { @@ -68,13 +70,11 @@ type ColumnChange = { const board = ref([]) function buildColumns(): Column[] { - const defs: Omit[] = [ - { key: 'inbox', title: 'Inbox', statusId: null }, - ...statuses.value.map((s) => ({ key: `status-${s.id}`, title: s.name, statusId: s.id })), - ] - return defs.map((def) => ({ - ...def, - cards: cards.cards.filter((card) => card.status_id === def.statusId), + return statuses.value.map((s) => ({ + key: `status-${s.id}`, + title: s.name, + statusId: s.id, + cards: cards.cards.filter((card) => card.status_id === s.id), })) } @@ -86,12 +86,21 @@ function rebuildBoard() { // (e.g. after a move is persisted, or a failed move is rolled back). watch([() => cards.cards, statuses], rebuildBoard, { deep: true }) -function onColumnChange(change: ColumnChange, column: Column) { - // `added` (card dragged in from another column) or `moved` (reordered within - // this one): persist this column's new id order. The source column, if any, - // is re-packed server-side. `removed` needs no action here. - if (change.added || change.moved) { - void run(cards.reorderColumn(column.statusId, column.cards.map((c) => c.id))) +async function onColumnChange(change: ColumnChange, column: Column) { + // `added` (card dragged in, from another column here or from the sidebar's + // inbox) or `moved` (reordered within this one): persist this column's new + // id order. The column it left -- another status, or the inbox -- is + // re-packed server-side. `removed` needs no action here. + if (!change.added && !change.moved) return + + actionError.value = null + try { + await reorderColumn(projectId, column.statusId, column.cards.map((c) => c.id)) + } catch (e) { + actionError.value = e instanceof ApiError ? e.message : 'Something went wrong.' + } finally { + // Either side of the drag could have been the inbox, so refresh both. + await Promise.all([inbox.load(), cards.load(projectId)]) } } @@ -218,21 +227,6 @@ async function onCreate() { submitting.value = false } } - -// New cards always land in the inbox (no status), so this just adds one. -async function onCreateInbox() { - if (!newInboxText.value.trim()) return - addingToInbox.value = true - actionError.value = null - try { - await cards.add(newInboxText.value) - newInboxText.value = '' - } catch (e) { - actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.' - } finally { - addingToInbox.value = false - } -} - -
- - -