- GET /api/v1/bm/materials takes q: candidates over every material, in rank order, with the snippet of the student list; 503 when the engine cannot answer - bm_materials and bm_manage_list_materials take q; under MCP the snippet is plain text with ellipses where text was cut - MCPMaterialPrivacy no longer claims no file text is ever returned - README documents q, the snippet, the 503, the Search section and bm:materials-reindex
45 KiB
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, 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 |
|
| 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 |
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}}
Newest publication first. 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"}],
"can_manage": false}}
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}]}}
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, so the list without q is byte for byte as before. 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.
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.
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. |
| 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. |
| 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", "Zjazd 2"]}
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).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).- 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.
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 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_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). 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, once file text is indexed, from an attached file. - 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. 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.
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.
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, 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.