Jakub Zych 30df20c48d feat(02-01): gate the public presentation path
- 404 /_presentations before the SPA shell
- serve /prezentacje/{slugHash} only when presentations is active
- rebuild the embedded student app
2026-10-06 18:14:24 +02:00
2026-10-02 14:26:43 +02:00

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.

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/<token>.ics) of the class sessions, meetings and deadlines, see 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 <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:

{"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:

{"id": 1, "code": null, "name": "…", "form": "wyklad", "semester": 1}

form is one of wyklad, cwiczenia, konwersatorium, seminarium, inne.

A class session:

{"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:

{"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):

{"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):

{"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):

{"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):

{"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

{"data": {
  "next_sessions": [<session>, …],
  "upcoming_deadlines": [<deadline>, …],
  "current_meeting": <meeting> | null,
  "next_meeting": <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": [<course with counters>, …]} in programme order (semester, sort_order, name). /courses/{id} answers one course with counters plus:

{"data": {…course with counters…,
  "sessions": [<session>, …],
  "readings": [<reading>, …],
  "materials": [<material>, …]}}

GET /schedule, GET /meetings, GET /deadlines

/schedule answers {"data": [<session>, …]} 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": [<meeting>, …]} by number. /deadlines answers {"data": [<deadline>, …]} 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": [<reading>, …]} in course and sort_order order; an unknown course gives an empty list. The progress routes answer:

{"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}

{"data": [<material>, …], "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:

{"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:

{"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:

{"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/.

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

./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):

{
  "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

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.

Description
No description provided
Readme 4.3 MiB
Languages
Go 96.6%
HTML 2%
JavaScript 1.4%