Files
project-manager/README.md
T

438 lines
16 KiB
Markdown
Raw Normal View History

# PHP Project Manager
2026-09-03 17:35:10 +01:00
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**.
2026-09-03 17:35:10 +01:00
## 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 |
2026-09-04 10:29:13 +01:00
| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done |
| 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done |
| 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done |
Registration signs the user in immediately and emails a magic link that verifies
the address; `user.email_verified` stays `false` until the link is opened. See
[Email verification](#email-verification--profile).
2026-09-03 17:35:10 +01:00
2026-09-03 17:45:35 +01:00
## Run with Docker
2026-09-03 17:35:10 +01:00
2026-09-03 17:45:35 +01:00
The only requirement is Docker with the Compose plugin.
2026-09-03 17:35:10 +01:00
2026-09-03 17:45:35 +01:00
```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.
2026-09-03 17:45:35 +01:00
- 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)).
2026-09-03 17:45:35 +01:00
- 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`.
2026-09-03 17:45:35 +01:00
- `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:
2026-09-03 17:35:10 +01:00
```bash
sudo dnf install php-cli php-pdo php-mbstring composer
```
2026-09-03 17:45:35 +01:00
### Setup
2026-09-03 17:35:10 +01:00
```bash
composer install
cp .env.example .env # optional; sane defaults are used without it
composer migrate # creates storage/database.sqlite and its tables
```
2026-09-03 17:45:35 +01:00
### Running
2026-09-03 17:35:10 +01:00
```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.
2026-09-03 17:35:10 +01:00
## 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`; `/register` creates an
account and signs in immediately. See [web/README.md](web/README.md).
2026-09-03 17:35:10 +01:00
## 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".
2026-09-03 17:35:10 +01:00
## 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"
},
2026-09-03 17:35:10 +01:00
"token": "<jwt>",
"expires_at": "2026-09-04T12:00:00+00:00"
}
```
New accounts are created with an unverified email (`email_verified: false`).
2026-09-03 17:35:10 +01:00
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).
2026-09-04 10:29:13 +01:00
### `POST /api/auth/magic-link`
Request: `{ "email": "ada@example.com" }`.
Emails a one-time login link (`<APP_URL>/verify-email?token=…`, 15-minute
expiry). Always returns `202` with the same message regardless of whether the
address is registered, so accounts can't be enumerated; a link is only actually
sent when the account exists and hasn't been emailed in the last 60 seconds.
Opening the link (`POST /api/auth/verify-email`) signs the user in and verifies
the address if it wasn't already. `422` if the address is malformed.
2026-09-03 17:35:10 +01:00
### `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"
}
}
2026-09-03 17:35:10 +01:00
```
`pending_email` is the address a still-valid email-change link is waiting on, or
`null`.
2026-09-03 17:35:10 +01:00
`401` if the header is missing, malformed, or the token is invalid/expired.
### Email verification & profile
2026-09-04 10:29:13 +01:00
Magic links — `<APP_URL>/verify-email?token=<opaque>` — expire **15 minutes**
after they are sent; only a hash of the token is stored. The same link/route
backs three things: verifying a new account, [passwordless
login](#post-apiauthmagic-link), and confirming an email change.
| 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 |
2026-09-04 10:29:13 +01:00
| `POST` | `/api/auth/magic-link` | — | email a passwordless login link (see above) |
| `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.
### Projects
2026-09-03 18:30:52 +01:00
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
2026-09-03 18:30:52 +01:00
`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`) |
2026-09-03 18:30:52 +01:00
`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`.
2026-09-03 18:30:52 +01:00
Create/update body: `title` (required on create, 1255 chars), `description`
(optional, ≤ 2000 chars, defaults to `""`). `PATCH` needs at least one field.
Project representation:
2026-09-03 18:30:52 +01:00
```json
{
"project": {
2026-09-03 18:30:52 +01:00
"id": 1,
"title": "Website relaunch",
"description": "Q3",
2026-09-03 18:30:52 +01:00
"owner_id": 1,
"card_count": 3,
2026-09-03 18:30:52 +01:00
"completed_count": 1,
"created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z"
}
}
```
`GET /api/projects` returns `{ "projects": [ … ] }`.
2026-09-03 18:30:52 +01:00
Creating a project also seeds it with three **statuses** — "To do", "Doing",
"Done" (see [Statuses](#statuses)).
### Cards
2026-09-03 18:30:52 +01:00
Scoped to a project; the parent project's ownership is checked first
(`404` otherwise).
2026-09-03 18:30:52 +01:00
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/projects/{id}/cards` | every card, grouped by column (inbox first) then `position` |
| `POST` | `/api/projects/{id}/cards` | add a card |
| `PUT` | `/api/projects/{id}/cards/order` | set the order/contents of one status column |
| `GET` | `/api/projects/{id}/cards/{cardId}` | one card |
| `PATCH` | `/api/projects/{id}/cards/{cardId}` | update `text`, `complete`, and/or `status_id` |
| `DELETE` | `/api/projects/{id}/cards/{cardId}` | delete the card (`204`) |
2026-09-03 18:30:52 +01:00
Create body: `text` (required, 11000 chars), `complete` (optional bool,
default `false`). `PATCH` needs at least one field.
2026-09-03 18:30:52 +01:00
**Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards
that share a `(project_id, status_id)`. The inbox (`status_id IS NULL`) is its
own column. New cards go to the end of the inbox. There is no project-wide order.
A new card has **no** status (`status_id: null`) — it sits in the project
"inbox" until the user gives it one. Two ways to move it:
- `PATCH …/cards/{cardId}` with `status_id` (a status id in this project, or
`null` for the inbox) — appends the card to the end of the destination column
and re-packs the one it left. `422` for an unknown or foreign status.
- `PUT …/cards/order` with `{ "status_id": <id|null>, "card_ids": [3, 1, 2] }`
makes those cards the exact contents of that column, in that order (positions
rewritten to `0..n-1`). Any card dragged in from another column is re-parented
and its old column re-packed, all in one transaction. `card_ids` must be
distinct cards of this project and must include every card already in the
target column (`422` otherwise). Returns `{ "cards": [ … ] }` for the whole
project. This is what the kanban board calls on every drop.
A status row that is deleted clears itself from its cards rather than deleting
them.
Card representation:
2026-09-03 18:30:52 +01:00
```json
{
"card": {
2026-09-03 18:30:52 +01:00
"id": 10,
"project_id": 1,
"text": "Design homepage",
2026-09-03 18:30:52 +01:00
"complete": false,
"position": 0,
"status_id": null,
"status": null,
2026-09-03 18:30:52 +01:00
"created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z"
}
}
```
`status` is the embedded `{ id, name }` of the linked status, or `null` when the
card has none. `GET …/cards` returns `{ "cards": [ … ] }`.
### Statuses
Every project has an ordered set of card statuses, created with the project:
"To do", "Doing", "Done". They are project-specific — each project owns its own
rows. There is no create/update/delete for the statuses themselves yet; a card
is moved between them (or to the inbox) via `PATCH …/cards/{cardId}`.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/projects/{id}/statuses` | the project's statuses, ordered by `position` |
```json
{
"statuses": [
{ "id": 1, "project_id": 1, "name": "To do", "position": 0 },
{ "id": 2, "project_id": 1, "name": "Doing", "position": 1 },
{ "id": 3, "project_id": 1, "name": "Done", "position": 2 }
]
}
```
Requires `Authorization: Bearer <jwt>`; a project that is missing or not owned by
the caller responds `404`.
2026-09-03 18:30:52 +01:00
2026-09-03 17:35:10 +01:00
### 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"
2026-09-03 18:30:52 +01:00
PROJECT=$(curl -s -X POST $BASE/api/projects \
2026-09-03 18:30:52 +01:00
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Website relaunch","description":"Q3"}' \
2026-09-03 18:30:52 +01:00
| 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 \
2026-09-03 18:30:52 +01:00
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"Design homepage"}'
2026-09-03 18:30:52 +01:00
curl -s $BASE/api/projects/$PROJECT/cards -H "Authorization: Bearer $TOKEN"
2026-09-03 17:35:10 +01:00
```
## 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
2026-09-03 17:35:10 +01:00
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)
2026-09-03 18:30:52 +01:00
src/Support/Validator.php Request-body validation helper
2026-09-03 17:35:10 +01:00
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
2026-09-03 17:45:35 +01:00
docker/ Apache vhost + container entrypoint
web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host)
2026-09-03 17:35:10 +01:00
```
## Provenance
This project was generated with [Claude Code](https://claude.com/claude-code).