feat(manage): GET and PUT /api/v1/bm/materials/{id}

The management API could only upsert a material on its title and course,
so a renamed note became a second material. PUT /materials/{id} applies the
same body to one material (422 when the new title and course belong to
another one), GET /materials/{id} shows a draft or published material with
its public files, and the upsert, update and validation share one path.
The README documents the management API.
This commit is contained in:
Jakub Zych
2026-10-16 20:40:47 +02:00
parent 2a8cdac282
commit d2096e2128
5 changed files with 387 additions and 72 deletions

View File

@@ -185,6 +185,35 @@ The calendar is RFC 5545 (CRLF lines folded at 75 octets, escaped TEXT) with tim
`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). 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/courses` | read, courses | Every course (`courseJSON`), for the `course_id` of a material. |
| GET | `/api/v1/bm/materials` | read, materials | Every material, drafts included, newest first. |
| 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; 404 when unknown. |
| 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 material body (POST and PUT), unknown keys are a 422:
```json
{"course_id": 7, "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`. `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).
- `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).
- Files are not uploaded through this API; attach them in the admin.
## 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/`.