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
+46
View File
@@ -26,6 +26,51 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht]
## [0.5.0] – 2026-09-10
Praxisaufgaben: bearbeiten, belegen, abnehmen.
### Hinzugefügt
- Bearbeitungsstand je Aufgabe: *offen*, *in Arbeit*, *zur Abnahme*,
*abgenommen*; Beginn und Abgabe werden mit Zeitstempel vermerkt
- Notizfeld für den Azubi (bis 8000 Zeichen) – dieselben Notizen helfen später
beim Berichtsheft
- **Dateianhänge**: Fotos vom Aufbau, Bildschirmfotos, ein Netzplan als PDF.
Bis 25 MB je Datei, Positivliste erlaubter Typen (JPEG, PNG, WebP, GIF, HEIC,
PDF, Text, CSV)
- Der Speicherpfad wird ausschließlich aus einer selbst erzeugten UUID
gebildet, nie aus dem übermittelten Dateinamen
- Ausgeliefert wird nur über `/anhaenge/<kennung>`, mit Rechteprüfung bei
**jedem** Abruf und `Content-Security-Policy: sandbox`, damit ein Anhang
nicht im Kontext der Anwendung ausgeführt werden kann
- SVG ist ausdrücklich nicht erlaubt – es kann Skripte enthalten
- Der Ausbilder hakt die Bewertungskriterien einzeln ab, vergibt eine Note oder
kurze Bewertung und schreibt eine Rückmeldung
- Abnehmen sperrt die Aufgabe für den Azubi; Zurückgeben setzt sie auf
„in Arbeit", damit er nacharbeiten kann
- „Was auf dich wartet" auf der Ausbilderstartseite zeigt die offenen Abnahmen
mit Azubi und Abgabezeitpunkt
- Abgenommene Aufgaben zählen im Wochenfortschritt mit
- Eigene Seite „Nicht gefunden" für den angemeldeten Bereich
- 24 Tests für Dateiprüfung, Namenssäuberung, Speicherpfad und Pfadausbruch
### Behoben
- **Anhänge über 1 MB scheiterten stillschweigend.** Next.js deckelt
Serveraktionen ab Werk bei 1 MB – damit wäre kein einziges Handyfoto
durchgekommen, obwohl die Anwendung 25 MB zusagt. `bodySizeLimit` steht jetzt
auf 30 MB, und zu große Dateien werden schon im Browser abgefangen, bevor sie
übertragen werden. Im Beispiel für den Reverse Proxy steht
`client_max_body_size` entsprechend auf 30m
### Geändert
- Seiten verweigern fremde Daten jetzt mit **404** statt mit einem Fehler.
Damit verrät die Anwendung nicht, ob es die angefragten Daten überhaupt gibt –
und der Benutzer sieht einen deutschen Satz statt „Application error".
Serveraktionen brechen weiterhin hart ab
## [0.4.0] – 2026-09-10
Merkkarten mit Wiederholung nach Zeitabstand – der tägliche kleine Stapel
@@ -185,6 +230,7 @@ anmelden und verwalten.
- `npm run konto:anlegen` für das erste Ausbilderkonto
[Unveröffentlicht]: #unveröffentlicht
[0.5.0]: #050--2026-09-10
[0.4.0]: #040--2026-09-10
[0.3.0]: #030--2026-09-10
[0.2.0]: #020--2026-09-10
+34
View File
@@ -7,6 +7,40 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
---
## 2026-09-10 · Baustein 5 – Praxisaufgaben
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 11:55 | 12:05 | 0:10 | Datenmodell für Aufgabenstand und Anhänge, Migration, Schema |
| 12:05 | 12:20 | 0:15 | Dateiregeln (Typen, Größe, Namenssäuberung, Speicherpfad) und 24 Tests dazu |
| 12:20 | 12:35 | 0:15 | Repositories, Serveraktionen, Ausliefer-Route mit Rechteprüfung |
| 12:35 | 13:00 | 0:25 | Aufgabenliste, Aufgabenseite, Bewertungsformular, Abnahme |
| 13:00 | 13:20 | 0:20 | Durchstich – dabei den 1-MB-Deckel gefunden und behoben |
| 13:20 | 13:35 | 0:15 | Fremdzugriff geprüft, Seiten auf 404 umgestellt, Screenshots, Dokumente |
**Summe Baustein 5: rund 1 h 40 min**
### Was aufgefallen ist
- **Der wichtigste Fund des Tages:** Next.js begrenzt Serveraktionen ab Werk auf
1 MB Rumpfgröße. Meine eigene Grenze von 25 MB war damit nie erreichbar –
jedes Handyfoto (2 bis 5 MB) wäre stillschweigend gescheitert, mit einem
Fehler nur im Serverprotokoll. Genau das Abnahmekriterium „eine Praxisaufgabe
mit Foto abgeben" hätte nicht funktioniert. Gefunden hat den Fehler nur der
Durchstich mit einer echten Datei, nicht die Testdatei mit 200 Byte.
Gegenprobe danach: 3,8-MB-Foto hochgeladen, kommt mit 3 987 781 Byte zurück.
- Die eigene Fehlerseite für „kein Zugriff" hat nicht funktioniert, weil
Next.js Fehlermeldungen im Produktionsbetrieb schwärzt – der Text kommt nie
im Browser an. Statt daran herumzubasteln liefern die Seiten jetzt 404. Das
ist ohnehin die bessere Antwort: Ob es die Daten gibt, geht einen Unbefugten
nichts an. Die Anhang-Route machte das von Anfang an so.
- Anhänge hängen an Aufgabe **und** Azubi. Beim ersten Entwurf hätte ein
zweiter Azubi die Fotos des ersten an derselben Aufgabe gesehen.
- Geprüft mit einem eigens angelegten zweiten Ausbilderkonto: fremder Anhang
404, eigener 200, der zuständige Ausbilder 200. Danach wieder gelöscht.
---
## 2026-09-10 · Baustein 4 – Merkkarten
| Von | Bis | Dauer | Tätigkeit |
+44
View File
@@ -164,3 +164,47 @@ kommen zuletzt, damit zuerst der Rückstand abgebaut wird.
Fach 4 heißt: Abstand über drei Tage, also mindestens dreimal in Folge gekonnt.
Das ist die Schwelle, ab der die Karte im Wochenfortschritt als erledigt zählt.
Wer sie einmal richtig hatte, hat sie noch nicht gelernt.
## E18 · Anhänge: Pfad aus UUID, Auslieferung nur über eine geprüfte Route
Drei Regeln, die zusammengehören:
1. Der **Speicherpfad** ist `<jahr>/<uuid><endung>`. Der übermittelte Dateiname
landet nur als Anzeigename in der Datenbank und wird nie zum Bilden eines
Pfades benutzt. `speicherpfad()` wirft, wenn die Kennung keine UUID ist.
2. **Ausgeliefert** wird ausschließlich über `/anhaenge/<kennung>`. Es gibt
keinen Weg, über die Adresse eine beliebige Datei anzufordern, und die
Berechtigung wird bei jedem Abruf neu geprüft – nicht nur beim Hochladen.
3. Der Antwortkopf trägt `Content-Security-Policy: sandbox` und
`X-Content-Type-Options: nosniff`. Anhänge sind Daten, kein Programm.
SVG ist trotz `image/` **nicht** erlaubt: Es ist XML und kann Skripte enthalten.
## E19 · Anhänge hängen an Aufgabe **und** Azubi
Der Bezug lautet `<aufgabeId>::<azubiId>`. Beim ersten Entwurf hing er nur an
der Aufgabe – bei zwei Azubis hätte der zweite die Fotos des ersten gesehen.
## E20 · Kriterien werden über ihre Position gespeichert
`kriterien_erfuellt` ist eine JSON-Liste der Positionen in der Aufgabendatei,
etwa `[0,2,3]`. Wird die Reihenfolge der Kriterien nachträglich geändert,
verschiebt sich die Zuordnung. Neue Kriterien gehören deshalb ans Ende –
dieselbe Regel wie bei den Merkkarten (siehe E11).
## E21 · Fehlende Berechtigung ist ein 404, kein 403
Seiten rufen `erfordereSichtAufAzubi()` auf, das mit „nicht gefunden" endet.
Ein 403 würde bestätigen, dass es die angefragten Daten gibt. Serveraktionen
brechen weiterhin mit einem Fehler ab – dort wäre eine Weiterleitung sinnlos.
Nebeneffekt: Next.js schwärzt Fehlermeldungen im Produktionsbetrieb. Eine eigene
„Kein Zugriff"-Seite ließe sich anhand des Fehlertextes gar nicht erkennen.
## E22 · Der Deckel für Serveraktionen liegt über der eigenen Grenze
`serverActions.bodySizeLimit` steht auf 30 MB, die eigene Prüfung auf 25 MB.
Der Abstand ist Absicht: Eine zu große Datei soll die **eigene**, verständliche
Meldung bekommen und nicht den Rumpfgrößenfehler des Gerüsts, der nur im
Serverprotokoll auftaucht. Zusätzlich prüft der Browser vorab, damit am
Mobilfunk nicht erst 30 MB übertragen und dann abgelehnt werden.
+6 -8
View File
@@ -9,9 +9,9 @@ und – der wichtigste Teil – den gesetzlich vorgeschriebenen Ausbildungsnachw
Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
> **Stand:** Baustein 1 von 10 ist fertig (Fundament). Der
> [Fahrplan](ROADMAP.md) zeigt, was folgt, das
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
> **Stand:** Bausteine 1 bis 5 von 10 sind fertig – Fundament, Inhalte,
> Wochenplan, Merkkarten, Praxisaufgaben. Der [Fahrplan](ROADMAP.md) zeigt,
> was folgt, das [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
---
@@ -41,8 +41,8 @@ docker compose exec lernbar sh -c \
'node werkzeuge/konto.js --name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder'
```
> Solange das Konto-Werkzeug im Abbild noch nicht liegt (Baustein 1), geht es
> vom Entwicklungsrechner aus:
> Solange das Konto-Werkzeug im Abbild noch nicht liegt, geht es vom
> Entwicklungsrechner aus:
> `npm run konto:anlegen -- --name "…" --email … --rolle ausbilder`
Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und
@@ -75,7 +75,7 @@ server {
ssl_certificate /etc/ssl/lernbar.crt;
ssl_certificate_key /etc/ssl/lernbar.key;
client_max_body_size 25m; # Fotos vom Aufbau als Anhang
client_max_body_size 30m; # Fotos vom Aufbau als Anhang (Grenze der Anwendung: 25 MB)
location / {
proxy_pass http://127.0.0.1:3000;
@@ -136,8 +136,6 @@ Datei und Stelle. Prüfen ohne Start:
npm run inhalte:pruefen
```
*(Wird mit Baustein 2 scharf geschaltet.)*
---
## Datensicherung
+8 -1
View File
@@ -56,7 +56,14 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
## 0.4 – Nachweise
- [ ] **B5 · Praxisaufgaben mit Anhängen und Abnahme**
- [x] **B5 · Praxisaufgaben mit Anhängen und Abnahme**
- [x] Status offen / in Arbeit / zur Abnahme / abgenommen
- [x] Notizen des Azubis, Dateianhänge (Fotos, Bildschirmfotos, PDF)
- [x] Ausbilder hakt Kriterien einzeln ab, Note und Rückmeldung
- [x] Abnehmen sperrt die Aufgabe, Zurückgeben zur Nacharbeit
- [x] Anhänge nur über Kennung, Berechtigung bei jedem Abruf geprüft
- [x] „Was auf dich wartet" zeigt offene Abnahmen
- [x] Aufgaben zählen im Wochenfortschritt mit
- [ ] **B6 · Checklisten mit Durchläufen und PDF**
- [ ] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 100 KiB

+6
View File
@@ -7,6 +7,12 @@ const konfiguration: NextConfig = {
serverExternalPackages: ['better-sqlite3', '@node-rs/argon2', 'pdfkit'],
poweredByHeader: false,
reactStrictMode: true,
experimental: {
// Next.js deckelt Serveraktionen sonst bei 1 MB – damit wäre kein einziges
// Handyfoto durchgekommen. Etwas über der eigenen Grenze von 25 MB, damit
// eine zu große Datei die *eigene* Meldung bekommt und nicht die des Gerüsts.
serverActions: { bodySizeLimit: '30mb' },
},
eslint: { ignoreDuringBuilds: true },
async headers() {
return [
+17
View File
@@ -78,6 +78,15 @@ async function main() {
await seite.waitForSelector('h1');
await bild(seite, '04-wochenplan');
await seite.goto(`${BASIS}/aufgaben`);
await seite.waitForSelector('h1');
const abnahme = await seite.$('a[href*="/aufgaben/1.1?azubi="]');
if (abnahme) {
await abnahme.click();
await seite.waitForSelector('#abnahme');
await bild(seite, '05-aufgabe-abnahme');
}
await abmelden(seite);
// --- Azubi ---
@@ -101,6 +110,14 @@ async function main() {
await seite.waitForSelector('h1');
await bild(seite, '14-merkkarten');
await seite.goto(`${BASIS}/aufgaben`);
await seite.waitForSelector('h1');
await bild(seite, '16-aufgaben-liste');
await seite.goto(`${BASIS}/aufgaben/1.1`);
await seite.waitForSelector('h1');
await bild(seite, '17-aufgabe-azubi');
await seite.goto(`${BASIS}/glossar`);
await seite.waitForSelector('h1');
await bild(seite, '15-glossar');
@@ -0,0 +1,21 @@
import type { AufgabenStatus } from '@/lib/db/schema';
/**
* Status einer Praxisaufgabe. Nie nur über die Farbe – jede Stufe trägt ein
* eigenes Zeichen und ihren Namen.
*/
export const statusText: Record<AufgabenStatus, { text: string; ton: string; zeichen: string }> = {
offen: { text: 'offen', ton: 'neutral', zeichen: '○' },
in_arbeit: { text: 'in Arbeit', ton: 'warnung', zeichen: '◐' },
zur_abnahme: { text: 'zur Abnahme', ton: 'warnung', zeichen: '▲' },
abgenommen: { text: 'abgenommen', ton: 'gut', zeichen: '✓' },
};
export function StatusMarke({ status }: { status: AufgabenStatus }) {
const marke = statusText[status];
return (
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
);
}
@@ -0,0 +1,225 @@
'use client';
import { useActionState, useRef, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import {
anhangHochladen,
bewerten,
eigeneNotizSpeichern,
eigenenStatusSetzen,
type Rueckmeldung,
} from '../aktionen';
import type { AufgabenStatus } from '@/lib/db/schema';
export function StatusWaehlen({ aufgabeId, status }: { aufgabeId: string; status: AufgabenStatus }) {
const stufen: { wert: AufgabenStatus; text: string }[] = [
{ wert: 'offen', text: 'Offen' },
{ wert: 'in_arbeit', text: 'In Arbeit' },
{ wert: 'zur_abnahme', text: 'Zur Abnahme' },
];
return (
<form action={eigenenStatusSetzen} className="statuswahl">
<input type="hidden" name="aufgabeId" value={aufgabeId} />
{stufen.map((stufe) => (
<button
key={stufe.wert}
type="submit"
name="status"
value={stufe.wert}
className={`knopf ${status === stufe.wert ? '' : 'knopf-zweit'}`}
disabled={status === stufe.wert || status === 'abgenommen'}
aria-pressed={status === stufe.wert}
>
{stufe.text}
</button>
))}
</form>
);
}
export function NotizFormular({
aufgabeId,
notiz,
gesperrt,
}: {
aufgabeId: string;
notiz: string;
gesperrt: boolean;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(eigeneNotizSpeichern, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<label htmlFor="aufgaben-notiz">Deine Notizen</label>
<textarea
id="aufgaben-notiz"
name="notiz"
defaultValue={notiz}
rows={8}
maxLength={8000}
disabled={gesperrt}
placeholder="Was hast du gemacht, was ist aufgefallen, wo hakte es? Diese Notizen helfen dir später beim Berichtsheft."
/>
{gesperrt ? (
<p className="leise" style={{ margin: 0 }}>
Die Aufgabe ist abgenommen und damit gesperrt.
</p>
) : (
<div>
<Absenden className="knopf knopf-zweit">Notizen speichern</Absenden>
</div>
)}
</form>
);
}
/** Muss zur Prüfung auf dem Server passen – siehe lib/anhaenge/dateien.ts. */
const HOECHSTGROESSE = 25 * 1024 * 1024;
export function AnhangFormular({ aufgabeId, gesperrt }: { aufgabeId: string; gesperrt: boolean }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(anhangHochladen, {});
const [namen, setzeNamen] = useState<string[]>([]);
const [zuGross, setzeZuGross] = useState<string[]>([]);
const feld = useRef<HTMLInputElement>(null);
if (gesperrt) return null;
function beiAuswahl(dateien: FileList | null) {
const liste = Array.from(dateien ?? []);
setzeNamen(liste.map((d) => d.name));
// Vorab im Browser prüfen: Eine zu große Datei erst hochzuladen und dann
// abzulehnen kostet den Azubi im Zweifel Minuten am Mobilfunk.
setzeZuGross(liste.filter((d) => d.size > HOECHSTGROESSE).map((d) => d.name));
}
return (
<form
action={(daten) => {
setzeNamen([]);
setzeZuGross([]);
if (feld.current) feld.current.value = '';
return aktion(daten);
}}
className="stapel-eng"
>
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<label htmlFor="anhang-datei">Dateien anhängen</label>
<input
id="anhang-datei"
ref={feld}
name="datei"
type="file"
multiple
accept="image/jpeg,image/png,image/webp,image/gif,image/heic,application/pdf,text/plain,text/csv"
onChange={(e) => beiAuswahl(e.target.files)}
/>
<p className="leise" style={{ margin: 0 }}>
Fotos vom Aufbau, Bildschirmfotos, ein Netzplan als PDF. Höchstens 25 MB je Datei.
</p>
{zuGross.length > 0 ? (
<p className="hinweis hinweis-fehler" role="alert" style={{ margin: 0 }}>
Zu groß (mehr als 25 MB): {zuGross.join(', ')}. Bitte die Datei verkleinern.
</p>
) : namen.length > 0 ? (
<p className="leise" style={{ margin: 0 }}>
Ausgewählt: {namen.join(', ')}
</p>
) : null}
<div>
<Absenden className="knopf knopf-zweit" wartetext="Wird übertragen …">
Hochladen
</Absenden>
</div>
</form>
);
}
export function BewertungsFormular({
aufgabeId,
azubiId,
kriterien,
erfuellt,
bewertung,
rueckmeldung,
}: {
aufgabeId: string;
azubiId: string;
kriterien: string[];
erfuellt: number[];
bewertung: string;
rueckmeldung: string;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(bewerten, {});
const [angehakt, setzeAngehakt] = useState<number[]>(erfuellt);
function umschalten(stelle: number) {
setzeAngehakt((vorher) =>
vorher.includes(stelle) ? vorher.filter((n) => n !== stelle) : [...vorher, stelle],
);
}
return (
<form action={aktion} className="stapel">
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<input type="hidden" name="azubiId" value={azubiId} />
<fieldset className="kriterien">
<legend>
Bewertungskriterien{' '}
<span className="leise">
({angehakt.length} von {kriterien.length} erfüllt)
</span>
</legend>
{kriterien.map((text, stelle) => (
<label key={stelle} className="ankreuzfeld kriterium">
<input
type="checkbox"
name="kriterium"
value={stelle}
checked={angehakt.includes(stelle)}
onChange={() => umschalten(stelle)}
/>
<span style={{ fontWeight: 400 }}>{text}</span>
</label>
))}
</fieldset>
<div className="feld">
<label htmlFor="bewertung">Note oder kurze Bewertung</label>
<input
id="bewertung"
name="bewertung"
type="text"
maxLength={60}
defaultValue={bewertung}
placeholder={'z. B. "gut" oder die Note 2'}
/>
</div>
<div className="feld">
<label htmlFor="rueckmeldung">Rückmeldung an den Azubi</label>
<textarea
id="rueckmeldung"
name="rueckmeldung"
rows={5}
maxLength={4000}
defaultValue={rueckmeldung}
placeholder="Was war gut, was fehlt noch? Konkret genug, dass er damit weiterarbeiten kann."
/>
</div>
<div>
<Absenden className="knopf">Bewertung speichern</Absenden>
</div>
</form>
);
}
+310
View File
@@ -0,0 +1,310 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
import { anhaengeVon } from '@/lib/repos/anhaenge';
import { erfuellteKriterien, standVon } from '@/lib/repos/aufgaben';
import { istBild, megabyte } from '@/lib/anhaenge/dateien';
import { alsZeitpunktText } from '@/lib/zeit';
import { StatusMarke } from '../StatusMarke';
import { AnhangFormular, BewertungsFormular, NotizFormular, StatusWaehlen } from './Formulare';
import { anhangEntfernen, aufgabeAbnehmen, aufgabeZurueckgeben } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const aufgabe = inhalte().aufgabeNach.get(decodeURIComponent(id));
return { title: aufgabe ? `${aufgabe.id} – ${aufgabe.titel}` : 'Praxisaufgabe' };
}
export default async function AufgabenSeite({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ azubi?: string }>;
}) {
const benutzer = await erfordereBenutzer();
const { id } = await params;
const { azubi: azubiParameter } = await searchParams;
const alle = inhalte();
const aufgabe = alle.aufgabeNach.get(decodeURIComponent(id));
if (!aufgabe) notFound();
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
if (!azubiId) notFound();
// Serverseitige Rechteprüfung – die Kennung aus der Adresszeile wird nie geglaubt.
erfordereSichtAufAzubi(benutzer, azubiId);
const istAzubi = benutzer.rolle === 'azubi';
const azubi = istAzubi ? benutzer : benutzerNachId(azubiId);
if (!azubi) notFound();
const stand = standVon(azubiId, aufgabe.id);
const status = stand?.status ?? 'offen';
const gesperrt = status === 'abgenommen';
const erfuellt = erfuellteKriterien(stand);
const anhaenge = anhaengeVon('aufgabe', `${aufgabe.id}::${azubiId}`);
const lernfeld = alle.lernfeldNach.get(aufgabe.lernfeld);
const positionen = aufgabe.rahmenplan.map((p) => alle.positionNach.get(p)).filter((p) => p !== undefined);
const abnehmer = stand?.abgenommenVon ? benutzerNachId(stand.abgenommenVon) : null;
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/aufgaben">Praxisaufgaben</Link>
{!istAzubi ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="aufgaben-nummer aufgaben-nummer-gross">{aufgabe.id}</span>
<StatusMarke status={status} />
<span className="leise">
Woche {aufgabe.woche} · {aufgabe.zeitrahmen}
</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{aufgabe.titel}</h1>
<p className="handout-worum">{aufgabe.ziel}</p>
</header>
<div className="handout-zuordnung karte">
<dl>
<div>
<dt>Ausbildungsrahmenplan</dt>
<dd>
{positionen.map((p) => (
<span key={p.id} className="zuordnung-marke" title={`${p.titel} (${p.verordnung})`}>
<strong>{p.id}</strong> {p.kurz}
</span>
))}
</dd>
</div>
<div>
<dt>Lernfeld</dt>
<dd>
{lernfeld ? (
<span className="zuordnung-marke">
<strong>{lernfeld.id}</strong> {lernfeld.titel}
</span>
) : (
'–'
)}
</dd>
</div>
</dl>
</div>
{aufgabe.material.length > 0 ? (
<section className="karte" aria-labelledby="material">
<h2 id="material" style={{ fontSize: '1rem' }}>
Benötigtes Material
</h2>
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{aufgabe.material.map((posten, stelle) => (
<li key={stelle}>{posten}</li>
))}
</ul>
</section>
) : null}
{aufgabe.abschnitte.map((abschnitt, stelle) =>
abschnitt.titel ? (
<section key={stelle} aria-label={abschnitt.titel}>
<h2>{abschnitt.titel}</h2>
<div
className="fliesstext"
dangerouslySetInnerHTML={{ __html: verlinkeGlossar(abschnitt.html, alle.glossar) }}
/>
</section>
) : null,
)}
<section className="karte stapel" aria-labelledby="kriterien-liste">
<h2 id="kriterien-liste" style={{ marginBottom: 0 }}>
Bewertungskriterien
</h2>
<p className="leise" style={{ margin: 0 }}>
{erfuellt.length} von {aufgabe.kriterien.length} erfüllt · abgehakt wird vom Ausbilder
</p>
<ul className="kriterienliste">
{aufgabe.kriterien.map((text, stelle) => (
<li key={stelle} data-erfuellt={erfuellt.includes(stelle) ? 'ja' : 'nein'}>
<span aria-hidden="true">{erfuellt.includes(stelle) ? '✓' : '○'}</span>
<span>{text}</span>
<span className="nur-lesehilfe">{erfuellt.includes(stelle) ? ' – erfüllt' : ' – noch offen'}</span>
</li>
))}
</ul>
</section>
{/* --- Bearbeitung durch den Azubi --- */}
{istAzubi ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="bearbeitung">
<h2 id="bearbeitung" style={{ marginBottom: 0 }}>
Deine Bearbeitung
</h2>
<div>
<p className="leise" style={{ margin: '0 0 0.35rem' }}>
Stand setzen
</p>
<StatusWaehlen aufgabeId={aufgabe.id} status={status} />
{status === 'zur_abnahme' ? (
<p className="hinweis hinweis-warnung" style={{ marginTop: '0.6rem', marginBottom: 0 }}>
Abgegeben am {alsZeitpunktText(stand?.abgegebenAm)} – wartet auf die Abnahme.
</p>
) : null}
{gesperrt ? (
<p className="hinweis hinweis-gut" style={{ marginTop: '0.6rem', marginBottom: 0 }}>
✓ Abgenommen am {alsZeitpunktText(stand?.abgenommenAm)}
{abnehmer ? ` von ${abnehmer.name}` : ''}.
</p>
) : null}
</div>
<NotizFormular aufgabeId={aufgabe.id} notiz={stand?.notiz ?? ''} gesperrt={gesperrt} />
<AnhangFormular aufgabeId={aufgabe.id} gesperrt={gesperrt} />
</section>
) : (
<section className="karte stapel" aria-labelledby="azubi-stand">
<h2 id="azubi-stand" style={{ marginBottom: 0 }}>
Bearbeitung von {azubi.name}
</h2>
<p className="leise" style={{ margin: 0 }}>
{stand?.begonnenAm ? `Begonnen ${alsZeitpunktText(stand.begonnenAm)}` : 'Noch nicht begonnen'}
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
{stand?.abgenommenAm ? ` · abgenommen ${alsZeitpunktText(stand.abgenommenAm)}` : ''}
</p>
<div>
<h3 style={{ fontSize: '0.95rem', marginBottom: '0.3rem' }}>Notizen</h3>
{stand?.notiz ? (
<p className="notiz-anzeige">{stand.notiz}</p>
) : (
<p className="leise" style={{ margin: 0 }}>
Noch keine Notizen.
</p>
)}
</div>
</section>
)}
{/* --- Anhänge --- */}
<section className="karte stapel" aria-labelledby="anhaenge">
<h2 id="anhaenge" style={{ marginBottom: 0 }}>
Anhänge{anhaenge.length > 0 ? ` (${anhaenge.length})` : ''}
</h2>
{anhaenge.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Noch nichts angehängt.
</p>
) : (
<ul className="anhangliste">
{anhaenge.map((eintrag) => (
<li key={eintrag.id}>
<a href={`/anhaenge/${eintrag.id}`} target="_blank" rel="noreferrer" className="anhang-kachel">
{istBild(eintrag.mimeTyp) ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={`/anhaenge/${eintrag.id}`} alt={eintrag.beschriftung ?? eintrag.dateiname} />
) : (
<span className="anhang-symbol" aria-hidden="true">
{eintrag.mimeTyp === 'application/pdf' ? 'PDF' : 'TXT'}
</span>
)}
<span className="anhang-name">{eintrag.beschriftung ?? eintrag.dateiname}</span>
<span className="leise">{megabyte(eintrag.groesse)}</span>
</a>
{istAzubi && !gesperrt ? (
<form action={anhangEntfernen} className="nicht-drucken">
<input type="hidden" name="anhangId" value={eintrag.id} />
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<button type="submit" className="knopf knopf-still">
Entfernen
</button>
</form>
) : null}
</li>
))}
</ul>
)}
</section>
{/* --- Bewertung und Abnahme --- */}
{!istAzubi ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="abnahme">
<h2 id="abnahme" style={{ marginBottom: 0 }}>
Bewerten und abnehmen
</h2>
<BewertungsFormular
aufgabeId={aufgabe.id}
azubiId={azubiId}
kriterien={aufgabe.kriterien}
erfuellt={erfuellt}
bewertung={stand?.bewertung ?? ''}
rueckmeldung={stand?.rueckmeldung ?? ''}
/>
<hr />
<div className="reihe">
<form action={aufgabeAbnehmen}>
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<input type="hidden" name="azubiId" value={azubiId} />
<button type="submit" className="knopf knopf-akzent" disabled={status === 'abgenommen'}>
{status === 'abgenommen' ? 'Bereits abgenommen' : 'Aufgabe abnehmen'}
</button>
</form>
<form action={aufgabeZurueckgeben}>
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<input type="hidden" name="azubiId" value={azubiId} />
<button type="submit" className="knopf knopf-zweit" disabled={status === 'offen'}>
Zur Nacharbeit zurückgeben
</button>
</form>
</div>
<p className="leise" style={{ marginBottom: 0 }}>
Abnehmen sperrt die Aufgabe für den Azubi. Zurückgeben setzt sie auf &bdquo;in Arbeit&ldquo; – schreib in dem
Fall vorher eine Rückmeldung, sonst weiß er nicht, was fehlt.
</p>
</section>
) : null}
{/* --- Ergebnis, für beide sichtbar --- */}
{stand?.bewertung || stand?.rueckmeldung ? (
<section className="karte stapel-eng" aria-labelledby="ergebnis">
<h2 id="ergebnis" style={{ marginBottom: 0 }}>
Rückmeldung des Ausbilders
</h2>
{stand.bewertung ? (
<p style={{ margin: 0 }}>
<strong>Bewertung: {stand.bewertung}</strong>
</p>
) : null}
{stand.rueckmeldung ? <p className="notiz-anzeige">{stand.rueckmeldung}</p> : null}
</section>
) : null}
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
Quelle: <code>{aufgabe.datei}</code>
</p>
</article>
);
}
+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);
}
+115
View File
@@ -0,0 +1,115 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { staendeVon } from '@/lib/repos/aufgaben';
import { alsZeitpunktText } from '@/lib/zeit';
import { StatusMarke } from './StatusMarke';
export const metadata: Metadata = { title: 'Praxisaufgaben' };
export const dynamic = 'force-dynamic';
export default async function AufgabenListe() {
const benutzer = await erfordereBenutzer();
const alle = inhalte();
if (benutzer.rolle === 'ausbilder') {
const meine = ausbildungenVonAusbilder(benutzer.id);
return (
<div className="stapel">
<div>
<h1>Praxisaufgaben</h1>
<p className="leise">
{alle.aufgaben.length} Aufgaben · Stand deiner {meine.length === 1 ? 'Auszubildenden' : 'Auszubildenden'}
</p>
</div>
{meine.length === 0 ? (
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
meine.map(({ azubi }) => {
const staende = staendeVon(azubi.id);
const wartend = alle.aufgaben.filter((a) => staende.get(a.id)?.status === 'zur_abnahme');
return (
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
{azubi.name}
</h2>
{wartend.length > 0 ? (
<span className="marke-status" data-ton="warnung">
● {wartend.length} {wartend.length === 1 ? 'Abnahme' : 'Abnahmen'} offen
</span>
) : (
<span className="leise">nichts zur Abnahme</span>
)}
</div>
<ul className="aufgabenliste">
{alle.aufgaben.map((aufgabe) => {
const stand = staende.get(aufgabe.id);
return (
<li key={aufgabe.id}>
<Link href={`/aufgaben/${aufgabe.id}?azubi=${azubi.id}`} className="aufgabenzeile">
<span className="aufgaben-nummer">{aufgabe.id}</span>
<span className="aufgaben-titel">
{aufgabe.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
Woche {aufgabe.woche} · {aufgabe.zeitrahmen}
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
</span>
</span>
<StatusMarke status={stand?.status ?? 'offen'} />
</Link>
</li>
);
})}
</ul>
</section>
);
})
)}
</div>
);
}
// --- Sicht des Azubis ---
const staende = staendeVon(benutzer.id);
const abgenommen = alle.aufgaben.filter((a) => staende.get(a.id)?.status === 'abgenommen').length;
return (
<div className="stapel">
<div>
<h1>Praxisaufgaben</h1>
<p className="leise">
{abgenommen} von {alle.aufgaben.length} abgenommen
</p>
</div>
<ul className="aufgabenliste">
{alle.aufgaben.map((aufgabe) => {
const stand = staende.get(aufgabe.id);
return (
<li key={aufgabe.id}>
<Link href={`/aufgaben/${aufgabe.id}`} className="aufgabenzeile">
<span className="aufgaben-nummer">{aufgabe.id}</span>
<span className="aufgaben-titel">
{aufgabe.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
Woche {aufgabe.woche} · {aufgabe.zeitrahmen} · {aufgabe.kriterien.length} Kriterien
</span>
</span>
<StatusMarke status={stand?.status ?? 'offen'} />
</Link>
</li>
);
})}
</ul>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
import Link from 'next/link';
import { useEffect } from 'react';
/**
* Auffangnetz für den angemeldeten Bereich.
*
* Verweigerte Berechtigungen landen **nicht** hier, sondern auf der Seite
* „Nicht gefunden" – siehe erfordereSichtAufAzubi. Hier bleibt der echte
* Störfall übrig. Der Fehlertext selbst wird im Produktionsbetrieb von Next.js
* geschwärzt und steht nur im Serverprotokoll; deshalb gibt es hier keine
* Fallunterscheidung nach Meldung, sondern nur das Kennzeichen zur Fehlersuche.
*/
export default function Fehlerseite({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error('[lernbar] Fehler in der Oberfläche:', error);
}, [error]);
return (
<div className="karte-grund stapel" style={{ maxWidth: '38rem', margin: '2rem auto', textAlign: 'center' }}>
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
⚠
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Da ist etwas schiefgegangen</h1>
<p className="leise">
Der Vorgang ließ sich nicht abschließen. Der Fehler steht im Protokoll des Servers.
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/" className="knopf">
Zur Startseite
</Link>
<button type="button" className="knopf knopf-zweit" onClick={reset}>
Noch einmal versuchen
</button>
</div>
{error.digest ? (
<p className="leise" style={{ marginBottom: 0 }}>
Kennzeichen für die Fehlersuche: <code>{error.digest}</code>
</p>
) : null}
</div>
);
}
+2
View File
@@ -10,6 +10,7 @@ const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/', titel: 'Woche' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/glossar', titel: 'Glossar' },
];
@@ -18,6 +19,7 @@ const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/wochenplan', titel: 'Wochenplan' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/aufgaben', titel: 'Aufgaben' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
+27
View File
@@ -0,0 +1,27 @@
import Link from 'next/link';
/**
* „Nicht gefunden" im angemeldeten Bereich.
*
* Diese Seite erscheint auch dann, wenn jemand Daten anfragt, die ihn nichts
* angehen. Die Meldung ist deshalb absichtlich unbestimmt: Ob es die Daten
* gibt, wird nicht verraten.
*/
export default function NichtGefunden() {
return (
<div className="karte-grund stapel" style={{ maxWidth: '36rem', margin: '2rem auto', textAlign: 'center' }}>
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
🔍
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Nicht gefunden</h1>
<p className="leise">
Diese Seite gibt es nicht – oder sie gehört nicht zu deinem Bereich.
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/" className="knopf">
Zur Startseite
</Link>
</div>
</div>
);
}
+46 -10
View File
@@ -3,7 +3,10 @@ import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
import { aktuelleWochensicht, kurzstand } from '@/lib/planung/sicht';
import { alsDatumText, heute, kalenderwocheText } from '@/lib/zeit';
import { alsDatumText, alsZeitpunktText, heute, kalenderwocheText } from '@/lib/zeit';
import { inhalte } from '@/lib/inhalte';
import { wartetAufAbnahme } from '@/lib/repos/aufgaben';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { WochenAnsicht } from './WochenAnsicht';
export const dynamic = 'force-dynamic';
@@ -135,15 +138,48 @@ function AusbilderUeberblick({ ausbilderId, name }: { ausbilderId: string; name:
</section>
)}
<section className="karte" aria-labelledby="wartet">
<h2 id="wartet" style={{ fontSize: '1rem' }}>
Was auf dich wartet
</h2>
<p className="leise" style={{ marginBottom: 0 }}>
Offene Abnahmen, unkorrigierte Lernzielkontrollen und nicht abgezeichnete Berichtshefte erscheinen
hier, sobald diese Bausteine stehen.
</p>
</section>
<WartetAufMich ausbilderId={ausbilderId} />
</div>
);
}
// --- Was auf den Ausbilder wartet ------------------------------------------
function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
const alle = inhalte();
const abnahmen = wartetAufAbnahme(meine.map(({ azubi }) => azubi.id));
return (
<section className="karte stapel-eng" aria-labelledby="wartet">
<h2 id="wartet" style={{ fontSize: '1rem', marginBottom: 0 }}>
Was auf dich wartet
</h2>
{abnahmen.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Nichts zur Abnahme. Unkorrigierte Lernzielkontrollen und nicht abgezeichnete Berichtshefte kommen
hier dazu, sobald diese Bausteine stehen.
</p>
) : (
<ul className="offen-liste">
{abnahmen.map((stand) => {
const aufgabe = alle.aufgabeNach.get(stand.aufgabeId);
const azubi = benutzerNachId(stand.benutzerId);
return (
<li key={stand.id}>
<span>
<Link href={`/aufgaben/${stand.aufgabeId}?azubi=${stand.benutzerId}`}>
<strong>{stand.aufgabeId}</strong> {aufgabe?.titel ?? 'Aufgabe'}
</Link>
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
</span>
<span className="leise">abgegeben {alsZeitpunktText(stand.abgegebenAm)}</span>
</li>
);
})}
</ul>
)}
</section>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer, erfordereZugriffAufAzubi } from '@/lib/auth/rechte';
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { wochensicht } from '@/lib/planung/sicht';
@@ -37,7 +37,7 @@ export default async function WochenSeite({
// Der Kern: Ob dieser Betrachter diesen Azubi sehen darf, entscheidet der
// Server – nicht die Oberfläche und nicht die Adresszeile.
erfordereZugriffAufAzubi(benutzer, azubiId);
erfordereSichtAufAzubi(benutzer, azubiId);
const ausbildung = ausbildungNachAzubi(azubiId);
if (!ausbildung) notFound();
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { erfordereAusbilder, erfordereBewertungsrecht } from '@/lib/auth/rechte';
import { erfordereAusbilder, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
@@ -35,7 +35,7 @@ export default async function WochenplanSeite({ searchParams }: { searchParams:
const azubiId = azubiParameter ?? meine[0]!.azubi.id;
if (!azubiParameter) redirect(`/wochenplan?azubi=${azubiId}`);
erfordereBewertungsrecht(ausbilder, azubiId);
erfordereSichtAufAzubi(ausbilder, azubiId);
const azubi = benutzerNachId(azubiId);
const ausbildung = ausbildungNachAzubi(azubiId);
+52
View File
@@ -0,0 +1,52 @@
import { angemeldeterBenutzer } from '@/lib/auth/sitzung';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { ausbildungVonAzubi } from '@/lib/auth/rechte';
import { anhangLesen, anhangNachId } from '@/lib/repos/anhaenge';
import { istBild } from '@/lib/anhaenge/dateien';
export const dynamic = 'force-dynamic';
/**
* Liefert einen Anhang aus.
*
* Zwei Dinge sind hier wichtig:
* 1. Der Pfad kommt ausschließlich aus der Datenbank. Es gibt keinen Weg,
* über die Adresse eine beliebige Datei anzufordern.
* 2. Die Berechtigung wird bei **jedem** Abruf geprüft. Wer den Anhang
* hochgeladen hat, darf ihn sehen; sein Ausbilder auch, sonst niemand.
*/
export async function GET(_anfrage: Request, kontext: { params: Promise<{ id: string }> }) {
const benutzer = await angemeldeterBenutzer();
if (!benutzer) return new Response('Nicht angemeldet.', { status: 401 });
const { id } = await kontext.params;
const eintrag = anhangNachId(id);
if (!eintrag) return new Response('Nicht gefunden.', { status: 404 });
const bezug = ausbildungVonAzubi(eintrag.benutzerId);
if (!darfAzubiDatenSehen(benutzer, eintrag.benutzerId, bezug)) {
// Bewusst 404 statt 403: Ob es diesen Anhang gibt, geht Fremde nichts an.
return new Response('Nicht gefunden.', { status: 404 });
}
const inhalt = await anhangLesen(eintrag);
if (!inhalt) return new Response('Die Datei fehlt im Speicher.', { status: 410 });
// Bilder und PDF dürfen im Browser aufgehen, alles andere wird geladen.
const anzeigbar = istBild(eintrag.mimeTyp) || eintrag.mimeTyp === 'application/pdf';
const art = anzeigbar ? 'inline' : 'attachment';
return new Response(new Uint8Array(inhalt), {
headers: {
'Content-Type': eintrag.mimeTyp,
'Content-Length': String(inhalt.byteLength),
'Content-Disposition': `${art}; filename="${eintrag.dateiname}"; filename*=UTF-8''${encodeURIComponent(eintrag.dateiname)}`,
// Kein öffentlicher Zwischenspeicher – der Inhalt hängt am angemeldeten Benutzer.
'Cache-Control': 'private, max-age=300',
'X-Content-Type-Options': 'nosniff',
// Schutz davor, dass ein hochgeladenes SVG oder HTML im Kontext der
// Anwendung ausgeführt wird. Anhänge sind Daten, kein Programm.
'Content-Security-Policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox",
},
});
}
+124
View File
@@ -1100,3 +1100,127 @@ a.glossarbegriff:hover {
.bewerten { gap: 0.4rem; }
.bewerten-knopf { min-height: 84px; }
}
/* --- Praxisaufgaben --- */
.aufgaben-nummer {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.8rem;
padding: 0.15rem 0.55rem;
border-radius: var(--radius-klein);
background: var(--bernstein);
color: #26170a;
font-weight: 700;
font-size: 0.85rem;
flex-shrink: 0;
}
.aufgaben-nummer-gross { font-size: 1rem; padding: 0.25rem 0.75rem; }
.aufgabenliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
.aufgabenzeile {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 0.75rem 0.9rem;
text-decoration: none;
color: var(--text);
}
.aufgabenzeile:hover { background: var(--flaeche-kraeftig); color: var(--text); }
.aufgaben-titel { flex: 1; font-weight: 650; }
.statuswahl { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.kriterienliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
.kriterienliste li {
display: grid;
grid-template-columns: 1.2rem 1fr;
gap: 0.5rem;
align-items: start;
padding: 0.35rem 0;
border-bottom: 1px solid var(--rand-zart);
}
.kriterienliste li[data-erfuellt="ja"] { color: var(--gut); }
.kriterienliste li[data-erfuellt="ja"] > span:first-child { font-weight: 700; }
.kriterien {
border: 1px solid var(--rand);
border-radius: var(--radius-klein);
padding: 0.75rem 0.9rem;
margin: 0;
}
.kriterien legend { font-weight: 650; padding: 0 0.35rem; font-size: 0.95rem; }
.kriterium { padding: 0.3rem 0; }
.kriterium + .kriterium { border-top: 1px solid var(--rand-zart); }
.notiz-anzeige {
white-space: pre-wrap;
background: var(--grund);
border: 1px solid var(--rand-zart);
border-radius: var(--radius-klein);
padding: 0.6rem 0.8rem;
margin: 0;
line-height: 1.55;
}
/* --- Anhänge --- */
.anhangliste {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.anhangliste li { display: grid; gap: 0.3rem; }
.anhang-kachel {
display: grid;
gap: 0.3rem;
text-decoration: none;
color: var(--text);
background: var(--grund);
border: 1px solid var(--rand);
border-radius: var(--radius);
padding: 0.5rem;
}
.anhang-kachel:hover { border-color: var(--petrol); color: var(--text); }
.anhang-kachel img {
width: 100%;
height: 110px;
object-fit: cover;
border-radius: var(--radius-klein);
background: var(--flaeche);
}
.anhang-symbol {
display: grid;
place-items: center;
height: 110px;
border-radius: var(--radius-klein);
background: var(--flaeche-kraeftig);
font-weight: 700;
letter-spacing: 0.05em;
color: var(--text-leise);
}
.anhang-name {
font-size: 0.88rem;
font-weight: 600;
overflow-wrap: anywhere;
}
input[type="file"] {
width: 100%;
padding: 0.5rem;
border: 1px dashed var(--rand);
border-radius: var(--radius-klein);
background: var(--grund);
color: var(--text);
font: inherit;
min-height: 44px;
}
+133
View File
@@ -0,0 +1,133 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
HOECHSTGROESSE,
istBild,
istInnerhalb,
megabyte,
pruefeDatei,
saubererName,
speicherpfad,
} from './dateien';
describe('Dateien prüfen', () => {
it('nimmt ein Foto an', () => {
expect(pruefeDatei('aufbau.jpg', 'image/jpeg', 2_000_000)).toEqual({ endung: '.jpg' });
});
it('nimmt ein PDF an', () => {
expect(pruefeDatei('netzplan.pdf', 'application/pdf', 500_000)).toEqual({ endung: '.pdf' });
});
it('kommt mit einem Anhang am MIME-Typ zurecht', () => {
expect(pruefeDatei('notiz.txt', 'text/plain; charset=utf-8', 100)).toEqual({ endung: '.txt' });
});
it('weist unerlaubte Typen ab', () => {
for (const typ of ['application/x-msdownload', 'image/svg+xml', 'text/html', 'application/zip', '']) {
const ergebnis = pruefeDatei('etwas', typ, 1000);
expect(ergebnis, `Typ ${typ} hätte abgewiesen werden müssen`).toHaveProperty('fehler');
}
});
it('weist SVG ausdrücklich ab – es kann Skripte enthalten', () => {
expect(pruefeDatei('plan.svg', 'image/svg+xml', 1000)).toHaveProperty('fehler');
});
it('weist eine leere Datei ab', () => {
expect(pruefeDatei('leer.jpg', 'image/jpeg', 0)).toHaveProperty('fehler');
});
it('weist eine zu große Datei ab und nennt die Grenze', () => {
const ergebnis = pruefeDatei('riesig.jpg', 'image/jpeg', HOECHSTGROESSE + 1);
expect(ergebnis).toHaveProperty('fehler');
expect((ergebnis as { fehler: string }).fehler).toContain('25');
});
it('nimmt genau die Höchstgröße noch an', () => {
expect(pruefeDatei('gerade-noch.jpg', 'image/jpeg', HOECHSTGROESSE)).toEqual({ endung: '.jpg' });
});
it('ist beim MIME-Typ unempfindlich gegen Großschreibung', () => {
expect(pruefeDatei('foto.PNG', 'IMAGE/PNG', 1000)).toEqual({ endung: '.png' });
});
it('weist einen unbrauchbaren Dateinamen ab', () => {
expect(pruefeDatei('...', 'image/jpeg', 1000)).toHaveProperty('fehler');
});
});
describe('Dateinamen säubern', () => {
it('lässt einen normalen Namen stehen', () => {
expect(saubererName('Aufbau Halle 2.jpg')).toBe('Aufbau Halle 2.jpg');
});
it('entfernt Verzeichnisanteile', () => {
expect(saubererName('../../etc/passwd')).toBe('passwd');
expect(saubererName('C:\\Users\\x\\geheim.jpg')).toBe('geheim.jpg');
});
it('entfernt Steuerzeichen und Anführungszeichen', () => {
expect(saubererName('bild"; rm -rf.jpg')).toBe('bild; rm -rf.jpg');
expect(saubererName('zeile\numbruch.png')).toBe('zeileumbruch.png');
});
it('zieht mehrfache Punkte zusammen', () => {
expect(saubererName('foto..........jpg')).toBe('foto.jpg');
});
it('kürzt sehr lange Namen', () => {
expect(saubererName('a'.repeat(500) + '.jpg').length).toBeLessThanOrEqual(120);
});
it('gibt bei einem nur aus Punkten bestehenden Namen nichts zurück', () => {
expect(saubererName('..')).toBe('');
expect(saubererName(' ')).toBe('');
});
});
describe('Speicherpfad', () => {
it('baut den Pfad aus Jahr und Kennung', () => {
const kennung = '3f2504e0-4f89-41d3-9a0c-0305e82c3301';
expect(speicherpfad(kennung, '.jpg', 2026)).toBe(`2026/${kennung}.jpg`);
});
it('verweigert alles, was keine UUID ist – der Pfad darf nie aus einer Eingabe kommen', () => {
for (const boese of ['../../etc/passwd', 'nicht-uuid', '', '../3f2504e0-4f89-41d3-9a0c-0305e82c3301']) {
expect(() => speicherpfad(boese, '.jpg', 2026)).toThrow(/UUID/);
}
});
});
describe('Pfad liegt im Anhangverzeichnis', () => {
const wurzel = path.join(path.sep, 'daten', 'anhaenge');
it('erkennt einen Pfad darunter', () => {
expect(istInnerhalb(wurzel, path.join(wurzel, '2026', 'a.jpg'))).toBe(true);
});
it('erkennt das Verzeichnis selbst', () => {
expect(istInnerhalb(wurzel, wurzel)).toBe(true);
});
it('erkennt einen Ausbruch nach oben', () => {
expect(istInnerhalb(wurzel, path.join(wurzel, '..', '..', 'etc', 'passwd'))).toBe(false);
});
it('lässt sich nicht durch einen Namensanfang täuschen', () => {
expect(istInnerhalb(wurzel, `${wurzel}-geheim/a.jpg`)).toBe(false);
});
});
describe('Anzeige', () => {
it('schreibt Größen lesbar', () => {
expect(megabyte(512)).toBe('512 Byte');
expect(megabyte(2048)).toBe('2 KB');
expect(megabyte(3_500_000)).toBe('3,3 MB');
});
it('erkennt Bilder', () => {
expect(istBild('image/jpeg')).toBe(true);
expect(istBild('application/pdf')).toBe(false);
});
});
+104
View File
@@ -0,0 +1,104 @@
import path from 'node:path';
/**
* Regeln für hochgeladene Dateien – ohne Dateisystem, damit sie sich testen
* lassen.
*
* Der Grundsatz: Nichts, was der Browser schickt, landet ungeprüft im
* Dateisystem. Der Speicherpfad wird ausschließlich aus einer selbst erzeugten
* Kennung gebildet, der Anzeigename wird nur gesäubert.
*/
/** 25 MiB – ein Foto vom Aufbau passt locker, ein Video soll es nicht sein. */
export const HOECHSTGROESSE = 25 * 1024 * 1024;
/**
* Erlaubte Dateitypen. Bewusst eine kurze Positivliste: Fotos vom Aufbau,
* Bildschirmfotos, ein Netzplan als PDF, gelegentlich eine Textdatei.
*/
export const ERLAUBTE_TYPEN: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
'image/heic': '.heic',
'application/pdf': '.pdf',
'text/plain': '.txt',
'text/csv': '.csv',
};
export type Pruefergebnis = { fehler: string } | { endung: string };
export function pruefeDatei(name: string, mimeTyp: string, groesse: number): Pruefergebnis {
if (groesse <= 0) {
return { fehler: 'Die Datei ist leer.' };
}
if (groesse > HOECHSTGROESSE) {
return {
fehler: `Die Datei ist ${megabyte(groesse)} groß – erlaubt sind höchstens ${megabyte(HOECHSTGROESSE)}.`,
};
}
const typ = mimeTyp.split(';')[0]!.trim().toLowerCase();
const endung = ERLAUBTE_TYPEN[typ];
if (!endung) {
return {
fehler: `Dateien vom Typ „${typ || 'unbekannt'}" sind nicht erlaubt. Möglich sind Bilder, PDF und Textdateien.`,
};
}
if (saubererName(name) === '') {
return { fehler: 'Der Dateiname ist unbrauchbar.' };
}
return { endung };
}
export function megabyte(bytes: number): string {
if (bytes < 1024) return `${bytes} Byte`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`.replace('.', ',');
}
/**
* Säubert einen Dateinamen für die **Anzeige**. Er wird nie zum Bilden eines
* Pfades benutzt – aber er landet im Content-Disposition-Kopf und in der
* Oberfläche, und dort hat weder ein Zeilenumbruch noch ein Anführungszeichen
* etwas verloren.
*/
export function saubererName(name: string): string {
return path
.basename(name)
.replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/["\\]/g, '')
.replace(/\.{2,}/g, '.')
.replace(/^[.\s]+|[.\s]+$/g, '')
.slice(0, 120)
.trim();
}
/**
* Der Speicherpfad, relativ zum Anhangverzeichnis: `<jahr>/<kennung><endung>`.
*
* Nach Jahren unterteilt, damit nach drei Ausbildungsjahren nicht alles in
* einem Verzeichnis liegt. Der Dateiname ist die selbst erzeugte Kennung –
* damit ist ein Ausbruch aus dem Verzeichnis ausgeschlossen.
*/
export function speicherpfad(kennung: string, endung: string, jahr: number): string {
if (!/^[0-9a-f-]{36}$/i.test(kennung)) {
throw new Error('Die Kennung eines Anhangs muss eine UUID sein.');
}
return `${jahr}/${kennung}${endung}`;
}
/** Prüft, dass ein aus der Datenbank gelesener Pfad im Anhangverzeichnis liegt. */
export function istInnerhalb(wurzel: string, vollerPfad: string): boolean {
const auf = path.resolve(wurzel);
const ziel = path.resolve(vollerPfad);
return ziel === auf || ziel.startsWith(auf + path.sep);
}
/** Fasst zusammen, was am Bildschirm über einen Anhang steht. */
export function istBild(mimeTyp: string): boolean {
return mimeTyp.startsWith('image/');
}
+15 -1
View File
@@ -1,5 +1,5 @@
import 'server-only';
import { redirect } from 'next/navigation';
import { notFound, redirect } from 'next/navigation';
import { eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { angemeldeterBenutzer } from './sitzung';
@@ -45,6 +45,20 @@ export function erfordereZugriffAufAzubi(betrachter: Betrachter, azubiId: string
}
}
/**
* Dasselbe für Seiten – endet mit „nicht gefunden" statt mit einem Fehler.
*
* Bewusst 404 und nicht 403: Ob es zu dieser Kennung überhaupt Daten gibt, geht
* einen Unbefugten nichts an. Serveraktionen nehmen weiter die werfende
* Fassung, dort wäre eine Weiterleitung sinnlos.
*/
export function erfordereSichtAufAzubi(betrachter: Betrachter, azubiId: string): void {
const bezug = ausbildungVonAzubi(azubiId);
if (!darfAzubiDatenSehen(betrachter, azubiId, bezug)) {
notFound();
}
}
export function erfordereBewertungsrecht(betrachter: Betrachter, azubiId: string): void {
const bezug = ausbildungVonAzubi(azubiId);
if (!darfBewerten(betrachter, azubiId, bezug)) {
+45
View File
@@ -0,0 +1,45 @@
-- Baustein 5: Praxisaufgaben und Dateianhänge.
--
-- Ziel, Auftrag, Material und Bewertungskriterien stehen in der Aufgabendatei
-- unter content/aufgaben/. Hier steht, wie weit der Azubi damit ist.
CREATE TABLE aufgabe_status (
id TEXT PRIMARY KEY,
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
aufgabe_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen', 'in_arbeit', 'zur_abnahme', 'abgenommen')),
notiz TEXT NOT NULL DEFAULT '',
-- Welche Bewertungskriterien der Ausbilder abgehakt hat, als JSON-Liste der
-- Positionen in der Aufgabendatei, z. B. [0,2,3].
kriterien_erfuellt TEXT NOT NULL DEFAULT '[]',
bewertung TEXT,
rueckmeldung TEXT,
begonnen_am TEXT,
abgegeben_am TEXT,
abgenommen_von TEXT REFERENCES benutzer (id),
abgenommen_am TEXT,
geaendert_am TEXT NOT NULL
);
CREATE UNIQUE INDEX aufgabe_status_idx ON aufgabe_status (benutzer_id, aufgabe_id);
CREATE INDEX aufgabe_status_status_idx ON aufgabe_status (status);
-- Anhänge sind bewusst allgemein gehalten: Sie hängen später auch an
-- Checklisten-Durchläufen und Berichtsheftwochen.
CREATE TABLE anhang (
id TEXT PRIMARY KEY,
bezug_typ TEXT NOT NULL CHECK (bezug_typ IN ('aufgabe', 'checkliste', 'bericht')),
bezug_id TEXT NOT NULL,
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
dateiname TEXT NOT NULL,
mime_typ TEXT NOT NULL,
groesse INTEGER NOT NULL,
-- Pfad relativ zum Anhangverzeichnis. Nie aus einer Eingabe gebildet.
pfad TEXT NOT NULL,
beschriftung TEXT,
hochgeladen_am TEXT NOT NULL
);
CREATE INDEX anhang_bezug_idx ON anhang (bezug_typ, bezug_id);
CREATE INDEX anhang_benutzer_idx ON anhang (benutzer_id);
+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,
};
+2 -1
View File
@@ -3,6 +3,7 @@ import type { Checkliste, Kontrolle, Woche } from '../inhalte/schema';
import { gelesenSet } from '../repos/fortschritt';
import { planVon } from '../repos/wochenplanung';
import { uebersicht as kartenUebersicht } from '../repos/karten';
import { anzahlAbgenommen } from '../repos/aufgaben';
import { heute } from '../zeit';
import { aktuelleWoche } from './wochen';
import { wochenFortschritt, type Fortschritt } from './fortschritt';
@@ -76,9 +77,9 @@ export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer:
const fortschritt = wochenFortschritt({
handouts: { erledigt: handouts.filter((h) => h.gelesen).length, gesamt: handouts.length },
karten: { erledigt: kartenStand.gelernt, gesamt: kartenStand.gesamt },
aufgaben: { erledigt: anzahlAbgenommen(azubiId, aufgaben.map((a) => a.id)), gesamt: aufgaben.length },
// Diese Bereiche werden mit ihren Bausteinen scharf geschaltet. Bis dahin
// zeigen sie ehrlich an, was die Woche vorsieht und noch offen ist.
aufgaben: { erledigt: 0, gesamt: aufgaben.length },
checklisten: { erledigt: 0, gesamt: checklisten.length },
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
});
+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;
}