docs: tags hold topics, the zjazd is meeting_id

The backend materials list shows the zjazd (meeting, by number). The
MCP save and update material tools, the README material example and the
management token test stop modelling a zjazd as a tag, and the README
describes the one-way zjazd tag migration.
This commit is contained in:
Jakub Zych
2026-11-08 20:30:29 +01:00
parent 7cea8ab318
commit 54300de94c
4 changed files with 13 additions and 7 deletions

View File

@@ -290,14 +290,16 @@ The material body (POST and PUT), unknown keys are a 422:
```json
{"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"]}
"published_at": "2026-10-16T20:00:00+02:00", "tags": ["Globalizacja"]}
```
- `type`: `pdf`, `notatka`, `link`, `inne`. `body` is 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`, `source` and `url` are replaced as sent (absent means null).
- `meeting_id`: absent keeps the stored meeting (so scripts that never send it keep working), `null` clears it, otherwise an existing meeting (else a 422 on `meeting_id`). The managed material JSON shows `meeting_id`, and the material then lists under that zjazd in `GET /_bm/api/v1/materials?meeting=`.
- `published_at`: absent publishes a new material now and keeps an existing one as it is; `null` makes 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`: 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 is `meeting_id`, never a tag (the student app's Zjazd filter and badge read `meeting_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), `null` clears it, otherwise an existing user (else a 422 on `author_user_id`). `share_user_ids`: absent keeps the shares; otherwise the list of user ids replaces them (else a 422 on `share_user_ids`, as for the access routes). The managed material JSON shows `is_private`, `author_user_id`, `author` (`{"id", "name", "email"}` or null) and `shares` (a list, never null).
- Files go through `POST /materials/{id}/files`, `/files/inline` or a signed upload. All three use one helper, `storeMaterialFile`, which applies the 20-file cap, stores through `attach.Store` (the default document and image extensions, content type sniffed from the bytes, a random disk name; `file_name` keeps the client's name) and binds the file to the material; a refused or failed store deletes the blobs it wrote. The file `url` is under `storage.uploads.public_path_prefix`, served by the application when `storage.uploads.serve` is true (the app's `config/storage.yaml` sets it): GET only, `is_public` rows only, anything but a raster image as `Content-Disposition: attachment`.
@@ -358,7 +360,7 @@ Controllers (admin API path `<backend.uri>/api/v1/jz/bm/<name>`), all in the "St
| `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`).
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.

View File

@@ -139,7 +139,7 @@ RETURNING id`).Scan(&groupID).Error; err != nil {
}
idPath := func(id any) string { return "/api/v1/bm/materials/" + strconv.Itoa(int(id.(float64))) }
note := map[string]any{"title": "Definicje globalizacji", "type": "notatka", "body": "# Robertson\n\n> single place", "tags": []string{"Globalizacja", "Zjazd 2"}}
note := map[string]any{"title": "Definicje globalizacji", "type": "notatka", "body": "# Robertson\n\n> single place", "tags": []string{"Globalizacja", "Teoria 2"}}
rec, out := send(http.MethodPost, "/api/v1/bm/materials", note)
if rec.Code != http.StatusCreated || out["created"] != true {
t.Fatalf("create = %d %s", rec.Code, rec.Body.String())
@@ -148,7 +148,7 @@ RETURNING id`).Scan(&groupID).Error; err != nil {
if first["published_at"] == nil {
t.Error("a new material without published_at is not published")
}
if tags, _ := first["tags"].([]any); len(tags) != 2 || tags[1].(map[string]any)["slug"] != "zjazd-2" {
if tags, _ := first["tags"].([]any); len(tags) != 2 || tags[1].(map[string]any)["slug"] != "teoria-2" {
t.Errorf("tags = %v", first["tags"])
}

View File

@@ -207,7 +207,7 @@ type saveMaterialInput struct {
Source *string `json:"source" jsonschema:"source, at most 500 characters, or null; replaced as sent"`
URL *string `json:"url" jsonschema:"link, at most 2048 characters, or null; replaced as sent"`
PublishedAt *string `json:"published_at,omitempty" jsonschema:"publication time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-16 20:00; absent publishes a new material now and keeps an existing one as it is; null makes a draft"`
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names (1 to 100 characters each); absent keeps the tags, a list replaces them"`
Tags *[]string `json:"tags,omitempty" jsonschema:"topic tag names (1 to 100 characters each); absent keeps the tags, a list replaces them; tags hold topics only, the zjazd is set with meeting_id, never with a tag"`
IsPrivate *bool `json:"is_private,omitempty" jsonschema:"true makes the material private: only its author and the users it is shared with see it; absent keeps it, a new material is public"`
AuthorUserID *uint `json:"author_user_id,omitempty" jsonschema:"the author's user id from bm_manage_list_users; absent keeps it, a new material gets the caller as author; null clears it"`
ShareUserIDs *[]uint `json:"share_user_ids,omitempty" jsonschema:"user ids from bm_manage_list_users the material is shared with (at most 200); absent keeps the shares, a list replaces them"`
@@ -224,7 +224,7 @@ type updateMaterialInput struct {
Source *string `json:"source" jsonschema:"source, at most 500 characters, or null; replaced as sent"`
URL *string `json:"url" jsonschema:"link, at most 2048 characters, or null; replaced as sent"`
PublishedAt *string `json:"published_at,omitempty" jsonschema:"publication time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-16 20:00; absent keeps it; null makes a draft"`
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names (1 to 100 characters each); absent keeps the tags, a list replaces them"`
Tags *[]string `json:"tags,omitempty" jsonschema:"topic tag names (1 to 100 characters each); absent keeps the tags, a list replaces them; tags hold topics only, the zjazd is set with meeting_id, never with a tag"`
IsPrivate *bool `json:"is_private,omitempty" jsonschema:"true makes the material private: only its author and the users it is shared with see it; absent keeps it, a new material is public"`
AuthorUserID *uint `json:"author_user_id,omitempty" jsonschema:"the author's user id from bm_manage_list_users; absent keeps it, a new material gets the caller as author; null clears it"`
ShareUserIDs *[]uint `json:"share_user_ids,omitempty" jsonschema:"user ids from bm_manage_list_users the material is shared with (at most 200); absent keeps the shares, a list replaces them"`

View File

@@ -11,6 +11,10 @@ columns:
label: jz.bm::lang.common.course
relation: course
select: name
meeting:
label: jz.bm::lang.common.meeting
relation: meeting
select: number
published_at:
label: jz.bm::lang.materials.published_at
created_at: