# 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 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`, `Material` with file attachments (`system_files`, field `files`) and `Tag`, many-to-many through `jz_bm_material_tag`. - 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. - 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 ` (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 | ### 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"}]}} ``` ## 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 `/api/v1/jz/bm/`), 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 | 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 ```sh ./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): ```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), every API route through the real `jwt.auth` guard, and admin saves through the admin API.