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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
<http://localhost:8080> (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 <http://localhost:8080>: 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 <http://localhost:8025>. 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": <id|null>, "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 <jwt>`; 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
|
||||
|
||||
Reference in New Issue
Block a user