feat: private checklists for zjazd preparation

Per-user checklists with items that may link a course, reading, deadline,
material or own presentation deck. An item with a reading shares the
reading's read mark. Student API routes with token twins, MCP tools,
migrations, tests and README.
This commit is contained in:
Jakub Zych
2026-10-30 12:41:04 +01:00
parent 643f8cbf7c
commit d7cd5baedf
15 changed files with 1262 additions and 21 deletions

View File

@@ -16,11 +16,11 @@ Timestamps are stored in UTC (`TIMESTAMPTZ`) and written in RFC 3339 in Europe/W
## Features
- Models: `Course` (przedmiot, one row per course and form), `Meeting` (zjazd), `ClassSession` (zajęcia), `Deadline` (terminarz), `Reading` (lektura), `ReadingProgress`, `Material` with file attachments (`system_files`, field `files`) and `Tag`, many-to-many through `jz_bm_material_tag`, and `CalendarFeed` (one per user).
- Models: `Course` (przedmiot, one row per course and form), `Meeting` (zjazd), `ClassSession` (zajęcia), `Deadline` (terminarz), `Reading` (lektura), `ReadingProgress`, `Material` with file attachments (`system_files`, field `files`) and `Tag`, many-to-many through `jz_bm_material_tag`, `CalendarFeed` (one per user), and `Checklist` with `ChecklistItem` (each user's own checklists, e.g. the preparation for a zjazd).
- A subscribable calendar feed (`/_bm/calendar/<token>.ics`) of the class sessions, meetings and deadlines, see [Calendar feed](#calendar-feed).
- Courses that share an `elective_group` are alternatives ("do wyboru"), such as the specialisation courses.
- A material without `published_at` is a draft; a material whose `published_at` lies in the future is not shown yet.
- Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress and ownership rows; deleting a user deletes the user's calendar feed.
- Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress and ownership rows; deleting a user deletes the user's calendar feed and checklists; deleting a checklist deletes its items; deleting a meeting, course, reading, deadline or material unlinks the checklists and items that point at it.
- Admin screens for every model in a "Studia BM" navigation group, guarded by three permissions.
- `bm:seed`: an idempotent upsert of meetings, courses, class sessions, deadlines and readings from a JSON file, all or nothing, with `--prune` to delete what the file no longer lists and `--dry-run` to preview.
@@ -47,6 +47,16 @@ Every route except the calendar feed itself needs `Authorization: Bearer <golem1
| DELETE | `/_bm/api/v1/readings/{id}/owned` | | Clears the owned mark (idempotent) |
| GET | `/_bm/api/v1/materials` | `course`, `meeting`, `tag` (slug), `type`, `page`, `per_page` (1–100) | One page of published materials |
| GET | `/_bm/api/v1/materials/{id}` | | One published material with body, tags and files |
| GET | `/_bm/api/v1/checklists` | `meeting` (ID) | The caller's checklists with progress, without items |
| POST | `/_bm/api/v1/checklists` | | Creates a checklist for the caller; 201 |
| GET | `/_bm/api/v1/checklists/{id}` | | One of the caller's checklists with progress and items |
| PATCH | `/_bm/api/v1/checklists/{id}` | | Changes the sent fields |
| DELETE | `/_bm/api/v1/checklists/{id}` | | Deletes it with its items; 204 |
| POST | `/_bm/api/v1/checklists/{id}/items` | | Adds an item, last unless `sort_order` is sent; 201 |
| PATCH | `/_bm/api/v1/checklist-items/{id}` | | Changes the sent fields of an item |
| DELETE | `/_bm/api/v1/checklist-items/{id}` | | Deletes an item; 204 |
| PUT | `/_bm/api/v1/checklist-items/{id}/done` | | Marks the item done (idempotent) |
| DELETE | `/_bm/api/v1/checklist-items/{id}/done` | | Clears the done mark (idempotent) |
| GET | `/_bm/api/v1/calendar-feed` | | The caller's calendar feed, or `null` |
| PUT | `/_bm/api/v1/calendar-feed` | | Creates the feed when missing, applies the settings in the body |
| POST | `/_bm/api/v1/calendar-feed/rotate` | | A new token (the old URL stops working); 404 without a feed; `throttle:10,1` |
@@ -146,6 +156,20 @@ Marking a read reading again keeps the first `read_at`. Clearing answers `read_a
The owned routes record that the caller owns a copy of the reading, apart from whether it is read, and answer `{"data": {"reading_id": 7, "owned_at": "2026-10-26T12:00:00+01:00"}}` with the same rules: marking an owned reading again keeps the first `owned_at`, and clearing answers `owned_at: null`, also when the reading was not owned.
### Checklists
Checklists are private: every route reaches only the caller's own checklists and items, and another user's id answers 404 like a missing one. A checklist has `meeting_id` (optional zjazd), `title`, `description` and `sort_order`; it is answered with `meeting` (`id`, `number`, `starts_on`, `ends_on`) and `progress`:
```json
{"total": 4, "done": 1, "percent": 25, "minutes_total": 185, "minutes_done": 40, "minutes_left": 145}
```
An item has `kind` (`lektura`, `artykul`, `ksiazka`, `prezentacja`, `zadanie`, `inne`; default `zadanie`), `title`, `notes`, `url`, `due_at`, `estimate_minutes` (0–10000), `sort_order`, optional links `course_id`, `reading_id`, `deadline_id`, `material_id` (each must exist) and `presentation_slug` (must name one of the caller's decks in `golem15.presentations`, read by table name; the plugin is not required). `checklist_id` moves an item to another of the caller's checklists. It is answered with `done`, `done_at` and the linked rows (`course`, `reading`, `deadline`, `material` (published only), `presentation`), `null` when unset or gone.
**Readings share the mark.** An item with a `reading_id` has no done mark of its own: it is done exactly when the caller has read that reading. Marking it done inserts the `jz_bm_reading_progress` row and clearing it deletes the row, so `/readings` and every checklist with that reading agree. Deleting an item or a checklist leaves reading marks alone.
Bodies are JSON objects of columns checked like the management API: keys outside the writable columns, rule failures and unknown foreign keys answer 422 with errors per field.
### GET /materials, GET /materials/{id}
```json
@@ -251,12 +275,14 @@ The route is throttled per client IP (`throttle:jz.bm.upload-ip`, 10 a minute) a
`jz.bm` implements `pact.HasMCP`. With the framework's MCP endpoint (`pier`, the `summercms_mcp` build tag) its tools are offered at `/mcp` as `bm_<name>`, to OAuth sessions and personal tokens of `golem15.user` (guard `user`). Every tool binds a token-guarded route, every description is static text naming the scope it needs (and, for management tools, the permission code), and no tool has a `dry_run`. `jz.bm` still requires only `golem15.user`.
**Token twins of the student API.** `/_bm/api/v1/token/*` (`TokenPrefix`) mounts the student routes a second time, from the same `mountStudent` helper as the `jwt.auth` group, behind `user.api_token`, the per-user bucket `throttle:jz.bm.token` (240 a minute) and `locale.from-principal`, with `user.scope:read` on the reads and `user.scope:write` on the four reading marks. The handlers read `bouncer.User`, so a personal token or an MCP session reaches only its own user's data. The calendar feed and the chat have no twin. `TestTokenMirrorRoutes` fails when a student route is added to one surface only.
**Token twins of the student API.** `/_bm/api/v1/token/*` (`TokenPrefix`) mounts the student routes a second time, from the same `mountStudent` helper as the `jwt.auth` group, behind `user.api_token`, the per-user bucket `throttle:jz.bm.token` (240 a minute) and `locale.from-principal`, with `user.scope:read` on the reads and `user.scope:write` on the reading marks and the checklist writes. The handlers read `bouncer.User`, so a personal token or an MCP session reaches only its own user's data. The calendar feed and the chat have no twin. `TestTokenMirrorRoutes` fails when a student route is added to one surface only.
| Tool | Route | Scope |
|------|-------|-------|
| `bm_dashboard`, `bm_courses`, `bm_course`, `bm_schedule`, `bm_meetings`, `bm_deadlines`, `bm_readings`, `bm_materials`, `bm_material` | `GET /_bm/api/v1/token/…` | read |
| `bm_mark_reading_read`, `bm_unmark_reading_read`, `bm_mark_reading_owned`, `bm_unmark_reading_owned` | `PUT` / `DELETE /_bm/api/v1/token/readings/{id}/progress` and `/owned` | write |
| `bm_checklists`, `bm_checklist` | `GET /_bm/api/v1/token/checklists[/{id}]` | read |
| `bm_create_checklist`, `bm_update_checklist`, `bm_delete_checklist`, `bm_add_checklist_item`, `bm_update_checklist_item`, `bm_delete_checklist_item`, `bm_mark_checklist_item_done`, `bm_unmark_checklist_item_done` | `POST` / `PATCH` / `PUT` / `DELETE /_bm/api/v1/token/checklists…` and `/checklist-items…` | write |
**Management tools** bind the existing `/api/v1/bm` routes, whose `user.scope:*` and `user.permission:jz.bm.access_*` middleware filter the listing: a user whose groups lack the codes never sees them, and write tools need the write grant. Each input mirrors the model's `Fillable()` columns and `Rules()` (required columns are required on create, every update field is optional and nullable); enum values, lengths and date and time formats are in the field descriptions, and the routes still answer 422.