Files
project-manager/README.md
T
aneurinandClaude Sonnet 5 64ba21795b Bundle a Mailpit catcher and default Compose to SMTP
Adds a mailpit service (axllent/mailpit, ~15 MB, in-memory) to
docker-compose.yml — chosen over the unmaintained MailHog for the same
footprint. It has no profile, so `docker compose up -d` starts it alongside
the API; the app defaults to MAIL_TRANSPORT=smtp -> mailpit:1025 (no
auth/TLS) and mail is read at http://localhost:8025.

Also change the default MAIL_FROM to no-reply@todo.test: PHPMailer v7 rejects
the dotless no-reply@localhost as an invalid address.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 08:40:36 +01:00

374 lines
13 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 Todo List
A small todo-list application: a REST API written in PHP (Slim 4) backed by an
SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
## 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 | Todo list + item CRUD API | ✅ done |
| 4 | Frontend lists view — list index + create form | ✅ done |
| 5 | Frontend list detail — items UI with drag-and-drop reorder | ✅ done |
| 6 | List view — inline title/description editing, delete via a Manage menu | ✅ done |
| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ 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
[Email verification](#email-verification--profile).
## Run with Docker
The only requirement is Docker with the Compose plugin.
```bash
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`).
- 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 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`.
- `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 app as long as the document root is `public/` and
unknown paths fall through to `public/index.php`.
## Frontend
The Vue/TypeScript PWA lives in [web/](web/) and talks to this API. With the API
running (`docker compose up -d`):
```bash
cd web
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).
## 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:5173` | Frontend base URL used to build magic links |
| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) |
| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Todo List` | 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" }
```
### `POST /api/auth/register`
Request:
```json
{ "email": "ada@example.com", "password": "correct horse battery staple" }
```
`201 Created`:
```json
{
"user": {
"id": 1,
"email": "ada@example.com",
"email_verified": false,
"email_verified_at": null,
"pending_email": null,
"created_at": "2026-09-03T12:00:00Z"
},
"token": "<jwt>",
"expires_at": "2026-09-04T12:00:00+00:00"
}
```
New accounts are created with an unverified email (`email_verified: false`).
Errors: `422` invalid input, `409` email already registered.
Validation: `email` must be a valid address (≤ 255 chars); `password` must be
872 characters.
### `POST /api/auth/login`
Request:
```json
{ "email": "ada@example.com", "password": "correct horse battery staple" }
```
`200 OK`: same shape as register. `401` on bad credentials (the message does not
say whether it was the email or the password that was wrong).
### `GET /api/me`
Requires `Authorization: Bearer <jwt>`.
`200 OK`:
```json
{
"user": {
"id": 1,
"email": "ada@example.com",
"email_verified": false,
"email_verified_at": null,
"pending_email": null,
"created_at": "2026-09-03T12:00:00Z"
}
}
```
`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.
### Email verification & profile
Registration emails a magic link — `<APP_URL>/verify-email?token=<opaque>` — that
expires **15 minutes** after it is sent. Only a hash of the token is stored.
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/api/auth/verify-email` | — | consume a token: verify the address (or apply a pending change), then return a session so the caller is logged in |
| `POST` | `/api/email/verification` | ✔ | resend the verification email; `409` if already verified |
| `POST` | `/api/email/change` | ✔ | request a **deferred** email change |
`POST /api/auth/verify-email` body: `{ "token": "..." }`. Success returns the
same `{ user, token, expires_at }` envelope as login. A missing/invalid, already
used, or expired token is `400` (distinct messages).
`POST /api/email/verification` and `/api/email/change` are throttled to **once
per 60 seconds** per user (shared window). When throttled they return `429` with
`error.details.retry_after` (seconds). On success they return `202` with
`retry_after`, and `/api/email/change` also returns `pending_email`.
`POST /api/email/change` body: `{ "email": "new@example.com", "password": "<current>" }`.
The current password is required (`422` if wrong). The address must be free
(`409`) and different from the current one (`422`). The change is **not applied
until** the magic link sent to the new address is opened — until then `GET
/api/me` shows the old address with `pending_email` set.
### Todo lists
All routes below require `Authorization: Bearer <jwt>`. A list belongs to one
owner (the creator); another user's list — or a missing one — always responds
`404`.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/lists` | the caller's lists, sorted A→Z by title |
| `POST` | `/api/lists` | create a list |
| `GET` | `/api/lists/{id}` | one list |
| `PATCH` | `/api/lists/{id}` | update `title` and/or `description` |
| `DELETE` | `/api/lists/{id}` | delete the list and its items (`204`) |
`GET /api/lists` is always ordered alphabetically (case-insensitive) by title;
there is no other sort option. A user may own at most **100 lists** — 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.
List representation:
```json
{
"list": {
"id": 1,
"title": "Shopping",
"description": "For the week",
"owner_id": 1,
"item_count": 3,
"completed_count": 1,
"created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z"
}
}
```
`GET /api/lists` returns `{ "lists": [ … ] }`.
### Todo items
Scoped to a list; the parent list's ownership is checked first (`404` otherwise).
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/lists/{id}/items` | items, ordered by `position` then `id` |
| `POST` | `/api/lists/{id}/items` | add an item |
| `PUT` | `/api/lists/{id}/items/order` | reorder all items in one shot |
| `GET` | `/api/lists/{id}/items/{itemId}` | one item |
| `PATCH` | `/api/lists/{id}/items/{itemId}` | update `text`, `complete`, and/or `position` |
| `DELETE` | `/api/lists/{id}/items/{itemId}` | delete the item (`204`) |
Create body: `text` (required, 11000 chars), `complete` (optional bool,
default `false`), `position` (optional integer ≥ 0; when omitted the item is
appended after the current highest position). `PATCH` needs at least one field.
`position` is a plain sort key the client manages — updating one item never
renumbers its siblings.
`PUT …/items/order` takes `{ "item_ids": [3, 1, 2] }` — every item in the list,
each exactly once (`422` otherwise). It rewrites positions to `0..n-1` in one
transaction and returns `{ "items": [ … ] }` in the new order. This is what the
drag-and-drop reorder in the UI calls.
Item representation:
```json
{
"item": {
"id": 10,
"list_id": 1,
"text": "Milk",
"complete": false,
"position": 0,
"created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z"
}
}
```
`GET …/items` returns `{ "items": [ … ] }`.
### 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
```bash
BASE=http://localhost:8080
curl -s -X POST $BASE/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"password123"}'
TOKEN=$(curl -s -X POST $BASE/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"password123"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN"
LIST=$(curl -s -X POST $BASE/api/lists \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Shopping","description":"For the week"}' \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
curl -s -X POST $BASE/api/lists/$LIST/items \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"Milk"}'
curl -s $BASE/api/lists/$LIST/items -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, TodoList, TodoItem)
src/Repository/ Database access (User, EmailVerification, TodoList, TodoItem)
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
docker/ Apache vhost + container entrypoint
web/ Vue 3 + TypeScript + Vite PWA frontend
```
## Provenance
This project was generated with [Claude Code](https://claude.com/claude-code).