diff --git a/README.md b/README.md new file mode 100644 index 0000000..8573c24 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# jz.bm + +The study dashboard backend of the part-time master's programme "Bezpieczeństwo międzynarodowe" (UWr): courses, meetings, timetable, deadlines, reading lists with per-student progress, and study materials. + +```go +import _ "git.golem15.com/jakub/sm-bm-plugin" +``` + +## Overview + +`jz.bm` is a SummerCMS plugin. It keeps the programme's data in `jz_bm_*` tables, serves it to signed-in students as a read-only JSON API under `/_bm/api/v1`, and gives administrators admin screens to edit it. Students write one thing only: which readings they have read. + +Students are the users of `golem15.user`: the API sits behind its `jwt.auth` guard, and reading progress points at its `users` table. Course data is loaded from a JSON file with `bm:seed` and maintained in the admin afterwards. + +Timestamps are stored in UTC (`TIMESTAMPTZ`) and written in RFC 3339 in Europe/Warsaw (`2026-10-03T08:00:00+02:00`). Dates are `YYYY-MM-DD`. A date or time typed without an offset (in the admin or in a seed file) is read as Polish time. + +## Features + +- Models: `Course` (przedmiot, one row per course and form), `Meeting` (zjazd), `ClassSession` (zajęcia), `Deadline` (terminarz), `Reading` (lektura), `ReadingProgress`, `Material` with file attachments (`system_files`, field `files`) and `Tag`, many-to-many through `jz_bm_material_tag`. +- Courses that share an `elective_group` are alternatives ("do wyboru"), such as the specialisation courses. +- A material without `published_at` is a draft; a material whose `published_at` lies in the future is not shown yet. +- Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress rows. +- Admin screens for every model in a "Studia BM" navigation group, guarded by three permissions. +- `bm:seed`: an idempotent upsert of meetings, courses, class sessions, deadlines and readings from a JSON file, all or nothing. + +## API reference + +Every route needs `Authorization: Bearer ` (or the user plugin's token cookie) and answers 401 `{"error": true, "message": "..."}` without one. Responses are wrapped in `{"data": ...}`; `/materials` adds the `meta` block of `lagoon.Paginate`. A missing record, an unpublished material or a non-numeric `{id}` is a 404. Invalid query parameters are a 422 in Laravel's shape, with messages in the request locale: + +```json +{"message": "The to must be a date after or equal to from.", "errors": {"to": ["The to must be a date after or equal to from."]}} +``` + +| Method | Path | Query | Answers | +|--------|------|-------|---------| +| GET | `/_bm/api/v1/dashboard` | | Next sessions, upcoming deadlines, current and next meeting, reading progress | +| GET | `/_bm/api/v1/courses` | `semester` (1–4) | Every course with counters | +| GET | `/_bm/api/v1/courses/{id}` | | One course with its sessions, readings and published materials | +| GET | `/_bm/api/v1/schedule` | `from`, `to` (`YYYY-MM-DD`) | Class sessions starting in the range | +| GET | `/_bm/api/v1/meetings` | | Every meeting | +| GET | `/_bm/api/v1/deadlines` | `from`, `to` (`YYYY-MM-DD`) | Deadlines in the range | +| GET | `/_bm/api/v1/readings` | `course` (ID) | Readings with the caller's `read_at` | +| PUT | `/_bm/api/v1/readings/{id}/progress` | | Marks the reading read (idempotent) | +| DELETE | `/_bm/api/v1/readings/{id}/progress` | | Clears the mark (idempotent) | +| GET | `/_bm/api/v1/materials` | `course`, `tag` (slug), `type`, `page`, `per_page` (1–100) | One page of published materials | +| GET | `/_bm/api/v1/materials/{id}` | | One published material with body, tags and files | + +### Shared shapes + +A course reference, embedded in other records: + +```json +{"id": 1, "code": null, "name": "…", "form": "wyklad", "semester": 1} +``` + +`form` is one of `wyklad`, `cwiczenia`, `konwersatorium`, `seminarium`, `inne`. + +A class session: + +```json +{"id": 1, "course_id": 1, "meeting_id": 1, "meeting_number": 1, + "starts_at": "2026-10-03T08:00:00+02:00", "ends_at": "2026-10-03T09:30:00+02:00", + "room": "237", "kind": null, "notes": null, "is_online": false, + "course": {"id": 1, "code": null, "name": "…", "form": "wyklad", "semester": 1}} +``` + +A meeting: + +```json +{"id": 1, "number": 1, "starts_on": "2026-10-03", "ends_on": "2026-10-04", "notes": null, "sessions_count": 12} +``` + +A deadline (`kind` is one of `zaliczenie`, `egzamin`, `praca`, `organizacyjne`, `inne`; `course` is null when the deadline belongs to no course): + +```json +{"id": 1, "title": "…", "due_at": "2026-10-05T23:59:00+02:00", "kind": "organizacyjne", "description": null, "course_id": null, "course": null} +``` + +A reading (`read_at` is the caller's, or null): + +```json +{"id": 1, "course_id": 1, "course": {…}, "title": "…", "author": null, "source": null, "url": null, + "is_required": true, "sort_order": 1, "read_at": "2026-10-02T21:15:04+02:00"} +``` + +A material in a list (`type` is one of `pdf`, `notatka`, `link`, `inne`; `files_count` counts public files): + +```json +{"id": 1, "course_id": 1, "course": {…}, "title": "…", "type": "notatka", "source": null, "url": null, + "published_at": "2026-10-01T12:00:00+02:00", "tags": [{"id": 1, "name": "Geopolityka", "slug": "geopolityka"}], "files_count": 1} +``` + +A course with counters (`readings_read` is the caller's; `materials` counts published materials): + +```json +{"id": 1, "code": null, "name": "…", "form": "wyklad", "lecturer": "…", "semester": 1, "ects": 8, + "assessment": "egzamin", "elective_group": null, "description": null, "syllabus_url": null, "sort_order": 1, + "counts": {"sessions": 6, "readings": 4, "readings_read": 1, "materials": 2}} +``` + +### GET /dashboard + +```json +{"data": { + "next_sessions": [, …], + "upcoming_deadlines": [, …], + "current_meeting": | null, + "next_meeting": | null, + "readings": {"total": 40, "read": 3, "required_total": 25, "required_read": 2} +}} +``` + +`next_sessions` are the first sessions that have not ended yet, by start time; `upcoming_deadlines` the first deadlines not yet due. Both hold `jz.bm.dashboard_limit` items (5). `current_meeting` is the meeting whose dates include today in Warsaw; `next_meeting` the first one that starts after today. + +### GET /courses, GET /courses/{id} + +`/courses` answers `{"data": [, …]}` in programme order (semester, `sort_order`, name). `/courses/{id}` answers one course with counters plus: + +```json +{"data": {…course with counters…, + "sessions": [, …], + "readings": [, …], + "materials": [, …]}} +``` + +### GET /schedule, GET /meetings, GET /deadlines + +`/schedule` answers `{"data": [, …]}` by start time: the sessions that start from midnight of `from` (default today) until the end of `to` (default no limit), in Warsaw. `/meetings` answers `{"data": [, …]}` by number. `/deadlines` answers `{"data": [, …]}` by due time; without `from` it starts now, so it lists the deadlines not yet due. + +### GET /readings, PUT and DELETE /readings/{id}/progress + +`/readings` answers `{"data": [, …]}` in course and `sort_order` order; an unknown `course` gives an empty list. The progress routes answer: + +```json +{"data": {"reading_id": 7, "read_at": "2026-10-02T21:15:04+02:00"}} +``` + +Marking a read reading again keeps the first `read_at`. Clearing answers `read_at: null`, also when the reading was not marked. + +### GET /materials, GET /materials/{id} + +```json +{"data": [, …], "meta": {"current_page": 1, "last_page": 1, "per_page": 20, "total": 2}} +``` + +Newest publication first. The detail adds the Markdown body and the public files; `url` starts with `storage.uploads.public_path_prefix`: + +```json +{"data": {…material…, "body": "# …", + "files": [{"id": 3, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.pdf", "name": "wyklad.pdf", "size": 123456, "mime": "application/pdf"}]}} +``` + +## Admin + +Controllers (admin API path `/api/v1/jz/bm/`), all in the "Studia BM" navigation group: + +| Controller | Permission | +|------------|------------| +| `jz.bm.courses`, `jz.bm.readings` | `jz.bm.access_courses` | +| `jz.bm.meetings`, `jz.bm.class_sessions`, `jz.bm.deadlines` | `jz.bm.access_schedule` | +| `jz.bm.materials`, `jz.bm.tags` | `jz.bm.access_materials` | + +Courses, meetings, sessions and deadlines are picked through relation fields; a material's tags through a belongsToMany relation field. Dates are typed as `2026-10-03` and times as `2026-10-03 08:00` (Polish time) or RFC 3339; a value that does not parse is a 422 on its field. Labels are in Polish (`lang/pl`) and English (`lang/en`). + +Two things the admin cannot do yet, because the framework does not offer them: upload a material's files (there is no file upload form widget, so files are attached as `system_files` rows outside the admin), and list a course's readings and sessions inside the course form (relation managers handle only belongsToMany links). Both are marked `TODO: requires SummerCMS change` in the code. + +## Configuration + +Defaults in `config/config.yaml`, read as `jz.bm.`: + +| Key | Default | Sets | +|-----|---------|------| +| `jz.bm.materials_per_page` | `20` | Default page size of `/materials` (clamped to 1–100). | +| `jz.bm.dashboard_limit` | `5` | Sessions and deadlines on `/dashboard`. | + +## CLI commands + +### bm:seed + +```sh +./bin/bm bm:seed data/bm-2026-27.json +./bin/bm bm:seed data/bm-2026-27.json --dry-run +``` + +Reads a JSON file, validates all of it, and upserts it in one transaction. Each kind is matched on its identity, updated in place when a field changed, and created otherwise; rows that are not in the file are left alone, so running the same file again changes nothing. Any error (an unknown key in the document, a bad value, a course key or meeting number that does not exist in the file) names the record and writes nothing. `--dry-run` only validates. The command prints a table of created, updated and unchanged rows per kind. + +| Kind | Identity | +|------|----------| +| meetings | `number` | +| courses | `name` + `form` + `semester` | +| class_sessions | course + `starts_at` | +| deadlines | `title` + `due_at` | +| readings | course + `title` | + +The file format (every top-level array is optional, and nullable fields may be left out): + +```json +{ + "meetings": [{"number": 1, "starts_on": "2026-10-03", "ends_on": "2026-10-04", "notes": "stacjonarny"}], + "courses": [{"key": "glob-w", "code": null, "name": "…", "form": "wyklad", "lecturer": "…", "semester": 1, + "ects": 8, "assessment": "egzamin", "elective_group": null, "description": null, "syllabus_url": null, "sort_order": 1}], + "class_sessions": [{"course": "glob-w", "meeting": 1, "starts_at": "2026-10-03T08:00:00+02:00", "ends_at": "2026-10-03T09:30:00+02:00", + "room": "237", "kind": null, "is_online": false, "notes": null}], + "deadlines": [{"title": "…", "due_at": "2026-10-05T23:59:00+02:00", "course": null, "kind": "organizacyjne", "description": null}], + "readings": [{"course": "glob-w", "title": "…", "author": null, "source": null, "url": null, "is_required": true, "sort_order": 1}] +} +``` + +- `key` is a reference inside the file only; `course` in sessions, deadlines and readings names a course `key`, `meeting` in sessions a meeting `number`. +- `form`: `wyklad`, `cwiczenia`, `konwersatorium`, `seminarium`, `inne`. `semester`: 1–4. `kind` of a deadline: `zaliczenie`, `egzamin`, `praca`, `organizacyjne`, `inne`. +- Dates are `YYYY-MM-DD`; times RFC 3339 with an offset, or `YYYY-MM-DD HH:MM` in Polish time. +- A blank optional text is stored as null. + +`testdata/seed.example.json` is a small example with made-up data. + +## Dependencies + +- SummerCMS framework modules: `pact`, `party`, `backpack`, `surf`, `wire`, `bouncer`, `lagoon` (with `lagoon/attach`), `cabana`, `bonfire`, `phrasebook`. +- The `golem15.user` plugin (`git.golem15.com/golem15/sm-user-plugin`): the `users` table and the `jwt.auth` middleware. `jz.bm` declares it in `Requires`, so its migrations run first. +- GORM and gormigrate. Tests also use testcontainers-go and pgx. + +## Testing + +```sh +go vet ./... +go test ./... # unit tests and Postgres integration tests (Docker) +go test -short ./... # unit tests only +``` + +The integration tests start a `postgres:16-alpine` container through testcontainers and create a fresh database per test; they never touch a development database. They cover the migrations up and down, the schema constraints, `bm:seed` (idempotency, in-place updates, all-or-nothing), every API route through the real `jwt.auth` guard, and admin saves through the admin API.