Close the gaps an audit of courses, tasks, files and grades turned up

Every area — courses, rooms, boards, topics, tasks, files, quizzes, teams,
groups, submissions, grades — was checked for data the instance has and the
tools did not show.

Grades and feedback. A teacher's /homework page is a different page from a
student's: grade and comment live in the grading form, one block per
submission, so a teacher account reported every graded submission as having
neither. parseTeacherGrading reads the form, and list_submissions can now
include the written feedback and who handed the work in.

Names. /api/v1 is partly served: courses, users and classes survive in the
deployment's ingress table, and users/{id} is the only route from an id to a
name. Submitters, file creators and course teachers resolve through it, and
degrade to "not visible to this account" where a student may not read them.

Courses, rooms and classes. get_course adds the description, teachers,
member count and weekly timetable from /api/v1/courses. list_classes is new.
get_room reports what the account may do — allowedOperations is an object of
booleans, not the list it was typed as — and applicants and invitation links
where it may manage them.

Board and topic content. Link descriptions, image alt text, drawing and
video-conference titles, the ids behind external tools and H5P content (the
only thing resembling a quiz), and what a deleted element used to be. Topic
Etherpad pads are read like board pads, and htmlToText keeps table columns
apart and drops template indentation.

Files. A scan with no text layer falls back to the preview endpoint, whose
width and outputFormat are undocumented enums, so Claude gets a picture of
the page; list_files reports counts and sizes. Teams stay documented as
unreadable at any API version; their files come later.

What the crawl missed. Tasks attached to topics (18 of 60 on the live
account), each course's own file area, and — behind INDEX_PERSONAL_FILES —
personal files and submissions with their grade comments, so search and
what_changed cover grading. A submission hit points at get_task.

The local instance's preview profile gets an ImageMagick policy that allows
the coders its 7.1.2 build needs; the image's own denies them all.

110 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 20:19:16 +02:00
parent a3b17a680c
commit 5ae2210459
25 changed files with 1462 additions and 89 deletions

View File

@@ -1,6 +1,6 @@
import type { Config } from './config.ts';
import { SchulcloudClient } from './core/client.ts';
import type { MeResponse } from './core/types.ts';
import type { LegacyUser, MeResponse } from './core/types.ts';
import type { Indexer } from './indexer/indexer.ts';
import type { Store } from './store/store.ts';
@@ -18,6 +18,17 @@ export class ServerContext {
readonly store: Store | undefined;
readonly indexer: Indexer | undefined;
private identity: Promise<MeResponse> | undefined;
/**
* id -> display name, for the whole session.
*
* Submission `submitters`, file `creatorId` and course `teacherIds` are all
* bare ids, and the only route that resolves one is `/api/v1/users/{id}` —
* one request per person. Names do not change within a session and the same
* handful of people recur across every course, so this is cached hard,
* including the misses: a lookup a student is not allowed to make would
* otherwise be retried for every row it appears in.
*/
private readonly userNames = new Map<string, Promise<string | undefined>>();
constructor(config: Config, shared?: { client?: SchulcloudClient; store?: Store; indexer?: Indexer }) {
this.config = config;
@@ -40,8 +51,56 @@ export class ServerContext {
return (await this.me()).school.id;
}
/**
* Display name for a user id, or undefined when it cannot be resolved.
*
* Never throws: a 403 here is ordinary — a student may read their own
* classmates but not every id that appears on a board — and a row that
* falls back to the bare id is far better than a tool that fails.
*/
userName(userId: string): Promise<string | undefined> {
let pending = this.userNames.get(userId);
if (!pending) {
pending = this.client
.getLegacyUser(userId)
.then((user: LegacyUser) => {
const name = user.fullName ?? user.displayName ?? [user.firstName, user.lastName].filter(Boolean).join(' ');
return name.trim() || undefined;
})
.catch(() => undefined);
this.userNames.set(userId, pending);
}
return pending;
}
/** Resolves several ids at once, falling back to the id itself. */
async userNamesFor(userIds: string[]): Promise<string[]> {
const unique = [...new Set(userIds)];
const names = await Promise.all(unique.map(async (id) => (await this.userName(id)) ?? id));
return names;
}
/**
* Resolves several ids, reporting how many could not be read.
*
* `/api/v1/users/{id}` answers 403 for anyone but yourself unless the account
* has permission over them: a teacher can read their students, a student
* cannot read their teachers. Printing the raw id in that case is noise, so
* callers that would show a name to a human use this and say "2 others"
* instead of pasting two 24-character ids.
*/
async resolveNames(userIds: string[]): Promise<{ names: string[]; unresolved: number }> {
const unique = [...new Set(userIds)];
const resolved = await Promise.all(unique.map((id) => this.userName(id)));
return {
names: resolved.filter((name): name is string => Boolean(name)),
unresolved: resolved.filter((name) => !name).length,
};
}
/** Drops the cached identity so the next call re-reads it. */
reset(): void {
this.identity = undefined;
this.userNames.clear();
}
}