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:
co-authored by
Claude Opus 5
parent
bb592ad2bd
commit
ffbca01bdc
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user