Adds what the API actually permits, which is less than the request asked
for and worth being precise about.
GET /api/v3/submissions/status/task/{taskId} is the only submission
route — no list, no fetch-by-id — so a task id is the only way in. The
probe in the report missed it by trying /api/v3/submissions (404). Its
payload is {id, submitters, isSubmitted, isGraded, grade,
submittingCourseGroupName} and nothing more: no submitted text, no grade
comment, no graded-at. Those lived on /api/v1, which this instance does
not serve at all (404 across the board, confirmed — not the proxy). So
"what feedback did I get" is answerable only when the feedback is a file.
Submitted files are reachable, which covers the main workflow:
get_task now shows the submission id, graded state, grade, group, and
the handed-in files with ids ready for download_file. list_submissions
surveys tasks for "what have I handed in" and "what is still ungraded".
Both state the text/feedback gap rather than implying none was given.
Two things found while building it:
files-storage ignores the parentType path segment when listing —
.../gradings/{id} returns the same records, saying parentType
"submissions". Filtering on each record's own parentType, or a student's
own upload gets reported back as teacher feedback.
get_task could not find this task at all: the task lists only cover the
dashboard, and group-project tasks are absent from both, so it claimed
the id was wrong for a task the account can plainly see. It now falls
back to scanning course pages.
Also bounds live search by measured cost: resolving attachments needs a
request per board element, which is 2s for one course but 325s for all
of them — beyond any client timeout. An unscoped fresh search now reads
text only and says so.
38/38 smoke checks; verified end to end through Claude Code against a
real graded group submission.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
8.8 KiB
Markdown
177 lines
8.8 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.** "Rooms" are the newer standalone
|
|
collaboration spaces, unrelated to courses. On this instance the account has
|
|
none, so `GET /api/v3/rooms` returns `{"data":[]}` — which reads like a broken
|
|
endpoint but is simply an empty feature.
|
|
|
|
**`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'`.
|
|
|
|
**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.
|
|
|
|
**`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 contents are **not** retrievable through the API —
|
|
`GET /api/v3/collaborative-text-editor/{parentType}/{parentId}` returns a URL to
|
|
the Etherpad-style editor, not the document text.
|
|
|
|
## 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.
|