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
+63 -1
View File
@@ -124,6 +124,56 @@ export const kartenVerlauf = sqliteTable(
(t) => [index('karten_verlauf_benutzer_idx').on(t.benutzerId, t.bewertetAm)],
);
export const aufgabenStatus = ['offen', 'in_arbeit', 'zur_abnahme', 'abgenommen'] as const;
export type AufgabenStatus = (typeof aufgabenStatus)[number];
export const aufgabeStatus = sqliteTable(
'aufgabe_status',
{
id: text('id').primaryKey(),
benutzerId: text('benutzer_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
aufgabeId: text('aufgabe_id').notNull(),
status: text('status', { enum: aufgabenStatus }).notNull().default('offen'),
notiz: text('notiz').notNull().default(''),
kriterienErfuellt: text('kriterien_erfuellt').notNull().default('[]'),
bewertung: text('bewertung'),
rueckmeldung: text('rueckmeldung'),
begonnenAm: text('begonnen_am'),
abgegebenAm: text('abgegeben_am'),
abgenommenVon: text('abgenommen_von').references(() => benutzer.id),
abgenommenAm: text('abgenommen_am'),
geaendertAm: text('geaendert_am').notNull(),
},
(t) => [
uniqueIndex('aufgabe_status_idx').on(t.benutzerId, t.aufgabeId),
index('aufgabe_status_status_idx').on(t.status),
],
);
export const bezugTypen = ['aufgabe', 'checkliste', 'bericht'] as const;
export type BezugTyp = (typeof bezugTypen)[number];
export const anhang = sqliteTable(
'anhang',
{
id: text('id').primaryKey(),
bezugTyp: text('bezug_typ', { enum: bezugTypen }).notNull(),
bezugId: text('bezug_id').notNull(),
benutzerId: text('benutzer_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
dateiname: text('dateiname').notNull(),
mimeTyp: text('mime_typ').notNull(),
groesse: integer('groesse').notNull(),
pfad: text('pfad').notNull(),
beschriftung: text('beschriftung'),
hochgeladenAm: text('hochgeladen_am').notNull(),
},
(t) => [index('anhang_bezug_idx').on(t.bezugTyp, t.bezugId), index('anhang_benutzer_idx').on(t.benutzerId)],
);
export type Benutzer = typeof benutzer.$inferSelect;
export type Sitzung = typeof sitzung.$inferSelect;
export type Ausbildung = typeof ausbildung.$inferSelect;
@@ -131,6 +181,18 @@ export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
export type Wochenplanung = typeof wochenplanung.$inferSelect;
export type Karte = typeof karte.$inferSelect;
export type KartenVerlauf = typeof kartenVerlauf.$inferSelect;
export type AufgabeStatus = typeof aufgabeStatus.$inferSelect;
export type Anhang = typeof anhang.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen, wochenplanung, karte, kartenVerlauf };
export const alleTabellen = {
benutzer,
sitzung,
ausbildung,
handoutGelesen,
wochenplanung,
karte,
kartenVerlauf,
aufgabeStatus,
anhang,
};