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 one thing only: which readings they have read.
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 typed without an offset (in the admin or 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,Materialwith file attachments (system_files, fieldfiles) andTag, many-to-many throughjz_bm_material_tag. - Courses that share an
elective_groupare alternatives ("do wyboru"), such as the specialisation courses. - A material without
published_atis a draft; a material whosepublished_atlies 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.
- 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.
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:
{"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 |
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"}]}}
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 are typed as 2026-10-03 and times as 2026-10-03 08:00 (Polish time) or RFC 3339; a value that does not parse is a 422 on its field. Labels are in Polish (lang/pl) and English (lang/en).
Two things the admin cannot do yet, because the framework does not offer them: upload a material's files (there is no file upload form widget, so files are attached as system_files rows outside the admin), and list a course's readings and sessions inside the course form (relation managers handle only belongsToMany links). Both are marked TODO: requires SummerCMS change in the code.
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. |
CLI commands
bm:seed
./bin/bm bm:seed data/bm-2026-27.json
./bin/bm bm:seed data/bm-2026-27.json --dry-run
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. --dry-run only validates. The command prints a table of created, updated and unchanged rows per kind.
| 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}]
}
keyis a reference inside the file only;coursein sessions, deadlines and readings names a coursekey,meetingin sessions a meetingnumber.form:wyklad,cwiczenia,konwersatorium,seminarium,inne.semester: 1–4.kindof a deadline:zaliczenie,egzamin,praca,organizacyjne,inne.- Dates are
YYYY-MM-DD; times RFC 3339 with an offset, orYYYY-MM-DD HH:MMin 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(withlagoon/attach),cabana,bonfire,phrasebook. - The
golem15.userplugin (git.golem15.com/golem15/sm-user-plugin): theuserstable and thejwt.authmiddleware.jz.bmdeclares it inRequires, 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), every API route through the real jwt.auth guard, and admin saves through the admin API.