# jz.bm The study dashboard backend of the part-time master's programme "Bezpieczeństwo międzynarodowe" (UWr): courses, meetings, timetable, deadlines, reading lists with per-student progress, and study materials. ```go 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 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. Timestamps are stored in UTC (`TIMESTAMPTZ`) and written in RFC 3339 in Europe/Warsaw (`2026-10-03T08:00:00+02:00`). Dates are `YYYY-MM-DD`. A date or time without an offset in a seed file is read as Polish time. ## 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). - 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; 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 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."]}} ``` | Method | Path | Query | Answers | |--------|------|-------|---------| | GET | `/_bm/api/v1/dashboard` | | Next sessions, upcoming deadlines, current and next meeting, reading progress | | GET | `/_bm/api/v1/courses` | `semester` (1–4) | Every course with counters | | GET | `/_bm/api/v1/courses/{id}` | | One course with its sessions, readings and published materials | | GET | `/_bm/api/v1/schedule` | `from`, `to` (`YYYY-MM-DD`) | Class sessions starting in the range | | GET | `/_bm/api/v1/meetings` | | Every meeting | | GET | `/_bm/api/v1/deadlines` | `from`, `to` (`YYYY-MM-DD`) | Deadlines in the range | | GET | `/_bm/api/v1/readings` | `course` (ID) | Readings with the caller's `read_at` | | PUT | `/_bm/api/v1/readings/{id}/progress` | | Marks the reading read (idempotent) | | 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 A course reference, embedded in other records: ```json {"id": 1, "code": null, "name": "…", "form": "wyklad", "semester": 1} ``` `form` is one of `wyklad`, `cwiczenia`, `konwersatorium`, `seminarium`, `inne`. A class session: ```json {"id": 1, "course_id": 1, "meeting_id": 1, "meeting_number": 1, "starts_at": "2026-10-03T08:00:00+02:00", "ends_at": "2026-10-03T09:30:00+02:00", "room": "237", "kind": null, "notes": null, "is_online": false, "course": {"id": 1, "code": null, "name": "…", "form": "wyklad", "semester": 1}} ``` A meeting: ```json {"id": 1, "number": 1, "starts_on": "2026-10-03", "ends_on": "2026-10-04", "notes": null, "sessions_count": 12} ``` A deadline (`kind` is one of `zaliczenie`, `egzamin`, `praca`, `organizacyjne`, `inne`; `course` is null when the deadline belongs to no course): ```json {"id": 1, "title": "…", "due_at": "2026-10-05T23:59:00+02:00", "kind": "organizacyjne", "description": null, "course_id": null, "course": null} ``` A reading (`read_at` is the caller's, or null): ```json {"id": 1, "course_id": 1, "course": {…}, "title": "…", "author": null, "source": null, "url": null, "is_required": true, "sort_order": 1, "read_at": "2026-10-02T21:15:04+02:00"} ``` A material in a list (`type` is one of `pdf`, `notatka`, `link`, `inne`; `files_count` counts public files): ```json {"id": 1, "course_id": 1, "course": {…}, "title": "…", "type": "notatka", "source": null, "url": null, "published_at": "2026-10-01T12:00:00+02:00", "tags": [{"id": 1, "name": "Geopolityka", "slug": "geopolityka"}], "files_count": 1} ``` A course with counters (`readings_read` is the caller's; `materials` counts published materials): ```json {"id": 1, "code": null, "name": "…", "form": "wyklad", "lecturer": "…", "semester": 1, "ects": 8, "assessment": "egzamin", "elective_group": null, "description": null, "syllabus_url": null, "sort_order": 1, "counts": {"sessions": 6, "readings": 4, "readings_read": 1, "materials": 2}} ``` ### GET /dashboard ```json {"data": { "next_sessions": [, …], "upcoming_deadlines": [, …], "current_meeting": | null, "next_meeting": | null, "readings": {"total": 40, "read": 3, "required_total": 25, "required_read": 2} }} ``` `next_sessions` are the first sessions that have not ended yet, by start time; `upcoming_deadlines` the first deadlines not yet due. Both hold `jz.bm.dashboard_limit` items (5). `current_meeting` is the meeting whose dates include today in Warsaw; `next_meeting` the first one that starts after today. ### GET /courses, GET /courses/{id} `/courses` answers `{"data": [, …]}` in programme order (semester, `sort_order`, name). `/courses/{id}` answers one course with counters plus: ```json {"data": {…course with counters…, "sessions": [, …], "readings": [, …], "materials": [, …]}} ``` ### GET /schedule, GET /meetings, GET /deadlines `/schedule` answers `{"data": [, …]}` by start time: the sessions that start from midnight of `from` (default today) until the end of `to` (default no limit), in Warsaw. `/meetings` answers `{"data": [, …]}` by number. `/deadlines` answers `{"data": [, …]}` by due time; without `from` it starts now, so it lists the deadlines not yet due. ### GET /readings, PUT and DELETE /readings/{id}/progress `/readings` answers `{"data": [, …]}` in course and `sort_order` order; an unknown `course` gives an empty list. The progress routes answer: ```json {"data": {"reading_id": 7, "read_at": "2026-10-02T21:15:04+02:00"}} ``` Marking a read reading again keeps the first `read_at`. Clearing answers `read_at: null`, also when the reading was not marked. ### GET /materials, GET /materials/{id} ```json {"data": [, …], "meta": {"current_page": 1, "last_page": 1, "per_page": 20, "total": 2}} ``` Newest publication first. The detail adds the Markdown body and the public files; `url` starts with `storage.uploads.public_path_prefix`: ```json {"data": {…material…, "body": "# …", "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/`. ## Admin Controllers (admin API path `<backend.uri>/api/v1/jz/bm/<name>`), all in the "Studia BM" navigation group: | Controller | Permission | |------------|------------| | `jz.bm.courses`, `jz.bm.readings` | `jz.bm.access_courses` | | `jz.bm.meetings`, `jz.bm.class_sessions`, `jz.bm.deadlines` | `jz.bm.access_schedule` | | `jz.bm.materials`, `jz.bm.tags` | `jz.bm.access_materials` | Courses, meetings, sessions and deadlines are picked through relation fields; a material's tags through a belongsToMany relation field. Dates and times are edited with date pickers (`type: datepicker`); the admin shows times in the browser's time zone and stores them in UTC. Labels are in Polish (`lang/pl`) and English (`lang/en`). A material's files are uploaded in its form (`type: fileupload` on the attachMany `files`, public, up to 20 files of 50 MB). The course form embeds relation managers for its readings and class sessions, which create, edit and delete them, also before the course is first saved: `course_id` on both tables is nullable for that, the pending rows are bound to the form session and get the course on its first save, and `deferred:purge` deletes abandoned ones. The student API leaves out sessions and readings without a course. A class session added from the course form has no meeting; set it on the class sessions screen. ## Configuration Defaults in `config/config.yaml`, read as `jz.bm.<key>`: | Key | Default | Sets | |-----|---------|------| | `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 ### bm:seed ```sh ./bin/bm bm:seed data/bm-2026-27.json ./bin/bm bm:seed data/bm-2026-27.json --dry-run --prune ./bin/bm bm:seed data/bm-2026-27.json --prune ``` Reads a JSON file, validates all of it, and upserts it in one transaction. Each kind is matched on its identity, updated in place when a field changed, and created otherwise; rows that are not in the file are left alone, so running the same file again changes nothing. Any error (an unknown key in the document, a bad value, a course key or meeting number that does not exist in the file) names the record and writes nothing. The command prints a table of created, updated, unchanged and deleted rows per kind. `--prune` makes the file authoritative for what it covers: it deletes the courses of the file's semesters that the file does not list (their sessions and readings go with them, their deadlines and materials are unlinked), then the class sessions and readings of the listed courses that the file does not list. Reading progress of a deleted reading is deleted with it. Meetings and deadlines are never pruned. Each deleted row is printed. Use it when a timetable changes: a moved session is a new start time, so without `--prune` the old one would stay. `--dry-run` applies the seed (and the prune) in a transaction that is rolled back, so the table and the deleted rows show what a real run would do. | Kind | Identity | |------|----------| | meetings | `number` | | courses | `name` + `form` + `semester` | | class_sessions | course + `starts_at` | | deadlines | `title` + `due_at` | | readings | course + `title` | The file format (every top-level array is optional, and nullable fields may be left out): ```json { "meetings": [{"number": 1, "starts_on": "2026-10-03", "ends_on": "2026-10-04", "notes": "stacjonarny"}], "courses": [{"key": "glob-w", "code": null, "name": "…", "form": "wyklad", "lecturer": "…", "semester": 1, "ects": 8, "assessment": "egzamin", "elective_group": null, "description": null, "syllabus_url": null, "sort_order": 1}], "class_sessions": [{"course": "glob-w", "meeting": 1, "starts_at": "2026-10-03T08:00:00+02:00", "ends_at": "2026-10-03T09:30:00+02:00", "room": "237", "kind": null, "is_online": false, "notes": null}], "deadlines": [{"title": "…", "due_at": "2026-10-05T23:59:00+02:00", "course": null, "kind": "organizacyjne", "description": null}], "readings": [{"course": "glob-w", "title": "…", "author": null, "source": null, "url": null, "is_required": true, "sort_order": 1}] } ``` - `key` is a reference inside the file only; `course` in sessions, deadlines and readings names a course `key`, `meeting` in sessions a meeting `number`. - `form`: `wyklad`, `cwiczenia`, `konwersatorium`, `seminarium`, `inne`. `semester`: 1–4. `kind` of a deadline: `zaliczenie`, `egzamin`, `praca`, `organizacyjne`, `inne`. - Dates are `YYYY-MM-DD`; times RFC 3339 with an offset, or `YYYY-MM-DD HH:MM` in Polish time. - A blank optional text is stored as null. `testdata/seed.example.json` is a small example with made-up data. ## Dependencies - SummerCMS framework modules: `pact`, `party`, `backpack`, `surf`, `wire`, `bouncer`, `lagoon` (with `lagoon/attach`), `cabana`, `bonfire`, `phrasebook`. - The `golem15.user` plugin (`git.golem15.com/golem15/sm-user-plugin`): the `users` table and the `jwt.auth` middleware. `jz.bm` declares it in `Requires`, so its migrations run first. - GORM and gormigrate. Tests also use testcontainers-go and pgx. ## Testing ```sh go vet ./... 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, the calendar feed (per-user isolation, exclusions, rotation, headers), and admin saves through the admin API.