docs: document the calendar feed routes and jz.bm.public_url

This commit is contained in:
Jakub Zych
2026-10-02 17:38:48 +02:00
parent b39f627fe3
commit 57e4ff7cea

View File

@@ -8,7 +8,7 @@ import _ "git.golem15.com/jakub/sm-bm-plugin"
## Overview
`jz.bm` is a SummerCMS plugin. It keeps the programme's data in `jz_bm_*` tables, serves it to signed-in students as a read-only JSON API under `/_bm/api/v1`, and gives administrators admin screens to edit it. Students write one thing only: which readings they have read.
`jz.bm` is a SummerCMS plugin. It keeps the programme's data in `jz_bm_*` tables, serves it to signed-in students as a read-only JSON API under `/_bm/api/v1`, and gives administrators admin screens to edit it. Students write two things only: which readings they have read, and the settings of their calendar feed, a private ICS URL they subscribe to in Google Calendar or on a phone.
Students are the users of `golem15.user`: the API sits behind its `jwt.auth` guard, and reading progress points at its `users` table. Course data is loaded from a JSON file with `bm:seed` and maintained in the admin afterwards.
@@ -16,16 +16,17 @@ 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`.
- 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).
- 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 rows.
- 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 rows; deleting a user deletes the user's calendar feed.
- 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.
## API reference
Every route needs `Authorization: Bearer <golem15.user JWT>` (or the user plugin's token cookie) and answers 401 `{"error": true, "message": "..."}` without one. Responses are wrapped in `{"data": ...}`; `/materials` adds the `meta` block of `lagoon.Paginate`. A missing record, an unpublished material or a non-numeric `{id}` is a 404. Invalid query parameters are a 422 in Laravel's shape, with messages in the request locale:
Every route except the calendar feed itself needs `Authorization: Bearer <golem15.user JWT>` (or the user plugin's token cookie) and answers 401 `{"error": true, "message": "..."}` without one. Responses are wrapped in `{"data": ...}`; `/materials` adds the `meta` block of `lagoon.Paginate`. A missing record, an unpublished material or a non-numeric `{id}` is a 404. Invalid query parameters are a 422 in Laravel's shape, with messages in the request locale:
```json
{"message": "The to must be a date after or equal to from.", "errors": {"to": ["The to must be a date after or equal to from."]}}
@@ -44,6 +45,10 @@ Every route needs `Authorization: Bearer <golem15.user JWT>` (or the user plugin
| DELETE | `/_bm/api/v1/readings/{id}/progress` | | Clears the mark (idempotent) |
| GET | `/_bm/api/v1/materials` | `course`, `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/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` |
| DELETE | `/_bm/api/v1/calendar-feed` | | Deletes the feed; 204, also when there was none |
### Shared shapes
@@ -150,6 +155,36 @@ Newest publication first. The detail adds the Markdown body and the public files
"files": [{"id": 3, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.pdf", "name": "wyklad.pdf", "size": 123456, "mime": "application/pdf"}]}}
```
### Calendar feed
The calendar-feed routes answer the caller's feed:
```json
{"data": {"url": "https://bm.jakubzych.com/_bm/calendar/<64 hex digits>.ics",
"excluded_course_ids": [9, 10], "include_meetings": true, "include_deadlines": true,
"last_fetched_at": "2026-10-02T21:15:04+02:00"}}
```
`GET` answers `{"data": null}` while the caller has no feed. `PUT` takes a JSON object whose keys are all optional and creates the feed with a new token and the defaults (`[]`, `true`, `true`) when there is none:
```json
{"excluded_course_ids": [9, 10], "include_meetings": true, "include_deadlines": false}
```
`excluded_course_ids` replaces the whole list (duplicates are dropped); every ID must be an existing course, otherwise the answer is a 422 on `excluded_course_ids.<index>`. The booleans take `true`/`false` (or `1`/`0`). A body that is not a JSON object is a 400. `last_fetched_at` is the last time a calendar fetched the feed (written at most once a minute), or null.
`url` starts with `jz.bm.public_url`, or with the scheme and Host of the request when that key is empty.
The feed itself, `GET /_bm/calendar/{token}.ics` (also without `.ics`), needs no authentication: calendar clients fetch it without credentials, so the 32 random bytes of the token are the credential. Rotate the token when the URL leaks. An unknown or malformed token is a 404. The route is throttled per client IP (`throttle:jz.bm.calendar-ip`, 60 a minute) and per token (`throttle:jz.bm.calendar-token`, 20 a minute). The answer is `text/calendar; charset=utf-8` with `Content-Disposition: inline; filename="bm.ics"`, `Cache-Control: private, max-age=900` and an ETag (304 on revalidation).
The calendar is RFC 5545 (CRLF lines folded at 75 octets, escaped TEXT) with times in UTC, so it needs no VTIMEZONE; `X-WR-TIMEZONE` is `Europe/Warsaw` and `REFRESH-INTERVAL`/`X-PUBLISHED-TTL` ask for a refresh every 6 hours (Google refreshes subscribed calendars on its own schedule, usually every few hours). It lists:
- every class session of the courses not excluded: UID `session-<id>@bm.jakubzych.com`, summary `<course> – <form>` (`wykład`, `ćwiczenia`, `konwersatorium`, `seminarium`; nothing for `inne`) plus ` (online)`, location `Koszarowa 3, s. <room> (bud. 21 ISMiB)` or `online`, and a description with the lecturer, the meeting number, the notes, "Przedmiot do wyboru" for an elective and a link to `/przedmioty/<course id>`;
- with `include_meetings`, every meeting as an all-day event (`meeting-<id>@…`, `3. zjazd (stacjonarny)` or `(online)` read from the notes, `TRANSP:TRANSPARENT`);
- with `include_deadlines`, every deadline not tied to an excluded course, as a 30-minute event ending at the due time (`deadline-<id>@…`, `Termin: <title>`) with a display alarm a day before.
`DTSTAMP` and `LAST-MODIFIED` are the row's `updated_at`, so an unchanged timetable gives the same bytes.
## Student app
The built single-page app (Vue, source in the application repository's `frontend/`) is embedded from `public/` and served at `/`. Existing files are served as they are: `assets/*` (content-hashed) with `Cache-Control: public, max-age=31536000, immutable`, everything else with `no-cache` and an ETag (304 on revalidation). A GET for any other extension-less path answers `index.html` (client routes); a missing path with an extension is a 404. `/_user`, `/_bm`, `/_quizzes`, `/storage` and the admin path (`backend.uri`, default `/bmadmin`) are never answered by the app. Routes fails when `public/index.html` is missing. The output of `npm run build` in `frontend/` is committed in `public/`.
@@ -176,6 +211,7 @@ Defaults in `config/config.yaml`, read as `jz.bm.<key>`:
|-----|---------|------|
| `jz.bm.materials_per_page` | `20` | Default page size of `/materials` (clamped to 1–100). |
| `jz.bm.dashboard_limit` | `5` | Sessions and deadlines on `/dashboard`. |
| `jz.bm.public_url` | `""` | Absolute base URL of the application (`https://bm.jakubzych.com`), used in the calendar feed URL and the course links inside the feed. Empty means the scheme and Host of the request; set it behind a TLS-terminating proxy. The application overrides it in `config/jz.bm.yaml`. |
## CLI commands
@@ -236,4 +272,4 @@ go test ./... # unit tests and Postgres integration tests (Docker)
go test -short ./... # unit tests only
```
The integration tests start a `postgres:16-alpine` container through testcontainers and create a fresh database per test; they never touch a development database. They cover the migrations up and down, the schema constraints, `bm:seed` (idempotency, in-place updates, all-or-nothing, prune and dry run), every API route through the real `jwt.auth` guard, and admin saves through the admin API.
The integration tests start a `postgres:16-alpine` container through testcontainers and create a fresh database per test; they never touch a development database. They cover the migrations up and down, the schema constraints, `bm:seed` (idempotency, in-place updates, all-or-nothing, prune and dry run), every API route through the real `jwt.auth` guard, the calendar feed (per-user isolation, exclusions, rotation, headers), and admin saves through the admin API.