Add stage 3: todo list and item CRUD API
Two migrations add todo_lists (owner_id FK to users, title, description) and todo_items (list_id FK, text, complete, position), both with ON DELETE CASCADE. New endpoints under /api/lists, all behind AuthMiddleware: - lists: index / store / show / update (PATCH) / destroy - items: nested under a list, same five verbs Lists are owner-scoped — another user's or a missing list responds 404, never 403. New items append after the highest position unless one is given; the list carries item_count / completed_count. Item PATCH is partial and never renumbers siblings. Adds App\Support\Validator for request-body checks, TodoList/TodoItem repositories, and body()/user() helpers on the Controller base. Feature tests move their shared harness into tests/ApiTestCase; TodoTest covers CRUD, ownership isolation, ordering, completion counts, validation and cascade delete. Full suite: 15 passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,8 @@ SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
|
||||
|-------|-------|-------|
|
||||
| 1 | Auth API — register, login, `GET /me` | ✅ done |
|
||||
| 2 | Frontend shell — Vite PWA, auth-gated routing, register/login pages | ✅ done |
|
||||
| 3 | Todo CRUD (API + UI) | planned |
|
||||
| 3 | Todo list + item CRUD API | ✅ done |
|
||||
| 4 | Todo UI in the frontend | planned |
|
||||
|
||||
Registration signs the user in immediately, with the account's email marked
|
||||
unverified (`user.email_verified` is `false` until a future stage adds a
|
||||
@@ -171,6 +172,78 @@ Requires `Authorization: Bearer <jwt>`.
|
||||
|
||||
`401` if the header is missing, malformed, or the token is invalid/expired.
|
||||
|
||||
### 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, newest first |
|
||||
| `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`) |
|
||||
|
||||
Create/update body: `title` (required on create, 1–255 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 |
|
||||
| `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, 1–1000 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.
|
||||
|
||||
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:
|
||||
@@ -195,6 +268,17 @@ TOKEN=$(curl -s -X POST $BASE/api/auth/login \
|
||||
-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
|
||||
@@ -214,8 +298,9 @@ src/Support/Database.php PDO/SQLite connection
|
||||
src/Auth/JwtService.php Issue/verify JWTs
|
||||
src/Auth/AuthMiddleware.php Bearer-token authentication
|
||||
src/Http/JsonErrorHandler.php Uniform JSON error envelope
|
||||
src/Http/Controllers/ Request handlers
|
||||
src/Repository/ Database access
|
||||
src/Http/Controllers/ Request handlers (Auth, TodoList, TodoItem)
|
||||
src/Repository/ Database access (User, 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 One-command local stack (API; web via --profile frontend)
|
||||
|
||||
Reference in New Issue
Block a user