Files
project-manager/README.md
T
aneurinandClaude Sonnet 5 4ee0f24078 Make the inbox global instead of per-project
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, never one without the other. The inbox is global to a
user now, not per-project: cards can move from a project into the inbox and
back into any status column of any project.

Backend
- migrations/009: rebuilds `cards` (SQLite can't relax NOT NULL / add a CHECK
  in place) with a nullable project_id, a new owner_id (cards need direct
  ownership once they can have no project), and the CHECK constraint. Cards
  that had no status (the old per-project inbox) move to the new global inbox.
  status_id's FK is now ON DELETE RESTRICT, not SET NULL -- nulling it alone
  would violate the invariant, and there's no status-delete endpoint anyway.
- CardRepository: "column" is now (owner_id, project_id, status_id); every
  method that dealt with a project's columns is generalised to also cover the
  inbox and cross-project moves (orderColumn, idsInColumn, repack, ...).
- CardController/routes: single-card and ordering routes move to global,
  since a card may have no project to nest them under --
  GET/PATCH/DELETE /api/cards/{id}, PUT /api/cards/order (body now takes
  project_id + status_id, both null for the inbox). New GET/POST
  /api/inbox/cards. PATCH no longer accepts status_id -- moving a card, in or
  out of a project, is exclusively PUT /api/cards/order now. A card created
  directly in a project (POST /api/projects/{id}/cards) lands in its first
  status, since a project card can't have no status.
- Tests: ProjectTest/CardStatusTest updated for the new routes; CardOrderTest
  rewritten with full inbox/cross-project coverage. 57 tests pass.

