From c47c800d01d2d8fdc81b2d63c8b2857cb6c309ca Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Fri, 4 Sep 2026 13:37:17 +0100 Subject: [PATCH] Add per-project card statuses and a kanban board Statuses - Migration 006: card_statuses table (project-scoped) and cards.status_id, a nullable FK with ON DELETE SET NULL. Every new project is seeded with "To do" / "Doing" / "Done"; GET /api/projects/{id}/statuses lists them. - New cards have no status -- they sit in an "inbox" until moved. Project view - Full-width and tabbed: "All tasks" (a flat list, sorted by name case-insensitively) and "Kanban" (Inbox plus one column per status). - Drag a card within or between columns to reorder / restatus; the Inbox column has its own name + Add form. Ordering - Migration 007: `position` is now a dense 0..n-1 rank within a (project_id, status_id) column, not a project-wide order. New composite index idx_cards_project_status_position; existing rows re-ranked. - PUT /api/projects/{id}/cards/order takes { status_id, card_ids } and sets one column's contents and order, re-parenting moved-in cards and re-packing their source column in a single transaction. PATCH status_id appends the card to the end of the destination column. 58 phpunit tests pass; the frontend type-checks and builds. Co-Authored-By: Claude Sonnet 5 --- README.md | 123 ++++--- migrations/006_add_card_statuses.sql | 32 ++ .../007_scope_card_position_to_column.sql | 24 ++ src/Http/Controllers/CardController.php | 60 +++- src/Http/Controllers/CardStatusController.php | 64 ++++ src/Http/Controllers/ProjectController.php | 8 +- src/Repository/CardRepository.php | 197 ++++++++++-- src/Repository/CardStatusRepository.php | 79 +++++ src/Support/Validator.php | 21 ++ src/bootstrap.php | 17 +- tests/CardOrderTest.php | 256 +++++++++++++++ tests/CardStatusTest.php | 170 ++++++++++ web/README.md | 62 ++-- web/src/App.vue | 5 +- web/src/components/CardRow.vue | 17 +- web/src/components/KanbanCard.vue | 9 + web/src/router/index.ts | 2 +- web/src/stores/cards.ts | 17 +- web/src/style.css | 157 +++++++-- web/src/types.ts | 8 + web/src/views/ProjectView.vue | 300 +++++++++++++----- 21 files changed, 1389 insertions(+), 239 deletions(-) create mode 100644 migrations/006_add_card_statuses.sql create mode 100644 migrations/007_scope_card_position_to_column.sql create mode 100644 src/Http/Controllers/CardStatusController.php create mode 100644 src/Repository/CardStatusRepository.php create mode 100644 tests/CardOrderTest.php create mode 100644 tests/CardStatusTest.php create mode 100644 web/src/components/KanbanCard.vue diff --git a/README.md b/README.md index 04f561a..fa244b0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Each user owns **projects**, and each project holds ordered **cards**. | 6 | Project view — inline title/description editing, delete via a Manage menu | ✅ done | | 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done | | 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 | Registration signs the user in immediately and emails a magic link that verifies the address; `user.email_verified` stays `false` until the link is opened. See @@ -29,22 +31,24 @@ The only requirement is Docker with the Compose plugin. docker compose up -d ``` -This builds a PHP 8.3 + Apache image, applies migrations, and serves the API at - (e.g. `curl http://localhost:8080/api/health`). +This runs a multi-stage build — a Node stage compiles the Vue frontend, then a +PHP 8.3 + Apache stage bakes in the PHP source and the built SPA — applies +migrations, and serves the whole app at : the SPA at `/` +(assets and all) and the REST API under `/api` (e.g. +`curl http://localhost:8080/api/health`). Unknown paths fall back to the SPA +shell for client-side routing. - A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog successor — one ~15 MB Go binary, messages kept in memory) also starts. The API sends all email to it; read it at . Set `MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real. -- The project directory is bind-mounted into the container, so editing PHP - source takes effect without a rebuild (within ~2s, due to the opcache - revalidation interval). `vendor/` is used from the host — run `composer` - once first if it is missing (see "Run without Docker" below, or - `docker compose run --rm --entrypoint composer app install`). +- The image is the artifact: PHP source and the compiled frontend are copied in + at build time, not bind-mounted. Rebuild to pick up any code change: + `docker compose up -d --build`. For iterating on the frontend, run the Vite + dev server on the host instead (see [Frontend](#frontend)). - The SQLite database and the generated JWT signing key live in the `storage` - named volume, mounted at `/var/www/storage` (outside the bind-mounted source), - so they survive `docker compose restart` / `down` + `up`. -- Rebuild only after changing the `Dockerfile`: `docker compose up -d --build`. + named volume, mounted at `/var/www/storage`, so they survive + `docker compose restart` / `down` + `up`. - `docker compose down -v` removes the volume and gives you a clean database. - Override settings via the environment or a `.env` file in this directory (Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see @@ -73,13 +77,17 @@ composer migrate # creates storage/database.sqlite and its tables composer serve # http://localhost:8080 (php -S localhost:8080 -t public) ``` -Any web server can serve the app as long as the document root is `public/` and -unknown paths fall through to `public/index.php`. +Any web server can serve the API as long as the document root is `public/` and +unknown paths fall through to `public/index.php`. `public/.htaccess` also serves +a built frontend from `public/` (copy `web/dist/` there) and only falls back to +`index.php` for `/api` and when no `index.html` is present. ## Frontend -The Vue/TypeScript PWA lives in [web/](web/) and talks to this API. With the API -running (`docker compose up -d`): +The Vue/TypeScript PWA lives in [web/](web/). The production build is compiled +into the Docker image and served from the `app` container at `/`. For frontend +development, run the Vite dev server on the host — with the API container running +(`docker compose up -d`): ```bash cd web @@ -87,12 +95,6 @@ npm install npm run dev # http://localhost:5173, proxies /api to localhost:8080 ``` -Or run it inside Compose alongside the API: - -```bash -docker compose --profile frontend up -d -``` - Unauthenticated visitors are redirected to `/login`; `/register` creates an account and signs in immediately. See [web/README.md](web/README.md). @@ -107,7 +109,7 @@ environment). See [.env.example](.env.example). | `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location | | `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key | | `JWT_TTL` | `86400` | Token lifetime in seconds | -| `APP_URL` | `http://localhost:5173` | Frontend base URL used to build magic links | +| `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) | | `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) | | `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Projects` | Envelope sender | | `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes | @@ -276,6 +278,9 @@ Project representation: `GET /api/projects` returns `{ "projects": [ … ] }`. +Creating a project also seeds it with three **statuses** — "To do", "Doing", +"Done" (see [Statuses](#statuses)). + ### Cards Scoped to a project; the parent project's ownership is checked first @@ -283,23 +288,36 @@ Scoped to a project; the parent project's ownership is checked first | Method | Path | Purpose | |--------|------|---------| -| `GET` | `/api/projects/{id}/cards` | cards, ordered by `position` then `id` | +| `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` | reorder all cards in one shot | +| `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 `position` | +| `PATCH` | `/api/projects/{id}/cards/{cardId}` | update `text`, `complete`, and/or `status_id` | | `DELETE` | `/api/projects/{id}/cards/{cardId}` | delete the card (`204`) | Create body: `text` (required, 1–1000 chars), `complete` (optional bool, -default `false`), `position` (optional integer ≥ 0; when omitted the card is -appended after the current highest position). `PATCH` needs at least one field. -`position` is a plain sort key the client manages — updating one card never -renumbers its siblings. +default `false`). `PATCH` needs at least one field. -`PUT …/cards/order` takes `{ "card_ids": [3, 1, 2] }` — every card in the -project, each exactly once (`422` otherwise). It rewrites positions to `0..n-1` -in one transaction and returns `{ "cards": [ … ] }` in the new order. This is -what the drag-and-drop reorder in the UI calls. +**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. + +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: + +- `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. Card representation: @@ -311,13 +329,40 @@ Card representation: "text": "Design homepage", "complete": false, "position": 0, + "status_id": null, + "status": null, "created_at": "2026-09-03T12:00:00Z", "updated_at": "2026-09-03T12:00:00Z" } } ``` -`GET …/cards` returns `{ "cards": [ … ] }`. +`status` is the embedded `{ id, name }` of the linked status, or `null` when the +card has none. `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}`. + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/projects/{id}/statuses` | the project's statuses, ordered by `position` | + +```json +{ + "statuses": [ + { "id": 1, "project_id": 1, "name": "To do", "position": 0 }, + { "id": 2, "project_id": 1, "name": "Doing", "position": 1 }, + { "id": 3, "project_id": 1, "name": "Done", "position": 2 } + ] +} +``` + +Requires `Authorization: Bearer `; a project that is missing or not owned by +the caller responds `404`. ### Error shape @@ -349,6 +394,8 @@ PROJECT=$(curl -s -X POST $BASE/api/projects \ -d '{"title":"Website relaunch","description":"Q3"}' \ | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) +curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN" + curl -s -X POST $BASE/api/projects/$PROJECT/cards \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"text":"Design homepage"}' @@ -375,14 +422,14 @@ src/Auth/AuthMiddleware.php Bearer-token authentication src/Auth/SessionPayload.php Shared user + session JSON shape src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier src/Http/JsonErrorHandler.php Uniform JSON error envelope -src/Http/Controllers/ Request handlers (Auth, EmailVerification, Project, Card) -src/Repository/ Database access (User, EmailVerification, Project, Card) +src/Http/Controllers/ Request handlers (Auth, EmailVerification, Project, Card, CardStatus) +src/Repository/ Database access (User, EmailVerification, Project, Card, CardStatus) src/Support/Validator.php Request-body validation helper migrations/*.sql Schema, applied by bin/migrate.php -Dockerfile PHP 8.3 + Apache image -docker-compose.yml Local stack: API + Mailpit; web via --profile frontend +Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime +docker-compose.yml Local stack: app (SPA + API) + Mailpit docker/ Apache vhost + container entrypoint -web/ Vue 3 + TypeScript + Vite PWA frontend +web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host) ``` ## Provenance diff --git a/migrations/006_add_card_statuses.sql b/migrations/006_add_card_statuses.sql new file mode 100644 index 0000000..e386890 --- /dev/null +++ b/migrations/006_add_card_statuses.sql @@ -0,0 +1,32 @@ +-- Project-specific card statuses. Every project gets a "To do" / "Doing" / +-- "Done" set when it is created (see CardStatusRepository::seedDefaults); the +-- backfill below covers projects that already existed. +CREATE TABLE IF NOT EXISTS card_statuses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + name TEXT NOT NULL, + 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')) +); + +CREATE INDEX IF NOT EXISTS idx_card_statuses_project_position + ON card_statuses (project_id, position); + +-- Nullable, and left NULL for existing cards: a card without a status sits in +-- the project "inbox" until the user gives it one. Losing a status (a deleted +-- status row) clears the link rather than removing the card. +ALTER TABLE cards + ADD COLUMN status_id INTEGER REFERENCES card_statuses (id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_cards_status ON cards (status_id); + +-- Backfill: give every pre-existing project the default status set. +INSERT INTO card_statuses (project_id, name, position) +SELECT p.id, d.name, d.position +FROM projects p +CROSS JOIN ( + SELECT 'To do' AS name, 0 AS position + UNION ALL SELECT 'Doing', 1 + UNION ALL SELECT 'Done', 2 +) d; diff --git a/migrations/007_scope_card_position_to_column.sql b/migrations/007_scope_card_position_to_column.sql new file mode 100644 index 0000000..6ea3140 --- /dev/null +++ b/migrations/007_scope_card_position_to_column.sql @@ -0,0 +1,24 @@ +-- `cards.position` was a project-wide sort key. It is now a rank *within a +-- column* — the group of cards sharing a (project_id, status_id). The inbox +-- (status_id IS NULL) is its own column. The "all tasks" view no longer uses +-- position at all (it sorts alphabetically); ordering is a per-column concern +-- driven by PUT /api/projects/{id}/cards/order. + +DROP INDEX IF EXISTS idx_cards_project_position; + +CREATE INDEX IF NOT EXISTS idx_cards_project_status_position + ON cards (project_id, status_id, position); + +-- Re-pack every existing column to a dense 0..n-1, preserving current order. +-- The CTE is evaluated against the pre-update snapshot, so this is safe despite +-- writing the same table. +WITH ranked (id, rk) AS ( + SELECT id, + row_number() OVER ( + PARTITION BY 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 2ab5664..31f5e3a 100644 --- a/src/Http/Controllers/CardController.php +++ b/src/Http/Controllers/CardController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers; use App\Exception\ApiException; use App\Repository\CardRepository; +use App\Repository\CardStatusRepository; use App\Repository\ProjectRepository; use App\Support\Validator; use Psr\Http\Message\ResponseInterface as Response; @@ -22,6 +23,7 @@ final class CardController extends Controller public function __construct( private readonly ProjectRepository $projects, private readonly CardRepository $cards, + private readonly CardStatusRepository $statuses, ) { } @@ -82,11 +84,17 @@ final class CardController extends Controller if ($validator->has('complete')) { $fields['complete'] = $validator->optionalBool('complete'); } - if ($validator->has('position')) { - $fields['position'] = $validator->optionalInt('position', 0); + 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, position.'); + $validator->add('text', 'Provide at least one of: text, complete, status_id.'); } $validator->assert(); @@ -111,29 +119,45 @@ final class CardController extends Controller /** * PUT /api/projects/{projectId}/cards/order * - * Body: { "card_ids": [3, 1, 2] } — every card in the project, exactly once, - * in the desired order. Positions are rewritten to 0..n-1. + * Sets the contents and order of one status 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. */ public function reorder(Request $request, Response $response, array $args): Response { $projectId = $this->requireOwnedProjectId($request, $args); + $body = $this->body($request); - $order = $this->body($request)['card_ids'] ?? null; + $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); + } + } + + $order = $body['card_ids'] ?? null; if (!is_array($order) || array_filter($order, static fn ($id) => !is_int($id)) !== []) { throw new ApiException('card_ids must be an array of card IDs.', 422); } - /** @var int[] $order */ - $expected = $this->cards->idsForProject($projectId); - $given = $order; - sort($given); - sort($expected); - - if ($given !== $expected) { - throw new ApiException('card_ids must contain every card in the project exactly once.', 422); + 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($this->cards->idsInColumn($projectId, $statusId), $order) !== []) { + throw new ApiException('card_ids must include every card already in this column.', 422); } - $cards = $this->cards->reorder($projectId, $order); + $cards = $this->cards->orderColumn($projectId, $statusId, $order); $this->projects->touch($projectId); return $this->json($response, ['cards' => array_map($this->present(...), $cards)]); @@ -155,7 +179,7 @@ final class CardController extends Controller /** * @param array $args - * @return array{id: int, project_id: int, text: string, complete: bool, position: int, created_at: string, updated_at: string} + * @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} */ private function requireCard(int $projectId, array $args): array { @@ -169,7 +193,7 @@ final class CardController extends Controller } /** - * @param array{id: int, project_id: int, text: string, complete: bool, position: int, created_at: string, updated_at: string} $card + * @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 * @return array */ private function present(array $card): array @@ -180,6 +204,8 @@ final class CardController extends Controller 'text' => $card['text'], 'complete' => $card['complete'], 'position' => $card['position'], + 'status_id' => $card['status_id'], + 'status' => $card['status'], 'created_at' => $card['created_at'], 'updated_at' => $card['updated_at'], ]; diff --git a/src/Http/Controllers/CardStatusController.php b/src/Http/Controllers/CardStatusController.php new file mode 100644 index 0000000..5d14d32 --- /dev/null +++ b/src/Http/Controllers/CardStatusController.php @@ -0,0 +1,64 @@ +requireOwnedProjectId($request, $args); + + return $this->json($response, [ + 'statuses' => array_map($this->present(...), $this->statuses->allForProject($projectId)), + ]); + } + + /** + * @param array $args + */ + private function requireOwnedProjectId(Request $request, array $args): int + { + $project = $this->projects->findOwnedBy((int) $args['projectId'], $this->user($request)['id']); + + if ($project === null) { + throw new ApiException('Project not found.', 404); + } + + return $project['id']; + } + + /** + * @param array{id: int, project_id: int, name: string, position: int, created_at: string, updated_at: string} $status + * @return array + */ + private function present(array $status): array + { + return [ + 'id' => $status['id'], + 'project_id' => $status['project_id'], + 'name' => $status['name'], + 'position' => $status['position'], + ]; + } +} diff --git a/src/Http/Controllers/ProjectController.php b/src/Http/Controllers/ProjectController.php index fb1a1be..e308504 100644 --- a/src/Http/Controllers/ProjectController.php +++ b/src/Http/Controllers/ProjectController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers; use App\Exception\ApiException; +use App\Repository\CardStatusRepository; use App\Repository\ProjectRepository; use App\Support\Validator; use Psr\Http\Message\ResponseInterface as Response; @@ -20,8 +21,10 @@ final class ProjectController extends Controller private const DESCRIPTION_MAX = 2000; private const MAX_PROJECTS_PER_OWNER = 100; - public function __construct(private readonly ProjectRepository $projects) - { + public function __construct( + private readonly ProjectRepository $projects, + private readonly CardStatusRepository $statuses, + ) { } /** @@ -54,6 +57,7 @@ final class ProjectController extends Controller } $project = $this->projects->create($ownerId, $title, $description); + $this->statuses->seedDefaults($project['id']); return $this->json($response, ['project' => $this->present($project)], 201); } diff --git a/src/Repository/CardRepository.php b/src/Repository/CardRepository.php index 02f3d79..b921e89 100644 --- a/src/Repository/CardRepository.php +++ b/src/Repository/CardRepository.php @@ -9,24 +9,41 @@ 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. + * + * @phpstan-type CardStatus array{id: int, name: string} * @phpstan-type CardRow array{ * id: int, project_id: int, text: string, complete: bool, position: int, + * status_id: int|null, status: CardStatus|null, * created_at: string, updated_at: string * } */ final class CardRepository { + private const SELECT = <<<'SQL' + SELECT c.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 + LEFT JOIN card_statuses s ON s.id = c.status_id + SQL; + public function __construct(private readonly PDO $pdo) { } /** + * 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 { $stmt = $this->pdo->prepare( - 'SELECT * FROM cards WHERE project_id = :project ORDER BY position ASC, id ASC' + self::SELECT . ' WHERE c.project_id = :project ORDER BY c.status_id, c.position ASC, c.id ASC' ); $stmt->execute(['project' => $projectId]); @@ -38,7 +55,7 @@ final class CardRepository */ public function findInProject(int $id, int $projectId): ?array { - $stmt = $this->pdo->prepare('SELECT * FROM cards WHERE id = :id AND project_id = :project'); + $stmt = $this->pdo->prepare(self::SELECT . ' WHERE c.id = :id AND c.project_id = :project'); $stmt->execute(['id' => $id, 'project' => $projectId]); $row = $stmt->fetch(); @@ -51,7 +68,8 @@ final class CardRepository */ public function create(int $projectId, string $text, bool $complete, ?int $position): array { - $position ??= $this->nextPosition($projectId); + // A new card has no status — it goes to the end of the inbox column. + $position ??= $this->nextPositionInColumn($projectId, null); $stmt = $this->pdo->prepare( 'INSERT INTO cards (project_id, text, complete, position) @@ -71,12 +89,22 @@ final class CardRepository } /** - * @param array{text?: string, complete?: bool, position?: int} $fields + * 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(). + * + * @param array{text?: string, complete?: bool, status_id?: int|null} $fields * @return CardRow */ public function update(int $id, int $projectId, array $fields): array { - $sets = ['updated_at = ' . "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"]; + /** @var CardRow $card */ + $card = $this->findInProject($id, $projectId); + + $movesColumn = array_key_exists('status_id', $fields) && $fields['status_id'] !== $card['status_id']; + $sourceColumn = $card['status_id']; + + $sets = ['updated_at = ' . $this->nowExpr()]; $params = ['id' => $id]; if (array_key_exists('text', $fields)) { @@ -87,18 +115,40 @@ final class CardRepository $sets[] = 'complete = :complete'; $params['complete'] = $fields['complete'] ? 1 : 0; } - if (array_key_exists('position', $fields)) { + 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'] = $fields['position']; + $params['position'] = $this->nextPositionInColumn($projectId, $fields['status_id']); } - $stmt = $this->pdo->prepare('UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id'); - $stmt->execute($params); + $sql = 'UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id'; - /** @var CardRow $card */ - $card = $this->findInProject($id, $projectId); + if (!$movesColumn) { + $this->pdo->prepare($sql)->execute($params); - return $card; + /** @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; } public function delete(int $id): void @@ -107,39 +157,69 @@ final class CardRepository } /** - * IDs of every card in the project, in current position order. + * IDs of every card in the project. * * @return int[] */ public function idsForProject(int $projectId): array { - $stmt = $this->pdo->prepare( - 'SELECT id FROM cards WHERE project_id = :project ORDER BY position ASC, id ASC' - ); + $stmt = $this->pdo->prepare('SELECT id FROM cards WHERE project_id = :project'); $stmt->execute(['project' => $projectId]); return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN)); } /** - * Assign positions 0..n-1 to the given cards in one transaction. + * IDs of the cards currently in one column, in position order. * - * @param int[] $orderedIds every card id in the project, exactly once - * @return CardRow[] the project's cards in their new order + * @return int[] */ - public function reorder(int $projectId, array $orderedIds): array + public function idsInColumn(int $projectId, ?int $statusId): array { + [$match, $params] = $this->columnMatch($statusId); $stmt = $this->pdo->prepare( - 'UPDATE cards SET position = :position, - updated_at = ' . "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')" . ' - WHERE id = :id AND project_id = :project' + "SELECT id FROM cards WHERE project_id = :project AND {$match} ORDER BY position ASC, id ASC" ); + $stmt->execute(['project' => $projectId] + $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. + * + * @param int[] $orderedIds + * @return CardRow[] the project's cards, grouped by column + */ + public function orderColumn(int $projectId, ?int $statusId, array $orderedIds): array + { + $orderedIds = array_values($orderedIds); $this->pdo->beginTransaction(); try { - foreach (array_values($orderedIds) as $position => $id) { - $stmt->execute(['position' => $position, 'id' => $id, 'project' => $projectId]); + $sourceColumns = $this->columnsOf($projectId, $orderedIds); + + $place = $this->pdo->prepare( + 'UPDATE cards SET status_id = :status, position = :position, updated_at = ' . $this->nowExpr() . ' + WHERE id = :id AND project_id = :project' + ); + foreach ($orderedIds as $position => $id) { + $place->execute([ + 'status' => $statusId, + 'position' => $position, + 'id' => $id, + 'project' => $projectId, + ]); } + + foreach ($sourceColumns as $source) { + if ($source !== $statusId) { + $this->repack($projectId, $source); + } + } + $this->pdo->commit(); } catch (\Throwable $e) { $this->pdo->rollBack(); @@ -149,16 +229,70 @@ final class CardRepository return $this->allForProject($projectId); } - private function nextPosition(int $projectId): int + /** + * The distinct status_id values (columns) the given cards currently sit in. + * + * @param int[] $ids + * @return array + */ + private function columnsOf(int $projectId, array $ids): array { + if ($ids === []) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($ids), '?')); $stmt = $this->pdo->prepare( - 'SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE project_id = :project' + "SELECT DISTINCT status_id FROM cards WHERE project_id = ? AND id IN ($placeholders)" ); - $stmt->execute(['project' => $projectId]); + $stmt->execute([$projectId, ...$ids]); + + return array_map( + static fn ($value) => $value === null ? null : (int) $value, + $stmt->fetchAll(PDO::FETCH_COLUMN), + ); + } + + /** Rewrite one column's positions to a dense 0..n-1 in current order. */ + private function repack(int $projectId, ?int $statusId): void + { + $ids = $this->idsInColumn($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 $projectId, ?int $statusId): int + { + [$match, $params] = $this->columnMatch($statusId); + $stmt = $this->pdo->prepare( + "SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE project_id = :project AND {$match}" + ); + $stmt->execute(['project' => $projectId] + $params); return (int) $stmt->fetchColumn(); } + /** + * A WHERE fragment matching one column, since SQLite needs `IS NULL` (not + * `= NULL`) for the inbox. + * + * @return array{0: string, 1: array} + */ + private function columnMatch(?int $statusId): array + { + return $statusId === null + ? ['status_id IS NULL', []] + : ['status_id = :status', ['status' => $statusId]]; + } + + private function nowExpr(): string + { + return "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"; + } + /** * @param array $row * @return CardRow @@ -170,6 +304,13 @@ final class CardRepository $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; } diff --git a/src/Repository/CardStatusRepository.php b/src/Repository/CardStatusRepository.php new file mode 100644 index 0000000..b6fe311 --- /dev/null +++ b/src/Repository/CardStatusRepository.php @@ -0,0 +1,79 @@ +pdo->prepare( + 'SELECT * FROM card_statuses WHERE project_id = :project ORDER BY position ASC, id ASC' + ); + $stmt->execute(['project' => $projectId]); + + return array_map($this->cast(...), $stmt->fetchAll()); + } + + /** + * @return CardStatusRow|null + */ + public function findInProject(int $id, int $projectId): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM card_statuses WHERE id = :id AND project_id = :project'); + $stmt->execute(['id' => $id, 'project' => $projectId]); + + $row = $stmt->fetch(); + + return $row === false ? null : $this->cast($row); + } + + /** Insert the default status set for a freshly created project. */ + public function seedDefaults(int $projectId): void + { + $stmt = $this->pdo->prepare( + 'INSERT INTO card_statuses (project_id, name, position) VALUES (:project, :name, :position)' + ); + + foreach (array_values(self::DEFAULTS) as $position => $name) { + $stmt->execute(['project' => $projectId, 'name' => $name, 'position' => $position]); + } + } + + /** + * @param array $row + * @return CardStatusRow + */ + private function cast(array $row): array + { + $row['id'] = (int) $row['id']; + $row['project_id'] = (int) $row['project_id']; + $row['position'] = (int) $row['position']; + + /** @var CardStatusRow $row */ + return $row; + } +} diff --git a/src/Support/Validator.php b/src/Support/Validator.php index 0dee993..2c41e25 100644 --- a/src/Support/Validator.php +++ b/src/Support/Validator.php @@ -90,6 +90,27 @@ 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 9ca6951..dea8251 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -7,6 +7,7 @@ use App\Auth\JwtService; use App\Auth\SessionPayload; use App\Http\Controllers\AuthController; use App\Http\Controllers\CardController; +use App\Http\Controllers\CardStatusController; use App\Http\Controllers\EmailVerificationController; use App\Http\Controllers\ProjectController; use App\Http\JsonErrorHandler; @@ -15,6 +16,7 @@ use App\Mail\LogMailer; use App\Mail\Mailer; use App\Mail\PhpMailerMailer; use App\Repository\CardRepository; +use App\Repository\CardStatusRepository; use App\Repository\EmailVerificationRepository; use App\Repository\ProjectRepository; use App\Repository\UserRepository; @@ -43,6 +45,7 @@ $errorMiddleware->setDefaultErrorHandler( $users = new UserRepository($database->pdo()); $projects = new ProjectRepository($database->pdo()); $cards = new CardRepository($database->pdo()); +$cardStatuses = new CardStatusRepository($database->pdo()); $verificationTokens = new EmailVerificationRepository($database->pdo()); $jwt = new JwtService($config->jwtSecret, $config->jwtTtl); $session = new SessionPayload($jwt, $verificationTokens); @@ -55,8 +58,9 @@ $verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->app $authController = new AuthController($users, $session, $verifier); $emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session); -$projectController = new ProjectController($projects); -$cardController = new CardController($projects, $cards); +$projectController = new ProjectController($projects, $cardStatuses); +$cardController = new CardController($projects, $cards, $cardStatuses); +$cardStatusController = new CardStatusController($projects, $cardStatuses); $authMiddleware = new AuthMiddleware($jwt, $users); // --- Routes --------------------------------------------------------------- @@ -66,6 +70,7 @@ $app->group('/api', function (RouteCollectorProxy $group) use ( $emailController, $projectController, $cardController, + $cardStatusController, $authMiddleware, ) { $group->get('/health', function (Request $request, Response $response): Response { @@ -82,13 +87,19 @@ $app->group('/api', function (RouteCollectorProxy $group) use ( $group->post('/email/verification', [$emailController, 'resend'])->add($authMiddleware); $group->post('/email/change', [$emailController, 'requestChange'])->add($authMiddleware); - $group->group('/projects', function (RouteCollectorProxy $projects) use ($projectController, $cardController) { + $group->group('/projects', function (RouteCollectorProxy $projects) use ( + $projectController, + $cardController, + $cardStatusController, + ) { $projects->get('', [$projectController, 'index']); $projects->post('', [$projectController, 'store']); $projects->get('/{projectId:[0-9]+}', [$projectController, 'show']); $projects->patch('/{projectId:[0-9]+}', [$projectController, 'update']); $projects->delete('/{projectId:[0-9]+}', [$projectController, 'destroy']); + $projects->get('/{projectId:[0-9]+}/statuses', [$cardStatusController, 'index']); + $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']); diff --git a/tests/CardOrderTest.php b/tests/CardOrderTest.php new file mode 100644 index 0000000..90bcdc3 --- /dev/null +++ b/tests/CardOrderTest.php @@ -0,0 +1,256 @@ + $auth */ + private function newProject(array $auth, string $title = 'Board'): int + { + return $this->decode($this->request('POST', '/api/projects', ['title' => $title], $auth))['project']['id']; + } + + /** @param array $auth @return array */ + private function statuses(int $projectId, array $auth): array + { + return $this->decode( + $this->request('GET', "/api/projects/{$projectId}/statuses", null, $auth), + )['statuses']; + } + + /** @param array $auth */ + private function addCard(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 + { + return array_map( + static fn (array $c): array => [ + 'text' => $c['text'], + 'status_id' => $c['status_id'], + 'position' => $c['position'], + ], + $this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'], + ); + } + + public function test_new_cards_are_ranked_densely_within_the_inbox(): void + { + $auth = $this->authHeader(); + $projectId = $this->newProject($auth); + + foreach (['A', 'B', 'C'] as $text) { + $this->addCard($projectId, $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), + ); + } + + public function test_reorder_within_the_inbox_column(): void + { + $auth = $this->authHeader(); + $projectId = $this->newProject($auth); + $ids = []; + foreach (['A', 'B', 'C'] as $text) { + $ids[$text] = $this->addCard($projectId, $text, $auth); + } + + $response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ + 'status_id' => null, + 'card_ids' => [$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')); + } + + public function test_ordering_a_status_column_moves_cards_into_it_and_repacks_the_inbox(): 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); + } + + // 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); + + $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']]); + } + + public function test_moving_a_card_out_of_the_inbox_repacks_the_survivors(): 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); // inbox 0,1,2 + } + + // Pull the middle card into "To do". + $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ + 'status_id' => $todo, + 'card_ids' => [$ids['B']], + ], $auth); + + $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, + ); + } + + public function test_a_new_inbox_card_lands_after_the_repacked_survivors(): 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); // inbox: A@0, B@1 + + $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ + 'status_id' => $todo, + 'card_ids' => [$a], + ], $auth); // inbox now: B@0 + + $newId = $this->addCard($projectId, 'C', $auth); + $byId = []; + foreach ($this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'] as $c) { + $byId[$c['id']] = $c; + } + + self::assertSame(0, $byId[$b]['position']); + self::assertSame(1, $byId[$newId]['position']); + self::assertNull($byId[$newId]['status_id']); + } + + public function test_patch_status_appends_the_card_to_the_destination_column(): 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); + + $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']; + + self::assertSame($todo, $moved['status_id']); + self::assertSame(1, $moved['position']); // after `first`, which took slot 0 + } + + public function test_reorder_rejects_a_foreign_status(): 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); + + $response = $this->request('PUT', "/api/projects/{$mine}/cards/order", [ + 'status_id' => $foreignStatus, + 'card_ids' => [$card], + ], $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 + { + $owner = $this->authHeader('owner@example.com'); + $other = $this->authHeader('other@example.com'); + $projectId = $this->newProject($owner); + $card = $this->addCard($projectId, 'x', $owner); + + self::assertSame(404, $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ + 'status_id' => null, + 'card_ids' => [$card], + ], $other)->getStatusCode()); + } +} diff --git a/tests/CardStatusTest.php b/tests/CardStatusTest.php new file mode 100644 index 0000000..a51f41b --- /dev/null +++ b/tests/CardStatusTest.php @@ -0,0 +1,170 @@ +decode( + $this->request('POST', '/api/projects', ['title' => $title], $auth), + )['project']['id']; + } + + /** @param array $auth @return array */ + private function statuses(int $projectId, array $auth): array + { + return $this->decode( + $this->request('GET', "/api/projects/{$projectId}/statuses", null, $auth), + )['statuses']; + } + + public function test_statuses_require_authentication(): void + { + $projectId = $this->newProject($this->authHeader()); + + self::assertSame(401, $this->request('GET', "/api/projects/{$projectId}/statuses")->getStatusCode()); + } + + public function test_new_projects_are_seeded_with_the_default_statuses(): void + { + $auth = $this->authHeader(); + $projectId = $this->newProject($auth); + + $statuses = $this->statuses($projectId, $auth); + + self::assertSame(['To do', 'Doing', 'Done'], array_column($statuses, 'name')); + self::assertSame([0, 1, 2], array_column($statuses, 'position')); + self::assertSame([$projectId, $projectId, $projectId], array_column($statuses, 'project_id')); + } + + public function test_each_project_gets_its_own_status_rows(): void + { + $auth = $this->authHeader(); + $first = $this->newProject($auth, 'One'); + $second = $this->newProject($auth, 'Two'); + + $firstIds = array_column($this->statuses($first, $auth), 'id'); + $secondIds = array_column($this->statuses($second, $auth), 'id'); + + self::assertSame([], array_intersect($firstIds, $secondIds)); + } + + public function test_statuses_are_only_visible_to_the_project_owner(): void + { + $owner = $this->authHeader('owner@example.com'); + $other = $this->authHeader('other@example.com'); + $projectId = $this->newProject($owner, 'Private'); + + self::assertSame(200, $this->request('GET', "/api/projects/{$projectId}/statuses", null, $owner)->getStatusCode()); + self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}/statuses", null, $other)->getStatusCode()); + } + + public function test_a_new_card_has_no_status(): void + { + $auth = $this->authHeader(); + $projectId = $this->newProject($auth); + + $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']); + } + + public function test_a_card_can_be_moved_between_statuses_and_back_to_the_inbox(): void + { + $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('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->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 + { + $auth = $this->authHeader(); + $projectId = $this->newProject($auth, 'Temp'); + + $count = fn (): int => (int) $this->db() + ->query("SELECT COUNT(*) FROM card_statuses WHERE project_id = {$projectId}") + ->fetchColumn(); + + self::assertSame(3, $count()); + + $this->request('DELETE', "/api/projects/{$projectId}", null, $auth); + + self::assertSame(0, $count()); + } +} diff --git a/web/README.md b/web/README.md index 856093a..138e334 100644 --- a/web/README.md +++ b/web/README.md @@ -2,7 +2,7 @@ Vue 3 + TypeScript + Vite PWA. Talks to the REST API in the parent directory. -## Develop on the host +## Develop ```bash npm install @@ -14,17 +14,6 @@ run `docker compose up -d` in the parent directory first). Override the target with `VITE_PROXY_TARGET`, or point the app at a different API entirely with `VITE_API_BASE_URL` (see [.env.example](.env.example)). -## Develop in Docker - -From the parent directory: - -```bash -docker compose --profile frontend up -d -``` - -Runs this dev server alongside the API. `/api` is proxied to the `app` container. -After changing `package.json`, rebuild: `docker compose build web`. - ## Build ```bash @@ -32,6 +21,11 @@ npm run build # type-checks, then emits dist/ npm run preview ``` +The parent `Dockerfile` runs this build in a Node stage and copies `dist/` into +the PHP image's `public/`, so the `app` container serves the compiled SPA at `/`. +There is no separate frontend container — a production image is `docker compose +build app` from the parent directory. + ## Layout ``` @@ -39,25 +33,47 @@ 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, register/login/fetchMe src/stores/projects.ts Pinia store: the user's projects (fetch + create) -src/stores/cards.ts Pinia store: one project's cards (CRUD + drag reorder) +src/stores/cards.ts Pinia store: one project's cards (CRUD + reorderColumn) src/lib/api.ts fetch wrapper, bearer token, typed ApiError -src/components/CardRow.vue checkbox + editable text + delete, one card +src/components/CardRow.vue editable text + status chip + delete, one card +src/components/KanbanCard.vue small draggable card for the board columns src/views/ HomeView, ProjectView, LoginView, RegisterView, ProfileView, VerifyEmailView ``` ## Project detail -`/projects/:id` shows one project. The title and description are inline-editable -(saved on blur via `PATCH /api/projects/:id`; the description shows an "Add a -description" placeholder when empty). A **Manage** menu (top right) has a -**Delete project** action that opens a confirmation modal; confirming calls -`DELETE /api/projects/:id` and returns to the all-projects view. +`/projects/:id` shows one project. It renders on a **full-width** layout (the +route sets `meta.wide`, which widens `.app__main` in `App.vue`). The title and +description are inline-editable (saved on blur via `PATCH /api/projects/:id`; the +description shows an "Add a description" placeholder when empty). A **Manage** +menu (top right) has a **Delete project** action that opens a confirmation modal; +confirming calls `DELETE /api/projects/:id` and returns to the all-projects view. -Each card row is a checkbox, an inline-editable text field (saved on blur), a -delete button, and a drag handle. Reordering uses `vuedraggable`; on drop the -whole new order is persisted via `PUT /api/projects/:id/cards/order`, and the -server response replaces local state. +Below the header are two tabs (local `activeTab` state, `v-show` so both stay +mounted): + +### All tasks + +The flat card list, **sorted by name (case-insensitive)** via a `sortedCards` +computed — there is no manual order here. Each row is an inline-editable text +field (saved on blur), a status chip (`card.status.name` or "No status"), and a +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. + +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. ## Auth flow diff --git a/web/src/App.vue b/web/src/App.vue index dd0e1d5..e4d8b2a 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,5 +1,5 @@