diff --git a/.env.example b/.env.example index 8e1a61a..75891d0 100644 --- a/.env.example +++ b/.env.example @@ -29,7 +29,7 @@ APP_URL=http://localhost:5173 # log — append messages to MAIL_LOG_PATH instead of sending (dev/test) MAIL_TRANSPORT=mail MAIL_FROM=no-reply@todo.test -MAIL_FROM_NAME=Todo List +MAIL_FROM_NAME=Projects MAIL_LOG_PATH=storage/mail.log # Only used when MAIL_TRANSPORT=smtp. diff --git a/README.md b/README.md index b01fa24..04f561a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ -# PHP Todo List +# PHP Project Manager -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/). +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 @@ -9,10 +10,10 @@ 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 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 | +| 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 | @@ -108,7 +109,7 @@ environment). See [.env.example](.env.example). | `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_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` | @@ -235,37 +236,37 @@ The current password is required (`422` if wrong). The address must be free 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 +### Projects -All routes below require `Authorization: Bearer `. A list belongs to one -owner (the creator); another user's list — or a missing one — always responds +All routes below require `Authorization: Bearer `. A project belongs to one +owner (the creator); another user's project — 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/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/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`. +`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, 1–255 chars), `description` (optional, ≤ 2000 chars, defaults to `""`). `PATCH` needs at least one field. -List representation: +Project representation: ```json { - "list": { + "project": { "id": 1, - "title": "Shopping", - "description": "For the week", + "title": "Website relaunch", + "description": "Q3", "owner_id": 1, - "item_count": 3, + "card_count": 3, "completed_count": 1, "created_at": "2026-09-03T12:00:00Z", "updated_at": "2026-09-03T12:00:00Z" @@ -273,40 +274,41 @@ List representation: } ``` -`GET /api/lists` returns `{ "lists": [ … ] }`. +`GET /api/projects` returns `{ "projects": [ … ] }`. -### Todo items +### Cards -Scoped to a list; the parent list's ownership is checked first (`404` otherwise). +Scoped to a project; the parent project'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`) | +| `GET` | `/api/projects/{id}/cards` | cards, ordered by `position` then `id` | +| `POST` | `/api/projects/{id}/cards` | add a card | +| `PUT` | `/api/projects/{id}/cards/order` | reorder all cards in one shot | +| `GET` | `/api/projects/{id}/cards/{cardId}` | one card | +| `PATCH` | `/api/projects/{id}/cards/{cardId}` | update `text`, `complete`, and/or `position` | +| `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 item is +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 item never +`position` is a plain sort key the client manages — updating one card 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. +`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. -Item representation: +Card representation: ```json { - "item": { + "card": { "id": 10, - "list_id": 1, - "text": "Milk", + "project_id": 1, + "text": "Design homepage", "complete": false, "position": 0, "created_at": "2026-09-03T12:00:00Z", @@ -315,7 +317,7 @@ Item representation: } ``` -`GET …/items` returns `{ "items": [ … ] }`. +`GET …/cards` returns `{ "cards": [ … ] }`. ### Error shape @@ -342,16 +344,16 @@ TOKEN=$(curl -s -X POST $BASE/api/auth/login \ curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN" -LIST=$(curl -s -X POST $BASE/api/lists \ +PROJECT=$(curl -s -X POST $BASE/api/projects \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ - -d '{"title":"Shopping","description":"For the week"}' \ + -d '{"title":"Website relaunch","description":"Q3"}' \ | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) -curl -s -X POST $BASE/api/lists/$LIST/items \ +curl -s -X POST $BASE/api/projects/$PROJECT/cards \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ - -d '{"text":"Milk"}' + -d '{"text":"Design homepage"}' -curl -s $BASE/api/lists/$LIST/items -H "Authorization: Bearer $TOKEN" +curl -s $BASE/api/projects/$PROJECT/cards -H "Authorization: Bearer $TOKEN" ``` ## Tests @@ -373,8 +375,8 @@ 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/Http/Controllers/ Request handlers (Auth, EmailVerification, Project, Card) +src/Repository/ Database access (User, EmailVerification, Project, Card) src/Support/Validator.php Request-body validation helper migrations/*.sql Schema, applied by bin/migrate.php Dockerfile PHP 8.3 + Apache image diff --git a/composer.json b/composer.json index 214e894..245148a 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "aneurin/php-todo-list", - "description": "Simple todo list REST API + SPA (PHP, SQLite)", + "description": "Simple project-management REST API + SPA (PHP, SQLite)", "type": "project", "license": "MIT", "require": { diff --git a/migrations/003_create_todo_lists_table.sql b/migrations/003_create_projects_table.sql similarity index 75% rename from migrations/003_create_todo_lists_table.sql rename to migrations/003_create_projects_table.sql index f8162e4..3991338 100644 --- a/migrations/003_create_todo_lists_table.sql +++ b/migrations/003_create_projects_table.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS todo_lists ( +CREATE TABLE IF NOT EXISTS projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, title TEXT NOT NULL, @@ -7,4 +7,4 @@ CREATE TABLE IF NOT EXISTS todo_lists ( updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); -CREATE INDEX IF NOT EXISTS idx_todo_lists_owner ON todo_lists (owner_id); +CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects (owner_id); diff --git a/migrations/004_create_todo_items_table.sql b/migrations/004_create_cards_table.sql similarity index 62% rename from migrations/004_create_todo_items_table.sql rename to migrations/004_create_cards_table.sql index e41d1c3..8e64175 100644 --- a/migrations/004_create_todo_items_table.sql +++ b/migrations/004_create_cards_table.sql @@ -1,6 +1,6 @@ -CREATE TABLE IF NOT EXISTS todo_items ( +CREATE TABLE IF NOT EXISTS cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, - list_id INTEGER NOT NULL REFERENCES todo_lists (id) ON DELETE CASCADE, + project_id INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, text TEXT NOT NULL, complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)), position INTEGER NOT NULL DEFAULT 0, @@ -8,4 +8,4 @@ CREATE TABLE IF NOT EXISTS todo_items ( updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); -CREATE INDEX IF NOT EXISTS idx_todo_items_list_position ON todo_items (list_id, position); +CREATE INDEX IF NOT EXISTS idx_cards_project_position ON cards (project_id, position); diff --git a/src/Http/Controllers/CardController.php b/src/Http/Controllers/CardController.php new file mode 100644 index 0000000..2ab5664 --- /dev/null +++ b/src/Http/Controllers/CardController.php @@ -0,0 +1,187 @@ +requireOwnedProjectId($request, $args); + + return $this->json($response, [ + 'cards' => array_map($this->present(...), $this->cards->allForProject($projectId)), + ]); + } + + /** + * POST /api/projects/{projectId}/cards + */ + public function store(Request $request, Response $response, array $args): Response + { + $projectId = $this->requireOwnedProjectId($request, $args); + + $validator = new Validator($this->body($request)); + $text = $validator->requiredString('text', self::TEXT_MAX); + $complete = $validator->optionalBool('complete') ?? false; + $position = $validator->optionalInt('position', 0); + $validator->assert(); + + $card = $this->cards->create($projectId, $text, $complete, $position); + $this->projects->touch($projectId); + + return $this->json($response, ['card' => $this->present($card)], 201); + } + + /** + * GET /api/projects/{projectId}/cards/{cardId} + */ + public function show(Request $request, Response $response, array $args): Response + { + $projectId = $this->requireOwnedProjectId($request, $args); + + return $this->json($response, ['card' => $this->present($this->requireCard($projectId, $args))]); + } + + /** + * PATCH /api/projects/{projectId}/cards/{cardId} + */ + public function update(Request $request, Response $response, array $args): Response + { + $projectId = $this->requireOwnedProjectId($request, $args); + $card = $this->requireCard($projectId, $args); + + $validator = new Validator($this->body($request)); + $fields = []; + if ($validator->has('text')) { + $fields['text'] = $validator->requiredString('text', self::TEXT_MAX); + } + if ($validator->has('complete')) { + $fields['complete'] = $validator->optionalBool('complete'); + } + if ($validator->has('position')) { + $fields['position'] = $validator->optionalInt('position', 0); + } + if ($fields === [] && !$validator->failed()) { + $validator->add('text', 'Provide at least one of: text, complete, position.'); + } + $validator->assert(); + + $updated = $this->cards->update($card['id'], $projectId, $fields); + $this->projects->touch($projectId); + + return $this->json($response, ['card' => $this->present($updated)]); + } + + /** + * DELETE /api/projects/{projectId}/cards/{cardId} + */ + public function destroy(Request $request, Response $response, array $args): Response + { + $projectId = $this->requireOwnedProjectId($request, $args); + $this->cards->delete($this->requireCard($projectId, $args)['id']); + $this->projects->touch($projectId); + + return $response->withStatus(204); + } + + /** + * 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. + */ + public function reorder(Request $request, Response $response, array $args): Response + { + $projectId = $this->requireOwnedProjectId($request, $args); + + $order = $this->body($request)['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); + } + + $cards = $this->cards->reorder($projectId, $order); + $this->projects->touch($projectId); + + return $this->json($response, ['cards' => array_map($this->present(...), $cards)]); + } + + /** + * @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 $args + * @return array{id: int, project_id: int, text: string, complete: bool, position: int, created_at: string, updated_at: string} + */ + private function requireCard(int $projectId, array $args): array + { + $card = $this->cards->findInProject((int) $args['cardId'], $projectId); + + if ($card === null) { + throw new ApiException('Card not found.', 404); + } + + return $card; + } + + /** + * @param array{id: int, project_id: int, text: string, complete: bool, position: int, created_at: string, updated_at: string} $card + * @return array + */ + private function present(array $card): array + { + return [ + 'id' => $card['id'], + 'project_id' => $card['project_id'], + 'text' => $card['text'], + 'complete' => $card['complete'], + 'position' => $card['position'], + 'created_at' => $card['created_at'], + 'updated_at' => $card['updated_at'], + ]; + } +} diff --git a/src/Http/Controllers/ProjectController.php b/src/Http/Controllers/ProjectController.php new file mode 100644 index 0000000..fb1a1be --- /dev/null +++ b/src/Http/Controllers/ProjectController.php @@ -0,0 +1,139 @@ +projects->allForOwner($this->user($request)['id']); + + return $this->json($response, ['projects' => array_map($this->present(...), $projects)]); + } + + /** + * POST /api/projects + */ + public function store(Request $request, Response $response): Response + { + $ownerId = $this->user($request)['id']; + + $validator = new Validator($this->body($request)); + $title = $validator->requiredString('title', self::TITLE_MAX); + $description = $validator->optionalString('description', self::DESCRIPTION_MAX) ?? ''; + $validator->assert(); + + if ($this->projects->countForOwner($ownerId) >= self::MAX_PROJECTS_PER_OWNER) { + throw new ApiException( + sprintf('You have reached the maximum of %d projects.', self::MAX_PROJECTS_PER_OWNER), + 409, + ); + } + + $project = $this->projects->create($ownerId, $title, $description); + + return $this->json($response, ['project' => $this->present($project)], 201); + } + + /** + * GET /api/projects/{projectId} + */ + public function show(Request $request, Response $response, array $args): Response + { + return $this->json($response, ['project' => $this->present($this->requireOwnedProject($request, $args))]); + } + + /** + * PATCH /api/projects/{projectId} + */ + public function update(Request $request, Response $response, array $args): Response + { + $project = $this->requireOwnedProject($request, $args); + + $validator = new Validator($this->body($request)); + $fields = []; + if ($validator->has('title')) { + $fields['title'] = $validator->requiredString('title', self::TITLE_MAX); + } + if ($validator->has('description')) { + $fields['description'] = $validator->optionalString('description', self::DESCRIPTION_MAX) ?? ''; + } + if ($fields === [] && !$validator->failed()) { + $validator->add('title', 'Provide at least one of: title, description.'); + } + $validator->assert(); + + $updated = $this->projects->update($project['id'], $project['owner_id'], $fields); + + return $this->json($response, ['project' => $this->present($updated)]); + } + + /** + * DELETE /api/projects/{projectId} + */ + public function destroy(Request $request, Response $response, array $args): Response + { + $this->projects->delete($this->requireOwnedProject($request, $args)['id']); + + return $response->withStatus(204); + } + + /** + * Load the project named in the route, or 404 if it is missing or not owned + * by the authenticated user. + * + * @param array $args + * @return array{id: int, owner_id: int, title: string, description: string, card_count: int, completed_count: int, created_at: string, updated_at: string} + */ + private function requireOwnedProject(Request $request, array $args): array + { + $project = $this->projects->findOwnedBy((int) $args['projectId'], $this->user($request)['id']); + + if ($project === null) { + throw new ApiException('Project not found.', 404); + } + + return $project; + } + + /** + * @param array{id: int, owner_id: int, title: string, description: string, card_count: int, completed_count: int, created_at: string, updated_at: string} $project + * @return array + */ + private function present(array $project): array + { + return [ + 'id' => $project['id'], + 'title' => $project['title'], + 'description' => $project['description'], + 'owner_id' => $project['owner_id'], + 'card_count' => $project['card_count'], + 'completed_count' => $project['completed_count'], + 'created_at' => $project['created_at'], + 'updated_at' => $project['updated_at'], + ]; + } +} diff --git a/src/Http/Controllers/TodoItemController.php b/src/Http/Controllers/TodoItemController.php deleted file mode 100644 index 937a82c..0000000 --- a/src/Http/Controllers/TodoItemController.php +++ /dev/null @@ -1,187 +0,0 @@ -requireOwnedListId($request, $args); - - return $this->json($response, [ - 'items' => array_map($this->present(...), $this->items->allForList($listId)), - ]); - } - - /** - * POST /api/lists/{listId}/items - */ - public function store(Request $request, Response $response, array $args): Response - { - $listId = $this->requireOwnedListId($request, $args); - - $validator = new Validator($this->body($request)); - $text = $validator->requiredString('text', self::TEXT_MAX); - $complete = $validator->optionalBool('complete') ?? false; - $position = $validator->optionalInt('position', 0); - $validator->assert(); - - $item = $this->items->create($listId, $text, $complete, $position); - $this->lists->touch($listId); - - return $this->json($response, ['item' => $this->present($item)], 201); - } - - /** - * GET /api/lists/{listId}/items/{itemId} - */ - public function show(Request $request, Response $response, array $args): Response - { - $listId = $this->requireOwnedListId($request, $args); - - return $this->json($response, ['item' => $this->present($this->requireItem($listId, $args))]); - } - - /** - * PATCH /api/lists/{listId}/items/{itemId} - */ - public function update(Request $request, Response $response, array $args): Response - { - $listId = $this->requireOwnedListId($request, $args); - $item = $this->requireItem($listId, $args); - - $validator = new Validator($this->body($request)); - $fields = []; - if ($validator->has('text')) { - $fields['text'] = $validator->requiredString('text', self::TEXT_MAX); - } - if ($validator->has('complete')) { - $fields['complete'] = $validator->optionalBool('complete'); - } - if ($validator->has('position')) { - $fields['position'] = $validator->optionalInt('position', 0); - } - if ($fields === [] && !$validator->failed()) { - $validator->add('text', 'Provide at least one of: text, complete, position.'); - } - $validator->assert(); - - $updated = $this->items->update($item['id'], $listId, $fields); - $this->lists->touch($listId); - - return $this->json($response, ['item' => $this->present($updated)]); - } - - /** - * DELETE /api/lists/{listId}/items/{itemId} - */ - public function destroy(Request $request, Response $response, array $args): Response - { - $listId = $this->requireOwnedListId($request, $args); - $this->items->delete($this->requireItem($listId, $args)['id']); - $this->lists->touch($listId); - - return $response->withStatus(204); - } - - /** - * PUT /api/lists/{listId}/items/order - * - * Body: { "item_ids": [3, 1, 2] } — every item in the list, exactly once, - * in the desired order. Positions are rewritten to 0..n-1. - */ - public function reorder(Request $request, Response $response, array $args): Response - { - $listId = $this->requireOwnedListId($request, $args); - - $order = $this->body($request)['item_ids'] ?? null; - if (!is_array($order) || array_filter($order, static fn ($id) => !is_int($id)) !== []) { - throw new ApiException('item_ids must be an array of item IDs.', 422); - } - - /** @var int[] $order */ - $expected = $this->items->idsForList($listId); - $given = $order; - sort($given); - sort($expected); - - if ($given !== $expected) { - throw new ApiException('item_ids must contain every item in the list exactly once.', 422); - } - - $items = $this->items->reorder($listId, $order); - $this->lists->touch($listId); - - return $this->json($response, ['items' => array_map($this->present(...), $items)]); - } - - /** - * @param array $args - */ - private function requireOwnedListId(Request $request, array $args): int - { - $list = $this->lists->findOwnedBy((int) $args['listId'], $this->user($request)['id']); - - if ($list === null) { - throw new ApiException('List not found.', 404); - } - - return $list['id']; - } - - /** - * @param array $args - * @return array{id: int, list_id: int, text: string, complete: bool, position: int, created_at: string, updated_at: string} - */ - private function requireItem(int $listId, array $args): array - { - $item = $this->items->findInList((int) $args['itemId'], $listId); - - if ($item === null) { - throw new ApiException('Item not found.', 404); - } - - return $item; - } - - /** - * @param array{id: int, list_id: int, text: string, complete: bool, position: int, created_at: string, updated_at: string} $item - * @return array - */ - private function present(array $item): array - { - return [ - 'id' => $item['id'], - 'list_id' => $item['list_id'], - 'text' => $item['text'], - 'complete' => $item['complete'], - 'position' => $item['position'], - 'created_at' => $item['created_at'], - 'updated_at' => $item['updated_at'], - ]; - } -} diff --git a/src/Http/Controllers/TodoListController.php b/src/Http/Controllers/TodoListController.php deleted file mode 100644 index bc52b28..0000000 --- a/src/Http/Controllers/TodoListController.php +++ /dev/null @@ -1,139 +0,0 @@ -lists->allForOwner($this->user($request)['id']); - - return $this->json($response, ['lists' => array_map($this->present(...), $lists)]); - } - - /** - * POST /api/lists - */ - public function store(Request $request, Response $response): Response - { - $ownerId = $this->user($request)['id']; - - $validator = new Validator($this->body($request)); - $title = $validator->requiredString('title', self::TITLE_MAX); - $description = $validator->optionalString('description', self::DESCRIPTION_MAX) ?? ''; - $validator->assert(); - - if ($this->lists->countForOwner($ownerId) >= self::MAX_LISTS_PER_OWNER) { - throw new ApiException( - sprintf('You have reached the maximum of %d lists.', self::MAX_LISTS_PER_OWNER), - 409, - ); - } - - $list = $this->lists->create($ownerId, $title, $description); - - return $this->json($response, ['list' => $this->present($list)], 201); - } - - /** - * GET /api/lists/{listId} - */ - public function show(Request $request, Response $response, array $args): Response - { - return $this->json($response, ['list' => $this->present($this->requireOwnedList($request, $args))]); - } - - /** - * PATCH /api/lists/{listId} - */ - public function update(Request $request, Response $response, array $args): Response - { - $list = $this->requireOwnedList($request, $args); - - $validator = new Validator($this->body($request)); - $fields = []; - if ($validator->has('title')) { - $fields['title'] = $validator->requiredString('title', self::TITLE_MAX); - } - if ($validator->has('description')) { - $fields['description'] = $validator->optionalString('description', self::DESCRIPTION_MAX) ?? ''; - } - if ($fields === [] && !$validator->failed()) { - $validator->add('title', 'Provide at least one of: title, description.'); - } - $validator->assert(); - - $updated = $this->lists->update($list['id'], $list['owner_id'], $fields); - - return $this->json($response, ['list' => $this->present($updated)]); - } - - /** - * DELETE /api/lists/{listId} - */ - public function destroy(Request $request, Response $response, array $args): Response - { - $this->lists->delete($this->requireOwnedList($request, $args)['id']); - - return $response->withStatus(204); - } - - /** - * Load the list named in the route, or 404 if it is missing or not owned by - * the authenticated user. - * - * @param array $args - * @return array{id: int, owner_id: int, title: string, description: string, item_count: int, completed_count: int, created_at: string, updated_at: string} - */ - private function requireOwnedList(Request $request, array $args): array - { - $list = $this->lists->findOwnedBy((int) $args['listId'], $this->user($request)['id']); - - if ($list === null) { - throw new ApiException('List not found.', 404); - } - - return $list; - } - - /** - * @param array{id: int, owner_id: int, title: string, description: string, item_count: int, completed_count: int, created_at: string, updated_at: string} $list - * @return array - */ - private function present(array $list): array - { - return [ - 'id' => $list['id'], - 'title' => $list['title'], - 'description' => $list['description'], - 'owner_id' => $list['owner_id'], - 'item_count' => $list['item_count'], - 'completed_count' => $list['completed_count'], - 'created_at' => $list['created_at'], - 'updated_at' => $list['updated_at'], - ]; - } -} diff --git a/src/Repository/TodoItemRepository.php b/src/Repository/CardRepository.php similarity index 50% rename from src/Repository/TodoItemRepository.php rename to src/Repository/CardRepository.php index 5fcb050..02f3d79 100644 --- a/src/Repository/TodoItemRepository.php +++ b/src/Repository/CardRepository.php @@ -7,39 +7,39 @@ namespace App\Repository; use PDO; /** - * Data access for the `todo_items` table. + * Data access for the `cards` table. * - * @phpstan-type TodoItemRow array{ - * id: int, list_id: int, text: string, complete: bool, position: int, + * @phpstan-type CardRow array{ + * id: int, project_id: int, text: string, complete: bool, position: int, * created_at: string, updated_at: string * } */ -final class TodoItemRepository +final class CardRepository { public function __construct(private readonly PDO $pdo) { } /** - * @return TodoItemRow[] + * @return CardRow[] */ - public function allForList(int $listId): array + public function allForProject(int $projectId): array { $stmt = $this->pdo->prepare( - 'SELECT * FROM todo_items WHERE list_id = :list ORDER BY position ASC, id ASC' + 'SELECT * FROM cards WHERE project_id = :project ORDER BY position ASC, id ASC' ); - $stmt->execute(['list' => $listId]); + $stmt->execute(['project' => $projectId]); return array_map($this->cast(...), $stmt->fetchAll()); } /** - * @return TodoItemRow|null + * @return CardRow|null */ - public function findInList(int $id, int $listId): ?array + public function findInProject(int $id, int $projectId): ?array { - $stmt = $this->pdo->prepare('SELECT * FROM todo_items WHERE id = :id AND list_id = :list'); - $stmt->execute(['id' => $id, 'list' => $listId]); + $stmt = $this->pdo->prepare('SELECT * FROM cards WHERE id = :id AND project_id = :project'); + $stmt->execute(['id' => $id, 'project' => $projectId]); $row = $stmt->fetch(); @@ -47,34 +47,34 @@ final class TodoItemRepository } /** - * @return TodoItemRow + * @return CardRow */ - public function create(int $listId, string $text, bool $complete, ?int $position): array + public function create(int $projectId, string $text, bool $complete, ?int $position): array { - $position ??= $this->nextPosition($listId); + $position ??= $this->nextPosition($projectId); $stmt = $this->pdo->prepare( - 'INSERT INTO todo_items (list_id, text, complete, position) - VALUES (:list, :text, :complete, :position)' + 'INSERT INTO cards (project_id, text, complete, position) + VALUES (:project, :text, :complete, :position)' ); $stmt->execute([ - 'list' => $listId, + 'project' => $projectId, 'text' => $text, 'complete' => $complete ? 1 : 0, 'position' => $position, ]); - /** @var TodoItemRow $item */ - $item = $this->findInList((int) $this->pdo->lastInsertId(), $listId); + /** @var CardRow $card */ + $card = $this->findInProject((int) $this->pdo->lastInsertId(), $projectId); - return $item; + return $card; } /** * @param array{text?: string, complete?: bool, position?: int} $fields - * @return TodoItemRow + * @return CardRow */ - public function update(int $id, int $listId, array $fields): array + public function update(int $id, int $projectId, array $fields): array { $sets = ['updated_at = ' . "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"]; $params = ['id' => $id]; @@ -92,53 +92,53 @@ final class TodoItemRepository $params['position'] = $fields['position']; } - $stmt = $this->pdo->prepare('UPDATE todo_items SET ' . implode(', ', $sets) . ' WHERE id = :id'); + $stmt = $this->pdo->prepare('UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id'); $stmt->execute($params); - /** @var TodoItemRow $item */ - $item = $this->findInList($id, $listId); + /** @var CardRow $card */ + $card = $this->findInProject($id, $projectId); - return $item; + return $card; } public function delete(int $id): void { - $this->pdo->prepare('DELETE FROM todo_items WHERE id = :id')->execute(['id' => $id]); + $this->pdo->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $id]); } /** - * IDs of every item in the list, in current position order. + * IDs of every card in the project, in current position order. * * @return int[] */ - public function idsForList(int $listId): array + public function idsForProject(int $projectId): array { $stmt = $this->pdo->prepare( - 'SELECT id FROM todo_items WHERE list_id = :list ORDER BY position ASC, id ASC' + 'SELECT id FROM cards WHERE project_id = :project ORDER BY position ASC, id ASC' ); - $stmt->execute(['list' => $listId]); + $stmt->execute(['project' => $projectId]); return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN)); } /** - * Assign positions 0..n-1 to the given items in one transaction. + * Assign positions 0..n-1 to the given cards in one transaction. * - * @param int[] $orderedIds every item id in the list, exactly once - * @return TodoItemRow[] the list's items in their new order + * @param int[] $orderedIds every card id in the project, exactly once + * @return CardRow[] the project's cards in their new order */ - public function reorder(int $listId, array $orderedIds): array + public function reorder(int $projectId, array $orderedIds): array { $stmt = $this->pdo->prepare( - 'UPDATE todo_items SET position = :position, + 'UPDATE cards SET position = :position, updated_at = ' . "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')" . ' - WHERE id = :id AND list_id = :list' + WHERE id = :id AND project_id = :project' ); $this->pdo->beginTransaction(); try { foreach (array_values($orderedIds) as $position => $id) { - $stmt->execute(['position' => $position, 'id' => $id, 'list' => $listId]); + $stmt->execute(['position' => $position, 'id' => $id, 'project' => $projectId]); } $this->pdo->commit(); } catch (\Throwable $e) { @@ -146,31 +146,31 @@ final class TodoItemRepository throw $e; } - return $this->allForList($listId); + return $this->allForProject($projectId); } - private function nextPosition(int $listId): int + private function nextPosition(int $projectId): int { $stmt = $this->pdo->prepare( - 'SELECT COALESCE(MAX(position), -1) + 1 FROM todo_items WHERE list_id = :list' + 'SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE project_id = :project' ); - $stmt->execute(['list' => $listId]); + $stmt->execute(['project' => $projectId]); return (int) $stmt->fetchColumn(); } /** * @param array $row - * @return TodoItemRow + * @return CardRow */ private function cast(array $row): array { $row['id'] = (int) $row['id']; - $row['list_id'] = (int) $row['list_id']; + $row['project_id'] = (int) $row['project_id']; $row['complete'] = (bool) $row['complete']; $row['position'] = (int) $row['position']; - /** @var TodoItemRow $row */ + /** @var CardRow $row */ return $row; } } diff --git a/src/Repository/TodoListRepository.php b/src/Repository/ProjectRepository.php similarity index 56% rename from src/Repository/TodoListRepository.php rename to src/Repository/ProjectRepository.php index 5255185..60b3d28 100644 --- a/src/Repository/TodoListRepository.php +++ b/src/Repository/ProjectRepository.php @@ -7,20 +7,20 @@ namespace App\Repository; use PDO; /** - * Data access for the `todo_lists` table. + * Data access for the `projects` table. * - * @phpstan-type TodoListRow array{ + * @phpstan-type ProjectRow array{ * id: int, owner_id: int, title: string, description: string, - * item_count: int, completed_count: int, created_at: string, updated_at: string + * card_count: int, completed_count: int, created_at: string, updated_at: string * } */ -final class TodoListRepository +final class ProjectRepository { private const SELECT = <<<'SQL' - SELECT l.id, l.owner_id, l.title, l.description, l.created_at, l.updated_at, - (SELECT COUNT(*) FROM todo_items i WHERE i.list_id = l.id) AS item_count, - (SELECT COUNT(*) FROM todo_items i WHERE i.list_id = l.id AND i.complete = 1) AS completed_count - FROM todo_lists l + SELECT p.id, p.owner_id, p.title, p.description, p.created_at, p.updated_at, + (SELECT COUNT(*) FROM cards c WHERE c.project_id = p.id) AS card_count, + (SELECT COUNT(*) FROM cards c WHERE c.project_id = p.id AND c.complete = 1) AS completed_count + FROM projects p SQL; public function __construct(private readonly PDO $pdo) @@ -28,15 +28,15 @@ final class TodoListRepository } /** - * Every list owned by the user, always sorted alphabetically by title + * Every project owned by the user, always sorted alphabetically by title * (case-insensitive). There is deliberately no other ordering option. * - * @return TodoListRow[] + * @return ProjectRow[] */ public function allForOwner(int $ownerId): array { $stmt = $this->pdo->prepare( - self::SELECT . ' WHERE l.owner_id = :owner ORDER BY l.title COLLATE NOCASE ASC, l.id ASC' + self::SELECT . ' WHERE p.owner_id = :owner ORDER BY p.title COLLATE NOCASE ASC, p.id ASC' ); $stmt->execute(['owner' => $ownerId]); @@ -45,18 +45,18 @@ final class TodoListRepository public function countForOwner(int $ownerId): int { - $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM todo_lists WHERE owner_id = :owner'); + $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM projects WHERE owner_id = :owner'); $stmt->execute(['owner' => $ownerId]); return (int) $stmt->fetchColumn(); } /** - * @return TodoListRow|null + * @return ProjectRow|null */ public function findOwnedBy(int $id, int $ownerId): ?array { - $stmt = $this->pdo->prepare(self::SELECT . ' WHERE l.id = :id AND l.owner_id = :owner'); + $stmt = $this->pdo->prepare(self::SELECT . ' WHERE p.id = :id AND p.owner_id = :owner'); $stmt->execute(['id' => $id, 'owner' => $ownerId]); $row = $stmt->fetch(); @@ -65,12 +65,12 @@ final class TodoListRepository } /** - * @return TodoListRow + * @return ProjectRow */ public function create(int $ownerId, string $title, string $description): array { $stmt = $this->pdo->prepare( - 'INSERT INTO todo_lists (owner_id, title, description) VALUES (:owner, :title, :description)' + 'INSERT INTO projects (owner_id, title, description) VALUES (:owner, :title, :description)' ); $stmt->execute([ 'owner' => $ownerId, @@ -78,15 +78,15 @@ final class TodoListRepository 'description' => $description, ]); - /** @var TodoListRow $list */ - $list = $this->findOwnedBy((int) $this->pdo->lastInsertId(), $ownerId); + /** @var ProjectRow $project */ + $project = $this->findOwnedBy((int) $this->pdo->lastInsertId(), $ownerId); - return $list; + return $project; } /** * @param array{title?: string, description?: string} $fields - * @return TodoListRow + * @return ProjectRow */ public function update(int $id, int $ownerId, array $fields): array { @@ -100,24 +100,24 @@ final class TodoListRepository } } - $stmt = $this->pdo->prepare('UPDATE todo_lists SET ' . implode(', ', $sets) . ' WHERE id = :id'); + $stmt = $this->pdo->prepare('UPDATE projects SET ' . implode(', ', $sets) . ' WHERE id = :id'); $stmt->execute($params); - /** @var TodoListRow $list */ - $list = $this->findOwnedBy($id, $ownerId); + /** @var ProjectRow $project */ + $project = $this->findOwnedBy($id, $ownerId); - return $list; + return $project; } public function delete(int $id): void { - $this->pdo->prepare('DELETE FROM todo_lists WHERE id = :id')->execute(['id' => $id]); + $this->pdo->prepare('DELETE FROM projects WHERE id = :id')->execute(['id' => $id]); } - /** Bump updated_at, e.g. when the list's items change. */ + /** Bump updated_at, e.g. when the project's cards change. */ public function touch(int $id): void { - $this->pdo->prepare('UPDATE todo_lists SET updated_at = ' . $this->nowExpr() . ' WHERE id = :id') + $this->pdo->prepare('UPDATE projects SET updated_at = ' . $this->nowExpr() . ' WHERE id = :id') ->execute(['id' => $id]); } @@ -128,16 +128,16 @@ final class TodoListRepository /** * @param array $row - * @return TodoListRow + * @return ProjectRow */ private function cast(array $row): array { $row['id'] = (int) $row['id']; $row['owner_id'] = (int) $row['owner_id']; - $row['item_count'] = (int) $row['item_count']; + $row['card_count'] = (int) $row['card_count']; $row['completed_count'] = (int) $row['completed_count']; - /** @var TodoListRow $row */ + /** @var ProjectRow $row */ return $row; } } diff --git a/src/Support/Config.php b/src/Support/Config.php index 26d5bb5..a61b8f8 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -51,7 +51,7 @@ final class Config $mail = new MailConfig( transport: strtolower(self::env('MAIL_TRANSPORT', 'mail')), fromAddress: self::env('MAIL_FROM', 'no-reply@todo.test'), - fromName: self::env('MAIL_FROM_NAME', 'Todo List'), + fromName: self::env('MAIL_FROM_NAME', 'Projects'), logPath: $mailLogPath, smtpHost: self::env('MAIL_SMTP_HOST'), smtpPort: (int) (self::env('MAIL_SMTP_PORT') ?? '587'), diff --git a/src/bootstrap.php b/src/bootstrap.php index 2333206..9ca6951 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -6,17 +6,17 @@ use App\Auth\AuthMiddleware; use App\Auth\JwtService; use App\Auth\SessionPayload; use App\Http\Controllers\AuthController; +use App\Http\Controllers\CardController; use App\Http\Controllers\EmailVerificationController; -use App\Http\Controllers\TodoItemController; -use App\Http\Controllers\TodoListController; +use App\Http\Controllers\ProjectController; use App\Http\JsonErrorHandler; use App\Mail\EmailVerifier; use App\Mail\LogMailer; use App\Mail\Mailer; use App\Mail\PhpMailerMailer; +use App\Repository\CardRepository; use App\Repository\EmailVerificationRepository; -use App\Repository\TodoItemRepository; -use App\Repository\TodoListRepository; +use App\Repository\ProjectRepository; use App\Repository\UserRepository; use App\Support\Config; use App\Support\Database; @@ -41,8 +41,8 @@ $errorMiddleware->setDefaultErrorHandler( // --- Wiring ----------------------------------------------------------------- $users = new UserRepository($database->pdo()); -$todoLists = new TodoListRepository($database->pdo()); -$todoItems = new TodoItemRepository($database->pdo()); +$projects = new ProjectRepository($database->pdo()); +$cards = new CardRepository($database->pdo()); $verificationTokens = new EmailVerificationRepository($database->pdo()); $jwt = new JwtService($config->jwtSecret, $config->jwtTtl); $session = new SessionPayload($jwt, $verificationTokens); @@ -55,8 +55,8 @@ $verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->app $authController = new AuthController($users, $session, $verifier); $emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session); -$listController = new TodoListController($todoLists); -$itemController = new TodoItemController($todoLists, $todoItems); +$projectController = new ProjectController($projects); +$cardController = new CardController($projects, $cards); $authMiddleware = new AuthMiddleware($jwt, $users); // --- Routes --------------------------------------------------------------- @@ -64,8 +64,8 @@ $authMiddleware = new AuthMiddleware($jwt, $users); $app->group('/api', function (RouteCollectorProxy $group) use ( $authController, $emailController, - $listController, - $itemController, + $projectController, + $cardController, $authMiddleware, ) { $group->get('/health', function (Request $request, Response $response): Response { @@ -82,19 +82,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('/lists', function (RouteCollectorProxy $lists) use ($listController, $itemController) { - $lists->get('', [$listController, 'index']); - $lists->post('', [$listController, 'store']); - $lists->get('/{listId:[0-9]+}', [$listController, 'show']); - $lists->patch('/{listId:[0-9]+}', [$listController, 'update']); - $lists->delete('/{listId:[0-9]+}', [$listController, 'destroy']); + $group->group('/projects', function (RouteCollectorProxy $projects) use ($projectController, $cardController) { + $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']); - $lists->get('/{listId:[0-9]+}/items', [$itemController, 'index']); - $lists->post('/{listId:[0-9]+}/items', [$itemController, 'store']); - $lists->put('/{listId:[0-9]+}/items/order', [$itemController, 'reorder']); - $lists->get('/{listId:[0-9]+}/items/{itemId:[0-9]+}', [$itemController, 'show']); - $lists->patch('/{listId:[0-9]+}/items/{itemId:[0-9]+}', [$itemController, 'update']); - $lists->delete('/{listId:[0-9]+}/items/{itemId:[0-9]+}', [$itemController, 'destroy']); + $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']); + $projects->get('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'show']); + $projects->patch('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'update']); + $projects->delete('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'destroy']); })->add($authMiddleware); }); diff --git a/tests/ProjectTest.php b/tests/ProjectTest.php new file mode 100644 index 0000000..3b5014f --- /dev/null +++ b/tests/ProjectTest.php @@ -0,0 +1,245 @@ +request('GET', '/api/projects')->getStatusCode()); + } + + public function test_create_and_list_projects(): void + { + $auth = $this->authHeader(); + + $created = $this->request('POST', '/api/projects', [ + 'title' => ' Groceries ', + 'description' => 'Weekly shop', + ], $auth); + + self::assertSame(201, $created->getStatusCode()); + $project = $this->decode($created)['project']; + self::assertSame('Groceries', $project['title']); + self::assertSame('Weekly shop', $project['description']); + self::assertSame(0, $project['card_count']); + + $index = $this->decode($this->request('GET', '/api/projects', null, $auth)); + self::assertCount(1, $index['projects']); + self::assertSame($project['id'], $index['projects'][0]['id']); + } + + public function test_projects_come_back_alphabetically(): void + { + $auth = $this->authHeader(); + + foreach (['Banana', 'apple', 'Cherry'] as $title) { + $this->request('POST', '/api/projects', ['title' => $title], $auth); + } + + $titles = array_column($this->decode($this->request('GET', '/api/projects', null, $auth))['projects'], 'title'); + self::assertSame(['apple', 'Banana', 'Cherry'], $titles); + } + + public function test_an_owner_cannot_exceed_100_projects(): void + { + $auth = $this->authHeader(); + + for ($i = 1; $i <= 100; $i++) { + $response = $this->request('POST', '/api/projects', ['title' => "Project {$i}"], $auth); + self::assertSame(201, $response->getStatusCode(), "project {$i} should be created"); + } + + $overflow = $this->request('POST', '/api/projects', ['title' => 'One too many'], $auth); + self::assertSame(409, $overflow->getStatusCode()); + self::assertStringContainsString('100', $this->decode($overflow)['error']['message']); + + // The cap is per owner, so a different user is unaffected. + $other = $this->authHeader('roomy@example.com'); + self::assertSame(201, $this->request('POST', '/api/projects', ['title' => 'Fine'], $other)->getStatusCode()); + } + + public function test_project_creation_validates_title(): void + { + $response = $this->request('POST', '/api/projects', ['description' => 'no title'], $this->authHeader()); + + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('title', $this->decode($response)['error']['details']); + } + + public function test_a_project_is_only_visible_to_its_owner(): void + { + $owner = $this->authHeader('owner@example.com'); + $other = $this->authHeader('other@example.com'); + + $projectId = $this->decode( + $this->request('POST', '/api/projects', ['title' => 'Private'], $owner), + )['project']['id']; + + self::assertSame(200, $this->request('GET', "/api/projects/{$projectId}", null, $owner)->getStatusCode()); + self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}", null, $other)->getStatusCode()); + self::assertSame(404, $this->request('PATCH', "/api/projects/{$projectId}", ['title' => 'x'], $other)->getStatusCode()); + self::assertSame(404, $this->request('DELETE', "/api/projects/{$projectId}", null, $other)->getStatusCode()); + } + + public function test_update_and_delete_project(): void + { + $auth = $this->authHeader(); + $projectId = $this->decode( + $this->request('POST', '/api/projects', ['title' => 'Draft'], $auth), + )['project']['id']; + + $updated = $this->decode( + $this->request('PATCH', "/api/projects/{$projectId}", ['title' => 'Final'], $auth), + )['project']; + self::assertSame('Final', $updated['title']); + + self::assertSame(204, $this->request('DELETE', "/api/projects/{$projectId}", null, $auth)->getStatusCode()); + self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}", null, $auth)->getStatusCode()); + } + + public function test_cards_append_in_order_and_track_completion(): void + { + $auth = $this->authHeader(); + $projectId = $this->decode( + $this->request('POST', '/api/projects', ['title' => 'Chores'], $auth), + )['project']['id']; + + foreach (['Wash up', 'Hoover', 'Bins'] as $text) { + $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth); + } + + $cards = $this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards']; + self::assertSame(['Wash up', 'Hoover', 'Bins'], array_column($cards, 'text')); + self::assertSame([0, 1, 2], array_column($cards, 'position')); + self::assertFalse($cards[0]['complete']); + + $done = $this->decode( + $this->request('PATCH', "/api/projects/{$projectId}/cards/{$cards[0]['id']}", ['complete' => true], $auth), + )['card']; + self::assertTrue($done['complete']); + + $project = $this->decode($this->request('GET', "/api/projects/{$projectId}", null, $auth))['project']; + self::assertSame(3, $project['card_count']); + self::assertSame(1, $project['completed_count']); + } + + public function test_card_creation_accepts_explicit_position_and_validates_text(): void + { + $auth = $this->authHeader(); + $projectId = $this->decode( + $this->request('POST', '/api/projects', ['title' => 'P'], $auth), + )['project']['id']; + + $card = $this->decode( + $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'Pinned', 'position' => 5], $auth), + )['card']; + self::assertSame(5, $card['position']); + + $bad = $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => ' '], $auth); + self::assertSame(422, $bad->getStatusCode()); + self::assertArrayHasKey('text', $this->decode($bad)['error']['details']); + } + + public function test_cards_can_be_reordered_in_bulk(): void + { + $auth = $this->authHeader(); + $projectId = $this->decode( + $this->request('POST', '/api/projects', ['title' => 'Reorder'], $auth), + )['project']['id']; + + $ids = []; + foreach (['A', 'B', 'C'] as $text) { + $ids[$text] = $this->decode( + $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth), + )['card']['id']; + } + + $response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ + 'card_ids' => [$ids['C'], $ids['A'], $ids['B']], + ], $auth); + + self::assertSame(200, $response->getStatusCode()); + $cards = $this->decode($response)['cards']; + self::assertSame(['C', 'A', 'B'], array_column($cards, 'text')); + self::assertSame([0, 1, 2], array_column($cards, 'position')); + + // Order persists on a fresh read. + $reread = $this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards']; + self::assertSame(['C', 'A', 'B'], array_column($reread, 'text')); + } + + public function test_reorder_rejects_an_incomplete_id_set(): void + { + $auth = $this->authHeader(); + $projectId = $this->decode( + $this->request('POST', '/api/projects', ['title' => 'Reorder'], $auth), + )['project']['id']; + $first = $this->decode( + $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'one'], $auth), + )['card']['id']; + $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'two'], $auth); + + $response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ + 'card_ids' => [$first], + ], $auth); + + self::assertSame(422, $response->getStatusCode()); + } + + public function test_reorder_is_scoped_to_the_owner(): void + { + $owner = $this->authHeader('ro@example.com'); + $other = $this->authHeader('rx@example.com'); + $projectId = $this->decode( + $this->request('POST', '/api/projects', ['title' => 'Mine'], $owner), + )['project']['id']; + $cardId = $this->decode( + $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $owner), + )['card']['id']; + + self::assertSame(404, $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ + 'card_ids' => [$cardId], + ], $other)->getStatusCode()); + } + + public function test_deleting_a_project_cascades_to_its_cards(): void + { + $auth = $this->authHeader(); + $projectId = $this->decode( + $this->request('POST', '/api/projects', ['title' => 'Temp'], $auth), + )['project']['id']; + $cardId = $this->decode( + $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth), + )['card']['id']; + + $this->request('DELETE', "/api/projects/{$projectId}", null, $auth); + + // The parent project is gone, so the card route 404s on the project check. + self::assertSame( + 404, + $this->request('GET', "/api/projects/{$projectId}/cards/{$cardId}", null, $auth)->getStatusCode(), + ); + } + + public function test_cards_under_another_users_project_are_not_reachable(): void + { + $owner = $this->authHeader('owner2@example.com'); + $other = $this->authHeader('other2@example.com'); + + $projectId = $this->decode( + $this->request('POST', '/api/projects', ['title' => 'Mine'], $owner), + )['project']['id']; + + self::assertSame( + 404, + $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'sneaky'], $other)->getStatusCode(), + ); + self::assertSame( + 404, + $this->request('GET', "/api/projects/{$projectId}/cards", null, $other)->getStatusCode(), + ); + } +} diff --git a/tests/TodoTest.php b/tests/TodoTest.php deleted file mode 100644 index fd5e872..0000000 --- a/tests/TodoTest.php +++ /dev/null @@ -1,245 +0,0 @@ -request('GET', '/api/lists')->getStatusCode()); - } - - public function test_create_and_list_lists(): void - { - $auth = $this->authHeader(); - - $created = $this->request('POST', '/api/lists', [ - 'title' => ' Groceries ', - 'description' => 'Weekly shop', - ], $auth); - - self::assertSame(201, $created->getStatusCode()); - $list = $this->decode($created)['list']; - self::assertSame('Groceries', $list['title']); - self::assertSame('Weekly shop', $list['description']); - self::assertSame(0, $list['item_count']); - - $index = $this->decode($this->request('GET', '/api/lists', null, $auth)); - self::assertCount(1, $index['lists']); - self::assertSame($list['id'], $index['lists'][0]['id']); - } - - public function test_lists_come_back_alphabetically(): void - { - $auth = $this->authHeader(); - - foreach (['Banana', 'apple', 'Cherry'] as $title) { - $this->request('POST', '/api/lists', ['title' => $title], $auth); - } - - $titles = array_column($this->decode($this->request('GET', '/api/lists', null, $auth))['lists'], 'title'); - self::assertSame(['apple', 'Banana', 'Cherry'], $titles); - } - - public function test_an_owner_cannot_exceed_100_lists(): void - { - $auth = $this->authHeader(); - - for ($i = 1; $i <= 100; $i++) { - $response = $this->request('POST', '/api/lists', ['title' => "List {$i}"], $auth); - self::assertSame(201, $response->getStatusCode(), "list {$i} should be created"); - } - - $overflow = $this->request('POST', '/api/lists', ['title' => 'One too many'], $auth); - self::assertSame(409, $overflow->getStatusCode()); - self::assertStringContainsString('100', $this->decode($overflow)['error']['message']); - - // The cap is per owner, so a different user is unaffected. - $other = $this->authHeader('roomy@example.com'); - self::assertSame(201, $this->request('POST', '/api/lists', ['title' => 'Fine'], $other)->getStatusCode()); - } - - public function test_list_creation_validates_title(): void - { - $response = $this->request('POST', '/api/lists', ['description' => 'no title'], $this->authHeader()); - - self::assertSame(422, $response->getStatusCode()); - self::assertArrayHasKey('title', $this->decode($response)['error']['details']); - } - - public function test_a_list_is_only_visible_to_its_owner(): void - { - $owner = $this->authHeader('owner@example.com'); - $other = $this->authHeader('other@example.com'); - - $listId = $this->decode( - $this->request('POST', '/api/lists', ['title' => 'Private'], $owner), - )['list']['id']; - - self::assertSame(200, $this->request('GET', "/api/lists/{$listId}", null, $owner)->getStatusCode()); - self::assertSame(404, $this->request('GET', "/api/lists/{$listId}", null, $other)->getStatusCode()); - self::assertSame(404, $this->request('PATCH', "/api/lists/{$listId}", ['title' => 'x'], $other)->getStatusCode()); - self::assertSame(404, $this->request('DELETE', "/api/lists/{$listId}", null, $other)->getStatusCode()); - } - - public function test_update_and_delete_list(): void - { - $auth = $this->authHeader(); - $listId = $this->decode( - $this->request('POST', '/api/lists', ['title' => 'Draft'], $auth), - )['list']['id']; - - $updated = $this->decode( - $this->request('PATCH', "/api/lists/{$listId}", ['title' => 'Final'], $auth), - )['list']; - self::assertSame('Final', $updated['title']); - - self::assertSame(204, $this->request('DELETE', "/api/lists/{$listId}", null, $auth)->getStatusCode()); - self::assertSame(404, $this->request('GET', "/api/lists/{$listId}", null, $auth)->getStatusCode()); - } - - public function test_items_append_in_order_and_track_completion(): void - { - $auth = $this->authHeader(); - $listId = $this->decode( - $this->request('POST', '/api/lists', ['title' => 'Chores'], $auth), - )['list']['id']; - - foreach (['Wash up', 'Hoover', 'Bins'] as $text) { - $this->request('POST', "/api/lists/{$listId}/items", ['text' => $text], $auth); - } - - $items = $this->decode($this->request('GET', "/api/lists/{$listId}/items", null, $auth))['items']; - self::assertSame(['Wash up', 'Hoover', 'Bins'], array_column($items, 'text')); - self::assertSame([0, 1, 2], array_column($items, 'position')); - self::assertFalse($items[0]['complete']); - - $done = $this->decode( - $this->request('PATCH', "/api/lists/{$listId}/items/{$items[0]['id']}", ['complete' => true], $auth), - )['item']; - self::assertTrue($done['complete']); - - $list = $this->decode($this->request('GET', "/api/lists/{$listId}", null, $auth))['list']; - self::assertSame(3, $list['item_count']); - self::assertSame(1, $list['completed_count']); - } - - public function test_item_creation_accepts_explicit_position_and_validates_text(): void - { - $auth = $this->authHeader(); - $listId = $this->decode( - $this->request('POST', '/api/lists', ['title' => 'L'], $auth), - )['list']['id']; - - $item = $this->decode( - $this->request('POST', "/api/lists/{$listId}/items", ['text' => 'Pinned', 'position' => 5], $auth), - )['item']; - self::assertSame(5, $item['position']); - - $bad = $this->request('POST', "/api/lists/{$listId}/items", ['text' => ' '], $auth); - self::assertSame(422, $bad->getStatusCode()); - self::assertArrayHasKey('text', $this->decode($bad)['error']['details']); - } - - public function test_items_can_be_reordered_in_bulk(): void - { - $auth = $this->authHeader(); - $listId = $this->decode( - $this->request('POST', '/api/lists', ['title' => 'Reorder'], $auth), - )['list']['id']; - - $ids = []; - foreach (['A', 'B', 'C'] as $text) { - $ids[$text] = $this->decode( - $this->request('POST', "/api/lists/{$listId}/items", ['text' => $text], $auth), - )['item']['id']; - } - - $response = $this->request('PUT', "/api/lists/{$listId}/items/order", [ - 'item_ids' => [$ids['C'], $ids['A'], $ids['B']], - ], $auth); - - self::assertSame(200, $response->getStatusCode()); - $items = $this->decode($response)['items']; - self::assertSame(['C', 'A', 'B'], array_column($items, 'text')); - self::assertSame([0, 1, 2], array_column($items, 'position')); - - // Order persists on a fresh read. - $reread = $this->decode($this->request('GET', "/api/lists/{$listId}/items", null, $auth))['items']; - self::assertSame(['C', 'A', 'B'], array_column($reread, 'text')); - } - - public function test_reorder_rejects_an_incomplete_id_set(): void - { - $auth = $this->authHeader(); - $listId = $this->decode( - $this->request('POST', '/api/lists', ['title' => 'Reorder'], $auth), - )['list']['id']; - $first = $this->decode( - $this->request('POST', "/api/lists/{$listId}/items", ['text' => 'one'], $auth), - )['item']['id']; - $this->request('POST', "/api/lists/{$listId}/items", ['text' => 'two'], $auth); - - $response = $this->request('PUT', "/api/lists/{$listId}/items/order", [ - 'item_ids' => [$first], - ], $auth); - - self::assertSame(422, $response->getStatusCode()); - } - - public function test_reorder_is_scoped_to_the_owner(): void - { - $owner = $this->authHeader('ro@example.com'); - $other = $this->authHeader('rx@example.com'); - $listId = $this->decode( - $this->request('POST', '/api/lists', ['title' => 'Mine'], $owner), - )['list']['id']; - $itemId = $this->decode( - $this->request('POST', "/api/lists/{$listId}/items", ['text' => 'x'], $owner), - )['item']['id']; - - self::assertSame(404, $this->request('PUT', "/api/lists/{$listId}/items/order", [ - 'item_ids' => [$itemId], - ], $other)->getStatusCode()); - } - - public function test_deleting_a_list_cascades_to_its_items(): void - { - $auth = $this->authHeader(); - $listId = $this->decode( - $this->request('POST', '/api/lists', ['title' => 'Temp'], $auth), - )['list']['id']; - $itemId = $this->decode( - $this->request('POST', "/api/lists/{$listId}/items", ['text' => 'x'], $auth), - )['item']['id']; - - $this->request('DELETE', "/api/lists/{$listId}", null, $auth); - - // The parent list is gone, so the item route 404s on the list check. - self::assertSame( - 404, - $this->request('GET', "/api/lists/{$listId}/items/{$itemId}", null, $auth)->getStatusCode(), - ); - } - - public function test_items_under_another_users_list_are_not_reachable(): void - { - $owner = $this->authHeader('owner2@example.com'); - $other = $this->authHeader('other2@example.com'); - - $listId = $this->decode( - $this->request('POST', '/api/lists', ['title' => 'Mine'], $owner), - )['list']['id']; - - self::assertSame( - 404, - $this->request('POST', "/api/lists/{$listId}/items", ['text' => 'sneaky'], $other)->getStatusCode(), - ); - self::assertSame( - 404, - $this->request('GET', "/api/lists/{$listId}/items", null, $other)->getStatusCode(), - ); - } -} diff --git a/web/README.md b/web/README.md index 86334d4..856093a 100644 --- a/web/README.md +++ b/web/README.md @@ -1,4 +1,4 @@ -# Todo List — web +# Project Manager — web Vue 3 + TypeScript + Vite PWA. Talks to the REST API in the parent directory. @@ -38,25 +38,25 @@ npm run preview 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/lists.ts Pinia store: the user's lists (fetch + create) -src/stores/items.ts Pinia store: one list's items (CRUD + drag reorder) +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/lib/api.ts fetch wrapper, bearer token, typed ApiError -src/components/TodoItemRow.vue checkbox + editable text + delete, one item -src/views/ HomeView, ListView, LoginView, RegisterView, +src/components/CardRow.vue checkbox + editable text + delete, one card +src/views/ HomeView, ProjectView, LoginView, RegisterView, ProfileView, VerifyEmailView ``` -## List detail +## Project detail -`/lists/:id` shows one list. The title and description are inline-editable -(saved on blur via `PATCH /api/lists/:id`; the description shows an "Add a +`/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 list** action that opens a confirmation modal; confirming calls -`DELETE /api/lists/:id` and returns to the all-lists view. +**Delete project** action that opens a confirmation modal; confirming calls +`DELETE /api/projects/:id` and returns to the all-projects view. -Each item row is a checkbox, an inline-editable text field (saved on blur), a +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/lists/:id/items/order`, and the +whole new order is persisted via `PUT /api/projects/:id/cards/order`, and the server response replaces local state. ## Auth flow @@ -81,7 +81,7 @@ server response replaces local state. - `/verify-email?token=…` is the target for every magic link (verification, passwordless login, email change). `VerifyEmailView` POSTs the token to the API, which returns a session — so opening any link both verifies the address - and signs the user in — then redirects to the lists. + and signs the user in — then redirects to the projects. - `/profile` (`ProfileView`) shows the address and verification status. When unverified it offers a **Resend** button; the API throttles to once a minute, and the button shows a live countdown (driven by `retry_after`, and by `429` diff --git a/web/index.html b/web/index.html index 2706ba9..c687296 100644 --- a/web/index.html +++ b/web/index.html @@ -6,8 +6,8 @@ - - Todo List + + Projects
diff --git a/web/src/App.vue b/web/src/App.vue index d71eef2..dd0e1d5 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,18 +1,18 @@ @@ -20,7 +20,7 @@ async function onLogout() {