diff --git a/README.md b/README.md index dd3418b..3a07323 100644 --- a/README.md +++ b/README.md @@ -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/.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 ` (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 ` (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 ` (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.`. 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-@bm.jakubzych.com`, summary ` –
` (`wykład`, `ćwiczenia`, `konwersatorium`, `seminarium`; nothing for `inne`) plus ` (online)`, location `Koszarowa 3, s. (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/`; +- with `include_meetings`, every meeting as an all-day event (`meeting-@…`, `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-@…`, `Termin: `) 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.