Baustein 8: Lernzielkontrollen mit Freigabe, Abgabe und Korrektur

- Ausbilder gibt frei; vorher sieht der Azubi die Fragen nicht
- Freigabe laesst sich zuruecknehmen, solange nicht abgegeben wurde
- Antworten werden beim Tippen je Frage zwischengespeichert
- Musterloesungen erst nach dem Absenden: sichtbareKontrolle() entfernt sie
  serverseitig, bevor die Daten in die Ausgabe gelangen
- Korrektur mit Punkten und Anmerkung je Frage, Summe und Note laufen mit
- Notenschluessel der IHK; solange eine Frage offen ist, bleibt das Ergebnis
  vorlaeufig und die Kontrolle gilt nicht als korrigiert
- Ergebnis bei beiden sichtbar, Schnitt ueber alle Kontrollen
- Abgegebene Kontrollen unter "Was auf dich wartet" und im Wochenfortschritt
- scripts/pruefungen/musterloesung-dichtheit.mjs prueft die Sperre gegen den
  echten Wortlaut aus der Inhaltsdatei im rohen Seitenquelltext

36 neue Tests, davon 21 fuer die Sichtbarkeit der Musterloesung.
Insgesamt 277.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-11 08:57:00 +02:00
co-authored by Claude Opus 5
parent 7c4a051347
commit d226a6bae8
42 changed files with 2132 additions and 9 deletions
+204
View File
@@ -0,0 +1,204 @@
import crypto from 'node:crypto';
import { and, eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { jetzt } from '../zeit';
import { begrenzePunkte, werteAus, type Ergebnis, type Fragenstand } from '../kontrollen/bewertung';
import type { Kontrolle as Kontrollinhalt } from '../inhalte/schema';
import type { Kontrolle, KontrollAntwort } from '../db/schema';
/** Bearbeitungsstand der Lernzielkontrollen. */
export function standVon(benutzerId: string, kontrolleId: string): Kontrolle | null {
return (
datenbank()
.select()
.from(schema.kontrolle)
.where(and(eq(schema.kontrolle.benutzerId, benutzerId), eq(schema.kontrolle.kontrolleId, kontrolleId)))
.get() ?? null
);
}
export function standNachId(id: string): Kontrolle | null {
return datenbank().select().from(schema.kontrolle).where(eq(schema.kontrolle.id, id)).get() ?? null;
}
export function staendeVon(benutzerId: string): Map<string, Kontrolle> {
const zeilen = datenbank()
.select()
.from(schema.kontrolle)
.where(eq(schema.kontrolle.benutzerId, benutzerId))
.all();
return new Map(zeilen.map((z) => [z.kontrolleId, z]));
}
export function antwortenVon(standId: string): Map<string, KontrollAntwort> {
const zeilen = datenbank()
.select()
.from(schema.kontrollAntwort)
.where(eq(schema.kontrollAntwort.kontrolleId, standId))
.all();
return new Map(zeilen.map((z) => [z.frageId, z]));
}
/** Legt bei Bedarf eine Zeile an – noch ohne Freigabe. */
export function sorgeFuerStand(benutzerId: string, kontrolleId: string): Kontrolle {
const vorhanden = standVon(benutzerId, kontrolleId);
if (vorhanden) return vorhanden;
const datensatz: Kontrolle = {
id: crypto.randomUUID(),
benutzerId,
kontrolleId,
freigegebenAm: null,
freigegebenVon: null,
abgegebenAm: null,
punkte: null,
anmerkung: null,
korrigiertVon: null,
korrigiertAm: null,
geaendertAm: jetzt(),
};
datenbank().insert(schema.kontrolle).values(datensatz).run();
return datensatz;
}
export function freigeben(benutzerId: string, kontrolleId: string, ausbilderId: string): Kontrolle {
const stand = sorgeFuerStand(benutzerId, kontrolleId);
datenbank()
.update(schema.kontrolle)
.set({ freigegebenAm: jetzt(), freigegebenVon: ausbilderId, geaendertAm: jetzt() })
.where(eq(schema.kontrolle.id, stand.id))
.run();
return { ...stand, freigegebenAm: jetzt(), freigegebenVon: ausbilderId };
}
/** Freigabe zurücknehmen – nur solange nichts abgegeben wurde. */
export function freigabeZuruecknehmen(standId: string): void {
datenbank()
.update(schema.kontrolle)
.set({ freigegebenAm: null, freigegebenVon: null, geaendertAm: jetzt() })
.where(eq(schema.kontrolle.id, standId))
.run();
}
/**
* Eine einzelne Antwort speichern.
*
* Wird beim Tippen laufend aufgerufen – deshalb ein einzelner Schreibvorgang
* je Frage statt eines Rundumschlags über das ganze Formular.
*/
export function antwortSpeichern(standId: string, frageId: string, antwort: string): void {
datenbank()
.insert(schema.kontrollAntwort)
.values({
id: crypto.randomUUID(),
kontrolleId: standId,
frageId,
antwort,
punkte: null,
anmerkung: null,
geaendertAm: jetzt(),
})
.onConflictDoUpdate({
target: [schema.kontrollAntwort.kontrolleId, schema.kontrollAntwort.frageId],
set: { antwort, geaendertAm: jetzt() },
})
.run();
}
export function abgeben(standId: string): void {
datenbank()
.update(schema.kontrolle)
.set({ abgegebenAm: jetzt(), geaendertAm: jetzt() })
.where(eq(schema.kontrolle.id, standId))
.run();
}
/**
* Punkte und Anmerkungen des Ausbilders schreiben.
*
* Die Gesamtpunktzahl wird aus den Einzelpunkten gerechnet und mitgeschrieben,
* damit Listen und Auswertungen nicht jedes Mal alle Antworten laden müssen.
*/
export function korrigieren(
standId: string,
ausbilderId: string,
inhalt: Kontrollinhalt,
bewertungen: { frageId: string; punkte: number | null; anmerkung: string | null }[],
anmerkung: string | null,
): Ergebnis {
const db = datenbank();
const wann = jetzt();
const moeglichNach = new Map(inhalt.fragen.map((f) => [f.id, f.punkte]));
for (const bewertung of bewertungen) {
const moeglich = moeglichNach.get(bewertung.frageId);
if (moeglich === undefined) continue; // Frage gibt es nicht (mehr)
const punkte = bewertung.punkte === null ? null : begrenzePunkte(bewertung.punkte, moeglich);
db.insert(schema.kontrollAntwort)
.values({
id: crypto.randomUUID(),
kontrolleId: standId,
frageId: bewertung.frageId,
antwort: '',
punkte,
anmerkung: bewertung.anmerkung,
geaendertAm: wann,
})
.onConflictDoUpdate({
target: [schema.kontrollAntwort.kontrolleId, schema.kontrollAntwort.frageId],
set: { punkte, anmerkung: bewertung.anmerkung, geaendertAm: wann },
})
.run();
}
const ergebnis = ergebnisVon(standId, inhalt);
db.update(schema.kontrolle)
.set({
punkte: ergebnis.erreicht,
anmerkung,
korrigiertVon: ausbilderId,
korrigiertAm: ergebnis.vollstaendig ? wann : null,
geaendertAm: wann,
})
.where(eq(schema.kontrolle.id, standId))
.run();
return ergebnis;
}
/** Rechnet das Ergebnis aus den gespeicherten Einzelpunkten. */
export function ergebnisVon(standId: string, inhalt: Kontrollinhalt): Ergebnis {
const antworten = antwortenVon(standId);
const fragen: Fragenstand[] = inhalt.fragen.map((frage) => ({
frageId: frage.id,
moeglich: frage.punkte,
erreicht: antworten.get(frage.id)?.punkte ?? null,
}));
return werteAus(fragen);
}
/** Abgegebene, aber noch nicht fertig korrigierte Kontrollen – für „Was wartet auf mich". */
export function wartetAufKorrektur(benutzerIds: string[]): Kontrolle[] {
if (benutzerIds.length === 0) return [];
return datenbank()
.select()
.from(schema.kontrolle)
.all()
.filter((z) => benutzerIds.includes(z.benutzerId) && z.abgegebenAm !== null && z.korrigiertAm === null);
}
/** Zählt abgegebene Kontrollen einer Liste – für den Wochenfortschritt. */
export function anzahlAbgegeben(benutzerId: string, kontrollIds: string[]): number {
if (kontrollIds.length === 0) return 0;
const staende = staendeVon(benutzerId);
return kontrollIds.filter((id) => staende.get(id)?.abgegebenAm != null).length;
}