Serve rooms ("Räume"), which are not courses however the urls read

The account this was built against is in no rooms, so the whole space was
invisible and easy to dismiss as an empty endpoint. It is not empty in
general — the user had rooms until a teacher removed access — and the
UI's naming actively hides the distinction: the sidebar's *Kurse* entry
links to `/rooms/courses-overview` and lists courses, while *Räume* links
to `/rooms` and lists rooms. A url containing `/rooms` identifies neither.

list_rooms and get_room cover the latter. A room holds boards and nothing
else, so get_room lists boards for get_board (which already reports "in
room" from the board context) plus who else is in it. Room boards report
`isVisible`, which the course-page projection does not, so a draft is
named as a draft instead of being offered and then answering 403.

Rooms also go through the crawl, or they would have become the next
blind spot: their boards are indexed, searchable by both the index and
the live-crawl path, diffed by what_changed, and mirrored by the CLI
under the room's name. The board traversal and the snapshot matcher are
now shared between courses and rooms rather than duplicated, which also
fixed the live-crawl path silently not searching pad contents.

The CLI needed no new command — it is file-centric and inherits rooms
through the manifest — but `--course` now accepts a room id, and says so.

`kind` gains 'room'; the column is plain TEXT, so no migration. 112 tests.
Smoke: 42/42 and 44/44 local, 41/41 and 43/43 live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-13 19:24:53 +02:00
parent 3a168e37e5
commit 1de026ca43
19 changed files with 584 additions and 95 deletions

View File

@@ -26,11 +26,22 @@ function assertDisposable(url: string): void {
}
}
function snapshot(courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[]): Snapshot {
function snapshot(
courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[],
rooms: { id: string; name: string; boardText?: string }[] = [],
): Snapshot {
return {
crawledAt: new Date(),
schoolId: 'school1',
failures: [],
rooms: rooms.map((r) => ({
id: r.id,
name: r.name,
boards: r.boardText
? [{ id: `${r.id}-b`, title: 'Raum-Board', courseId: r.id, text: r.boardText,
board: { id: `${r.id}-b`, title: 'Raum-Board', columns: [], fileCount: 0 } }]
: [],
})),
courses: courses.map((c) => ({
course: { id: c.id, title: c.title, shortTitle: c.title.slice(0, 2), displayColor: '#000' },
title: c.title,
@@ -122,6 +133,25 @@ describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, ()
assert.ok(!diff.removed.some((n) => n.nodeId === 'f2'), 'and not a deleted one');
});
it('stores rooms alongside courses, and notices when one changes', async () => {
// Rooms are a separate space, not a kind of course: they must land in the
// index under their own kind, or room content becomes unsearchable the way
// submitted files once were.
const before = await store.saveSnapshot(
snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }], [{ id: 'r1', name: 'Projektraum', boardText: 'Projektsteuerung' }]),
'full',
);
const hits = await store.search('Projektsteuerung', { limit: 5 });
assert.ok(hits.some((h) => h.nodeId === 'r1-b'), 'a room board is searchable');
const after = await store.saveSnapshot(
snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }], [{ id: 'r1', name: 'Projektraum Informatik', boardText: 'Projektsteuerung' }]),
'full',
);
const diff = await store.diff(before, after);
assert.ok(diff.changed.some((n) => n.nodeId === 'r1' && n.kind === 'room'), 'a renamed room is reported as changed');
});
it('carries other courses forward on a per-course crawl', async () => {
await store.saveSnapshot(
snapshot([