docs/API.md gains the endpoint and what it returns, why `play` is the bigger and lesser of the two, the element-by-id route found while probing, and the per-library shapes with the traps in them — the string "true", the inline cloze markers, the correct-option-first convention, and the UI subtrees that drown an exercise in button labels. CLAUDE.md's "no quiz of its own" note said a contentId was the only handle onto the content. That was the reason nobody looked further, so it now says where to look instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
412 lines
23 KiB
Markdown
412 lines
23 KiB
Markdown
# The Schulcloud API, as verified against this instance
|
|
|
|
Everything here was confirmed against `https://schulcloud-thueringen.de` with a
|
|
real student account on 2026-09-11, not inferred from source. Where upstream
|
|
source and live behaviour disagreed, live behaviour won.
|
|
|
|
## Two services, one origin
|
|
|
|
| Service | Source repo | Base path | Self-documenting at |
|
|
|---|---|---|---|
|
|
| Main server (NestJS) | [`schulcloud-server`](https://github.com/hpi-schul-cloud/schulcloud-server) | `/api/v3/` | `/api/v3/docs`, `/api/v3/docs-json` |
|
|
| Files storage | [`file-storage`](https://github.com/hpi-schul-cloud/file-storage) | `/api/v3/file/` | `/api/v3/file/docs`, `/api/v3/file/docs-json` |
|
|
|
|
Both accept the same bearer token. The files service was split out of
|
|
`schulcloud-server` into its own repository, which is why no `file` paths
|
|
appear in the main `docs-json` — a detail that will send you in circles if you
|
|
only read the main spec. Fetch both:
|
|
|
|
```bash
|
|
curl -s "$TSC_URL/api/v3/docs-json" -o docs-v3.json # 212 paths
|
|
curl -s "$TSC_URL/api/v3/file/docs-json" -o docs-file.json # 26 paths
|
|
```
|
|
|
|
These are the authoritative reference for *this* instance's deployed version.
|
|
Prefer them over the GitHub sources, which track `main` and may be ahead.
|
|
|
|
## Which repositories matter
|
|
|
|
The `hpi-schul-cloud` org has ~100 repos, most archived or superseded. The live
|
|
ones relevant here:
|
|
|
|
- **`schulcloud-server`** — the API. Read `apps/server/src/modules/<module>/api/`
|
|
for controllers and DTOs.
|
|
- **`file-storage`** — the files service, extracted from the above.
|
|
`src/modules/files-storage/api/controller/files-storage.controller.ts` is the
|
|
whole surface.
|
|
- **`nuxt-client`** — the current web front end. Useful for seeing which API
|
|
calls the real UI makes in which order.
|
|
- **`schulcloud-client`** — the *legacy* Handlebars front end. Still receives
|
|
commits, but it is not where new features land.
|
|
|
|
Superseded/archived and worth ignoring: `authorization-service`,
|
|
`schulcloud-editor`, `nexboard-api-js`, `end-to-end-tests`, `docker-compose`,
|
|
`H5P-Nodejs-library`, `shd-client`.
|
|
|
|
Note the naming: `/api/v1` is the old Feathers surface. On this instance
|
|
`/api/v1/docs` 404s, and the v3 NestJS API covers everything this server needs.
|
|
|
|
## Content model
|
|
|
|
```
|
|
Course ─┬─ column board ─── column ─── card ─── element ─┬─ richText
|
|
│ ├─ file ──── fileRecord(s)
|
|
│ ├─ link
|
|
│ └─ …
|
|
├─ lesson (Thema) ─── contents[] + materials[]
|
|
└─ task (Aufgabe) ─── description + fileRecord(s)
|
|
```
|
|
|
|
On the account this was built against: 26 courses holding 30 column boards, 18
|
|
lessons, 42 tasks and 175 files. **Column boards hold the great majority of
|
|
current material**; lessons are the older format.
|
|
|
|
## Endpoints this server uses
|
|
|
|
| Purpose | Call |
|
|
|---|---|
|
|
| Identity, school id, permissions | `GET /api/v3/me` |
|
|
| Courses | `GET /api/v3/courses?skip&limit` |
|
|
| One course's contents | `GET /api/v3/course-rooms/{courseId}/board` |
|
|
| Dashboard tiles | `GET /api/v3/dashboard` |
|
|
| Tasks | `GET /api/v3/tasks`, `GET /api/v3/tasks/finished` |
|
|
| Lesson body | `GET /api/v3/lessons/{lessonId}` |
|
|
| Lesson's tasks | `GET /api/v3/lessons/{lessonId}/tasks` |
|
|
| Board structure | `GET /api/v3/boards/{boardId}` |
|
|
| What a board belongs to | `GET /api/v3/boards/{boardId}/context` |
|
|
| Card bodies | `GET /api/v3/cards?ids=<id>&ids=<id>` |
|
|
| Files of an entity | `GET /api/v3/file/list/{storageLocation}/{storageLocationId}/{parentType}/{parentId}` |
|
|
| One file's metadata | `GET /api/v3/file/{fileRecordId}` |
|
|
| File bytes | `GET /api/v3/file/download/{fileRecordId}/{fileName}` |
|
|
| News | `GET /api/v3/news` |
|
|
| Instance settings (no auth) | `GET /api/v3/config/public` |
|
|
| Remaining idle budget | `POST /api/v3/authentication/refresh-session` → `{expiresInSeconds}` |
|
|
|
|
### Gotchas that cost real time
|
|
|
|
**`course-rooms`, not `courses`, for course contents.** `GET /api/v3/courses/{id}`
|
|
does not exist. The route that returns a course's lessons/tasks/boards is
|
|
`GET /api/v3/course-rooms/{roomId}/board`, and its `:roomId` is the *course* id.
|
|
Nothing in the naming suggests this.
|
|
|
|
**`/api/v3/rooms` is a different feature, and the UI's naming hides it.** Rooms
|
|
("Räume") are the newer standalone collaboration spaces. The sidebar's *Kurse*
|
|
entry links to `/rooms/courses-overview` and lists **courses** (served by
|
|
`/api/v3/dashboard` + `/api/v3/courses`), while *Räume* links to `/rooms` and
|
|
lists **rooms** — so a url containing `/rooms` identifies neither.
|
|
|
|
A room holds boards and nothing else: no lessons, no tasks. `GET /rooms`
|
|
answers `{"data":[]}` with no `total`, derived from real memberships, so an
|
|
empty result means the account is in no rooms — which is also what it looks
|
|
like after a teacher deletes a room or revokes access. `GET /rooms/{id}/boards`
|
|
does report `isVisible`, unlike the course-page projection, so a room's draft
|
|
boards can be identified without trying to open one.
|
|
|
|
**`limit` maxima are enforced and mis-documented.** The OpenAPI schema says
|
|
`maximum: 99`; the runtime validator rejects anything `> 100`. Page at 99 to
|
|
satisfy both. Asking for 200 returns a `400 API_VALIDATION_ERROR`, not a
|
|
truncated list.
|
|
|
|
**There is no `GET /tasks/{id}`.** Single-task detail has to be assembled: the
|
|
list endpoints give metadata but *omit `description`*, which appears only on the
|
|
course page's task element. `get_task` does this join.
|
|
|
|
**Board files need three calls.** A `file` element's `content` carries only
|
|
`{caption, alternativeText}` — no file id. The bytes are found by listing
|
|
files-storage with `parentType: 'boardnodes'` and the **element** id as
|
|
`parentId`. This is the single least discoverable part of the API, and applies
|
|
equally to `fileFolder` and `drawing` elements.
|
|
|
|
**`GET /cards?ids=` accepts at most 20 ids.** Above that the request fails with
|
|
`400 "each value in ids must be a mongodb id"` — blaming the ids when the real
|
|
problem is how many there are. Express/NestJS parse the query string with `qs`,
|
|
whose default `arrayLimit` is 20; past it, repeated params become an object
|
|
keyed `"0"`, `"1"`, … and `@IsMongoId({ each: true })` then rejects every value.
|
|
Nothing in the controller or its DTO says so: the limit lives in the query
|
|
parser underneath them. Verified live — 20 ids return 200, 21 return 400 with
|
|
identical ids. A board with more than 20 cards is therefore unreadable in one
|
|
request; `getCards` chunks at 20.
|
|
|
|
**Submissions are nearly invisible.** The only route is
|
|
`GET /api/v3/submissions/status/task/{taskId}` — there is no `GET /submissions`
|
|
and no fetch-by-id, so a task id is the only way to reach a submission. The
|
|
response carries `{id, submitters, isSubmitted, isGraded, grade,
|
|
submittingCourseGroupName}` and **nothing else**: no submitted text, no grade
|
|
comment, no graded-at. Those lived on the legacy Feathers API, and `/api/v1` is
|
|
not served on this instance (404 across the board), so they are simply
|
|
unavailable. Submitted *files* are reachable through files-storage with
|
|
`parentType: 'submissions'`.
|
|
|
|
**Submitted text and written feedback exist only in the rendered web page.**
|
|
The legacy front end calls the Feathers API server-side for
|
|
`submission.comment`, `submission.grade` and `submission.gradeComment` and
|
|
renders them into `GET /homework/{taskId}`. That Feathers API is not exposed
|
|
publicly (`/api/v1/*` 404s), so the page is the only way to reach these from
|
|
outside. `core/homework-page.ts` parses it, hooked on the `data-testid`
|
|
attributes the project's own e2e tests use. Note the page authenticates with the
|
|
`jwt` **cookie** — an `Authorization` header is ignored and redirects to the
|
|
identity provider. Measured: 4 of 7 graded submissions in one course carried
|
|
feedback no API call can return.
|
|
|
|
**A grade is a percentage or nothing.** The submission schema is
|
|
`grade: { type: Number, min: 0, max: 100 }` and `gradeComment: { type: String }`
|
|
— there is no textual grade field. In practice teachers frequently grade with
|
|
`gradeComment` alone and leave `grade` unset, so a written "OK" is the whole
|
|
verdict for that submission. Treat a missing `grade` as "no percentage given",
|
|
never as "ungraded" (`graded` is its own boolean) and never as an error.
|
|
|
|
**files-storage ignores `parentType` when listing.** Asking for
|
|
`.../gradings/{submissionId}` returns the files parented to that id whatever
|
|
their type — the records come back saying `parentType: "submissions"`. The path
|
|
segment appears to serve authorisation, not filtering, so filter on each
|
|
record's own `parentType` or a student's own upload will be reported back as
|
|
teacher feedback.
|
|
|
|
**`storageLocationId` is the school id** (from `/me`), with
|
|
`storageLocation: 'school'`, for every parent type in normal use.
|
|
|
|
**Lesson ids come back as buffers.** `GET /api/v3/lessons/{id}` returns nested
|
|
ids as `{buffer:{type:'Buffer',data:[...]}}` rather than hex strings — a leak
|
|
from the legacy Mongo serialisation. `normalizeObjectId` in `src/render.ts`
|
|
converts them.
|
|
|
|
**The JWT's `exp` is not the session lifetime, and the source misleads here.**
|
|
Both the current and legacy whitelist implementations re-set a Valkey TTL on
|
|
every authenticated request, which reads as a sliding window. The live instance
|
|
does not behave that way: a session ends ~2 h after **login**, and successful
|
|
reads in between do not extend it (measured — see `docs/AUTH.md`). This is the
|
|
clearest case in this API of live behaviour diverging from upstream source.
|
|
|
|
**`GET /api/v3/config/public` is unauthenticated and useful.** 78 keys of
|
|
instance configuration, including the session timeouts and feature flags. Handy
|
|
for checking deployed settings without a token.
|
|
|
|
**`GET /lessons/{id}/tasks` returns a bare array, and its items have no id.**
|
|
Every other list endpoint returns `{data, total}`; this one returns the array
|
|
directly, so reading `.data` silently yields `undefined`. Worse, the items are
|
|
`LessonLinkedTaskResponse`, which has no id property at all — name, description
|
|
and dates only. A task attached to a topic is therefore unidentifiable from the
|
|
API: it is not a task element on the course page (the topic reports only
|
|
`numberOfPublishedTasks`), and once past due it is in neither `/tasks` nor
|
|
`/tasks/finished`. On the account this server was built for that hid 18 of 60
|
|
tasks, submissions and grades included. The ids are recoverable only from the
|
|
legacy topic page, which links each task as `/homework/{id}` —
|
|
`core/lesson-page.ts`.
|
|
|
|
**A student's task lists exclude past-due tasks.** `/tasks` drops a task once
|
|
its due date passes; `/tasks/finished` holds only what the student ticked off.
|
|
A submitted, graded, past-due task is in neither. Reach it through the course
|
|
page, or through its topic.
|
|
|
|
**An unpublished board is listed but cannot be opened.** The course-board
|
|
projection reports a draft board with its title, while `GET /boards/{id}`
|
|
answers 403 for anyone who cannot edit it. Treat a 403 there as "probably not
|
|
published yet", not as an access problem.
|
|
|
|
**`PATCH /file/rename/{fileRecordId}` mutates a file record in place.** The id
|
|
and size stay the same, so any change detection keyed on those alone misses it.
|
|
|
|
**`Content-Disposition` on downloads is malformed.** It comes back as
|
|
`attachment;; filename="…"` — note the doubled semicolon — and the filename is
|
|
percent-encoded inside the quotes. Parse defensively.
|
|
|
|
### Content element types
|
|
|
|
From `ContentElementType` in `schulcloud-server`, all seen live except where
|
|
noted: `richText`, `file`, `fileFolder`, `link`, `drawing`,
|
|
`collaborativeTextEditor`, `externalTool`, `videoConference`, `h5p`, `deleted`.
|
|
|
|
Collaborative text editor elements come back with `content: {}` — no pad id, no
|
|
url, nothing. `GET /api/v3/collaborative-text-editor/content-element/{elementId}`
|
|
returns the pad url, and **also sets an Etherpad `sessionID` cookie** in its
|
|
response. With that cookie, Etherpad's own `/etherpad/p/{padId}/export/txt`
|
|
returns the document as plain text. So the contents *are* reachable, in two
|
|
hops and without Etherpad's API key; `core/etherpad.ts` does this. The url is
|
|
built from the server's `ETHERPAD__PAD_URI`, so it must be checked against the
|
|
instance host before the session cookie is sent to it.
|
|
|
|
### H5P elements are the quizzes, and one request holds a whole one
|
|
|
|
An `h5p` element carries nothing but `content: { contentId }`. The content
|
|
itself comes from the H5P service, whose API lives under `/api/v3/h5p-editor/`
|
|
— not in `docs-json`, and with no document of its own
|
|
(`/api/v3/h5p-editor/docs-json` is a 404). The deployment's ingress table routes
|
|
`/h5p/player` and `/h5p/editor`, which are the front-end apps, not this API.
|
|
|
|
- **`GET /api/v3/h5p-editor/params/{contentId}`** returns the JSON the player is
|
|
fed: `{ h5p: <metadata>, library, params: { metadata, params } }`. The inner
|
|
`params` is the exercise — **every question, every option and which are
|
|
correct** — so the player showing one question at a time is a display detail,
|
|
not a limit on what can be read. Bearer auth, same as everything else.
|
|
- `GET /api/v3/h5p-editor/play/{contentId}` is the same content wrapped in the
|
|
player's integration object: 74 KB against 51 KB for the live quiz below,
|
|
because it carries script and style lists. `params` is both smaller and
|
|
complete, so nothing needs `play`.
|
|
- `GET /api/v3/elements/{elementId}` returns a single board element with its
|
|
content, which is how an element's `contentId` can be re-read without its
|
|
board. (`/api/v3/board/element/{id}` does not exist.)
|
|
|
|
The **shape inside `params` belongs to the H5P library** the teacher used, which
|
|
is where the work is. Verified against the live quiz "Quiz zur formalen
|
|
Gestaltung einer Projektdoku" (`H5P.QuestionSet`, 20 questions):
|
|
|
|
- A `QuestionSet` holds `questions: [{ library, params, subContentId }]`, each
|
|
sub-content naming its own library — `H5P.MultiChoice 1.16` here. Any other
|
|
main library *is* a single question, with the same `params` shape.
|
|
- `H5P.MultiChoice`: `question` and `answers[].text` are HTML,
|
|
`answers[].correct` is the solution, `answers[].tipsAndFeedback.tip` a hint,
|
|
and `behaviour.singleAnswer` is what makes the player draw radio buttons —
|
|
the only honest source for "tick exactly one".
|
|
- `H5P.TrueFalse` stores `correct` as the **string** `"true"`/`"false"`, with
|
|
the button labels in `l10n`.
|
|
- Cloze libraries (`H5P.Blanks`, `H5P.DragText`, `H5P.MarkTheWords`) mark the
|
|
solutions inside the text as `*answer:tip*`, alternatives separated by `/`.
|
|
- `H5P.SingleChoiceSet` and `H5P.Summary` put the **correct option first** and
|
|
let the player shuffle; nothing else marks it.
|
|
- Every payload also carries `UI`, `l10n`, `behaviour` and `overallFeedback`
|
|
subtrees of button labels and display settings. Anything that harvests text
|
|
generically has to skip them, or the exercise reads as "Überprüfen,
|
|
Wiederholen, Absenden".
|
|
|
|
`core/h5p.ts` models the libraries above and harvests the text of anything else
|
|
under a label saying so — a teacher's exercise reported as "0 questions" would
|
|
be worse than a clumsy rendering of it.
|
|
|
|
## The file manager ("Dateien") is a third store
|
|
|
|
Persönliche Dateien, Kurs-Dateien, Team-Dateien and Geteilte Dateien are the
|
|
**legacy file system**: a `files` collection with real folders (`isDirectory`,
|
|
`parent`, `owner`, `refOwnerModel`), served by the legacy Feathers
|
|
`fileStorage` service. It shares nothing with files-storage. Asking
|
|
`/api/v3/file/list/school/{school}/courses/{courseId}` answers **0** for a
|
|
course whose file manager holds dozens of worksheets — measured: 21 of 26
|
|
courses on the live account keep files here, some nothing else.
|
|
|
|
**Its service is not in the public ingress**, so the only way in is the legacy
|
|
client. Verified live:
|
|
|
|
| Route (legacy client, `jwt` cookie) | Returns | Notes |
|
|
|---|---|---|
|
|
| `GET /files/my/` , `/files/my/{folder}` | HTML listing | personal root / one folder |
|
|
| `GET /files/courses/` | HTML listing | the courses, *as folders* (ids = course ids) |
|
|
| `GET /files/courses/{course}` , `/files/courses/{course}/{folder}` | HTML listing | **one** folder segment at any depth |
|
|
| `GET /files/teams/…` | HTML listing | same shape as courses |
|
|
| `GET /files/shared/` | HTML listing | flat; shared *folders* cannot be opened (no route; the UI's link 404s) |
|
|
| `GET /files/signedurl?file={id}&name={name}` | `{"url": …}` | pre-signed S3 url, **another host** (live: `s3.hidrive.strato.com`) |
|
|
| `GET /files/permittedDirectories/` | JSON tree | **lists every course with no folders in any** — see below |
|
|
| `GET /files/search/?q=` | HTML | **504** on live: an unindexed regex over every file record |
|
|
|
|
Listings are parsed on the attributes the page's own scripts use:
|
|
`data-folder-id` with the name inside `.card-title-directory` (emitted
|
|
**unescaped**, `{{{stripOnlyScript name}}}`), and `data-file-id`,
|
|
`data-file-name`, `data-file-size`, `data-file-viewer-type` on each
|
|
`.card.file`. A blocked file carries `btn-file-danger` and no viewer type. No
|
|
dates are rendered.
|
|
|
|
**`permittedDirectories` is broken for courses.** The directory service's
|
|
query matches course folders on `refOwnerModel: 'courses'`; the records say
|
|
`'course'`. Live result: 26 courses, 0 folders; personal folders come through.
|
|
Listings are the only complete view — which is also what the UI shows.
|
|
|
|
**Some GET routes write.** `GET /files/share/?file=` mints a share token when
|
|
the file has none (`PATCH /fileStorage/shared/{id}`), and
|
|
`GET /files/file?…&share=…` grants the caller a permission on the file.
|
|
`GET /files/fileModel/{id}/proxy` forwards to the latter. A GET-only client is
|
|
therefore *not* read-only against this surface by itself: `core/client.ts`
|
|
allows only the listing routes and `/files/signedurl`, by pattern.
|
|
|
|
**The signed-url service returns its error instead of throwing it**
|
|
(`.catch((err) => new Forbidden(err))`), so a refused file is a 200 whose body
|
|
has no `url`.
|
|
|
|
**Course names contain `/`** in real data ("LF07 - FIA24A/B - Sb/Ha",
|
|
"FIA24/FIP24 IT LF12"), and a real file is literally called `..docx`. Paths
|
|
built from names cannot be split naively; `core/legacy-files.ts` resolves by
|
|
trying joined segments, and accepts ids as segments.
|
|
|
|
Two more, seen while building the local fixture:
|
|
|
|
- **`getRefOwnerModel(owner)` answers "a course, or else `teams`".** Any owner
|
|
id that is not a course — a *user* included — is recorded as a team's. The
|
|
upload page never sends an owner for personal files (`data-owner=""`), so the
|
|
server defaults to the creator and records `user`; send the user id and every
|
|
later permission check dereferences a team that does not exist.
|
|
- **The file permission service writes `refOwnerModel`** where the "shared with
|
|
me" query reads `refPermModel`, so a share made through it never appears
|
|
under Geteilte Dateien. The share-link flow patches `/files/{id}` directly.
|
|
|
|
## WebUntis is a fourth store, and holds the timetable
|
|
|
|
Schulcloud's `times` on `/api/v1/courses` are empty for this school, so nothing
|
|
in Schulcloud says when a lesson happens, let alone that it was cancelled. That
|
|
lives in **WebUntis**, a separate product with its own login. What this server
|
|
uses is the API the Untis Mobile app uses, verified against
|
|
`ags-erfurt.webuntis.com` on 2026-09-17.
|
|
|
|
- **One endpoint, JSON-RPC:** `POST /WebUntis/jsonrpc_intern.do?m=<method>&school=<school>&v=i3.2`,
|
|
with `{"jsonrpc":"2.0","method":<method>,"params":[{…, "auth":{…}}]}`.
|
|
- **`v` is not optional.** Omit it and the call fails with `-8998` carrying a
|
|
Java `NullPointerException` from `getParameter`, which reads like a bug in
|
|
the request body and is not.
|
|
- Errors arrive with **HTTP 200** and an `error` member, so the body has to be
|
|
checked before the status.
|
|
- A plain `user-agent: schulcloud-mcp` is accepted; there is no need to
|
|
impersonate the app.
|
|
- **Authentication is a TOTP over a static base32 key**, no password and no
|
|
session: `auth: { user, otp, clientTime }` on every call, where `otp` is the
|
|
6-digit RFC 6238 code for the key from Profil → Freigaben → Untis Mobile.
|
|
- **Send the code as a string.** One in ten starts with a zero, which a JSON
|
|
number silently drops. Both shapes are accepted, so only the string is
|
|
always right.
|
|
- `-8504 bad credentials` = wrong key or user. `-8524 invalid client time` =
|
|
the host's clock is off, which is its own failure and worth naming.
|
|
- The response sets a `JSESSIONID`, but nothing needs it: each request
|
|
authenticates itself, which is why there is no keepalive on this side.
|
|
- **Methods that exist** (and are all this server may call — see the allowlist
|
|
in `core/untis.ts`): `getUserData2017`, `getTimetable2017`,
|
|
`getLessonTopic2017`, `getHomeWork2017`, `getMessagesOfDay2017`.
|
|
`getClassregEvents2017` and `getSchoolyears2017` answer "Method not found";
|
|
`getPeriodData2017` answers with empty objects for a student.
|
|
- **`startDateTime` claims to be UTC and is not.** Lessons come back as
|
|
`2026-09-21T08:00Z` and the school's time grid starts at 08:00 local, so the
|
|
`Z` is decoration. `new Date(...)` would move every lesson by an hour or two,
|
|
twice a year by a different amount; `splitLocal` takes the string apart
|
|
instead.
|
|
- **A substitution is two periods, not one changed period.** The original turns
|
|
up with `is: ["CANCELLED"]` and the replacement beside it with
|
|
`is: ["IRREGULAR"]`, same slot, different teacher. `orgId` on an element also
|
|
exists (the room moves that way), so both have to be read. Statuses seen on a
|
|
real account: `REGULAR`, `CANCELLED`, `IRREGULAR`.
|
|
- **`getTimetable2017` carries the whole master data with every answer** —
|
|
subjects, teachers with full names, rooms, classes, every holiday of six
|
|
school years. There is a `masterDataTimestamp` delta protocol; its removal
|
|
semantics are unverified, so this server asks for the full set and caches it
|
|
for a few hours.
|
|
- **Homework hangs off the periods too.** `getHomeWork2017` filters by the
|
|
homework's own dates, so a window ending today shows nothing due tomorrow and
|
|
nothing set last month — and the same items appear inline on the timetable's
|
|
periods, where they need no second call.
|
|
- **`getLessonTopic2017` takes `periodId`, singular.** It answers with
|
|
`previousTopics`: what the earlier lessons of that series actually covered,
|
|
from the class register. A `periodIds` array is rejected as "period 0 not
|
|
found".
|
|
- **The exam module is unused at this school**, so `getExams2017` is empty and
|
|
`period.exam` is null. Announced tests are typed into the period's **info
|
|
text** instead ("LF10: Leistungskontrolle agile Softwareentwicklung …"), which
|
|
makes `text.info` the most valuable field in the payload rather than a
|
|
footnote.
|
|
- **A day with no lessons is not necessarily a holiday.** At a vocational school
|
|
the weeks spent in the company simply have no periods, and `holidays` says
|
|
nothing about them. Reporting "Ferien" there would be wrong; so would an
|
|
empty answer.
|
|
- **The key can write.** This account's `rights` are `CLASSREGISTER`,
|
|
`R_MY_ABSENCES`, `W_OWN_ABSENCE`, `R_OFFICEHOURS` — the mobile API can report
|
|
an absence for the user. Nothing here does, and the allowlist is what
|
|
guarantees it.
|
|
|
|
## Re-verifying after an upstream release
|
|
|
|
`npm run probe` re-checks every assumption above against the live instance and
|
|
prints what it finds — both token clocks included: days until hard expiry, and
|
|
seconds of idle budget remaining.
|