Baustein 5: Praxisaufgaben mit Anhaengen und Abnahme

- Status offen / in Arbeit / zur Abnahme / abgenommen, mit Zeitstempeln
- Notizfeld fuer den Azubi, Dateianhaenge bis 25 MB
- Speicherpfad nur aus selbst erzeugter UUID, nie aus dem Dateinamen
- Auslieferung ueber /anhaenge/<kennung> mit Rechtepruefung bei jedem Abruf,
  CSP sandbox und nosniff; SVG ausdruecklich nicht erlaubt
- Anhaenge haengen an Aufgabe UND Azubi
- Ausbilder hakt Kriterien einzeln ab, Note und Rueckmeldung, Abnahme sperrt
- Zurueckgeben zur Nacharbeit
- "Was auf dich wartet" zeigt offene Abnahmen
- Abgenommene Aufgaben zaehlen im Wochenfortschritt mit

Behoben: Next.js deckelt Serveraktionen ab Werk bei 1 MB - damit waere kein
Handyfoto durchgekommen. bodySizeLimit jetzt 30 MB, Vorabpruefung im Browser,
client_max_body_size im Proxy-Beispiel auf 30m.

Geaendert: Seiten verweigern fremde Daten mit 404 statt mit einem Fehler.
Eigene Seiten fuer "Nicht gefunden" und Stoerfaelle.

