import type { Config } from './config.ts'; import { SchulcloudClient } from './core/client.ts'; import { FileManager } from './core/legacy-files.ts'; import type { LegacyUser, MeResponse } from './core/types.ts'; import { UntisClient } from './core/untis.ts'; import type { Indexer } from './indexer/indexer.ts'; import type { Store } from './store/store.ts'; /** * Per-process state shared by every tool. * * The only thing worth holding onto is the identity from `/api/v3/me`: the * school id is a required path segment for every files-storage call, and it * cannot change for a given JWT. Everything else is fetched live. */ export class ServerContext { readonly config: Config; readonly client: SchulcloudClient; /** The "Dateien" file manager; shared across sessions when the process provides one. */ readonly files: FileManager; /** Shared across sessions; undefined when running without an index. */ readonly store: Store | undefined; readonly indexer: Indexer | undefined; /** WebUntis — the timetable — or undefined when the server has no key for it. */ readonly untis: UntisClient | undefined; private identity: Promise | 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>(); constructor( config: Config, shared?: { client?: SchulcloudClient; files?: FileManager; store?: Store; indexer?: Indexer; untis?: UntisClient; }, ) { this.config = config; this.client = shared?.client ?? new SchulcloudClient(config); this.files = shared?.files ?? new FileManager(this.client); this.store = shared?.store; this.indexer = shared?.indexer; this.untis = shared?.untis ?? (config.untis ? new UntisClient(config.untis, config.requestTimeoutMs) : undefined); } /** Cached `/me`. Shared promise, so concurrent first calls make one request. */ me(): Promise { this.identity ??= this.client.me().catch((error: unknown) => { // Don't cache a failure — a replaced JWT should be able to recover. this.identity = undefined; throw error; }); return this.identity; } async schoolId(): Promise { 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 { 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 { 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(); } }