Frontend
- New stores/inbox.ts (the global inbox) and lib/cardOrder.ts (the shared
  PUT /api/cards/order call, used by both the sidebar and a project's board).
- AppSidebar: an Inbox section under the project list -- a vuedraggable list
  in the same "kanban" drag group as every project's kanban columns, so a
  card drags straight from the sidebar into whichever project is open, or
  back out. (The empty-inbox state needed a real bugfix: it wasn't rendering
  a <draggable> at all, so there was nowhere to drop a card back into an
  empty inbox.) A drop reloads the inbox and, if a project is open, its cards.
- ProjectView's kanban board drops its synthetic Inbox column -- just the
  real statuses now.
- DashboardView simplified to a plain grid of project tiles (name + card
  count); its per-project "New" section is gone, since a project card can no
  longer have no status.
- stores/cards.ts: patch/remove move to the global /api/cards/{id} routes.

Verified end-to-end against the rebuilt container (existing per-project-inbox
cards correctly migrated to the global inbox, 0 invariant violations) and the
dev server via headless Chrome: sidebar inbox -> project A "To do" -> back to
inbox -> project B "Done", full journey confirmed via the API at each step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:22:33 +01:00

427 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PHP Project Manager
A small project-management application: a REST API written in PHP (Slim 4)
backed by an SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
Each user owns **projects**, and each project holds ordered **cards**.
## Status
| Stage | Scope | State |
|-------|-------|-------|
| 1 | Auth API — register, login, `GET /me` | ✅ done |
| 2 | Frontend shell — Vite PWA, auth-gated routing, register/login pages | ✅ done |
| 3 | Project + card CRUD API | ✅ done |
| 4 | Frontend projects view — project index + create form | ✅ done |
| 5 | Frontend project detail — cards UI with drag-and-drop reorder | ✅ done |
| 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 |
| 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
[Auth](#auth).
## Run with Docker
The only requirement is Docker with the Compose plugin.
```bash
docker compose up -d
```
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 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`, 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
[docker-compose.yml](docker-compose.yml)).
## Run without Docker
Requires PHP 8.1+ with the `pdo_sqlite` and `mbstring` extensions, plus
[Composer](https://getcomposer.org/). On Fedora:
```bash
sudo dnf install php-cli php-pdo php-mbstring composer
```
### Setup
```bash
composer install
cp .env.example .env # optional; sane defaults are used without it
composer migrate # creates storage/database.sqlite and its tables
```
### Running
```bash
composer serve # http://localhost:8080 (php -S localhost:8080 -t public)
```
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/). 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
npm install
npm run dev # http://localhost:5173, proxies /api to localhost:8080
```
Unauthenticated visitors are redirected to `/login` — enter an email address
and open the link that arrives; there is no separate sign-up. See
[web/README.md](web/README.md).
## Configuration
All settings are optional environment variables (read from `.env` or the real
environment). See [.env.example](.env.example).
| Variable | Default | Purpose |
|----------|---------|---------|
| `APP_DEBUG` | `false` | Include exception details in error responses |
| `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: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 |
| `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` |
Standalone, SMTP is opt-in and the API otherwise falls back to PHP's `mail()`.
Under Docker Compose the default is `MAIL_TRANSPORT=smtp` pointed at the bundled
Mailpit container (`mailpit:1025`, no auth/TLS); open <http://localhost:8025> to
read what was "sent".
## API
Base path: `/api`. All request and response bodies are JSON; send
`Content-Type: application/json`.
### `GET /api/health`
```json
{ "status": "ok" }
```
### Auth
There is no password and no separate registration endpoint. Entering an email
address and opening the link sent to it is the entire flow, for a brand-new
address and a returning one alike.
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/api/auth/magic-link` | — | email a one-time sign-in link, creating the account first if the address is new |
| `POST` | `/api/auth/verify-email` | — | consume the token: sign in, and (the first time) mark the address verified, or apply a pending email change |
| `GET` | `/api/me` | ✔ | the current user |
| `POST` | `/api/email/change` | ✔ | request a **deferred** email change |
#### `POST /api/auth/magic-link`
Request: `{ "email": "ada@example.com" }`.
Emails a one-time sign-in link (`<APP_URL>/verify-email?token=…`, 15-minute
expiry) and always returns `202` with the same message. If the address has no
account yet, one is created (unverified) right here — that's the only "sign
up" there is. A link is only actually (re-)sent if this address hasn't been
emailed one in the last 60 seconds. `422` if the address is malformed.
```json
{ "message": "Check your email for a link to sign in." }
```
#### `POST /api/auth/verify-email`
Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is
`400` (distinct messages). Success signs the caller in:
```json
{
"user": {
"id": 1,
"email": "ada@example.com",
"email_verified": true,
"email_verified_at": "2026-09-03T12:00:00Z",
"pending_email": null,
"created_at": "2026-09-03T12:00:00Z"
},
"token": "<jwt>",
"expires_at": "2026-09-04T12:00:00+00:00"
}
```
Opening a link is the only way to obtain a session, so an authenticated request
is always for a verified address — `email_verified` is `true` from the first
token a user's browser ever holds.
#### `GET /api/me`
Requires `Authorization: Bearer <jwt>`. `200 OK`: the same `user` object shown
above. `pending_email` is the address a still-valid email-change link is
waiting on, or `null`. `401` if the header is missing, malformed, or the token
is invalid/expired.
#### `POST /api/email/change`
Requires `Authorization: Bearer <jwt>`. Body: `{ "email": "new@example.com" }`.
The address must be free (`409`) and different from the current one (`422`).
Throttled to **once per 60 seconds** (shared with `/api/auth/magic-link`'s
resend window, per user) — `429` with `error.details.retry_after` when too
soon. On success, `202` with `retry_after` and `pending_email`:
```json
{
"message": "Confirmation email sent to the new address.",
"pending_email": "new@example.com",
"retry_after": 60
}
```
The change is **not applied until** the magic link sent to the new address is
opened — until then `GET /api/me` still shows the old address, with
`pending_email` set. Opening that link both changes the address and re-verifies
it, via the same `/api/auth/verify-email`.
### Projects
All routes below require `Authorization: Bearer <jwt>`. A project belongs to one
owner (the creator); another user's project — or a missing one — always responds
`404`.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/projects` | the caller's projects, sorted A→Z by title |
| `POST` | `/api/projects` | create a project |
| `GET` | `/api/projects/{id}` | one project |
| `PATCH` | `/api/projects/{id}` | update `title` and/or `description` |
| `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) |
`GET /api/projects` is always ordered alphabetically (case-insensitive) by
title; there is no other sort option. A user may own at most **100 projects**
creating one beyond that responds `409`.
Create/update body: `title` (required on create, 1255 chars), `description`
(optional, ≤ 2000 chars, defaults to `""`). `PATCH` needs at least one field.
Project representation:
```json
{
"project": {
"id": 1,
"title": "Website relaunch",
"description": "Q3",
"owner_id": 1,
"card_count": 3,
"completed_count": 1,
"created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z"
}
}
```
`GET /api/projects` returns `{ "projects": [ … ] }`.
Creating a project also seeds it with three **statuses** — "To do", "Doing",
"Done" (see [Statuses](#statuses)).
### Cards
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` | 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 (either creation route): `text` (required, 11000 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 an `(owner, project, status)`. The inbox is its own column, per
owner. `PUT /api/cards/order` sets one column's contents and order:
```json
{ "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] }
```
`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:
```json
{
"card": {
"id": 10,
"project_id": 1,
"text": "Design homepage",
"complete": false,
"position": 0,
"status_id": 2,
"status": { "id": 2, "name": "Doing" },
"created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z"
}
}
```
`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, and (as
a project card must always have one) a referenced status can't be deleted at
the database level either.
| 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
Every error response looks like:
```json
{ "error": { "message": "The submitted data was invalid.", "details": { "email": ["Email must be a valid address."] } } }
```
`details` is present only when relevant (e.g. validation).
## Try it
Signing in needs the link the API emails, so this pulls it back out of the
bundled Mailpit catcher (adjust if you've pointed `MAIL_TRANSPORT` elsewhere).
The API pretty-prints its JSON, so responses are piped through `tr -d ' \n'`
before grep (Mailpit's own JSON doesn't need that):
```bash
BASE=http://localhost:8080
curl -s -X POST $BASE/api/auth/magic-link \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com"}'
MSG_ID=$(curl -s "http://localhost:8025/api/v1/messages?limit=1" | grep -o '"ID":"[^"]*"' | head -1 | cut -d'"' -f4)
LINK_TOKEN=$(curl -s "http://localhost:8025/api/v1/message/$MSG_ID" | grep -o 'token=[a-f0-9]*' | head -1 | cut -d= -f2)
TOKEN=$(curl -s -X POST $BASE/api/auth/verify-email \
-H 'Content-Type: application/json' \
-d "{\"token\":\"$LINK_TOKEN\"}" | tr -d ' \n' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN"
PROJECT=$(curl -s -X POST $BASE/api/projects \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Website relaunch","description":"Q3"}' \
| tr -d ' \n' | 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"}'
curl -s $BASE/api/projects/$PROJECT/cards -H "Authorization: Bearer $TOKEN"
```
## Tests
```bash
composer install # installs phpunit (require-dev)
vendor/bin/phpunit
```
## Layout
```
public/index.php Front controller
src/bootstrap.php App wiring and route definitions
src/Support/Config.php Environment-driven configuration
src/Support/Database.php PDO/SQLite connection
src/Auth/JwtService.php Issue/verify JWTs
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, 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 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 (dev on the host)
```
## Provenance
This project was generated with [Claude Code](https://claude.com/claude-code).