24 neue Tests, insgesamt 157.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 12:18:54 +02:00
co-authored by Claude Opus 5
parent bb592ad2bd
commit ffbca01bdc
44 changed files with 1925 additions and 26 deletions
+103
View File
@@ -0,0 +1,103 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { and, asc, eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { anhangWurzel } from '../pfade';
import { jetzt } from '../zeit';
import { istInnerhalb, pruefeDatei, saubererName, speicherpfad } from '../anhaenge/dateien';
import type { Anhang, BezugTyp } from '../db/schema';
/** Dateianhänge: Datei im Dateisystem, Verweis in der Datenbank. */
export function anhaengeVon(bezugTyp: BezugTyp, bezugId: string): Anhang[] {
return datenbank()
.select()
.from(schema.anhang)
.where(and(eq(schema.anhang.bezugTyp, bezugTyp), eq(schema.anhang.bezugId, bezugId)))
.orderBy(asc(schema.anhang.hochgeladenAm))
.all();
}
export function anhangNachId(id: string): Anhang | null {
return datenbank().select().from(schema.anhang).where(eq(schema.anhang.id, id)).get() ?? null;
}
export type Ablageergebnis = { fehler: string } | { anhang: Anhang };
/**
* Nimmt eine hochgeladene Datei entgegen.
*
* Reihenfolge mit Absicht: erst prüfen, dann schreiben, dann eintragen.
* Schlägt der Eintrag fehl, wird die Datei wieder entfernt – sonst sammeln
* sich verwaiste Dateien im Volume an.
*/
export async function anhangAblegen(werte: {
bezugTyp: BezugTyp;
bezugId: string;
benutzerId: string;
datei: File;
beschriftung?: string;
}): Promise<Ablageergebnis> {
const { datei } = werte;
const geprueft = pruefeDatei(datei.name, datei.type, datei.size);
if ('fehler' in geprueft) return geprueft;
const id = crypto.randomUUID();
const jahr = new Date().getUTCFullYear();
const relativ = speicherpfad(id, geprueft.endung, jahr);
const ziel = path.join(anhangWurzel, relativ);
if (!istInnerhalb(anhangWurzel, ziel)) {
return { fehler: 'Ungültiger Ablageort.' };
}
await fs.mkdir(path.dirname(ziel), { recursive: true });
const inhalt = Buffer.from(await datei.arrayBuffer());
await fs.writeFile(ziel, inhalt);
try {
const datensatz: Anhang = {
id,
bezugTyp: werte.bezugTyp,
bezugId: werte.bezugId,
benutzerId: werte.benutzerId,
dateiname: saubererName(datei.name),
mimeTyp: datei.type.split(';')[0]!.trim().toLowerCase(),
groesse: inhalt.byteLength,
pfad: relativ,
beschriftung: werte.beschriftung?.trim() || null,
hochgeladenAm: jetzt(),
};
datenbank().insert(schema.anhang).values(datensatz).run();
return { anhang: datensatz };
} catch (fehler) {
await fs.rm(ziel, { force: true });
throw fehler;
}
}
/** Löscht Eintrag und Datei. Die Datei zuletzt – ein verwaister Eintrag wäre schlimmer. */
export async function anhangLoeschen(id: string): Promise<void> {
const eintrag = anhangNachId(id);
if (!eintrag) return;
datenbank().delete(schema.anhang).where(eq(schema.anhang.id, id)).run();
const ziel = path.join(anhangWurzel, eintrag.pfad);
if (istInnerhalb(anhangWurzel, ziel)) {
await fs.rm(ziel, { force: true });
}
}
/** Liest den Inhalt eines Anhangs. Der Pfad kommt aus der Datenbank, nie aus einer Eingabe. */
export async function anhangLesen(eintrag: Anhang): Promise<Buffer | null> {
const ziel = path.join(anhangWurzel, eintrag.pfad);
if (!istInnerhalb(anhangWurzel, ziel)) return null;
try {
return await fs.readFile(ziel);
} catch {
return null;
}
}
+139
View File
@@ -0,0 +1,139 @@
import crypto from 'node:crypto';
import { and, eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { jetzt } from '../zeit';
import type { AufgabeStatus, AufgabenStatus } from '../db/schema';
/** Bearbeitungsstand der Praxisaufgaben. */
export function standVon(benutzerId: string, aufgabeId: string): AufgabeStatus | null {
return (
datenbank()
.select()
.from(schema.aufgabeStatus)
.where(and(eq(schema.aufgabeStatus.benutzerId, benutzerId), eq(schema.aufgabeStatus.aufgabeId, aufgabeId)))
.get() ?? null
);
}
export function staendeVon(benutzerId: string): Map<string, AufgabeStatus> {
const zeilen = datenbank()
.select()
.from(schema.aufgabeStatus)
.where(eq(schema.aufgabeStatus.benutzerId, benutzerId))
.all();
return new Map(zeilen.map((z) => [z.aufgabeId, z]));
}
/** Legt bei Bedarf eine Zeile an – der Stand entsteht erst, wenn etwas passiert. */
export function sorgeFuerStand(benutzerId: string, aufgabeId: string): AufgabeStatus {
const vorhanden = standVon(benutzerId, aufgabeId);
if (vorhanden) return vorhanden;
const datensatz: AufgabeStatus = {
id: crypto.randomUUID(),
benutzerId,
aufgabeId,
status: 'offen',
notiz: '',
kriterienErfuellt: '[]',
bewertung: null,
rueckmeldung: null,
begonnenAm: null,
abgegebenAm: null,
abgenommenVon: null,
abgenommenAm: null,
geaendertAm: jetzt(),
};
datenbank().insert(schema.aufgabeStatus).values(datensatz).run();
return datensatz;
}
/** Die abgehakten Kriterien als Positionsliste. */
export function erfuellteKriterien(stand: AufgabeStatus | null | undefined): number[] {
if (!stand) return [];
try {
const gelesen: unknown = JSON.parse(stand.kriterienErfuellt);
if (!Array.isArray(gelesen)) return [];
return gelesen.filter((n): n is number => Number.isInteger(n) && n >= 0);
} catch {
return [];
}
}
function schreibe(id: string, werte: Partial<AufgabeStatus>): void {
datenbank()
.update(schema.aufgabeStatus)
.set({ ...werte, geaendertAm: jetzt() })
.where(eq(schema.aufgabeStatus.id, id))
.run();
}
/** Der Azubi setzt den Bearbeitungsstand. */
export function statusSetzen(benutzerId: string, aufgabeId: string, status: AufgabenStatus): AufgabeStatus {
const stand = sorgeFuerStand(benutzerId, aufgabeId);
const werte: Partial<AufgabeStatus> = { status };
if (status === 'in_arbeit' && !stand.begonnenAm) werte.begonnenAm = jetzt();
if (status === 'zur_abnahme') werte.abgegebenAm = jetzt();
if (status === 'offen') {
werte.abgegebenAm = null;
werte.begonnenAm = null;
}
if (status !== 'abgenommen') {
// Eine zurückgenommene Abgabe ist keine Abnahme mehr.
werte.abgenommenAm = null;
werte.abgenommenVon = null;
}
schreibe(stand.id, werte);
return { ...stand, ...werte };
}
export function notizSetzen(benutzerId: string, aufgabeId: string, notiz: string): void {
const stand = sorgeFuerStand(benutzerId, aufgabeId);
schreibe(stand.id, { notiz });
}
/** Der Ausbilder hakt Kriterien ab und gibt eine Rückmeldung. */
export function bewertungSetzen(
benutzerId: string,
aufgabeId: string,
werte: { kriterien: number[]; bewertung: string | null; rueckmeldung: string | null },
): void {
const stand = sorgeFuerStand(benutzerId, aufgabeId);
schreibe(stand.id, {
kriterienErfuellt: JSON.stringify([...new Set(werte.kriterien)].sort((a, b) => a - b)),
bewertung: werte.bewertung,
rueckmeldung: werte.rueckmeldung,
});
}
export function abnehmen(benutzerId: string, aufgabeId: string, ausbilderId: string): void {
const stand = sorgeFuerStand(benutzerId, aufgabeId);
schreibe(stand.id, { status: 'abgenommen', abgenommenVon: ausbilderId, abgenommenAm: jetzt() });
}
/** Zurückgeben: der Azubi soll nacharbeiten. */
export function zurueckgeben(benutzerId: string, aufgabeId: string): void {
const stand = sorgeFuerStand(benutzerId, aufgabeId);
schreibe(stand.id, { status: 'in_arbeit', abgegebenAm: null, abgenommenAm: null, abgenommenVon: null });
}
/** Alle Aufgaben, die auf eine Abnahme warten – für „Was wartet auf mich". */
export function wartetAufAbnahme(benutzerIds: string[]): AufgabeStatus[] {
if (benutzerIds.length === 0) return [];
return datenbank()
.select()
.from(schema.aufgabeStatus)
.where(eq(schema.aufgabeStatus.status, 'zur_abnahme'))
.all()
.filter((z) => benutzerIds.includes(z.benutzerId));
}
/** Wie viele Aufgaben einer Liste sind abgenommen? */
export function anzahlAbgenommen(benutzerId: string, aufgabeIds: string[]): number {
const staende = staendeVon(benutzerId);
return aufgabeIds.filter((id) => staende.get(id)?.status === 'abgenommen').length;
}