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
+186
View File
@@ -0,0 +1,186 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereBenutzer, erfordereBewertungsrecht, erfordereZugriffAufAzubi } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { anhangAblegen, anhangLoeschen, anhangNachId } from '@/lib/repos/anhaenge';
import {
abnehmen,
bewertungSetzen,
notizSetzen,
statusSetzen,
standVon,
zurueckgeben,
} from '@/lib/repos/aufgaben';
import { aufgabenStatus, type AufgabenStatus } from '@/lib/db/schema';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
function aktualisiere(aufgabeId: string) {
revalidatePath('/aufgaben');
revalidatePath(`/aufgaben/${aufgabeId}`);
revalidatePath('/');
}
function aufgabeGibtEs(aufgabeId: string): boolean {
return inhalte().aufgabeNach.has(aufgabeId);
}
// --- Der Azubi an seiner eigenen Aufgabe -----------------------------------
export async function eigenenStatusSetzen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return;
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const status = String(formular.get('status') ?? '') as AufgabenStatus;
if (!aufgabeGibtEs(aufgabeId)) return;
if (!aufgabenStatus.includes(status)) return;
// Abnehmen darf nur der Ausbilder.
if (status === 'abgenommen') return;
statusSetzen(benutzer.id, aufgabeId, status);
aktualisiere(aufgabeId);
}
export async function eigeneNotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Nur der Azubi schreibt seine eigenen Notizen.' };
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const notiz = String(formular.get('notiz') ?? '');
if (!aufgabeGibtEs(aufgabeId)) return { fehler: 'Diese Aufgabe gibt es nicht.' };
if (notiz.length > 8000) return { fehler: 'Die Notiz ist zu lang – höchstens 8000 Zeichen.' };
notizSetzen(benutzer.id, aufgabeId, notiz);
aktualisiere(aufgabeId);
return { erfolg: 'Notiz gespeichert.' };
}
export async function anhangHochladen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Anhänge lädt der Azubi hoch.' };
const aufgabeId = String(formular.get('aufgabeId') ?? '');
if (!aufgabeGibtEs(aufgabeId)) return { fehler: 'Diese Aufgabe gibt es nicht.' };
const stand = standVon(benutzer.id, aufgabeId);
if (stand?.status === 'abgenommen') {
return { fehler: 'Die Aufgabe ist abgenommen – sie lässt sich nicht mehr ändern.' };
}
const dateien = formular.getAll('datei').filter((d): d is File => d instanceof File && d.size > 0);
if (dateien.length === 0) return { fehler: 'Keine Datei ausgewählt.' };
const beschriftung = String(formular.get('beschriftung') ?? '');
const fehler: string[] = [];
let angelegt = 0;
for (const datei of dateien) {
const ergebnis = await anhangAblegen({
bezugTyp: 'aufgabe',
// Der Bezug ist Aufgabe **und** Azubi – sonst sähe ein zweiter Azubi
// die Anhänge des ersten an derselben Aufgabe.
bezugId: `${aufgabeId}::${benutzer.id}`,
benutzerId: benutzer.id,
datei,
beschriftung: dateien.length === 1 ? beschriftung : undefined,
});
if ('fehler' in ergebnis) {
fehler.push(`${datei.name}: ${ergebnis.fehler}`);
} else {
angelegt += 1;
}
}
aktualisiere(aufgabeId);
if (angelegt === 0) return { fehler: fehler.join(' · ') };
if (fehler.length > 0) {
return { fehler: `${angelegt} übernommen, aber: ${fehler.join(' · ')}` };
}
return { erfolg: `${angelegt} ${angelegt === 1 ? 'Anhang' : 'Anhänge'} hinzugefügt.` };
}
export async function anhangEntfernen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
const anhangId = String(formular.get('anhangId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const eintrag = anhangNachId(anhangId);
if (!eintrag) return;
// Nur der eigene Anhang, und nur solange die Aufgabe nicht abgenommen ist.
if (eintrag.benutzerId !== benutzer.id) return;
const stand = standVon(benutzer.id, aufgabeId);
if (stand?.status === 'abgenommen') return;
await anhangLoeschen(anhangId);
aktualisiere(aufgabeId);
}
// --- Der Ausbilder an der Aufgabe seines Azubis ----------------------------
export async function bewerten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
const aufgabe = inhalte().aufgabeNach.get(aufgabeId);
if (!aufgabe) return { fehler: 'Diese Aufgabe gibt es nicht.' };
const kriterien = formular
.getAll('kriterium')
.map((wert) => Number(wert))
.filter((n) => Number.isInteger(n) && n >= 0 && n < aufgabe.kriterien.length);
const bewertung = String(formular.get('bewertung') ?? '').trim();
const rueckmeldung = String(formular.get('rueckmeldung') ?? '').trim();
if (bewertung.length > 60) return { fehler: 'Die Bewertung ist zu lang – höchstens 60 Zeichen.' };
if (rueckmeldung.length > 4000) return { fehler: 'Die Rückmeldung ist zu lang – höchstens 4000 Zeichen.' };
bewertungSetzen(azubiId, aufgabeId, {
kriterien,
bewertung: bewertung || null,
rueckmeldung: rueckmeldung || null,
});
aktualisiere(aufgabeId);
return { erfolg: `Bewertung gespeichert – ${kriterien.length} von ${aufgabe.kriterien.length} Kriterien erfüllt.` };
}
export async function aufgabeAbnehmen(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
if (!aufgabeGibtEs(aufgabeId)) return;
abnehmen(azubiId, aufgabeId, ausbilder.id);
aktualisiere(aufgabeId);
}
export async function aufgabeZurueckgeben(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
if (!aufgabeGibtEs(aufgabeId)) return;
zurueckgeben(azubiId, aufgabeId);
aktualisiere(aufgabeId);
}
/** Wird von der Aufgabenseite gebraucht, um fremde Daten überhaupt lesen zu dürfen. */
export async function pruefeLesezugriff(azubiId: string): Promise<void> {
const benutzer = await erfordereBenutzer();
erfordereZugriffAufAzubi(benutzer, azubiId);
}