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,Materialwith file attachments (system_files, fieldfiles) andTag, many-to-many throughjz_bm_material_tag,CalendarFeed(one per user), andChecklistwithChecklistItem(each user's own checklists, e.g. the preparation for a zjazd). - A subscribable calendar feed (
/_bm/calendar/<token>.ics) of the class sessions, meetings and deadlines, see Calendar feed. - 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. - A material may be private (
is_private): only its author (author_user_id) and the users it is shared with (jz_bm_material_shares) see it, see Private materials. Existing materials are public and have no author. - 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 and ownership rows; deleting a user deletes the user's calendar feed and checklists; deleting a checklist deletes its items; deleting a meeting, course, reading, deadline or material unlinks the checklists and items that point at it; deleting a material or a user deletes their
jz_bm_material_sharesrows, and deleting a material's author clearsauthor_user_id. - 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--pruneto delete what the file no longer lists and--dry-runto 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) | |
| PUT | /_bm/api/v1/readings/{id}/owned |
Marks the reading owned (idempotent) | |
| DELETE | /_bm/api/v1/readings/{id}/owned |
Clears the owned mark (idempotent) | |
| GET | /_bm/api/v1/materials |
q (search, 2–100 characters), course, meeting, tag (slug), type, page, per_page (1–100) |
One page of the published materials the caller may see: without q the caller's pins first, by relevance with q, see Search |
| GET | /_bm/api/v1/materials/{id} |
One published material the caller may see, with body, tags, files and can_manage |
|
| PUT | /_bm/api/v1/materials/{id}/pin |
Pins the material for the caller only (idempotent); 204 | |
| DELETE | /_bm/api/v1/materials/{id}/pin |
Removes the caller's pin (idempotent); 204 | |
| GET | /_bm/api/v1/checklists |
meeting (ID) |
The caller's checklists with progress, without items |
| POST | /_bm/api/v1/checklists |
Creates a checklist for the caller; 201 | |
| GET | /_bm/api/v1/checklists/{id} |
One of the caller's checklists with progress and items | |
| PATCH | /_bm/api/v1/checklists/{id} |
Changes the sent fields | |
| DELETE | /_bm/api/v1/checklists/{id} |
Deletes it with its items; 204 | |
| POST | /_bm/api/v1/checklists/{id}/items |
Adds an item, last unless sort_order is sent; 201 |
|
| PATCH | /_bm/api/v1/checklist-items/{id} |
Changes the sent fields of an item | |
| DELETE | /_bm/api/v1/checklist-items/{id} |
Deletes an item; 204 | |
| PUT | /_bm/api/v1/checklist-items/{id}/done |
Marks the item done (idempotent) | |
| DELETE | /_bm/api/v1/checklist-items/{id}/done |
Clears the done mark (idempotent) | |
| 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 | |
| GET | /_bm/api/v1/materials/{id}/access |
The material's visibility, shares and the users to pick from; the author only, see Private materials | |
| PUT | /_bm/api/v1/materials/{id}/access |
Sets the visibility and the shares; the author only | |
| PUT | /_bm/api/v1/materials/{id} |
Saves the author's edit, revision-checked; see the author's routes | |
| GET | /_bm/api/v1/tags |
The tag dictionary, to material managers | |
| POST | /_bm/api/v1/materials/{id}/unpublish |
Moves the material to drafts; 204 | |
| POST | /_bm/api/v1/materials/{id}/images |
Stores one editor image; 201 |
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; meeting_id is the zjazd the reading is meant for, null when none):
{"id": 1, "course_id": 1, "course": {…}, "meeting_id": 4, "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; meeting_id is the zjazd the material is meant for, null when none; is_private is true for a private material, which only its author and the users it is shared with get):
{"id": 1, "course_id": 1, "course": {…}, "meeting_id": null, "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, "is_private": false}
A course with counters (readings_read is the caller's; materials counts the published materials the caller may see):
{"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 and /owned
/readings answers {"data": [<reading>, …]} in course and sort_order order; an unknown course gives an empty list. Each reading carries the caller's read_at and owned_at, null when unset; a course's readings in /courses/{id} carry the same pair. 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.
The owned routes record that the caller owns a copy of the reading, apart from whether it is read, and answer {"data": {"reading_id": 7, "owned_at": "2026-10-26T12:00:00+01:00"}} with the same rules: marking an owned reading again keeps the first owned_at, and clearing answers owned_at: null, also when the reading was not owned.
Checklists
Checklists are private: every route reaches only the caller's own checklists and items, and another user's id answers 404 like a missing one. A checklist has meeting_id (optional zjazd), title, description and sort_order; it is answered with meeting (id, number, starts_on, ends_on) and progress:
{"total": 4, "done": 1, "percent": 21, "minutes_total": 185, "minutes_done": 40, "minutes_left": 145}
percent is weighed by time: minutes_done * 100 / minutes_total, rounded down, so a long item counts more than a short one and an item without estimate_minutes counts nothing. A checklist with no estimates at all falls back to done * 100 / total.
An item has kind (lektura, artykul, ksiazka, prezentacja, zadanie, inne; default zadanie), title, notes, url, due_at, estimate_minutes (0–10000), sort_order, optional links course_id, reading_id, deadline_id, material_id (each must exist) and presentation_slug (must name one of the caller's decks in golem15.presentations, read by table name; the plugin is not required). checklist_id moves an item to another of the caller's checklists. It is answered with done, done_at and the linked rows (course, reading, deadline, material (published only), presentation), null when unset or gone.
Readings share the mark. An item with a reading_id has no done mark of its own: it is done exactly when the caller has read that reading. Marking it done inserts the jz_bm_reading_progress row and clearing it deletes the row, so /readings and every checklist with that reading agree. Deleting an item or a checklist leaves reading marks alone.
Bodies are JSON objects of columns checked like the management API: keys outside the writable columns, rule failures and unknown foreign keys answer 422 with errors per field.
GET /materials, GET /materials/{id}
{"data": [<material>, …], "meta": {"current_page": 1, "last_page": 1, "per_page": 20, "total": 2}}
Without q the caller's pinned materials come first (newest pin first), then the newest publication first, with the id as the last tie-breaker, so pages are stable. The filters apply to pinned rows too, and meta.total counts as before. Every row, the detail and the materials of /courses/{id} (which keep newest first) carry pinned, the caller's own pin; nobody else's pins show or change the order. With q the order stays relevance and pinned rows are only marked. meeting keeps the materials assigned to that zjazd (meeting id); like every filter it applies to published materials only. The detail adds the Markdown body and the public files; url starts with storage.uploads.public_path_prefix. can_manage is true when the caller may change the material's access (see below):
{"data": {…material…, "body": "# …",
"files": [{"id": 3, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.pdf", "name": "wyklad.pdf", "size": 123456, "mime": "application/pdf"}],
"pinned": false, "can_manage": false, "revision": 3}}
revision is the content revision (see Revisions); the author's edit sends it back.
q searches (see Search): a trimmed q of fewer than 2 characters is ignored, one over 100 is a 422. With q the page is in relevance order, combined with the other filters (AND); meta.total is counted in SQL over the materials the caller may see, never taken from the engine. A row whose match is in the body carries one snippet:
{"snippet": {"source": "body", "file_name": null, "lead": true, "trail": true,
"segments": [{"text": "proces ", "hit": false}, {"text": "globalizacji", "hit": true}, {"text": " zmienia państwa", "hit": false}]}}
A row whose match is only in an attached PDF carries a file snippet instead: "source": "file", file_name the name of that file (null when the engine's array index does not resolve to a current file) and lead and trail both true. A body snippet is preferred when both exist.
segments are the engine's plain text, verbatim, never markup: render each text as text and mark the hit ones. lead and trail say that text was cut before or after the excerpt. The key is absent when there is no snippet, and always without q. When the search engine is not configured or does not answer, a request with q is a 503 {"error": true, "message": "Search is unavailable."}; the list without q keeps working and never calls the engine.
PUT and DELETE /materials/{id}/pin
Pins are personal: they live in jz_bm_material_pins (migration 20261109130000_create_jz_bm_material_pins), one row per material and user, gone with the material or the user. Nobody but the caller sees a pin. Both routes need a material the caller may see (published and the privacy rule, as the detail), else a 404. PUT keeps the first pinned_at when the material is pinned again; DELETE of a material that is not pinned is fine. Both answer 204 with no body.
A pin is not content: it does not change revision or updated_at, does not resync the search document and publishes nothing on materials:{id}. The token twins under /_bm/api/v1/token need user.scope:write.
Private materials
A private material (is_private) is visible only to its author (author_user_id) and to the users it is shared with (jz_bm_material_shares); a public one to every student. The rule is one SQL fragment (visibleMaterialSQL in controllers/api/material_access.go) applied to every student read: /materials, /materials/{id}, the materials of /courses/{id}, the materials counters of /courses and /courses/{id}, the material linked from a checklist item, and through the token twins the student MCP tools. For anyone else a private material is a 404 and is not counted.
can_manage is true when the caller holds jz.bm.access_materials and is the material's author, or the material has no author yet. Such a caller uses the access routes (jwt only, no token twin; tokens and MCP use the management API):
GET /_bm/api/v1/materials/{id}/access
{"data": {"material_id": 4, "is_private": true, "author_user_id": 1,
"shares": [{"id": 2, "name": "Anna Nowak", "email": "anna@example.test"}],
"users": [{"id": 2, "name": "Anna Nowak", "email": "anna@example.test"}, …]}}
users is every user but the caller, by name; name is "name surname", else the email. PUT takes {"is_private": true, "user_ids": [2, 5]}: an absent key keeps the stored value, and user_ids (null meaning none) replaces the shares. Every id must be an existing user, at most 200 (else a 422 on user_ids); an unknown key such as author_user_id is a 422; the body is capped at 16 KB. Anyone else, including another manager when the material has an author, gets a 404, never a 403. The claim rule: the first access change of a material with no author makes the caller its author, so a private material is never invisible to everyone. /bmadmin can reassign the author.
The files of a private material are still public storage URLs: the API never shows them to a user who cannot see the material, but a leaked or guessed URL still downloads.
Author routes (no token twin)
The student app edits, unpublishes and adds editor images to a material through four routes. They are jwt only, like the access panel; tokens and MCP write materials through the management API. The {id} routes are for a caller with jz.bm.access_materials who is the material's author, or any such caller while the material has no author (the can_manage rule). Unlike the access panel they tell the two refusals apart: a material the caller cannot see (private and not theirs, a draft, not published yet, unknown) is a 404, and one they see but may not manage is a 403 {"error": true, "message": "Forbidden."}. Each route checks again on the row locked FOR UPDATE. No route changes the author, is_private or the shares, and only unpublish changes published_at. Each route has its own per-user throttle: 30 a minute for the edit and for images, 10 for unpublish.
PUT /materials/{id} takes exactly these keys, all required:
{"revision": 3, "title": "Notatka", "type": "notatka", "body": "# …", "source": null, "url": null, "meeting_id": 2, "tags": ["lektura", "cyber"]}
- An unknown key is a 422,
course_idandpublished_atincluded. The course stays the stored one. - A missing key is a 422 on that key, and so is a null
revision,title,typeortags. - The rules of the management
PUTapply: title 1–255,typeone ofpdf,notatka,link,inne, source ≤ 500, url ≤ 2048,meeting_idan existing meeting or null. A title and course another material has is a 422 ontitle. - Student-only rules:
url, when not empty, starts withhttp://orhttps://. There are at most 4 tags, and each must be in the dictionary (GET /tags), else a 422 ontags. The app never creates a tag. - The body is normalised as
bm-notatka pushdoes it: LF line ends, trimmed, one trailing newline, and empty means null. So a web save, a pull and a push of the same text never bump the revision. - A stale
revisionis a 409, and nothing is written.datais the student detail of the current version, body included:
{"error": true, "message": "The material was changed since the revision you sent.", "revision": 4, "updated_at": "2026-11-09T10:00:00+01:00", "data": {…detail…}}
The answer is the student detail (200), with the revision bumped when the content changed. The search document is resynced.
GET /tags answers {"data": [{"id": 1, "name": "Cyber", "slug": "cyber"}, …]}: every row of jz_bm_tags, by name. A caller without jz.bm.access_materials gets a 403.
POST /materials/{id}/unpublish ("Usuń" in the app) sets published_at to null, which makes the material a draft. The student API no longer serves it, so even the author gets a 404 afterwards. Nothing is deleted: a bm-notatka push without --draft, or /bmadmin, publishes it again. The revision stays. The search document is resynced (the management search keeps drafts). It answers 204.
POST /materials/{id}/images takes multipart/form-data with one part named file. The file is stored as a public file of the material through the same path as POST /api/v1/bm/materials/{id}/files, and the file text job is queued with it. Only JPEG, PNG, GIF and WebP pass, judged by the bytes; SVG and every other type are refused. The limits are 50 MB per file and 20 files per material, and the body is capped like the management upload. The revision does not change; the edit that links the image bumps it on save. The answer is 201:
{"data": {"id": 12, "url": "/storage/app/uploads/public/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.png", "name": "wklejony.png", "size": 48213, "mime": "image/png"}}
A refused file is a 422 {"message": "…", "errors": {"file": ["…"]}, "reason": "type"}, where reason is type, size or count. A second file part and a request with no file are both type refusals. A body over the limit is a 413 with "reason": "size". Editor images are public storage URLs, like every material file.
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 forinne) plus(online), locationKoszarowa 3, s. <room> (bud. 21 ISMiB)oronline, 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.
Management API
/api/v1/bm is what bm:seed and the materials screen do, over HTTP, for scripts: it sits behind the application-wide API token of golem15.user (Authorization: Bearer <token>, middleware user.api_token), not the students' jwt.auth. Every route needs a token scope (user.scope:read or user.scope:write) and, except /me, a permission of the token owner's groups (jz.bm.access_courses, jz.bm.access_schedule, jz.bm.access_materials, the codes of the admin screens); a missing one is a 403. The group is throttled per token (throttle:jz.bm.manage, 120 a minute); an MCP tool call is throttled per user instead (see MCP tools). Responses are {"data": …}; validation errors are a 422 in Laravel's shape ({"message", "errors": {field: [...]}}), a bad or oversized body a 400 or 413.
| Method | Route | Scope, permission | Answer |
|---|---|---|---|
| GET | /api/v1/bm/me |
read | The token owner's user_id and the token (id, name, scopes, expires_at). |
| GET | /api/v1/bm/materials |
read, materials | Every material, drafts and private ones included, newest first. q (2–100 characters) searches all of them, in relevance order, with the snippet of the student list; a 503 Search is unavailable. when the engine cannot answer. |
| GET | /api/v1/bm/materials/{id} |
read, materials | One material, draft or not, with its public files. 404 when unknown. |
| POST | /api/v1/bm/materials |
write, materials | Upsert on title + course_id: 201 {"data", "created": true} for a new material, 200 {"data", "created": false} for an updated one; 409 when an update sends a stale revision (see Revisions). |
| PUT | /api/v1/bm/materials/{id} |
write, materials | The same body applied to that material, so the title or course can change. 404 when unknown; 422 on title when another material already has the new title and course; 409 on a stale revision. |
| DELETE | /api/v1/bm/materials/{id} |
write, materials | 204, with the material's files (rows, then blobs); 404 when unknown. |
| POST | /api/v1/bm/materials/{id}/files |
write, materials | Multipart body, parts named file: each is stored as a public file of the material, after the ones it has. 201 with the material as GET shows it; 404 when unknown; 422 on file when there is no file part, a type is not allowed, a file is over 50 MB or the material would hold more than 20 files (nothing is stored then); 413 over the body cap (body.limit, 51 MB, so one large file per request). |
| POST | /api/v1/bm/materials/{id}/files/inline |
write, materials | JSON {"name": "notatki.pdf", "mime": "application/pdf", "content_base64": "…"} (mime optional; unknown keys are a 422): one file of at most 3000000 bytes decoded (MaterialInlineMaxBytes, so it fits under the MCP endpoint's 4 MiB request cap), stored like a multipart file; a sent mime must match the stored content type. Larger content is a 422 on content_base64 that points at the upload URL. 201 with the material as GET shows it plus a top-level warning; 413 over the body cap (body.limit, 4300000). |
| POST | /api/v1/bm/materials/{id}/files/upload-url |
write, materials | 201 {"data": {"upload_url", "expires_at", "max_bytes", "accept", "curl_example", "warning"}}: a signed, single-use URL for one file of this material by the caller, valid 10 minutes, at most 50 MiB (max_bytes 52428800). 503 when app.key or the cache is missing. See Signed uploads. |
| DELETE | /api/v1/bm/materials/{id}/files/{file} |
write, materials | 204: the file row, then its blob and thumbnails; 404 when the material or a file of it is unknown. |
| PATCH | /api/v1/bm/materials/{id}/visibility |
write, materials | {"is_private": true} (required): 200 with the material; a material with no author gets the caller as author; 404 when unknown. |
| PUT | /api/v1/bm/materials/{id}/shares |
write, materials | {"user_ids": [2, 5]} (the key is required, null or [] removes every share): the list replaces the shares; 422 on user_ids for an unknown user or more than 200; the claim rule as above; body capped at 16 KB. |
| GET | /api/v1/bm/users |
read, materials | [{"id", "name", "email"}, …] of every user, to pick an author or shares. |
| GET | /api/v1/bm/{records} |
read, see below | Every row of the table, filtered by ?column=value on the columns listed below (null matches NULL). |
| GET | /api/v1/bm/{records}/{id} |
read | One row; 404 when unknown. |
| POST | /api/v1/bm/{records} |
write | 201 with the new row. |
| PATCH | /api/v1/bm/{records}/{id} |
write | Only the sent columns change; 200 with the row. |
| DELETE | /api/v1/bm/{records}/{id} |
write | 204 (class sessions, readings, deadlines only; courses and meetings are deleted in the admin, because rows hang on them). |
| POST | /api/v1/bm/seed?dry_run=&prune= |
write, courses + schedule + materials | bm:seed over HTTP: the body is the seed document, the answer the summary table. |
The record tables (controllers/api/manage_records.go, mounted by mountRecords in routes.go):
{records} |
Model | Permission | List filters | Order |
|---|---|---|---|---|
courses |
Course |
courses | semester, form, code |
semester, sort_order |
meetings |
Meeting |
schedule | number | |
class-sessions |
ClassSession |
schedule | course_id, meeting_id |
starts_at |
readings |
Reading |
courses | course_id, meeting_id, is_required |
course_id, sort_order |
deadlines |
Deadline |
schedule | course_id, kind |
due_at |
A row is answered as an object of its columns (id, created_at and updated_at included); times are RFC 3339 in Europe/Warsaw, dates YYYY-MM-DD. A POST or PATCH body is an object of columns: only the model's Fillable() ones (another key is a 422 that lists them), with times as RFC 3339 or 2006-01-02 15:04 in Europe/Warsaw, dates as YYYY-MM-DD, "" or null clearing an optional column, and a number accepted for a text column. The model's Rules() (the admin's) run on the row as it would be saved, so a PATCH of one column still meets the required ones, and unique skips the row itself; TemporalErrors() checks the order of times; course_id and meeting_id must exist. A seed run (bm:seed, POST /seed) overwrites the columns it carries on the rows it matches, so changes made here are lost when the same rows are seeded again.
The material body (POST and PUT), unknown keys are a 422:
{"course_id": 7, "meeting_id": 2, "title": "Definicje globalizacji", "type": "notatka",
"body": "# Robertson\n\n> …", "source": null, "url": null,
"published_at": "2026-10-16T20:00:00+02:00", "tags": ["Globalizacja"]}
type:pdf,notatka,link,inne.bodyis Markdown, rendered by the student app (headings start at h2 there; the title is the page's h1).course_id: null or an existing course.body,sourceandurlare replaced as sent (absent means null).meeting_id: absent keeps the stored meeting (so scripts that never send it keep working),nullclears it, otherwise an existing meeting (else a 422 onmeeting_id). The managed material JSON showsmeeting_id, and the material then lists under that zjazd inGET /_bm/api/v1/materials?meeting=.published_at: absent publishes a new material now and keeps an existing one as it is;nullmakes it a draft; otherwise a time as in the seed file.tags: absent keeps the tags; otherwise the list of names replaces them, and unknown tags are created (slug from the name, Polish letters folded). Tags hold topics only: the zjazd ismeeting_id, never a tag (the student app's Zjazd filter and badge readmeeting_id).
The one-way migration 20261108140000_move_zjazd_tags_to_meetings moved the old zjazd tags onto meeting_id: a material tagged zjazd-N (slug matching ^zjazd-[0-9]{1,6}$) with no meeting got meeting number N (the lowest when it had several), an existing meeting_id was kept, and every such tag was deleted with its links; a slug such as zjazd-3-24-10 is a topic and stays. A tag naming a missing meeting, a tag disagreeing with meeting_id and a material with several zjazd tags are logged at Warn under jz.bm. Its Rollback does nothing: the only way back is the pg_dump --data-only -t jz_bm_tags -t jz_bm_material_tag taken before migrate.
is_private: absent keeps it (a new material is public).author_user_id: absent keeps it (a new material gets the token user as author),nullclears it, otherwise an existing user (else a 422 onauthor_user_id).share_user_ids: absent keeps the shares; otherwise the list of user ids replaces them (else a 422 onshare_user_ids, as for the access routes). The managed material JSON showsis_private,author_user_id,author({"id", "name", "email"}or null) andshares(a list, never null).revision: optional, the revision the caller last read; see Revisions.- Files go through
POST /materials/{id}/files,/files/inlineor a signed upload. All three use one helper,storeMaterialFile, which applies the 20-file cap, stores throughattach.Store(the default document and image extensions, content type sniffed from the bytes, a random disk name;file_namekeeps the client's name) and binds the file to the material; a refused or failed store deletes the blobs it wrote. The fileurlis understorage.uploads.public_path_prefix, served by the application whenstorage.uploads.serveis true (the app'sconfig/storage.yamlsets it): GET only,is_publicrows only, anything but a raster image asContent-Disposition: attachment.
Revisions
Every material answer of the management API (GET /materials, GET /materials/{id} and every POST and PUT) carries revision, starting at 1, and updated_at; the student detail GET /_bm/api/v1/materials/{id} (and its token twin) carries revision too. The revision goes up by one only when the material's content changes: its title, type, body, source, url, course, zjazd (meeting_id) or tags. A write that changes only published_at, is_private, the author or the shares, a file attach or detach, a student's pin, or a write that sends identical content, keeps it (migration 20261109120000_add_revision_to_jz_bm_materials; models.ReviseMaterial is the only writer of the column).
POST and PUT accept an optional revision. When it is sent and is not the stored one, the answer is a 409 and nothing is written:
{"error": true, "message": "The material was changed since the revision you sent.",
"revision": 4, "updated_at": "2026-11-09T12:00:00+01:00", "data": <material>}
data is the material as GET shows it, so the caller can diff or pull; over plain HTTP it carries the body, under MCP the body is left out as in every other MCP answer. Without revision the write overwrites as before, so callers that never send it keep working. The compare and the write run on the row locked FOR UPDATE, so two writers holding the same revision cannot both succeed. A POST that creates a material ignores revision (the new material starts at 1). Saves in /bmadmin are not checked (the form has no revision), but they bump the revision when they change the content, so a later write with an older revision gets the 409 instead of silently overwriting the admin edit.
Signed uploads
POST /_bm/api/v1/uploads/{ticket} takes the file of a signed upload URL, for example curl -fsS -F 'file=@wyklad.pdf' '<upload_url>'. It has no bearer: the ticket in the path is the credential. The ticket is base64url(payload).base64url(HMAC-SHA256(app.key, "jz.bm.upload.v1:" + payload)) with the payload material|<material id>|<user id>|<expiry unix>|<nonce> (16 random bytes in hex). Issuing stores the payload under the nonce in the application cache (cooler) for 10 minutes; the upload takes it out again with an atomic pull, so a URL works once, and a restart of the memory cache voids every issued URL. The URL carries no bearer token and no key.
The route is throttled per client IP (throttle:jz.bm.upload-ip, 10 a minute) and per ticket (throttle:jz.bm.upload-ticket, 3 a minute, keyed by a SHA-256 digest of the ticket) and capped at body.limit 51 MB. A malformed, forged, expired or used ticket is a 403 {"error": true, "message": "Invalid or expired upload URL."} before the database is touched; so is a ticket whose user no longer exists or no longer holds jz.bm.access_materials. A deleted material is a 404. The body must be multipart with exactly one part, named file (else a 422 on file); the file goes through attach.Store with the material file limits (50 MiB, the default extensions, the 20-file cap). The answer is 201 {"data": {"material_id", "file": {"id", "name", "size", "mime", "url"}}, "warning"} with an absolute url (app.url, else jz.bm.public_url, else the request). A refused upload still uses up its URL: ask for a new one.
Material files are public to anyone with the link. The inline and signed answers and every file tool say so.
MCP tools
jz.bm implements pact.HasMCP. With the framework's MCP endpoint (pier, the summercms_mcp build tag) its tools are offered at /mcp as bm_<name>, to OAuth sessions and personal tokens of golem15.user (guard user). Every tool binds a token-guarded route, every description is static text naming the scope it needs (and, for management tools, the permission code), and no tool has a dry_run. jz.bm still requires only golem15.user.
Token twins of the student API. /_bm/api/v1/token/* (TokenPrefix) mounts the student routes a second time, from the same mountStudent helper as the jwt.auth group, behind user.api_token, the per-user bucket throttle:jz.bm.token (240 a minute) and locale.from-principal, with user.scope:read on the reads and user.scope:write on the reading marks, the material pins and the checklist writes. The handlers read bouncer.User, so a personal token or an MCP session reaches only its own user's data. The calendar feed and the chat have no twin. TestTokenMirrorRoutes fails when a student route is added to one surface only.
| Tool | Route | Scope |
|---|---|---|
bm_dashboard, bm_courses, bm_course, bm_schedule, bm_meetings, bm_deadlines, bm_readings, bm_materials, bm_material |
GET /_bm/api/v1/token/… |
read |
bm_mark_reading_read, bm_unmark_reading_read, bm_mark_reading_owned, bm_unmark_reading_owned |
PUT / DELETE /_bm/api/v1/token/readings/{id}/progress and /owned |
write |
bm_pin_material, bm_unpin_material |
PUT / DELETE /_bm/api/v1/token/materials/{id}/pin |
write |
bm_checklists, bm_checklist |
GET /_bm/api/v1/token/checklists[/{id}] |
read |
bm_create_checklist, bm_update_checklist, bm_delete_checklist, bm_add_checklist_item, bm_update_checklist_item, bm_delete_checklist_item, bm_mark_checklist_item_done, bm_unmark_checklist_item_done |
POST / PATCH / PUT / DELETE /_bm/api/v1/token/checklists… and /checklist-items… |
write |
Management tools bind the existing /api/v1/bm routes, whose user.scope:* and user.permission:jz.bm.access_* middleware filter the listing: a user whose groups lack the codes never sees them, and write tools need the write grant. Each input mirrors the model's Fillable() columns and Rules() (required columns are required on create, every update field is optional and nullable); enum values, lengths and date and time formats are in the field descriptions, and the routes still answer 422.
| Table | Tools | Permission |
|---|---|---|
| courses | bm_manage_list_courses, _show_course, _create_course, _update_course |
jz.bm.access_courses |
| meetings | bm_manage_list_meetings, _show_meeting, _create_meeting, _update_meeting |
jz.bm.access_schedule |
| class sessions | bm_manage_list_class_sessions, _show_class_session, _create_class_session, _update_class_session, _delete_class_session |
jz.bm.access_schedule |
| readings | bm_manage_list_readings, _show_reading, _create_reading, _update_reading, _delete_reading |
jz.bm.access_courses |
| deadlines | bm_manage_list_deadlines, _show_deadline, _create_deadline, _update_deadline, _delete_deadline |
jz.bm.access_schedule |
| materials | bm_manage_list_materials, _show_material, _save_material (upsert on title and course), _update_material, _delete_material, _set_material_visibility, _set_material_shares, _list_users |
jz.bm.access_materials |
| material files | bm_manage_attach_material_file (base64, at most 3000000 bytes), _create_material_upload (signed one-time URL, then curl), _detach_material_file (destructive) | jz.bm.access_materials |
The file tools return metadata and links, never file contents, and their descriptions carry the public-file warning. POST /seed and GET /me are not tools. Courses and meetings have no delete tool, because the route has none. The material save and update tools require body, source and url (each may be null), because the route replaces them as sent; their descriptions tell the agent to read the material with include_body first to keep its body. They also take is_private, author_user_id and share_user_ids (absent keeps each), and an optional revision: the revision last read with bm_manage_show_material; a material changed since then is refused with 409 and its current revision, and without it the write overwrites (see Revisions). bm_manage_set_material_visibility and bm_manage_set_material_shares change only the visibility or the shares (ids from bm_manage_list_users), with the claim rule of the access routes. The student tools bm_materials, bm_material and bm_course show only the materials the token's user may see.
The MCP view. Only an answer to an MCP tool call (a request pier dispatched in-process, carrying the api_token dispatch marker, which an HTTP request can never carry) is changed; plain HTTP answers are byte for byte as before.
- Links: the middleware
jz.bm.mcp-links(on the token twins and the management API) prefixes every JSON string under a key namedurlor ending in_urlthat starts with a single/withapp.url, elsejz.bm.public_url, else the request's scheme and Host, so file and syllabus links are ready to follow. - Material bodies (possible transcript text):
bm_material,bm_manage_list_materialsandbm_manage_show_materialleavebodyout unlessinclude_body=true, and mark an omitted non-empty body withbody_omitted: true; the material write answers never echo it. Material tools return metadata and links, never whole file contents. - Search:
bm_materialsandbm_manage_list_materialstakeqas the HTTP lists do. Withqthe results come by relevance and a material may carrysnippetas a plain string, the matched passage with…where text was cut; it can come from the body or from an attached PDF. - Throttling:
jz.bm.managekeys an MCP call by its user (jz.bm.manage.user|<id>) before the bearer and IP keys.
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. The material form has the is_private switch, the author picker (author, a belongsTo of users on author_user_id, by email) and shared_with (a belongsToMany of users through jz_bm_material_shares, by email); the list shows is_private and the zjazd (meeting, by number). 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. |
Search
Materials are searched through the framework's beachcomber module with the Typesense engine (search.driver: typesense; the blank import of beachcomber/typesense is in search.go). models.Material is a beachcomber.Searchable (models/material_search.go) kept in the index jz_bm_materials (after search.prefix). A document holds id, title, source, url, tags and tag_slugs, course (name and code), course_id, meeting_id, meeting_number, type, published_at (Unix seconds, 0 for a draft), the body with its Markdown syntax removed and file_texts (the text of attached files). No field sets a locale, so the default one folds diacritics and lodz finds Łódź.
The index holds every material, drafts and private ones included, and no visibility: the engine's ids are candidates only. A search asks for at most 250 of them (title weighted over tags, source and course, then body, link and file text), and SQL re-gates them with the same visibility rule and filters as the list, counts them and pages over them in the engine's order. A snippet is read only for a row SQL returned, so a material the caller may not see never reaches an answer, its count or a snippet.
The beachcomber service is app-wide, and so is its Gate: jz.bm installs one that is on only for contexts it marks (classes.MarkMaterialSearch), so golem15.chatchannels messages and DMs are never indexed. Every material write syncs explicitly after it commits: the admin form (create, update, delete), POST, PUT and DELETE /api/v1/bm/materials. A failed sync is logged with the material id and never fails the write.
Attached PDFs are searchable too. After any attach (the admin form, POST /api/v1/bm/materials/{id}/files, /files/inline and the signed upload, so also the MCP attach and upload tools) the background job jz.bm.extract_material_text (River, through conga; enqueued inside the attach transaction, three attempts of at most two minutes) runs pdftotext from poppler-utils on each new public PDF of the material and caches the text in jz_bm_material_file_texts, then resyncs the material, whose document carries the texts as file_texts, in file order. pdftotext is an optional runtime tool on the server: it runs without a shell on a 0600 temp file under storage/app/tmp, with a 60 s timeout and at most 1 MiB of text per file (the row's truncated is set beyond that). A scan or a broken PDF stores an empty text and adds nothing (there is no OCR); a missing pdftotext is logged once and stores nothing, so a later run retries. Detaching a file drops its text row (foreign-key cascade) and resyncs the material. bm:materials-reindex extracts the text of any PDF that has none before syncing each material, which backfills PDFs attached before file search existed.
Configuration, in the app's config/search.yaml: search.driver, search.prefix, search.typesense.host, port, protocol and connection_timeout_seconds. The API key goes only in the environment, SUMMER_SEARCH__TYPESENSE__API_KEY; with no key the engine is not configured, nothing is sent and every search answers 503.
Realtime
An open editor learns of a newer revision before its own save (D-20); the save-time 409 stays the guarantee. After every write that bumps a material's revision (the author's PUT /_bm/api/v1/materials/{id}, the management PUT and POST upsert, so also bm-notatka push and the MCP tools, and a /bmadmin save) the server enqueues, on the write's own transaction, one broadcast on channel materials:{id} with event updated.jz.bm.material and payload {"material_id": 5, "revision": 4}. It is published only if the write commits, and only ids and the revision number travel, never content. The enqueue runs in a savepoint: a failure is logged and never fails or loses the save. A write that does not bump (visibility, shares, unpublish, files, a student's pin, identical content) publishes nothing (classes.MaterialRevisionNotifier).
jz.bm owns the realtime namespace materials. Its authorizer accepts only channels of the form materials:{id} (^materials:[1-9][0-9]{0,18}$; a presence: prefix, a leading zero or trailing characters are denied) and allows a subscribe only when the material is visible to the user and they may manage it: jz.bm.access_materials and its author, or any such user while it has no author (api.CanWatchMaterial), checked on current state at every subscribe. The connection-token guard jz.bm.realtime-access admits material managers besides chat-group members and presentation owners, so every possible editor can get a token; the token and subscribe-proxy routes are mounted only while golem15.chat or golem15.presentations is active. The Centrifugo instance needs the materials namespace (see the app's DEPLOY.md).
CLI commands
bm:materials-reindex
./bin/bm bm:materials-reindex
./bin/bm bm:materials-reindex --fresh
Ensures the jz_bm_materials collection and syncs every material through the same path as the sync after a save, first extracting the text of its PDFs that have none yet (a missing pdftotext is one warning, and the run goes on without file text), ending with Reindexed: N synced, M failed; it exits non-zero when any material failed. --fresh first drops the collection and recreates it from models.Material.SearchIndexSchema: use it on the first deploy and after a schema change. Without an engine the command refuses with an error naming search.driver and search.typesense.api_key; --if-configured makes that a notice and a zero exit. The plugin schedules bm:materials-reindex --if-configured every night at 04:15 (Schedule, run by serve's scheduler), because course renames, meeting renumbering, failed syncs and Typesense restarts can leave the index stale. Run it by hand after a Typesense restart.
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}]
}
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 (v0.6.4):
pact,party,backpack,surf,wire,bouncer,lagoon(withlagoon/attach),cabana,bonfire,phrasebook,beachcomber(withbeachcomber/typesense). - 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, 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.