Baustein 1: Grundgeruest, Datenbank, Anmeldung, zwei Rollen

Legt das Fundament der Lern-Bar an:

- Next.js 15 (App Router), React 19, TypeScript, ESLint, Vitest
- SQLite ueber Drizzle hinter einer Repository-Schicht
- Eigener Migrationslauf mit Pruefsummen, beim Start angewendet
- Argon2id, serverseitige Sitzungen, HttpOnly-Cookie, SameSite=Lax
- Rollen Ausbilder und Azubi, Rechteregeln als reine Funktionen
- Verwaltung: Azubikonto und Ausbildung anlegen, Passwort, Sperren
- Gestaltung Petrol/Bernstein, helle und dunkle Darstellung
- Dockerfile, docker-compose, Lebenszeichen, update.sh mit Rueckfall
- Sicherung und Wiederherstellung als Shell-Skripte
- 29 Tests fuer Rechte, Zeitrechnung und Migrationslauf

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 00:52:44 +02:00
co-authored by Claude Opus 5
commit e856349cd4
51 changed files with 11883 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.git
.github
daten
sicherungen
playwright-report
test-results
e2e
docs/bilder
+11
View File
@@ -0,0 +1,11 @@
# Im Repository gilt durchgängig LF. Sonst landen Zeilenumbrüche aus Windows
# in den Shell-Skripten und der Linux-Container startet sie nicht mehr.
* text=auto eol=lf
*.sh text eol=lf
Dockerfile text eol=lf
*.png binary
*.jpg binary
*.pdf binary
*.ico binary
*.webp binary
+20
View File
@@ -0,0 +1,20 @@
node_modules/
.next/
out/
next-env.d.ts
*.tsbuildinfo
# Laufzeitdaten – gehören ins Volume, nicht ins Repository
daten/
sicherungen/
*.sqlite
*.sqlite-*
# Werkzeuge
.env
.env.local
playwright-report/
test-results/
.vscode/
.DS_Store
Thumbs.db
+61
View File
@@ -0,0 +1,61 @@
# Änderungsprotokoll
Alle nennenswerten Änderungen an der Lern-Bar stehen hier.
Aufbau nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionsnummern nach [Semantischer Versionierung](https://semver.org/lang/de/).
## Wie versioniert wird
- **Patch** (0.1.**x**) – Fehlerbehebungen, Textkorrekturen, Inhaltsänderungen
- **Minor** (0.**x**.0) – ein neuer Baustein aus dem [Fahrplan](ROADMAP.md)
- **Major** (**x**.0.0) – 1.0.0 ist die erste abnahmefähige Fassung; danach
nur noch bei Änderungen, die einen Eingriff beim Betrieb erfordern
(Datenformat, Umgebungsvariablen, Betriebsablauf)
Eine Fassung wird so festgeschrieben:
```bash
npm version minor -m "Fassung %s" # hebt package.json und setzt ein Git-Etikett
git push --follow-tags
```
Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
Überschrift mit Nummer und Datum.
---
## [Unveröffentlicht]
## [0.1.0] – 2026-09-10
Erster Baustein: das Fundament steht, die Anwendung lässt sich starten,
anmelden und verwalten.
### Hinzugefügt
- Projektgerüst mit Next.js 15 (App Router), React 19, TypeScript
- SQLite über Drizzle ORM; der gesamte Datenzugriff liegt hinter einer
Repository-Schicht, damit ein späterer Wechsel auf PostgreSQL kein Umbau ist
- Eigener Migrationslauf: `src/lib/db/migrationen/*.sql` werden beim Start
einmalig angewendet und mit Prüfsumme vermerkt; nachträgliche Änderungen an
bereits angewendeten Dateien lassen die Anwendung mit klarer Meldung abbrechen
- Anmeldung mit Argon2id, serverseitigen Sitzungen, HttpOnly-Cookie und
`SameSite=Lax`; im Cookie steht nur ein Zufallswert, gespeichert wird dessen
SHA-256-Abdruck
- Zwei Rollen (`ausbilder`, `azubi`); die Rechteregeln liegen als reine
Funktionen in `src/lib/auth/regeln.ts` und sind vollständig testbar
- Verwaltungsbereich für den Ausbilder: Azubikonto samt Ausbildung anlegen,
Eckdaten ändern, Passwort setzen, Konto sperren und entsperren
- Gestaltung in Petrol/Bernstein, helle und dunkle Darstellung mit Umschalter
in der Kopfzeile, nur Systemschriften, keine Ressourcen von außen
- Zeitrechnung für Europe/Berlin und Kalenderwochen nach ISO 8601
- Mehrstufiges Dockerfile, `docker-compose.yml` mit einem Dienst und einem
Volume, Lebenszeichen unter `/api/gesundheit`
- `update.sh` für den Linux-Server: sichert, holt den Quelltext, baut, schaltet
um und fällt bei fehlendem Lebenszeichen automatisch auf die vorherige
Fassung zurück
- `sicherung.sh` und `wiederherstellen.sh` für Datenbank und Anhänge
- `npm run konto:anlegen` für das erste Ausbilderkonto
[Unveröffentlicht]: #unveröffentlicht
[0.1.0]: #010--2026-09-10
+40
View File
@@ -0,0 +1,40 @@
# Arbeitstagebuch
Zeiten in Europe/Berlin. Jeder Abschnitt entspricht einem Baustein aus dem
[Fahrplan](ROADMAP.md).
Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
---
## 2026-09-10 · Baustein 1 – Fundament
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 00:15 | 00:20 | 0:05 | Auftrag gesichtet, fehlende Stackvorgabe geklärt (Next.js 15 + React 19 + TypeScript), Repository angelegt |
| 00:20 | 00:28 | 0:08 | Abhängigkeiten installiert; `better-sqlite3` auf v12 gehoben, weil es für Node 24 sonst keine fertige Binärdatei gibt und der Bau über Visual Studio scheiterte |
| 00:28 | 00:40 | 0:12 | Datenbankschicht: Drizzle-Schema, eigener Migrationslauf mit Prüfsummen, Verbindung als Prozess-Singleton |
| 00:40 | 00:52 | 0:12 | Zeitrechnung (Europe/Berlin, ISO-Kalenderwochen), Argon2id, serverseitige Sitzungen, Rechteregeln als reine Funktionen |
| 00:52 | 01:10 | 0:18 | Gestaltung: Farbwelt Petrol/Bernstein, helle und dunkle Darstellung, Bedienelemente, Druckansicht |
| 01:10 | 01:25 | 0:15 | Anmeldung, Rahmenlayout, Startseite, Verwaltung (Konto und Ausbildung anlegen, Passwort, Sperren) |
| 01:25 | 01:32 | 0:07 | Erstes Ausbilderkonto über Skript angelegt, Typprüfung und Bau grün, Server geprüft |
| 01:32 | 01:50 | 0:18 | Dockerfile, docker-compose, Lebenszeichen, `update.sh` mit Rückfall, Sicherung und Wiederherstellung |
| 01:50 | 02:05 | 0:15 | Fahrplan, Änderungsprotokoll, Arbeitstagebuch, Entscheidungen, README |
**Summe Baustein 1: rund 1 h 50 min**
### Was aufgefallen ist
- Der Auftrag verweist zweimal auf einen „Kopf des Prompts" (Stack, Anmeldung),
der nicht mitgeliefert wurde. Die Anmeldung war aus den technischen Vorgaben
ableitbar, der Stack nicht – dafür gab es eine Rückfrage.
- `npm` blockiert seit Version 11 Installationsskripte fremder Pakete.
`better-sqlite3`, `esbuild` und `unrs-resolver` mussten einmalig über
`npm approve-scripts` freigegeben werden. Das gehört in die README, sonst
läuft ein Fremder in genau diese Wand.
- Windows und LF: ohne `.gitattributes` hätte Git die Shell-Skripte mit CRLF
ausgecheckt, und der Linux-Container hätte sie nicht mehr gestartet.
---
<!-- Neue Bausteine werden oben angefügt, damit der aktuelle Stand zuerst steht. -->
+68
View File
@@ -0,0 +1,68 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Lern-Bar – mehrstufiges Abbild
#
# Debian-slim statt Alpine, weil better-sqlite3 und @node-rs/argon2 dort
# fertige Binärdateien mitbringen und nichts übersetzt werden muss.
# ---------------------------------------------------------------------------
ARG NODE_VERSION=22-bookworm-slim
# --- Abhängigkeiten -------------------------------------------------------
FROM node:${NODE_VERSION} AS abhaengigkeiten
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts=false --no-audit --no-fund
# --- Übersetzen -----------------------------------------------------------
FROM node:${NODE_VERSION} AS bau
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=abhaengigkeiten /app/node_modules ./node_modules
COPY . .
RUN npm run build
# --- Laufzeit -------------------------------------------------------------
FROM node:${NODE_VERSION} AS laufzeit
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
TZ=Europe/Berlin \
PORT=3000 \
HOSTNAME=0.0.0.0 \
LERNBAR_DATEN=/daten \
LERNBAR_INHALTE=/app/content \
LERNBAR_MIGRATIONEN=/app/migrationen
# tini fängt Signale ab, damit `docker stop` sauber beendet.
RUN apt-get update && apt-get install -y --no-install-recommends tini ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Standalone-Ausgabe von Next.js bringt genau die nötigen node_modules mit.
COPY --from=bau /app/.next/standalone ./
COPY --from=bau /app/.next/static ./.next/static
COPY --from=bau /app/public ./public
# Inhalte und Migrationen liegen als Dateien im Abbild.
COPY --from=bau /app/content ./content
COPY --from=bau /app/src/lib/db/migrationen ./migrationen
# Werkzeuge für Sicherung und Wiederherstellung im Container.
COPY --from=bau /app/scripts/im-container ./werkzeuge
RUN chmod +x ./werkzeuge/*.sh
RUN mkdir -p /daten && chown -R node:node /daten /app
USER node
VOLUME ["/daten"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "server.js"]
+84
View File
@@ -0,0 +1,84 @@
# Entscheidungen
Kurze Notizen zu Festlegungen, die im Auftrag offen waren. Jede Notiz nennt
die Lage, die Wahl und den Grund.
---
## E1 · Stack: Next.js 15 mit App Router
Der Auftrag verweist auf eine Stackvorgabe im „Kopf des Prompts", die nicht
mitgeliefert wurde. Auf Rückfrage: **Next.js 15 (App Router), React 19,
TypeScript**. Datenzugriff über Drizzle ORM auf better-sqlite3.
Serveraktionen ersetzen eine eigene REST-Schicht. Das hält die Rechteprüfung
dort, wo die Daten geholt werden, statt sie über eine API zu verteilen.
## E2 · Zeitstempel als ISO-8601 in UTC
Der Auftrag verlangt „alle Zeitstempel mit Zeitzone". Gespeichert wird
`2026-09-10T07:15:00.000Z`, also ein eindeutiger Zeitpunkt in UTC. Angezeigt
wird durchgehend in Europe/Berlin.
Grund: UTC-Strings sortieren sich lexikografisch richtig, was bei SQLite ohne
echten Zeitstempeltyp entscheidend ist. Ein gespeicherter Ortsversatz
(`+02:00`) würde beim Wechsel zwischen Sommer- und Winterzeit falsch sortieren.
Reine Kalenderdaten – Berichtszeitraum, Tagesnotiz, Ausbildungsbeginn – sind
`YYYY-MM-DD` ohne Uhrzeit, weil sie auch keine haben.
## E3 · Eigener Migrationslauf statt drizzle-kit
Migrationen sind handgeschriebene SQL-Dateien unter
`src/lib/db/migrationen/`, die beim Start der Reihe nach genau einmal
angewendet und mit SHA-256-Prüfsumme vermerkt werden.
Grund: volle Kontrolle über Indizes und `CHECK`-Bedingungen (Statuswerte sind
Aufzählungen, keine freien Zeichenketten), und beim Start ist im Protokoll
sichtbar, was passiert ist. Wird eine bereits angewendete Datei nachträglich
verändert, bricht der Start mit klarer Meldung ab.
Der Preis: das Drizzle-Schema muss von Hand zu den SQL-Dateien passen. Dagegen
läuft ein Test, der die migrierte Datenbank gegen das Schema abgleicht.
## E4 · Schlüssel sind UUIDs, keine laufenden Nummern
Textspalten mit `crypto.randomUUID()`. Läuft auf SQLite und PostgreSQL
gleichermaßen, und beim Zurückspielen einer Sicherung gibt es keine Kollisionen
mit zwischenzeitlich vergebenen Nummern.
## E5 · Debian-slim als Grundlage des Abbilds, nicht Alpine
`better-sqlite3` und `@node-rs/argon2` bringen für glibc fertige Binärdateien
mit, für musl nicht. Auf Alpine müsste im Abbild übersetzt werden – längerer
Bau, größere Bauschicht, mehr Fehlerquellen. Der Größenunterschied im
Laufzeitabbild ist bei einem selbst gehosteten Dienst ohne Belang.
## E6 · Passwortprüfung mit gleichbleibender Antwortzeit
Bei unbekannter E-Mail-Adresse wird trotzdem einmal gegen einen festen
Argon2-Wert gerechnet. Sonst verrät die Antwortzeit, welche Adressen es gibt.
Die Fehlermeldung ist in beiden Fällen dieselbe.
## E7 · Anhänge liegen im Dateisystem, nicht in der Datenbank
Unter `<daten>/anhaenge/`, in der Datenbank steht nur der Verweis. Hält die
Datenbankdatei klein und die Sicherung schnell; das Archiv nimmt beides mit.
## E8 · Sicherung als Shell-Skript, nicht als Node-Werkzeug
`scripts/im-container/sicherung.sh` läuft im Laufzeitabbild, das kein
TypeScript kennt, und auf dem Entwicklungsrechner unter Git Bash. Eine zweite
Fassung in TypeScript wäre eine zweite Fehlerquelle. Die konsistente Kopie der
Datenbank entsteht über die Sicherungsschnittstelle von SQLite – ein `cp` wäre
bei aktivem WAL-Modus nicht verlässlich.
## E9 · Kein `main`-Zweig ohne Freigabe
Die verbindliche Arbeitsweise verbietet Commits direkt auf `main`. Die
Erstfassung entsteht deshalb vollständig auf `feat/lernbar-erstfassung`.
`main` bleibt leer, bis eine Freigabe erfolgt.
---
<!-- Weitere Entscheidungen werden unten angefügt und fortlaufend nummeriert. -->
+267
View File
@@ -0,0 +1,267 @@
# Lern-Bar
Selbst gehostete Anwendung für die betriebliche Ausbildung zum/zur
**Fachinformatiker/-in für Systemintegration**. Sie ersetzt den Stapel PDFs:
Wochenplan, Handouts, Merkkarten, Praxisaufgaben, Checklisten, Lernzielkontrollen
und – der wichtigste Teil – den gesetzlich vorgeschriebenen Ausbildungsnachweis
(„Berichtsheft").
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.
---
## In fünf Minuten zum laufenden System
### Voraussetzungen
- **Betrieb:** Docker mit Compose-Erweiterung (Docker 24 oder neuer)
- **Entwicklung:** Node.js 22 oder neuer, Git
- Ein Reverse Proxy, der TLS beendet (nginx, Caddy, Traefik) – die Anwendung
selbst spricht nur HTTP und lauscht nur auf der Schleife
### Betrieb mit Docker
```bash
git clone <repository-url> lernbar
cd lernbar
docker compose up -d --build
```
Die Anwendung lauscht danach auf `127.0.0.1:3000`. Erstes Ausbilderkonto
anlegen:
```bash
docker compose exec lernbar node -e "1" >/dev/null # Container erreichbar?
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:
> `npm run konto:anlegen -- --name "…" --email … --rolle ausbilder`
Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und
das Ausbildungsverhältnis mit Beginndatum einrichten.
### Entwicklung
```bash
npm install
npm approve-scripts better-sqlite3 esbuild unrs-resolver # nur beim ersten Mal
npm run konto:anlegen -- --name "Vorname Nachname" --email du@betrieb.de --rolle ausbilder
npm run dev
```
Ohne `npm approve-scripts` bleiben `better-sqlite3` und `esbuild` unübersetzt –
npm blockiert seit Version 11 die Installationsskripte fremder Pakete.
Die Anwendung läuft dann auf <http://localhost:3000>. Datenbank und Anhänge
landen unter `daten/` (nicht im Repository).
---
## Beispiel für einen Reverse Proxy
```nginx
server {
listen 443 ssl http2;
server_name lernbar.betrieb.local;
ssl_certificate /etc/ssl/lernbar.crt;
ssl_certificate_key /etc/ssl/lernbar.key;
client_max_body_size 25m; # Fotos vom Aufbau als Anhang
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
---
## Inhalte pflegen
Die fachlichen Inhalte sind **Dateien im Repository**, keine
Datenbankeinträge – Bearbeiten in VS Code, Versionieren mit Git, Sichern mit
`git clone`.
```
content/
wochen/ 01-arbeitsalltag.yaml …
handouts/ N3-subnetting-rechnen.md …
aufgaben/ 2.2-notebooks-vergleichen.md …
checklisten/ 02-windows-installieren.yaml …
lernzielkontrollen/ woche-03.yaml …
glossar.yaml
rahmenplan.yaml Berufsbildpositionen und Lernfelder
```
Ein Handout ist Markdown mit YAML-Kopf:
```markdown
---
id: N3
titel: Subnetting rechnen – der Weg in vier Schritten
block: netzwerk
woche: 3
worum: Eine Adresse und eine Maske reichen, um Netz, Broadcast und Hostbereich zu bestimmen.
rahmenplan: [B2, A8]
lernfeld: LF3
merksatz: Blockgröße finden, Vielfache aufschreiben, eigene Adresse einsortieren.
karten:
- frage: Zu welchem Netz gehört 192.168.10.200 bei der Maske 255.255.255.224?
antwort: Blockgröße 32, Netze bei .192 und .224 – die Adresse gehört zu 192.168.10.192/27.
---
## Das Wesentliche
…
```
Beim Start werden alle Inhalte eingelesen und geprüft: Jede Kennung muss es
geben, jedes Handout gehört zu einer Woche, jede Rahmenplanposition steht in
`rahmenplan.yaml`. Passt etwas nicht, startet die Anwendung nicht und nennt
Datei und Stelle. Prüfen ohne Start:
```bash
npm run inhalte:pruefen
```
*(Wird mit Baustein 2 scharf geschaltet.)*
---
## Datensicherung
Gesichert werden **Datenbank und Anhänge**. Die Inhalte stecken im Repository
und brauchen keine eigene Sicherung.
### Sicherung ziehen
```bash
# im laufenden Container
docker compose exec lernbar /app/werkzeuge/sicherung.sh /daten/sicherungen
# aus dem Volume herausholen
docker compose cp lernbar:/daten/sicherungen ./sicherungen
```
Auf dem Entwicklungsrechner:
```bash
LERNBAR_DATEN=./daten npm run backup -- ./sicherungen
```
Das Archiv heißt `lernbar-JJJJMMTT-HHMMSS.tar.gz` und enthält
`lernbar.sqlite`, `anhaenge/` und eine kurze Beschreibung. Die Kopie der
Datenbank entsteht über die Sicherungsschnittstelle von SQLite, ist also auch
im laufenden Betrieb in sich stimmig.
### Zurückspielen
```bash
docker compose stop lernbar
docker compose cp ./sicherungen/lernbar-20260910-0300.tar.gz lernbar:/tmp/
docker compose start lernbar
docker compose exec lernbar /app/werkzeuge/wiederherstellen.sh /tmp/lernbar-20260910-0300.tar.gz
docker compose restart lernbar
```
Der bisherige Stand wird nicht gelöscht, sondern nach
`/daten/verdraengt-<zeitstempel>/` verschoben.
### Nächtlich per Cronjob
`/etc/cron.d/lernbar-sicherung`:
```cron
# Jede Nacht um 03:00 sichern, Archive älter als 30 Tage wegräumen.
SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * root cd /opt/lernbar && docker compose exec -T lernbar /app/werkzeuge/sicherung.sh /daten/sicherungen >> /var/log/lernbar-sicherung.log 2>&1
30 3 * * * root cd /opt/lernbar && docker compose exec -T lernbar find /daten/sicherungen -name 'lernbar-*.tar.gz' -mtime +30 -delete
```
Liegt das Volume auf einem Pfad, den die Sicherung des Servers ohnehin
mitnimmt, ist damit alles erledigt. Sonst zusätzlich mit `docker compose cp`
herauskopieren und wegschaffen.
---
## Aktualisieren
Auf dem Linux-Server:
```bash
cd /opt/lernbar
./update.sh
```
Das Skript sichert zuerst, holt den Quelltext, baut das Abbild neu, schaltet um
und wartet auf das Lebenszeichen. Meldet sich die neue Fassung nicht gesund,
schaltet es selbsttätig auf das vorherige Abbild zurück und zeigt die letzten
Protokollzeilen.
| Angabe | Wirkung |
|---|---|
| *(ohne)* | sichern, holen, bauen, umschalten |
| `--ohne-git` | nur neu bauen und neu starten |
| `--ohne-sicherung` | Sicherung überspringen (nicht empfohlen) |
| `--zweig main` | einen bestimmten Git-Zweig holen |
| `--hilfe` | Kurzhilfe |
---
## Umgebungsvariablen
| Name | Vorgabe im Container | Bedeutung |
|---|---|---|
| `PORT` | `3000` | Lauschport |
| `TZ` | `Europe/Berlin` | Zeitzone |
| `LERNBAR_DATEN` | `/daten` | Wurzel für Datenbank und Anhänge |
| `LERNBAR_DB` | `<daten>/lernbar.sqlite` | abweichender Pfad der Datenbank |
| `LERNBAR_INHALTE` | `/app/content` | Wurzel der Inhaltsdateien |
| `LERNBAR_MIGRATIONEN` | `/app/migrationen` | Verzeichnis der SQL-Migrationen |
---
## Prüfen und Testen
```bash
npm run typecheck # TypeScript
npm run lint # ESLint
npm run test # Vitest – Logik, die wehtut
npm run e2e # Playwright – der Hauptweg von Anfang bis Ende
```
---
## Aufbau
```
src/
app/ Seiten und Serveraktionen (App Router)
komponenten/ wiederverwendete Bausteine der Oberfläche
lib/
auth/ Passwörter, Sitzungen, Rechteregeln
db/ Verbindung, Schema, Migrationen
repos/ der gesamte Datenzugriff – nur hier wird SQL gesprochen
zeit/ Europe/Berlin, ISO-Kalenderwochen
content/ fachliche Inhalte als Markdown und YAML
scripts/ Werkzeuge für die Kommandozeile
im-container/ Sicherung und Wiederherstellung (Shell, läuft überall)
```
Weiterführend: [Fahrplan](ROADMAP.md) ·
[Änderungsprotokoll](CHANGELOG.md) · [Arbeitstagebuch](DEVLOG.md) ·
[Entscheidungen](ENTSCHEIDUNGEN.md)
+69
View File
@@ -0,0 +1,69 @@
# Fahrplan
Die Bausteine werden der Reihe nach umgesetzt. Nach jedem Baustein gibt es
einen lauffähigen Stand, einen Eintrag im [Änderungsprotokoll](CHANGELOG.md)
und eine Zeitnotiz im [Arbeitstagebuch](DEVLOG.md).
Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
---
## 0.1 – Fundament
- [x] **B1 · Gerüst, Docker, Datenbank, Migrationen, Anmeldung, zwei Rollen**
- [x] Next.js 15 (App Router), TypeScript, ESLint, Vitest, Playwright
- [x] SQLite über Drizzle, eigener Migrationslauf beim Start
- [x] Argon2id, serverseitige Sitzungen, HttpOnly-Cookie, SameSite=Lax
- [x] Rollen `ausbilder` / `azubi`, reine Rechteregeln getrennt testbar
- [x] Verwaltung: Azubikonto und Ausbildung anlegen, Passwort setzen, sperren
- [x] Dockerfile (mehrstufig), docker-compose.yml, Lebenszeichen
- [x] `update.sh` für den Linux-Server mit Sicherung und Rückfall
- [x] `sicherung.sh` / `wiederherstellen.sh`
## 0.2 – Inhalte
- [ ] **B2 · Inhalte einlesen und validieren, Handouts anzeigen, Suche**
- [ ] Markdown mit YAML-Frontmatter, Zod-Prüfung, Start bricht bei Fehler ab
- [ ] Fehlermeldung nennt Datei und Zeile
- [ ] Handout-Ansicht mit festem Aufbau, Themenblöcke, „gelesen" merken
- [ ] Volltextsuche über alle Handouts
- [ ] Neu einlesen bei Dateiänderung im Entwicklungsbetrieb
## 0.3 – Woche und Wiederholung
- [ ] **B3 · Wochenplan als Startseite, Fortschritt, Wochen verschieben**
- [ ] **B4 · Merkkarten mit Wiederholung nach Zeitabstand (SM-2)**
## 0.4 – Nachweise
- [ ] **B5 · Praxisaufgaben mit Anhängen und Abnahme**
- [ ] **B6 · Checklisten mit Durchläufen und PDF**
- [ ] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
## 0.5 – Bewertung
- [ ] **B8 · Lernzielkontrollen mit Freigabe, Abgabe, Korrektur**
- [ ] **B9 · Auswertung, Rahmenplanabdeckung, Feedbackgespräch**
## 1.0 – Abrundung
- [ ] **B10 · PWA, Offlinebetrieb, Druckansichten, Feinschliff**
- [ ] Anleitung mit Screenshots (`docs/ANLEITUNG.md`), Bilder automatisiert
über `npm run screenshots`
- [ ] Ende-zu-Ende-Test über den Hauptweg
- [ ] Abnahmekriterien einzeln durchgespielt und protokolliert
---
## Danach – bewusst zurückgestellt
- Erinnerung freitags per E-Mail (in der Anwendung reicht zunächst)
- Importskript für vorhandenes Material (52 Handouts, 8 Checklisten, …),
sobald die Quelldateien vorliegen
- Wechsel auf PostgreSQL (vorbereitet durch die Repository-Schicht, aber
ohne Anlass nicht sinnvoll)
## Ausdrücklich nicht geplant
Mehrmandantenfähigkeit · Chat und Kommentare · Gamification ·
Inhaltseditor in der Oberfläche · IHK-Schnittstellen · KI-Funktionen
+34
View File
@@ -0,0 +1,34 @@
# Lern-Bar – ein Dienst, ein Volume, mehr braucht es nicht.
# Vor einem TLS-beendenden Reverse Proxy betreiben (nginx, Caddy, Traefik).
services:
lernbar:
build:
context: .
dockerfile: Dockerfile
image: lernbar:lokal
container_name: lernbar
restart: unless-stopped
environment:
TZ: Europe/Berlin
PORT: "3000"
# Hinter einem Proxy, der TLS beendet, bleibt das hier http.
NODE_ENV: production
ports:
# Nur auf der Schleife lauschen – nach außen geht der Reverse Proxy.
- "127.0.0.1:3000:3000"
volumes:
- lernbar-daten:/daten
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:3000/api/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
lernbar-daten:
+17
View File
@@ -0,0 +1,17 @@
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FlatCompat } from '@eslint/eslintrc';
const compat = new FlatCompat({ baseDirectory: dirname(fileURLToPath(import.meta.url)) });
const konfiguration = [
{ ignores: ['.next/**', 'node_modules/**', 'daten/**', 'sicherungen/**', 'next-env.d.ts', 'playwright-report/**', 'test-results/**'] },
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
},
},
];
export default konfiguration;
+25
View File
@@ -0,0 +1,25 @@
import type { NextConfig } from 'next';
const konfiguration: NextConfig = {
// Standalone-Ausgabe: das Laufzeit-Image braucht kein node_modules-Verzeichnis.
output: 'standalone',
// Native Module dürfen nicht gebündelt werden.
serverExternalPackages: ['better-sqlite3', '@node-rs/argon2', 'pdfkit'],
poweredByHeader: false,
reactStrictMode: true,
eslint: { ignoreDuringBuilds: true },
async headers() {
return [
{
source: '/:pfad*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'same-origin' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
],
},
];
},
};
export default konfiguration;
+8501
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
{
"name": "lernbar",
"version": "0.1.0",
"private": true,
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test",
"inhalte:pruefen": "tsx scripts/inhalte-pruefen.ts",
"konto:anlegen": "tsx scripts/konto-anlegen.ts",
"backup": "bash scripts/im-container/sicherung.sh",
"restore": "bash scripts/im-container/wiederherstellen.sh",
"screenshots": "tsx scripts/screenshots.ts"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"better-sqlite3": "^12.11.1",
"drizzle-orm": "^0.45.2",
"gray-matter": "^4.0.3",
"markdown-it": "^14.1.0",
"next": "^15.5.4",
"pdfkit": "^0.17.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"server-only": "^0.0.1",
"yaml": "^2.8.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.7",
"@playwright/test": "^1.55.0",
"@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.15.0",
"@types/pdfkit": "^0.14.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"eslint": "^9.34.0",
"eslint-config-next": "^15.5.4",
"tsx": "^4.20.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
},
"allowScripts": {
"better-sqlite3@12.11.1": true,
"esbuild@0.28.2": true,
"unrs-resolver@1.12.2": true
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Lern-Bar">
<rect width="64" height="64" rx="12" fill="#1F4E5F"/>
<rect x="14" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
<rect x="27" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
<rect x="40" y="16" width="10" height="32" rx="2" fill="#D98A2B"/>
</svg>

After

Width:  |  Height:  |  Size: 364 B

+52
View File
@@ -0,0 +1,52 @@
#!/bin/sh
# ---------------------------------------------------------------------------
# Sicherung: Datenbank und Anhänge in ein Archiv schreiben.
#
# Läuft sowohl im Container als auch auf dem Entwicklungsrechner (Git Bash).
#
# sicherung.sh [zielverzeichnis]
#
# Ergebnis: <zielverzeichnis>/lernbar-JJJJMMTT-HHMMSS.tar.gz
# ---------------------------------------------------------------------------
set -eu
DATEN="${LERNBAR_DATEN:-/daten}"
DB="${LERNBAR_DB:-$DATEN/lernbar.sqlite}"
ZIEL="${1:-${LERNBAR_SICHERUNGEN:-$DATEN/sicherungen}}"
if [ ! -f "$DB" ]; then
echo "Keine Datenbank unter $DB gefunden." >&2
exit 1
fi
mkdir -p "$ZIEL"
STEMPEL="$(date +%Y%m%d-%H%M%S)"
ARBEIT="$(mktemp -d)"
trap 'rm -rf "$ARBEIT"' EXIT
# Konsistente Kopie über die Sicherungsschnittstelle von SQLite. Ein blosses
# `cp` wäre bei aktivem WAL-Modus nicht verlässlich.
node -e "
const Database = require('better-sqlite3');
const db = new Database(process.argv[1], { readonly: true });
db.backup(process.argv[2]).then(() => { db.close(); }).catch((f) => { console.error(f); process.exit(1); });
" "$DB" "$ARBEIT/lernbar.sqlite"
mkdir -p "$ARBEIT/anhaenge"
if [ -d "$DATEN/anhaenge" ]; then
cp -R "$DATEN/anhaenge/." "$ARBEIT/anhaenge/" 2>/dev/null || true
fi
cat > "$ARBEIT/sicherung.txt" <<ENDE
Lern-Bar Sicherung
Erstellt am: $(date -Iseconds)
Quelle: $DATEN
Inhalt: lernbar.sqlite (Datenbank), anhaenge/ (hochgeladene Dateien)
Zurückspielen: wiederherstellen.sh <diese-datei.tar.gz>
ENDE
ARCHIV="$ZIEL/lernbar-$STEMPEL.tar.gz"
tar -czf "$ARCHIV" -C "$ARBEIT" lernbar.sqlite anhaenge sicherung.txt
echo "$ARCHIV"
+65
View File
@@ -0,0 +1,65 @@
#!/bin/sh
# ---------------------------------------------------------------------------
# Wiederherstellung aus einem Sicherungsarchiv.
#
# wiederherstellen.sh <archiv.tar.gz> [--ohne-rueckfrage]
#
# Der vorhandene Datenbestand wird vorher zur Seite gelegt
# (daten/verdraengt-<zeitstempel>/), nichts wird endgültig gelöscht.
# Die Anwendung sollte dabei gestoppt sein.
# ---------------------------------------------------------------------------
set -eu
ARCHIV="${1:-}"
BESTAETIGT="${2:-}"
if [ -z "$ARCHIV" ] || [ ! -f "$ARCHIV" ]; then
echo "Aufruf: wiederherstellen.sh <archiv.tar.gz> [--ohne-rueckfrage]" >&2
exit 1
fi
DATEN="${LERNBAR_DATEN:-/daten}"
DB="${LERNBAR_DB:-$DATEN/lernbar.sqlite}"
if [ "$BESTAETIGT" != "--ohne-rueckfrage" ]; then
printf 'Der aktuelle Datenbestand unter %s wird ersetzt. Fortfahren? [j/N] ' "$DATEN"
read -r antwort
case "$antwort" in
j | J | ja | Ja) ;;
*)
echo "Abgebrochen."
exit 1
;;
esac
fi
ARBEIT="$(mktemp -d)"
trap 'rm -rf "$ARBEIT"' EXIT
tar -xzf "$ARCHIV" -C "$ARBEIT"
if [ ! -f "$ARBEIT/lernbar.sqlite" ]; then
echo "Das Archiv enthält keine lernbar.sqlite – falsche Datei?" >&2
exit 1
fi
STEMPEL="$(date +%Y%m%d-%H%M%S)"
VERDRAENGT="$DATEN/verdraengt-$STEMPEL"
mkdir -p "$VERDRAENGT"
for datei in "$DB" "$DB-wal" "$DB-shm"; do
[ -e "$datei" ] && mv "$datei" "$VERDRAENGT/" || true
done
if [ -d "$DATEN/anhaenge" ]; then
mv "$DATEN/anhaenge" "$VERDRAENGT/anhaenge"
fi
mkdir -p "$DATEN"
cp "$ARBEIT/lernbar.sqlite" "$DB"
mkdir -p "$DATEN/anhaenge"
if [ -d "$ARBEIT/anhaenge" ]; then
cp -R "$ARBEIT/anhaenge/." "$DATEN/anhaenge/" 2>/dev/null || true
fi
echo "Wiederhergestellt aus $ARCHIV."
echo "Der vorherige Stand liegt unter $VERDRAENGT und kann gelöscht werden, sobald alles passt."
+58
View File
@@ -0,0 +1,58 @@
/**
* Legt ein Konto an – gedacht für das allererste Ausbilderkonto.
*
* npm run konto:anlegen -- --name "Max Mustermann" --email max@betrieb.de --rolle ausbilder
*
* Ohne --passwort wird eines erzeugt und ausgegeben.
*/
import crypto from 'node:crypto';
import { benutzerAnlegen, benutzerNachEmail, passwortSetzen } from '../src/lib/repos/benutzer';
import { rohVerbindung } from '../src/lib/db';
function argument(name: string): string | undefined {
const index = process.argv.indexOf(`--${name}`);
if (index === -1) return undefined;
return process.argv[index + 1];
}
function erzeugePasswort(): string {
// Gut vorlesbar: keine leicht verwechselbaren Zeichen.
const zeichen = 'abcdefghijkmnpqrstuvwxyz23456789';
const roh = crypto.randomBytes(16);
return Array.from(roh, (b) => zeichen[b % zeichen.length]).join('');
}
async function main() {
rohVerbindung(); // Migrationen anwenden
const name = argument('name');
const email = argument('email');
const rolle = (argument('rolle') ?? 'ausbilder') as 'ausbilder' | 'azubi';
const passwort = argument('passwort') ?? erzeugePasswort();
if (!name || !email) {
console.error('Aufruf: npm run konto:anlegen -- --name "Vorname Nachname" --email adresse@betrieb.de [--rolle ausbilder|azubi] [--passwort ...]');
process.exit(1);
}
if (rolle !== 'ausbilder' && rolle !== 'azubi') {
console.error('--rolle muss "ausbilder" oder "azubi" sein.');
process.exit(1);
}
const vorhanden = benutzerNachEmail(email);
if (vorhanden) {
await passwortSetzen(vorhanden.id, passwort);
console.log(`Konto ${email} gab es schon – Passwort neu gesetzt.`);
} else {
await benutzerAnlegen({ name, email, passwort, rolle });
console.log(`Konto angelegt: ${name} <${email}> als ${rolle}.`);
}
console.log(`Passwort: ${passwort}`);
console.log('Bitte notieren und nach der ersten Anmeldung ändern.');
}
main().catch((fehler) => {
console.error(fehler);
process.exit(1);
});
+36
View File
@@ -0,0 +1,36 @@
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { Kopfzeile, type Navigationseintrag } from '@/komponenten/Kopfzeile';
import { abmelden } from '../anmelden/aktionen';
export const dynamic = 'force-dynamic';
// Die Navigation wächst mit den Bausteinen mit – hier stehen nur Ziele,
// die es auch wirklich gibt.
const fuerAzubi: Navigationseintrag[] = [{ pfad: '/', titel: 'Woche' }];
const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/', titel: 'Überblick' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
export default async function AngemeldetLayout({ children }: { children: React.ReactNode }) {
const benutzer = await erfordereBenutzer();
const eintraege = benutzer.rolle === 'ausbilder' ? fuerAusbilder : fuerAzubi;
return (
<>
<a href="#inhalt" className="sprung">
Zum Inhalt springen
</a>
<Kopfzeile
eintraege={eintraege}
name={benutzer.name}
rolle={benutzer.rolle === 'ausbilder' ? 'Ausbilder' : 'Azubi'}
abmeldeAktion={abmelden}
/>
<main id="inhalt" className="huelle">
{children}
</main>
</>
);
}
+70
View File
@@ -0,0 +1,70 @@
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { alsDatumText, kalenderwocheText, heute } from '@/lib/zeit';
export const dynamic = 'force-dynamic';
/**
* Vorläufige Startseite. Ab Baustein 3 steht hier der Wochenplan.
*/
export default async function StartSeite() {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle === 'ausbilder') {
const meine = ausbildungenVonAusbilder(benutzer.id);
return (
<div className="stapel">
<div>
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
<p className="leise">
{kalenderwocheText(heute())} · {alsDatumText(heute())}
</p>
</div>
<section className="karte stapel">
<h2 style={{ marginBottom: 0 }}>Ausbildungen</h2>
{meine.length === 0 ? (
<p style={{ marginBottom: 0 }}>
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{meine.map(({ ausbildung, azubi }) => (
<li key={ausbildung.id}>
<strong>{azubi.name}</strong> · seit {alsDatumText(ausbildung.beginn)}
</li>
))}
</ul>
)}
</section>
</div>
);
}
const ausbildung = ausbildungNachAzubi(benutzer.id);
return (
<div className="stapel">
<div>
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
<p className="leise">
{kalenderwocheText(heute())} · {alsDatumText(heute())}
</p>
</div>
<section className="karte">
{ausbildung ? (
<p style={{ marginBottom: 0 }}>
Deine Ausbildung läuft seit {alsDatumText(ausbildung.beginn)}
{ausbildung.probezeitBis ? ` (Probezeit bis ${alsDatumText(ausbildung.probezeitBis)})` : ''}.
</p>
) : (
<p style={{ marginBottom: 0 }}>
Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid.
</p>
)}
</section>
</div>
);
}
@@ -0,0 +1,111 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import {
ausbildungBearbeiten,
azubiAnlegen,
passwortZuruecksetzen,
type Rueckmeldung,
} from './aktionen';
export function AzubiAnlegenFormular({ vorschlagBeginn }: { vorschlagBeginn: string }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(azubiAnlegen, {});
return (
<form action={aktion} className="stapel">
<Meldung zustand={zustand} />
<div className="feld">
<label htmlFor="neu-name">Name</label>
<input id="neu-name" name="name" type="text" required autoComplete="off" placeholder="Vorname Nachname" />
</div>
<div className="feld">
<label htmlFor="neu-email">E-Mail-Adresse</label>
<input id="neu-email" name="email" type="email" required autoComplete="off" />
</div>
<div className="feld">
<label htmlFor="neu-passwort">Erstes Passwort</label>
<input id="neu-passwort" name="passwort" type="text" required minLength={10} autoComplete="off" />
<p className="leise" style={{ margin: '0.3rem 0 0' }}>
Mindestens 10 Zeichen. Gib es dem Azubi persönlich weiter – er kann es später selbst ändern.
</p>
</div>
<div className="reihe" style={{ alignItems: 'flex-end' }}>
<div className="feld" style={{ flex: '1 1 160px', marginTop: 0 }}>
<label htmlFor="neu-beginn">Ausbildungsbeginn</label>
<input id="neu-beginn" name="beginn" type="date" required defaultValue={vorschlagBeginn} />
</div>
<div className="feld" style={{ flex: '1 1 160px', marginTop: 0 }}>
<label htmlFor="neu-probezeit">Probezeit bis</label>
<input id="neu-probezeit" name="probezeitBis" type="date" />
</div>
<div className="feld" style={{ flex: '1 1 160px', marginTop: 0 }}>
<label htmlFor="neu-ende">Geplantes Ende</label>
<input id="neu-ende" name="ende" type="date" />
</div>
</div>
<div>
<Absenden>Konto anlegen</Absenden>
</div>
</form>
);
}
export function AusbildungFormular({
ausbildungId,
beginn,
probezeitBis,
ende,
}: {
ausbildungId: string;
beginn: string;
probezeitBis: string | null;
ende: string | null;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(ausbildungBearbeiten, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="ausbildungId" value={ausbildungId} />
<div className="reihe" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 150px' }}>
<label htmlFor={`beginn-${ausbildungId}`}>Beginn</label>
<input id={`beginn-${ausbildungId}`} name="beginn" type="date" defaultValue={beginn} required />
</div>
<div style={{ flex: '1 1 150px' }}>
<label htmlFor={`probe-${ausbildungId}`}>Probezeit bis</label>
<input id={`probe-${ausbildungId}`} name="probezeitBis" type="date" defaultValue={probezeitBis ?? ''} />
</div>
<div style={{ flex: '1 1 150px' }}>
<label htmlFor={`ende-${ausbildungId}`}>Ende</label>
<input id={`ende-${ausbildungId}`} name="ende" type="date" defaultValue={ende ?? ''} />
</div>
<Absenden className="knopf knopf-zweit">Speichern</Absenden>
</div>
</form>
);
}
export function PasswortFormular({ benutzerId, name }: { benutzerId: string; name: string }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(passwortZuruecksetzen, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="benutzerId" value={benutzerId} />
<div className="reihe" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 220px' }}>
<label htmlFor={`pw-${benutzerId}`}>Neues Passwort für {name}</label>
<input id={`pw-${benutzerId}`} name="passwort" type="text" minLength={10} required autoComplete="off" />
</div>
<Absenden className="knopf knopf-zweit">Passwort setzen</Absenden>
</div>
</form>
);
}
+103
View File
@@ -0,0 +1,103 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereAusbilder } from '@/lib/auth/rechte';
import { passwortMangel } from '@/lib/auth/passwort';
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereAusbilder();
const name = String(formular.get('name') ?? '').trim();
const email = String(formular.get('email') ?? '')
.trim()
.toLowerCase();
const passwort = String(formular.get('passwort') ?? '');
const beginn = String(formular.get('beginn') ?? '').trim();
const probezeitBis = String(formular.get('probezeitBis') ?? '').trim();
const ende = String(formular.get('ende') ?? '').trim();
if (!name) return { fehler: 'Bitte einen Namen eintragen.' };
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return { fehler: 'Die E-Mail-Adresse sieht nicht gültig aus.' };
const mangel = passwortMangel(passwort);
if (mangel) return { fehler: mangel };
if (!datumMuster.test(beginn)) return { fehler: 'Bitte ein Beginndatum wählen.' };
if (benutzerNachEmail(email)) return { fehler: 'Diese E-Mail-Adresse ist schon vergeben.' };
const azubi = await benutzerAnlegen({ name, email, passwort, rolle: 'azubi' });
ausbildungAnlegen({
azubiId: azubi.id,
ausbilderId: ausbilder.id,
beginn,
probezeitBis: datumMuster.test(probezeitBis) ? probezeitBis : null,
ende: datumMuster.test(ende) ? ende : null,
});
revalidatePath('/verwaltung');
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${beginn} eingerichtet.` };
}
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereAusbilder();
const id = String(formular.get('ausbildungId') ?? '');
const bestand = ausbildungNachId(id);
if (!bestand || bestand.ausbilderId !== ausbilder.id) {
return { fehler: 'Diese Ausbildung gehört nicht zu dir.' };
}
const beginn = String(formular.get('beginn') ?? '').trim();
const ende = String(formular.get('ende') ?? '').trim();
const probezeitBis = String(formular.get('probezeitBis') ?? '').trim();
if (!datumMuster.test(beginn)) return { fehler: 'Bitte ein gültiges Beginndatum wählen.' };
ausbildungAendern(id, {
beginn,
ende: datumMuster.test(ende) ? ende : null,
probezeitBis: datumMuster.test(probezeitBis) ? probezeitBis : null,
});
revalidatePath('/verwaltung');
return { erfolg: 'Ausbildung aktualisiert.' };
}
export async function passwortZuruecksetzen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereAusbilder();
const benutzerId = String(formular.get('benutzerId') ?? '');
const neu = String(formular.get('passwort') ?? '');
const ziel = benutzerNachId(benutzerId);
if (!ziel) return { fehler: 'Konto nicht gefunden.' };
const bezug = ausbildungNachAzubi(benutzerId);
const eigenesKonto = ziel.id === ausbilder.id;
const eigenerAzubi = ziel.rolle === 'azubi' && bezug?.ausbilderId === ausbilder.id;
if (!eigenesKonto && !eigenerAzubi) return { fehler: 'Dieses Konto darfst du nicht ändern.' };
const mangel = passwortMangel(neu);
if (mangel) return { fehler: mangel };
await passwortSetzen(benutzerId, neu);
revalidatePath('/verwaltung');
return { erfolg: `Neues Passwort für ${ziel.name} gesetzt. Alle offenen Sitzungen wurden beendet.` };
}
export async function kontoAktivSetzen(formular: FormData): Promise<void> {
const ausbilder = await erfordereAusbilder();
const benutzerId = String(formular.get('benutzerId') ?? '');
const aktiv = String(formular.get('aktiv') ?? '') === '1';
const ziel = benutzerNachId(benutzerId);
if (!ziel || ziel.id === ausbilder.id) return;
const bezug = ausbildungNachAzubi(benutzerId);
if (ziel.rolle !== 'azubi' || bezug?.ausbilderId !== ausbilder.id) return;
aktivSetzen(benutzerId, aktiv);
revalidatePath('/verwaltung');
}
+100
View File
@@ -0,0 +1,100 @@
import type { Metadata } from 'next';
import { erfordereAusbilder } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { alsDatumText, alsZeitpunktText, heute } from '@/lib/zeit';
import { AusbildungFormular, AzubiAnlegenFormular, PasswortFormular } from './Formulare';
import { kontoAktivSetzen } from './aktionen';
export const metadata: Metadata = { title: 'Verwaltung' };
export const dynamic = 'force-dynamic';
export default async function VerwaltungsSeite() {
const ausbilder = await erfordereAusbilder();
const meine = ausbildungenVonAusbilder(ausbilder.id);
return (
<div className="stapel">
<div>
<h1>Verwaltung</h1>
<p className="leise">
Hier legst du Azubikonten an und pflegst die Eckdaten der Ausbildung. Mehr Verwaltung gibt es bewusst nicht.
</p>
</div>
<section className="karte stapel" aria-labelledby="ueberschrift-azubis">
<h2 id="ueberschrift-azubis" style={{ marginBottom: 0 }}>
Deine Auszubildenden
</h2>
{meine.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Noch kein Azubikonto angelegt. Nimm dafür das Formular weiter unten.
</p>
) : (
<div className="stapel">
{meine.map(({ ausbildung, azubi }) => (
<article key={ausbildung.id} className="karte-grund stapel">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<div>
<h3 style={{ marginBottom: '0.15rem' }}>{azubi.name}</h3>
<p className="leise" style={{ margin: 0 }}>
{azubi.email} · {ausbildung.beruf}
</p>
<p className="leise" style={{ margin: 0 }}>
Beginn {alsDatumText(ausbildung.beginn)}
{ausbildung.probezeitBis ? ` · Probezeit bis ${alsDatumText(ausbildung.probezeitBis)}` : ''}
{ausbildung.ende ? ` · Ende ${alsDatumText(ausbildung.ende)}` : ''}
</p>
<p className="leise" style={{ margin: 0 }}>
Zuletzt angemeldet: {alsZeitpunktText(azubi.letzteAnmeldungAm)}
</p>
</div>
<div className="reihe">
<span className="marke-status" data-ton={azubi.aktiv === 1 ? 'gut' : 'warnung'}>
{azubi.aktiv === 1 ? '● aktiv' : '○ gesperrt'}
</span>
<form action={kontoAktivSetzen}>
<input type="hidden" name="benutzerId" value={azubi.id} />
<input type="hidden" name="aktiv" value={azubi.aktiv === 1 ? '0' : '1'} />
<button type="submit" className="knopf knopf-still">
{azubi.aktiv === 1 ? 'Sperren' : 'Entsperren'}
</button>
</form>
</div>
</div>
<AusbildungFormular
ausbildungId={ausbildung.id}
beginn={ausbildung.beginn}
probezeitBis={ausbildung.probezeitBis}
ende={ausbildung.ende}
/>
<PasswortFormular benutzerId={azubi.id} name={azubi.name} />
</article>
))}
</div>
)}
</section>
<section className="karte stapel" aria-labelledby="ueberschrift-neu">
<div>
<h2 id="ueberschrift-neu" style={{ marginBottom: '0.15rem' }}>
Neues Azubikonto
</h2>
<p className="leise" style={{ margin: 0 }}>
Konto und Ausbildungsverhältnis werden in einem Schritt angelegt.
</p>
</div>
<AzubiAnlegenFormular vorschlagBeginn={heute()} />
</section>
<section className="karte stapel" aria-labelledby="ueberschrift-eigenes">
<h2 id="ueberschrift-eigenes" style={{ marginBottom: 0 }}>
Eigenes Passwort
</h2>
<PasswortFormular benutzerId={ausbilder.id} name={ausbilder.name} />
</section>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { anmelden, type Anmeldezustand } from './aktionen';
function Absendeknopf() {
const { pending } = useFormStatus();
return (
<button type="submit" className="knopf" style={{ width: '100%' }} disabled={pending}>
{pending ? 'Einen Moment …' : 'Anmelden'}
</button>
);
}
export function AnmeldeFormular() {
const [zustand, aktion] = useActionState<Anmeldezustand, FormData>(anmelden, {});
return (
<form action={aktion} className="stapel">
{zustand.fehler ? (
<p className="hinweis hinweis-fehler" role="alert">
{zustand.fehler}
</p>
) : null}
<div className="feld">
<label htmlFor="email">E-Mail-Adresse</label>
<input
id="email"
name="email"
type="email"
autoComplete="username"
required
defaultValue={zustand.email ?? ''}
autoFocus
/>
</div>
<div className="feld">
<label htmlFor="passwort">Passwort</label>
<input id="passwort" name="passwort" type="password" autoComplete="current-password" required />
</div>
<Absendeknopf />
</form>
);
}
+46
View File
@@ -0,0 +1,46 @@
'use server';
import { eq } from 'drizzle-orm';
import { redirect } from 'next/navigation';
import { datenbank, schema } from '@/lib/db';
import { passwortPruefen } from '@/lib/auth/passwort';
import { sitzungAnlegen, sitzungBeenden } from '@/lib/auth/sitzung';
import { jetzt } from '@/lib/zeit';
export type Anmeldezustand = { fehler?: string; email?: string };
export async function anmelden(_vorher: Anmeldezustand, formular: FormData): Promise<Anmeldezustand> {
const email = String(formular.get('email') ?? '')
.trim()
.toLowerCase();
const passwort = String(formular.get('passwort') ?? '');
if (!email || !passwort) {
return { fehler: 'Bitte E-Mail-Adresse und Passwort eingeben.', email };
}
const db = datenbank();
const benutzer = db.select().from(schema.benutzer).where(eq(schema.benutzer.email, email)).get();
// Bewusst dieselbe Meldung für „gibt es nicht" und „Passwort falsch".
const abweisung: Anmeldezustand = { fehler: 'E-Mail-Adresse oder Passwort stimmt nicht.', email };
if (!benutzer || benutzer.aktiv !== 1) {
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
await passwortPruefen('$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', passwort);
return abweisung;
}
const stimmt = await passwortPruefen(benutzer.passwortHash, passwort);
if (!stimmt) return abweisung;
db.update(schema.benutzer).set({ letzteAnmeldungAm: jetzt() }).where(eq(schema.benutzer.id, benutzer.id)).run();
await sitzungAnlegen(benutzer.id);
redirect('/');
}
export async function abmelden(): Promise<void> {
await sitzungBeenden();
redirect('/anmelden');
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { angemeldeterBenutzer } from '@/lib/auth/sitzung';
import { AnmeldeFormular } from './AnmeldeFormular';
export const metadata: Metadata = { title: 'Anmelden' };
export const dynamic = 'force-dynamic';
export default async function AnmeldeSeite() {
if (await angemeldeterBenutzer()) redirect('/');
return (
<main className="anmeldung">
<div className="anmeldung-karte stapel">
<div>
<p className="leise" style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
Ausbildung Fachinformatiker/-in SI
</p>
<h1 style={{ marginBottom: '0.15rem' }}>
Lern<span style={{ color: 'var(--bernstein)' }}>-Bar</span>
</h1>
<p className="leise">Melde dich mit den Zugangsdaten an, die du von deinem Ausbilder bekommen hast.</p>
</div>
<AnmeldeFormular />
<p className="leise" style={{ marginBottom: 0 }}>
Kein Konto? Konten legt ausschließlich der Ausbilder an – es gibt keine Selbstregistrierung.
</p>
</div>
</main>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { rohVerbindung } from '@/lib/db';
export const dynamic = 'force-dynamic';
/**
* Lebenszeichen für Docker und den Reverse Proxy. Antwortet nur dann mit 200,
* wenn die Datenbank tatsächlich ansprechbar ist. Gibt bewusst nichts preis.
*/
export function GET() {
try {
rohVerbindung().prepare('SELECT 1').get();
return Response.json({ zustand: 'in Ordnung' });
} catch {
return Response.json({ zustand: 'gestört' }, { status: 503 });
}
}
+490
View File
@@ -0,0 +1,490 @@
/* ---------------------------------------------------------------------------
Lern-Bar – Gestaltung
Leitfarbe dunkles Petrol, Akzent warmes Bernstein, Karten sehr helles
Blaugrau. Nur Systemschriften, keine Zeichensätze von außen.
--------------------------------------------------------------------------- */
:root {
color-scheme: light;
--petrol: #1f4e5f;
--petrol-hell: #2c6b81;
--petrol-dunkel: #163a47;
--bernstein: #d98a2b;
--bernstein-dunkel: #a9660f;
--grund: #ffffff;
--flaeche: #edf3f5;
--flaeche-kraeftig: #dde8ec;
--rand: #c8d8de;
--rand-zart: #dde8ec;
--text: #16242a;
--text-leise: #4d646d;
--text-auf-petrol: #f2f8fa;
--gut: #1d6f4a;
--gut-flaeche: #e2f2ea;
--warnung: #8a5a00;
--warnung-flaeche: #fbf0dc;
--fehler: #9b2226;
--fehler-flaeche: #fbe9e9;
--radius: 10px;
--radius-klein: 6px;
--schatten: 0 1px 2px rgb(22 36 42 / 8%), 0 2px 8px rgb(22 36 42 / 5%);
--schrift: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--schrift-fest: ui-monospace, "Cascadia Mono", "Segoe UI Mono", Consolas, "Liberation Mono", monospace;
--breite: 1120px;
}
:root[data-thema="dunkel"] {
color-scheme: dark;
--petrol: #7fc0d4;
--petrol-hell: #a3d4e3;
--petrol-dunkel: #0f2a34;
--bernstein: #e5a552;
--bernstein-dunkel: #f0bd7c;
--grund: #0e1b21;
--flaeche: #16272f;
--flaeche-kraeftig: #1e343e;
--rand: #2c4652;
--rand-zart: #233b45;
--text: #e6eef1;
--text-leise: #a3b8c0;
--text-auf-petrol: #0e1b21;
--gut: #6cc79a;
--gut-flaeche: #14332a;
--warnung: #e3b264;
--warnung-flaeche: #33290f;
--fehler: #f08a8d;
--fehler-flaeche: #35191b;
--schatten: 0 1px 2px rgb(0 0 0 / 40%), 0 2px 8px rgb(0 0 0 / 30%);
}
@media (prefers-color-scheme: dark) {
:root:not([data-thema="hell"]) {
color-scheme: dark;
--petrol: #7fc0d4;
--petrol-hell: #a3d4e3;
--petrol-dunkel: #0f2a34;
--bernstein: #e5a552;
--bernstein-dunkel: #f0bd7c;
--grund: #0e1b21;
--flaeche: #16272f;
--flaeche-kraeftig: #1e343e;
--rand: #2c4652;
--rand-zart: #233b45;
--text: #e6eef1;
--text-leise: #a3b8c0;
--text-auf-petrol: #0e1b21;
--gut: #6cc79a;
--gut-flaeche: #14332a;
--warnung: #e3b264;
--warnung-flaeche: #33290f;
--fehler: #f08a8d;
--fehler-flaeche: #35191b;
--schatten: 0 1px 2px rgb(0 0 0 / 40%), 0 2px 8px rgb(0 0 0 / 30%);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--grund);
color: var(--text);
font-family: var(--schrift);
font-size: 16px;
line-height: 1.6;
min-height: 100dvh;
}
h1,
h2,
h3,
h4 {
line-height: 1.25;
margin: 0 0 0.5em;
font-weight: 650;
letter-spacing: -0.01em;
}
h1 { font-size: 1.65rem; }
h2 { font-size: 1.3rem; }
h3 { font-size: 1.08rem; }
p { margin: 0 0 1em; }
a {
color: var(--petrol);
text-underline-offset: 2px;
}
a:hover { color: var(--petrol-hell); }
:focus-visible {
outline: 3px solid var(--bernstein);
outline-offset: 2px;
border-radius: 3px;
}
hr {
border: 0;
border-top: 1px solid var(--rand-zart);
margin: 1.5rem 0;
}
code, kbd, samp, pre {
font-family: var(--schrift-fest);
font-size: 0.92em;
}
code {
background: var(--flaeche);
padding: 0.1em 0.35em;
border-radius: var(--radius-klein);
}
/* --- Grundbausteine --- */
.huelle {
max-width: var(--breite);
margin: 0 auto;
padding: 1.25rem 1rem 4rem;
}
.karte {
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 1rem 1.15rem;
}
.karte-grund {
background: var(--grund);
border: 1px solid var(--rand);
border-radius: var(--radius);
padding: 1rem 1.15rem;
box-shadow: var(--schatten);
}
.leise {
color: var(--text-leise);
font-size: 0.9rem;
}
.stapel > * + * { margin-top: 1rem; }
.stapel-eng > * + * { margin-top: 0.5rem; }
.reihe {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.raster {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
/* --- Bedienelemente --- */
.knopf {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
min-height: 44px;
padding: 0.55rem 1.1rem;
border-radius: var(--radius-klein);
border: 1px solid transparent;
background: var(--petrol);
color: var(--text-auf-petrol);
font: inherit;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background-color 0.12s ease;
}
.knopf:hover { background: var(--petrol-hell); color: var(--text-auf-petrol); }
.knopf:disabled { opacity: 0.55; cursor: not-allowed; }
.knopf-zweit {
background: transparent;
color: var(--petrol);
border-color: var(--rand);
}
.knopf-zweit:hover { background: var(--flaeche-kraeftig); color: var(--petrol); }
.knopf-akzent { background: var(--bernstein); color: #26170a; }
.knopf-akzent:hover { background: var(--bernstein-dunkel); color: #26170a; }
.knopf-still {
background: transparent;
color: var(--text-leise);
border-color: transparent;
padding: 0.4rem 0.6rem;
min-height: 38px;
font-weight: 500;
}
.knopf-still:hover { background: var(--flaeche); color: var(--text); }
label {
display: block;
font-weight: 600;
font-size: 0.92rem;
margin-bottom: 0.3rem;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="date"],
input[type="number"],
input[type="search"],
select,
textarea {
width: 100%;
min-height: 44px;
padding: 0.5rem 0.7rem;
border: 1px solid var(--rand);
border-radius: var(--radius-klein);
background: var(--grund);
color: var(--text);
font: inherit;
}
textarea {
min-height: 6rem;
resize: vertical;
line-height: 1.5;
}
input:disabled,
textarea:disabled,
select:disabled {
background: var(--flaeche);
color: var(--text-leise);
}
.feld + .feld { margin-top: 0.9rem; }
.hinweis {
border-radius: var(--radius-klein);
padding: 0.7rem 0.9rem;
border: 1px solid;
font-size: 0.95rem;
}
.hinweis-fehler { background: var(--fehler-flaeche); border-color: var(--fehler); color: var(--fehler); }
.hinweis-warnung { background: var(--warnung-flaeche); border-color: var(--warnung); color: var(--warnung); }
.hinweis-gut { background: var(--gut-flaeche); border-color: var(--gut); color: var(--gut); }
/* --- Kopfzeile --- */
.kopf {
background: var(--petrol-dunkel);
color: #f2f8fa;
border-bottom: 3px solid var(--bernstein);
}
:root[data-thema="dunkel"] .kopf { background: #0b2029; }
.kopf-innen {
max-width: var(--breite);
margin: 0 auto;
padding: 0.6rem 1rem;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.marke {
font-weight: 700;
font-size: 1.1rem;
color: #f2f8fa;
text-decoration: none;
letter-spacing: -0.01em;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.marke:hover { color: #fff; }
.marke .marke-akzent { color: #e5a552; }
.hauptnavigation {
display: flex;
gap: 0.15rem;
flex-wrap: wrap;
flex: 1;
}
.hauptnavigation a {
color: #cfe2e9;
text-decoration: none;
padding: 0.45rem 0.7rem;
border-radius: var(--radius-klein);
font-size: 0.95rem;
font-weight: 500;
white-space: nowrap;
}
.hauptnavigation a:hover { background: rgb(255 255 255 / 12%); color: #fff; }
.hauptnavigation a[aria-current="page"] {
background: rgb(255 255 255 / 16%);
color: #fff;
box-shadow: inset 0 -2px 0 #d98a2b;
}
.kopf-rechts {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.kopf-knopf {
background: transparent;
border: 1px solid rgb(255 255 255 / 25%);
color: #e3eef2;
border-radius: var(--radius-klein);
min-height: 38px;
padding: 0.3rem 0.65rem;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}
.kopf-knopf:hover { background: rgb(255 255 255 / 12%); }
/* --- Fortschritt --- */
.balken {
height: 10px;
background: var(--flaeche-kraeftig);
border-radius: 999px;
overflow: hidden;
border: 1px solid var(--rand-zart);
}
.balken > span {
display: block;
height: 100%;
background: var(--petrol);
border-radius: 999px;
}
/* --- Status-Marken: nie nur Farbe, immer auch ein Zeichen --- */
.marke-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.82rem;
font-weight: 650;
padding: 0.15rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--rand);
background: var(--grund);
color: var(--text-leise);
white-space: nowrap;
}
.marke-status[data-ton="gut"] { color: var(--gut); border-color: var(--gut); background: var(--gut-flaeche); }
.marke-status[data-ton="warnung"] { color: var(--warnung); border-color: var(--warnung); background: var(--warnung-flaeche); }
.marke-status[data-ton="fehler"] { color: var(--fehler); border-color: var(--fehler); background: var(--fehler-flaeche); }
/* --- Tabellen --- */
.tabelle-huelle { overflow-x: auto; }
table.tabelle {
border-collapse: collapse;
width: 100%;
font-size: 0.95rem;
}
table.tabelle th,
table.tabelle td {
text-align: left;
padding: 0.5rem 0.7rem;
border-bottom: 1px solid var(--rand-zart);
vertical-align: top;
}
table.tabelle th {
font-weight: 650;
background: var(--flaeche);
white-space: nowrap;
}
/* --- Anmeldung --- */
.anmeldung {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1.5rem 1rem;
background: linear-gradient(160deg, var(--flaeche) 0%, var(--grund) 60%);
}
.anmeldung-karte {
width: min(420px, 100%);
background: var(--grund);
border: 1px solid var(--rand);
border-radius: var(--radius);
padding: 1.75rem;
box-shadow: var(--schatten);
}
/* --- Sprungmarke --- */
.sprung {
position: absolute;
left: -9999px;
top: 0;
background: #d98a2b;
color: #26170a;
padding: 0.6rem 1rem;
z-index: 100;
border-radius: 0 0 var(--radius-klein) 0;
font-weight: 650;
}
.sprung:focus { left: 0; }
/* --- Druck --- */
@media print {
.kopf,
.nicht-drucken {
display: none !important;
}
body { background: #fff; color: #000; font-size: 11pt; }
.huelle { max-width: none; padding: 0; }
.karte, .karte-grund { border: 1px solid #999; box-shadow: none; background: #fff; }
a { color: #000; text-decoration: none; }
}
/* Nur für Bildschirmleser sichtbar */
.nur-lesehilfe {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata, Viewport } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: { default: 'Lern-Bar', template: '%s · Lern-Bar' },
description: 'Betriebliche Ausbildung Fachinformatiker/-in für Systemintegration',
applicationName: 'Lern-Bar',
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
themeColor: '#1f4e5f',
};
/**
* Setzt das gespeicherte Thema, bevor der erste Anstrich passiert – sonst
* blitzt beim Laden kurz die helle Fassung auf.
*/
const themaSkript = `(function(){try{var t=localStorage.getItem('lernbar-thema');if(t==='hell'||t==='dunkel'){document.documentElement.setAttribute('data-thema',t);}}catch(e){}})();`;
export default function WurzelLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<head>
<script dangerouslySetInnerHTML={{ __html: themaSkript }} />
</head>
<body>{children}</body>
</html>
);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Läuft einmal beim Hochfahren des Servers – vor der ersten Anfrage.
* Hier werden die Migrationen angewendet und die Inhalte eingelesen und
* validiert. Schlägt eines davon fehl, startet die Anwendung nicht.
*/
export async function register() {
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
const { rohVerbindung } = await import('./lib/db');
rohVerbindung(); // öffnet die Datenbank und wendet Migrationen an
console.log('[lernbar] Datenbank bereit.');
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useFormStatus } from 'react-dom';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
/** Zeigt Erfolgs- oder Fehlermeldung einer Serveraktion an. */
export function Meldung({ zustand }: { zustand: Rueckmeldung }) {
if (zustand.fehler) {
return (
<p className="hinweis hinweis-fehler" role="alert">
{zustand.fehler}
</p>
);
}
if (zustand.erfolg) {
return (
<p className="hinweis hinweis-gut" role="status">
{zustand.erfolg}
</p>
);
}
return null;
}
/** Absendeknopf, der während der Übertragung sperrt. */
export function Absenden({
children,
className = 'knopf',
wartetext = 'Einen Moment …',
}: {
children: React.ReactNode;
className?: string;
wartetext?: string;
}) {
const { pending } = useFormStatus();
return (
<button type="submit" className={className} disabled={pending}>
{pending ? wartetext : children}
</button>
);
}
+56
View File
@@ -0,0 +1,56 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ThemaUmschalter } from './ThemaUmschalter';
export type Navigationseintrag = { pfad: string; titel: string };
export function Kopfzeile({
eintraege,
name,
rolle,
abmeldeAktion,
}: {
eintraege: Navigationseintrag[];
name: string;
rolle: string;
abmeldeAktion: () => Promise<void>;
}) {
const pfad = usePathname();
function istAktiv(ziel: string): boolean {
if (ziel === '/') return pfad === '/';
return pfad === ziel || pfad.startsWith(`${ziel}/`);
}
return (
<header className="kopf">
<div className="kopf-innen">
<Link href="/" className="marke">
Lern<span className="marke-akzent">-Bar</span>
</Link>
<nav className="hauptnavigation" aria-label="Hauptbereiche">
{eintraege.map((e) => (
<Link key={e.pfad} href={e.pfad} aria-current={istAktiv(e.pfad) ? 'page' : undefined}>
{e.titel}
</Link>
))}
</nav>
<div className="kopf-rechts">
<span className="leise" style={{ color: '#cfe2e9', fontSize: '0.85rem' }}>
{name} · {rolle}
</span>
<ThemaUmschalter />
<form action={abmeldeAktion}>
<button type="submit" className="kopf-knopf">
Abmelden
</button>
</form>
</div>
</div>
</header>
);
}
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { useEffect, useState } from 'react';
type Thema = 'hell' | 'dunkel';
/** Umschalter für helle und dunkle Darstellung, gemerkt im Browser. */
export function ThemaUmschalter() {
const [thema, setThema] = useState<Thema | null>(null);
useEffect(() => {
const gesetzt = document.documentElement.getAttribute('data-thema');
if (gesetzt === 'hell' || gesetzt === 'dunkel') {
setThema(gesetzt);
return;
}
setThema(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dunkel' : 'hell');
}, []);
function umschalten() {
const neu: Thema = thema === 'dunkel' ? 'hell' : 'dunkel';
setThema(neu);
document.documentElement.setAttribute('data-thema', neu);
try {
localStorage.setItem('lernbar-thema', neu);
} catch {
/* Speichern verweigert – dann gilt die Wahl eben nur für diese Sitzung. */
}
}
const dunkel = thema === 'dunkel';
return (
<button
type="button"
className="kopf-knopf"
onClick={umschalten}
aria-pressed={dunkel}
title={dunkel ? 'Zur hellen Darstellung wechseln' : 'Zur dunklen Darstellung wechseln'}
>
<span aria-hidden="true">{thema === null ? '◐' : dunkel ? '☀' : '☾'}</span>
<span className="nur-lesehilfe"> {dunkel ? 'Helle Darstellung' : 'Dunkle Darstellung'}</span>
</button>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { hash, verify } from '@node-rs/argon2';
/**
* Argon2id mit bewusst konservativen Parametern – die Anmeldung passiert
* selten, ein paar hundert Millisekunden fallen niemandem auf.
*/
const einstellungen = {
algorithm: 2, // Algorithm.Argon2id – als Zahl, weil der Aufzählungstyp ein const enum ist
memoryCost: 19456, // 19 MiB, Empfehlung des OWASP-Spickzettels
timeCost: 2,
parallelism: 1,
} as const;
export async function passwortHashen(klartext: string): Promise<string> {
return hash(klartext, einstellungen);
}
export async function passwortPruefen(hashWert: string, klartext: string): Promise<boolean> {
try {
return await verify(hashWert, klartext);
} catch {
return false;
}
}
/** Mindestanforderung an ein Passwort – bewusst schlicht: Länge schlägt Zeichenklassen. */
export function passwortMangel(klartext: string): string | null {
if (klartext.length < 10) return 'Das Passwort muss mindestens 10 Zeichen lang sein.';
if (klartext.length > 200) return 'Das Passwort darf höchstens 200 Zeichen lang sein.';
if (/^\s|\s$/.test(klartext)) return 'Das Passwort darf nicht mit einem Leerzeichen beginnen oder enden.';
return null;
}
+56
View File
@@ -0,0 +1,56 @@
import 'server-only';
import { redirect } from 'next/navigation';
import { eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { angemeldeterBenutzer } from './sitzung';
import { darfAzubiDatenSehen, darfBewerten, istAusbilder, type Betrachter } from './regeln';
import type { Benutzer } from '../db/schema';
/** Angemeldeter Benutzer oder Weiterleitung zur Anmeldung. */
export async function erfordereBenutzer(): Promise<Benutzer> {
const benutzer = await angemeldeterBenutzer();
if (!benutzer) redirect('/anmelden');
return benutzer;
}
export async function erfordereAusbilder(): Promise<Benutzer> {
const benutzer = await erfordereBenutzer();
if (!istAusbilder(benutzer)) redirect('/');
return benutzer;
}
/** Das Ausbildungsverhältnis eines Azubis (höchstens eines). */
export function ausbildungVonAzubi(azubiId: string) {
return datenbank().select().from(schema.ausbildung).where(eq(schema.ausbildung.azubiId, azubiId)).get() ?? null;
}
/** Die Azubis, die dieser Ausbilder betreut. */
export function azubisVonAusbilder(ausbilderId: string) {
return datenbank()
.select({ benutzer: schema.benutzer, ausbildung: schema.ausbildung })
.from(schema.ausbildung)
.innerJoin(schema.benutzer, eq(schema.benutzer.id, schema.ausbildung.azubiId))
.where(eq(schema.ausbildung.ausbilderId, ausbilderId))
.all();
}
/**
* Prüft den Zugriff auf die Daten eines Azubis und bricht sonst ab.
* Jede Seite und jede Serveraktion, die fremde Daten anfasst, ruft das auf.
*/
export function erfordereZugriffAufAzubi(betrachter: Betrachter, azubiId: string): void {
const bezug = ausbildungVonAzubi(azubiId);
if (!darfAzubiDatenSehen(betrachter, azubiId, bezug)) {
throw new Error('Kein Zugriff auf diese Daten.');
}
}
export function erfordereBewertungsrecht(betrachter: Betrachter, azubiId: string): void {
const bezug = ausbildungVonAzubi(azubiId);
if (!darfBewerten(betrachter, azubiId, bezug)) {
throw new Error('Keine Berechtigung zum Bewerten.');
}
}
export { darfAzubiDatenSehen, darfBewerten, istAusbilder };
export type { Betrachter };
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import {
darfAzubiDatenSehen,
darfBewerten,
darfKontrolleBearbeiten,
darfMusterloesungSehen,
istAusbilder,
type Betrachter,
} from './regeln';
const ausbilder: Betrachter = { id: 'aus-1', rolle: 'ausbilder', aktiv: 1 };
const andererAusbilder: Betrachter = { id: 'aus-2', rolle: 'ausbilder', aktiv: 1 };
const azubi: Betrachter = { id: 'azu-1', rolle: 'azubi', aktiv: 1 };
const andererAzubi: Betrachter = { id: 'azu-2', rolle: 'azubi', aktiv: 1 };
const bezug = { azubiId: 'azu-1', ausbilderId: 'aus-1' };
describe('darfAzubiDatenSehen', () => {
it('lässt den Azubi seine eigenen Daten sehen', () => {
expect(darfAzubiDatenSehen(azubi, 'azu-1', bezug)).toBe(true);
});
it('verwehrt dem Azubi die Daten eines anderen Azubis', () => {
expect(darfAzubiDatenSehen(azubi, 'azu-2', { azubiId: 'azu-2', ausbilderId: 'aus-1' })).toBe(false);
expect(darfAzubiDatenSehen(andererAzubi, 'azu-1', bezug)).toBe(false);
});
it('lässt den zuständigen Ausbilder die Daten seines Azubis sehen', () => {
expect(darfAzubiDatenSehen(ausbilder, 'azu-1', bezug)).toBe(true);
});
it('verwehrt einem fremden Ausbilder die Daten', () => {
expect(darfAzubiDatenSehen(andererAusbilder, 'azu-1', bezug)).toBe(false);
});
it('verwehrt den Zugriff, wenn es gar kein Ausbildungsverhältnis gibt', () => {
expect(darfAzubiDatenSehen(ausbilder, 'azu-1', null)).toBe(false);
expect(darfAzubiDatenSehen(ausbilder, 'azu-1', undefined)).toBe(false);
});
it('verwehrt den Zugriff bei gesperrtem Konto – auch auf die eigenen Daten', () => {
expect(darfAzubiDatenSehen({ ...azubi, aktiv: 0 }, 'azu-1', bezug)).toBe(false);
expect(darfAzubiDatenSehen({ ...ausbilder, aktiv: 0 }, 'azu-1', bezug)).toBe(false);
});
it('lässt sich nicht durch einen Bezug auf einen anderen Azubi täuschen', () => {
// Bezug gehört zum Ausbilder, aber zu einem anderen Azubi.
expect(darfAzubiDatenSehen(ausbilder, 'azu-9', { azubiId: 'azu-1', ausbilderId: 'aus-1' })).toBe(false);
});
});
describe('darfBewerten', () => {
it('erlaubt es nur dem zuständigen Ausbilder', () => {
expect(darfBewerten(ausbilder, 'azu-1', bezug)).toBe(true);
expect(darfBewerten(andererAusbilder, 'azu-1', bezug)).toBe(false);
});
it('erlaubt es dem Azubi nie – auch nicht für sich selbst', () => {
expect(darfBewerten(azubi, 'azu-1', bezug)).toBe(false);
});
});
describe('istAusbilder', () => {
it('erkennt die Rolle und achtet auf das gesperrte Konto', () => {
expect(istAusbilder(ausbilder)).toBe(true);
expect(istAusbilder(azubi)).toBe(false);
expect(istAusbilder({ ...ausbilder, aktiv: 0 })).toBe(false);
});
});
describe('darfMusterloesungSehen', () => {
const nichtAbgegeben = { azubiId: 'azu-1', abgegebenAm: null };
const abgegeben = { azubiId: 'azu-1', abgegebenAm: '2026-09-10T09:00:00.000Z' };
it('zeigt dem Azubi die Musterlösung erst nach dem Absenden', () => {
expect(darfMusterloesungSehen(azubi, nichtAbgegeben, bezug)).toBe(false);
expect(darfMusterloesungSehen(azubi, abgegeben, bezug)).toBe(true);
});
it('zeigt sie dem zuständigen Ausbilder jederzeit', () => {
expect(darfMusterloesungSehen(ausbilder, nichtAbgegeben, bezug)).toBe(true);
});
it('zeigt sie niemandem Fremden', () => {
expect(darfMusterloesungSehen(andererAusbilder, abgegeben, bezug)).toBe(false);
expect(darfMusterloesungSehen(andererAzubi, abgegeben, bezug)).toBe(false);
});
});
describe('darfKontrolleBearbeiten', () => {
it('nur zwischen Freigabe und Abgabe', () => {
const gesperrt = { azubiId: 'azu-1', freigegebenAm: null, abgegebenAm: null };
const offen = { azubiId: 'azu-1', freigegebenAm: '2026-09-10T07:00:00.000Z', abgegebenAm: null };
const fertig = {
azubiId: 'azu-1',
freigegebenAm: '2026-09-10T07:00:00.000Z',
abgegebenAm: '2026-09-10T09:00:00.000Z',
};
expect(darfKontrolleBearbeiten(azubi, gesperrt)).toBe(false);
expect(darfKontrolleBearbeiten(azubi, offen)).toBe(true);
expect(darfKontrolleBearbeiten(azubi, fertig)).toBe(false);
});
it('gilt nicht für den Ausbilder – der korrigiert, er schreibt nicht mit', () => {
const offen = { azubiId: 'azu-1', freigegebenAm: '2026-09-10T07:00:00.000Z', abgegebenAm: null };
expect(darfKontrolleBearbeiten(ausbilder, offen)).toBe(false);
});
});
+56
View File
@@ -0,0 +1,56 @@
import type { Rolle } from '../db/schema';
/**
* Reine Rechteregeln, ohne Datenbank und ohne Next.js – damit sie sich
* vollständig testen lassen. Die Aufrufer holen die nötigen Datensätze und
* fragen hier nach dem Urteil.
*/
export type Betrachter = { id: string; rolle: Rolle; aktiv: number };
export type Ausbildungsbezug = { azubiId: string; ausbilderId: string } | null | undefined;
/** Darf der Betrachter die Daten dieses Azubis sehen? */
export function darfAzubiDatenSehen(betrachter: Betrachter, azubiId: string, bezug: Ausbildungsbezug): boolean {
if (betrachter.aktiv !== 1) return false;
if (betrachter.rolle === 'azubi') return betrachter.id === azubiId;
if (betrachter.rolle === 'ausbilder') {
if (!bezug) return false;
return bezug.azubiId === azubiId && bezug.ausbilderId === betrachter.id;
}
return false;
}
/** Darf der Betrachter die Daten dieses Azubis bewerten, abnehmen, abzeichnen? */
export function darfBewerten(betrachter: Betrachter, azubiId: string, bezug: Ausbildungsbezug): boolean {
return betrachter.rolle === 'ausbilder' && darfAzubiDatenSehen(betrachter, azubiId, bezug);
}
/** Nur der Ausbilder pflegt Konten und Wochenplanung. */
export function istAusbilder(betrachter: Betrachter): boolean {
return betrachter.aktiv === 1 && betrachter.rolle === 'ausbilder';
}
/**
* Musterlösungen sind der empfindlichste Teil: Der Azubi darf sie erst nach dem
* Absenden sehen. Diese Regel wird serverseitig vor jeder Ausgabe befragt –
* nicht erst in der Oberfläche.
*/
export function darfMusterloesungSehen(
betrachter: Betrachter,
kontrolle: { azubiId: string; abgegebenAm: string | null },
bezug: Ausbildungsbezug,
): boolean {
if (!darfAzubiDatenSehen(betrachter, kontrolle.azubiId, bezug)) return false;
if (betrachter.rolle === 'ausbilder') return true;
return kontrolle.abgegebenAm !== null;
}
/** Der Azubi darf eine Kontrolle nur bearbeiten, solange sie freigegeben und nicht abgegeben ist. */
export function darfKontrolleBearbeiten(
betrachter: Betrachter,
kontrolle: { azubiId: string; freigegebenAm: string | null; abgegebenAm: string | null },
): boolean {
if (betrachter.rolle !== 'azubi') return false;
if (betrachter.id !== kontrolle.azubiId) return false;
return kontrolle.freigegebenAm !== null && kontrolle.abgegebenAm === null;
}
+75
View File
@@ -0,0 +1,75 @@
import 'server-only';
import crypto from 'node:crypto';
import { cookies } from 'next/headers';
import { eq, lt } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { inTagen, jetzt } from '../zeit';
import type { Benutzer } from '../db/schema';
export const SITZUNGS_COOKIE = 'lernbar_sitzung';
const GUELTIG_TAGE = 30;
/**
* Sitzungen liegen serverseitig in der Datenbank. Im Cookie steht nur ein
* Zufallswert; gespeichert wird dessen SHA-256-Abdruck, damit ein Blick in die
* Datenbank keine gültigen Sitzungen verrät.
*/
function abdruck(wert: string): string {
return crypto.createHash('sha256').update(wert).digest('hex');
}
export async function sitzungAnlegen(benutzerId: string): Promise<void> {
const geheimnis = crypto.randomBytes(32).toString('base64url');
const db = datenbank();
db.insert(schema.sitzung)
.values({
id: abdruck(geheimnis),
benutzerId,
erstelltAm: jetzt(),
laeuftAbAm: inTagen(GUELTIG_TAGE),
})
.run();
// Abgelaufene Sitzungen bei Gelegenheit aufräumen.
db.delete(schema.sitzung).where(lt(schema.sitzung.laeuftAbAm, jetzt())).run();
const keksdose = await cookies();
keksdose.set(SITZUNGS_COOKIE, geheimnis, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: GUELTIG_TAGE * 24 * 3600,
});
}
export async function sitzungBeenden(): Promise<void> {
const keksdose = await cookies();
const geheimnis = keksdose.get(SITZUNGS_COOKIE)?.value;
if (geheimnis) {
datenbank().delete(schema.sitzung).where(eq(schema.sitzung.id, abdruck(geheimnis))).run();
}
keksdose.delete(SITZUNGS_COOKIE);
}
/** Der angemeldete Benutzer oder `null`. Führt keine Weiterleitung aus. */
export async function angemeldeterBenutzer(): Promise<Benutzer | null> {
const keksdose = await cookies();
const geheimnis = keksdose.get(SITZUNGS_COOKIE)?.value;
if (!geheimnis) return null;
const db = datenbank();
const treffer = db
.select({ benutzer: schema.benutzer, laeuftAbAm: schema.sitzung.laeuftAbAm })
.from(schema.sitzung)
.innerJoin(schema.benutzer, eq(schema.benutzer.id, schema.sitzung.benutzerId))
.where(eq(schema.sitzung.id, abdruck(geheimnis)))
.get();
if (!treffer) return null;
if (treffer.laeuftAbAm <= jetzt()) return null;
if (treffer.benutzer.aktiv !== 1) return null;
return treffer.benutzer;
}
+59
View File
@@ -0,0 +1,59 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
import { migriere } from './migrator';
import { anhangWurzel, datenbankDatei, datenWurzel } from '../pfade';
/**
* Eine einzige Verbindung pro Prozess. Der Verweis hängt am globalThis, damit
* das Neuladen im Entwicklungsbetrieb keine zweite Verbindung öffnet.
*
* Der gesamte Datenzugriff der Anwendung läuft über die Module unter
* `src/lib/repos`. Nur diese Datei kennt better-sqlite3 – ein Wechsel auf
* PostgreSQL tauscht Treiber und Dialekt hier aus, nicht in den Aufrufern.
*/
type Halter = { db?: BetterSQLite3Database<typeof schema>; roh?: Database.Database };
const halter = globalThis as unknown as { __lernbar?: Halter };
halter.__lernbar ??= {};
function oeffne(): { db: BetterSQLite3Database<typeof schema>; roh: Database.Database } {
fs.mkdirSync(datenWurzel, { recursive: true });
fs.mkdirSync(anhangWurzel, { recursive: true });
fs.mkdirSync(path.dirname(datenbankDatei), { recursive: true });
const roh = new Database(datenbankDatei);
roh.pragma('journal_mode = WAL');
roh.pragma('foreign_keys = ON');
roh.pragma('busy_timeout = 5000');
const ergebnis = migriere(roh);
if (ergebnis.angewendet.length > 0) {
console.log(`[lernbar] Migrationen angewendet: ${ergebnis.angewendet.join(', ')}`);
}
return { db: drizzle(roh, { schema }), roh };
}
function verbindung() {
if (!halter.__lernbar!.db) {
const { db, roh } = oeffne();
halter.__lernbar!.db = db;
halter.__lernbar!.roh = roh;
}
return halter.__lernbar!;
}
/** Der Abfrage-Einstieg für alle Repository-Module. */
export function datenbank(): BetterSQLite3Database<typeof schema> {
return verbindung().db!;
}
/** Rohzugriff – nur für Sicherung, Wiederherstellung und Tests. */
export function rohVerbindung(): Database.Database {
return verbindung().roh!;
}
export { schema };
@@ -0,0 +1,38 @@
-- Baustein 1: Benutzer, Sitzungen, Ausbildungsverhältnis.
CREATE TABLE benutzer (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
passwort_hash TEXT NOT NULL,
rolle TEXT NOT NULL CHECK (rolle IN ('ausbilder', 'azubi')),
aktiv INTEGER NOT NULL DEFAULT 1 CHECK (aktiv IN (0, 1)),
erstellt_am TEXT NOT NULL,
letzte_anmeldung_am TEXT
);
CREATE UNIQUE INDEX benutzer_email_idx ON benutzer (email);
CREATE TABLE sitzung (
id TEXT PRIMARY KEY,
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
erstellt_am TEXT NOT NULL,
laeuft_ab_am TEXT NOT NULL
);
CREATE INDEX sitzung_benutzer_idx ON sitzung (benutzer_id);
CREATE INDEX sitzung_ablauf_idx ON sitzung (laeuft_ab_am);
CREATE TABLE ausbildung (
id TEXT PRIMARY KEY,
azubi_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
ausbilder_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE RESTRICT,
beruf TEXT NOT NULL DEFAULT 'Fachinformatiker/-in für Systemintegration',
beginn TEXT NOT NULL,
ende TEXT,
probezeit_bis TEXT,
erstellt_am TEXT NOT NULL
);
CREATE UNIQUE INDEX ausbildung_azubi_idx ON ausbildung (azubi_id);
CREATE INDEX ausbildung_ausbilder_idx ON ausbildung (ausbilder_id);
+107
View File
@@ -0,0 +1,107 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { migriere, migrationsVerzeichnis } from './migrator';
import * as schema from './schema';
import { getTableConfig } from 'drizzle-orm/sqlite-core';
const aufraeumen: string[] = [];
function temporaeresVerzeichnis(): string {
const verzeichnis = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-test-'));
aufraeumen.push(verzeichnis);
return verzeichnis;
}
afterEach(() => {
while (aufraeumen.length > 0) {
const verzeichnis = aufraeumen.pop()!;
fs.rmSync(verzeichnis, { recursive: true, force: true });
}
});
describe('Migrationslauf', () => {
it('wendet jede Datei genau einmal an', () => {
const verzeichnis = temporaeresVerzeichnis();
fs.writeFileSync(path.join(verzeichnis, '0001_erste.sql'), 'CREATE TABLE a (id TEXT PRIMARY KEY);');
fs.writeFileSync(path.join(verzeichnis, '0002_zweite.sql'), 'CREATE TABLE b (id TEXT PRIMARY KEY);');
const db = new Database(':memory:');
const erster = migriere(db, verzeichnis);
expect(erster.angewendet).toEqual(['0001_erste', '0002_zweite']);
const zweiter = migriere(db, verzeichnis);
expect(zweiter.angewendet).toEqual([]);
expect(zweiter.uebersprungen).toBe(2);
});
it('wendet in der Reihenfolge der Dateinamen an, nicht in Lesereihenfolge', () => {
const verzeichnis = temporaeresVerzeichnis();
// Absichtlich in verkehrter Reihenfolge geschrieben: 0002 setzt 0001 voraus.
fs.writeFileSync(path.join(verzeichnis, '0002_zweite.sql'), 'ALTER TABLE a ADD COLUMN name TEXT;');
fs.writeFileSync(path.join(verzeichnis, '0001_erste.sql'), 'CREATE TABLE a (id TEXT PRIMARY KEY);');
const db = new Database(':memory:');
expect(() => migriere(db, verzeichnis)).not.toThrow();
});
it('merkt, wenn eine bereits angewendete Datei nachträglich verändert wurde', () => {
const verzeichnis = temporaeresVerzeichnis();
const datei = path.join(verzeichnis, '0001_erste.sql');
fs.writeFileSync(datei, 'CREATE TABLE a (id TEXT PRIMARY KEY);');
const db = new Database(':memory:');
migriere(db, verzeichnis);
fs.writeFileSync(datei, 'CREATE TABLE a (id TEXT PRIMARY KEY, extra TEXT);');
expect(() => migriere(db, verzeichnis)).toThrow(/verändert/);
});
it('lässt bei einem Fehler mitten in der Datei nichts Halbes zurück', () => {
const verzeichnis = temporaeresVerzeichnis();
fs.writeFileSync(
path.join(verzeichnis, '0001_kaputt.sql'),
'CREATE TABLE a (id TEXT PRIMARY KEY);\nDAS IST KEIN SQL;',
);
const db = new Database(':memory:');
expect(() => migriere(db, verzeichnis)).toThrow();
const tabellen = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((z) => (z as { name: string }).name);
expect(tabellen).not.toContain('a');
expect(
db.prepare('SELECT COUNT(*) AS anzahl FROM schema_migration').get() as { anzahl: number },
).toEqual({ anzahl: 0 });
});
it('beschwert sich über ein fehlendes Verzeichnis statt still nichts zu tun', () => {
const db = new Database(':memory:');
expect(() => migriere(db, path.join(os.tmpdir(), 'gibt-es-nicht-lernbar'))).toThrow(/nicht gefunden/);
});
});
describe('Schema und Migrationen passen zusammen', () => {
it('jede Tabelle und Spalte aus dem Drizzle-Schema existiert in der migrierten Datenbank', () => {
const db = new Database(':memory:');
migriere(db, migrationsVerzeichnis);
for (const tabelle of Object.values(schema.alleTabellen)) {
const aufbau = getTableConfig(tabelle);
const spalten = db
.prepare(`PRAGMA table_info(${aufbau.name})`)
.all()
.map((z) => (z as { name: string }).name);
expect(spalten.length, `Tabelle ${aufbau.name} fehlt in den Migrationen`).toBeGreaterThan(0);
for (const spalte of aufbau.columns) {
expect(spalten, `Spalte ${aufbau.name}.${spalte.name} fehlt in den Migrationen`).toContain(spalte.name);
}
}
});
});
+74
View File
@@ -0,0 +1,74 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import type Database from 'better-sqlite3';
/**
* Winziger, eigener Migrationslauf: alle .sql-Dateien im Verzeichnis werden
* nach Dateinamen sortiert genau einmal angewendet und mit Prüfsumme vermerkt.
* Bewusst kein Werkzeug von der Stange – der Bedarf ist eine Handvoll Dateien,
* und so ist beim Start klar sichtbar, was passiert.
*/
export const migrationsVerzeichnis =
process.env.LERNBAR_MIGRATIONEN ?? path.join(process.cwd(), 'src', 'lib', 'db', 'migrationen');
export type Migrationsergebnis = { angewendet: string[]; uebersprungen: number };
export function migriere(db: Database.Database, verzeichnis = migrationsVerzeichnis): Migrationsergebnis {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migration (
version TEXT PRIMARY KEY,
angewendet_am TEXT NOT NULL,
pruefsumme TEXT NOT NULL
)`);
if (!fs.existsSync(verzeichnis)) {
throw new Error(`Migrationsverzeichnis nicht gefunden: ${verzeichnis}`);
}
const dateien = fs
.readdirSync(verzeichnis)
.filter((d) => d.endsWith('.sql'))
.sort();
const bekannt = new Map<string, string>(
db
.prepare('SELECT version, pruefsumme FROM schema_migration')
.all()
.map((z) => [(z as { version: string }).version, (z as { pruefsumme: string }).pruefsumme]),
);
const angewendet: string[] = [];
let uebersprungen = 0;
for (const datei of dateien) {
const version = datei.replace(/\.sql$/, '');
const inhalt = fs.readFileSync(path.join(verzeichnis, datei), 'utf8');
const pruefsumme = crypto.createHash('sha256').update(inhalt).digest('hex');
const vorhanden = bekannt.get(version);
if (vorhanden) {
if (vorhanden !== pruefsumme) {
throw new Error(
`Migration ${datei} wurde nach dem Anwenden verändert. ` +
`Bereits angewendete Migrationen dürfen nicht bearbeitet werden – lege stattdessen eine neue Datei an.`,
);
}
uebersprungen += 1;
continue;
}
const lauf = db.transaction(() => {
db.exec(inhalt);
db.prepare('INSERT INTO schema_migration (version, angewendet_am, pruefsumme) VALUES (?, ?, ?)').run(
version,
new Date().toISOString(),
pruefsumme,
);
});
lauf();
angewendet.push(version);
}
return { angewendet, uebersprungen };
}
+59
View File
@@ -0,0 +1,59 @@
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
/** Rollen – bewusst nur zwei. */
export const rollen = ['ausbilder', 'azubi'] as const;
export type Rolle = (typeof rollen)[number];
export const benutzer = sqliteTable(
'benutzer',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
passwortHash: text('passwort_hash').notNull(),
rolle: text('rolle', { enum: rollen }).notNull(),
aktiv: integer('aktiv').notNull().default(1),
erstelltAm: text('erstellt_am').notNull(),
letzteAnmeldungAm: text('letzte_anmeldung_am'),
},
(t) => [uniqueIndex('benutzer_email_idx').on(t.email)],
);
export const sitzung = sqliteTable(
'sitzung',
{
id: text('id').primaryKey(),
benutzerId: text('benutzer_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
erstelltAm: text('erstellt_am').notNull(),
laeuftAbAm: text('laeuft_ab_am').notNull(),
},
(t) => [index('sitzung_benutzer_idx').on(t.benutzerId), index('sitzung_ablauf_idx').on(t.laeuftAbAm)],
);
export const ausbildung = sqliteTable(
'ausbildung',
{
id: text('id').primaryKey(),
azubiId: text('azubi_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
ausbilderId: text('ausbilder_id')
.notNull()
.references(() => benutzer.id),
beruf: text('beruf').notNull().default('Fachinformatiker/-in für Systemintegration'),
beginn: text('beginn').notNull(),
ende: text('ende'),
probezeitBis: text('probezeit_bis'),
erstelltAm: text('erstellt_am').notNull(),
},
(t) => [uniqueIndex('ausbildung_azubi_idx').on(t.azubiId), index('ausbildung_ausbilder_idx').on(t.ausbilderId)],
);
export type Benutzer = typeof benutzer.$inferSelect;
export type Sitzung = typeof sitzung.$inferSelect;
export type Ausbildung = typeof ausbildung.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = { benutzer, sitzung, ausbildung };
+14
View File
@@ -0,0 +1,14 @@
import path from 'node:path';
/**
* Alle veränderlichen Daten liegen unter einem Wurzelverzeichnis, das im
* Container als Volume eingehängt wird. Inhalte dagegen kommen aus dem
* Repository und sind unveränderlich.
*/
export const datenWurzel = path.resolve(process.env.LERNBAR_DATEN ?? path.join(process.cwd(), 'daten'));
export const datenbankDatei = process.env.LERNBAR_DB ?? path.join(datenWurzel, 'lernbar.sqlite');
export const anhangWurzel = path.join(datenWurzel, 'anhaenge');
export const inhaltWurzel = path.resolve(process.env.LERNBAR_INHALTE ?? path.join(process.cwd(), 'content'));
+63
View File
@@ -0,0 +1,63 @@
import crypto from 'node:crypto';
import { eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { jetzt } from '../zeit';
import type { Ausbildung, Benutzer } from '../db/schema';
export const STANDARD_BERUF = 'Fachinformatiker/-in für Systemintegration';
export function ausbildungNachAzubi(azubiId: string): Ausbildung | null {
return datenbank().select().from(schema.ausbildung).where(eq(schema.ausbildung.azubiId, azubiId)).get() ?? null;
}
export function ausbildungNachId(id: string): Ausbildung | null {
return datenbank().select().from(schema.ausbildung).where(eq(schema.ausbildung.id, id)).get() ?? null;
}
export type AusbildungMitAzubi = { ausbildung: Ausbildung; azubi: Benutzer };
export function ausbildungenVonAusbilder(ausbilderId: string): AusbildungMitAzubi[] {
return datenbank()
.select({ ausbildung: schema.ausbildung, azubi: schema.benutzer })
.from(schema.ausbildung)
.innerJoin(schema.benutzer, eq(schema.benutzer.id, schema.ausbildung.azubiId))
.where(eq(schema.ausbildung.ausbilderId, ausbilderId))
.all();
}
export function alleAusbildungen(): AusbildungMitAzubi[] {
return datenbank()
.select({ ausbildung: schema.ausbildung, azubi: schema.benutzer })
.from(schema.ausbildung)
.innerJoin(schema.benutzer, eq(schema.benutzer.id, schema.ausbildung.azubiId))
.all();
}
export function ausbildungAnlegen(werte: {
azubiId: string;
ausbilderId: string;
beruf?: string;
beginn: string;
ende?: string | null;
probezeitBis?: string | null;
}): Ausbildung {
const datensatz: Ausbildung = {
id: crypto.randomUUID(),
azubiId: werte.azubiId,
ausbilderId: werte.ausbilderId,
beruf: werte.beruf?.trim() || STANDARD_BERUF,
beginn: werte.beginn,
ende: werte.ende ?? null,
probezeitBis: werte.probezeitBis ?? null,
erstelltAm: jetzt(),
};
datenbank().insert(schema.ausbildung).values(datensatz).run();
return datensatz;
}
export function ausbildungAendern(
id: string,
werte: Partial<Pick<Ausbildung, 'beruf' | 'beginn' | 'ende' | 'probezeitBis' | 'ausbilderId'>>,
): void {
datenbank().update(schema.ausbildung).set(werte).where(eq(schema.ausbildung.id, id)).run();
}
+70
View File
@@ -0,0 +1,70 @@
import crypto from 'node:crypto';
import { asc, eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { passwortHashen } from '../auth/passwort';
import { jetzt } from '../zeit';
import type { Benutzer, Rolle } from '../db/schema';
/** Sämtlicher Zugriff auf die Tabelle `benutzer` läuft über dieses Modul. */
export function alleBenutzer(): Benutzer[] {
return datenbank().select().from(schema.benutzer).orderBy(asc(schema.benutzer.name)).all();
}
export function benutzerNachId(id: string): Benutzer | null {
return datenbank().select().from(schema.benutzer).where(eq(schema.benutzer.id, id)).get() ?? null;
}
export function benutzerNachEmail(email: string): Benutzer | null {
return (
datenbank()
.select()
.from(schema.benutzer)
.where(eq(schema.benutzer.email, email.trim().toLowerCase()))
.get() ?? null
);
}
export function benutzerNachRolle(rolle: Rolle): Benutzer[] {
return datenbank().select().from(schema.benutzer).where(eq(schema.benutzer.rolle, rolle)).all();
}
export async function benutzerAnlegen(werte: {
name: string;
email: string;
passwort: string;
rolle: Rolle;
}): Promise<Benutzer> {
const id = crypto.randomUUID();
const datensatz = {
id,
name: werte.name.trim(),
email: werte.email.trim().toLowerCase(),
passwortHash: await passwortHashen(werte.passwort),
rolle: werte.rolle,
aktiv: 1,
erstelltAm: jetzt(),
letzteAnmeldungAm: null,
} satisfies Benutzer;
datenbank().insert(schema.benutzer).values(datensatz).run();
return datensatz;
}
export async function passwortSetzen(benutzerId: string, neuesPasswort: string): Promise<void> {
const hash = await passwortHashen(neuesPasswort);
datenbank().update(schema.benutzer).set({ passwortHash: hash }).where(eq(schema.benutzer.id, benutzerId)).run();
// Alle bestehenden Sitzungen dieses Kontos verfallen.
datenbank().delete(schema.sitzung).where(eq(schema.sitzung.benutzerId, benutzerId)).run();
}
export function aktivSetzen(benutzerId: string, aktiv: boolean): void {
datenbank()
.update(schema.benutzer)
.set({ aktiv: aktiv ? 1 : 0 })
.where(eq(schema.benutzer.id, benutzerId))
.run();
if (!aktiv) {
datenbank().delete(schema.sitzung).where(eq(schema.sitzung.benutzerId, benutzerId)).run();
}
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Zeitrechnung der Anwendung.
*
* Zeitstempel werden als ISO-8601 in UTC gespeichert (mit `Z`), also als
* eindeutiger Zeitpunkt. Angezeigt wird immer in Europe/Berlin.
* Reine Kalenderdaten (Berichtszeitraum, Tagesnotiz) sind `YYYY-MM-DD`.
*/
export const ZEITZONE = 'Europe/Berlin';
/** Aktueller Zeitpunkt als ISO-8601 in UTC. */
export function jetzt(): string {
return new Date().toISOString();
}
/** Heutiges Kalenderdatum in Europe/Berlin als `YYYY-MM-DD`. */
export function heute(bezug: Date = new Date()): string {
return alsDatum(bezug);
}
/** Ein Zeitpunkt als Kalenderdatum in Europe/Berlin. */
export function alsDatum(zeitpunkt: Date | string): string {
const d = typeof zeitpunkt === 'string' ? new Date(zeitpunkt) : zeitpunkt;
const teile = new Intl.DateTimeFormat('sv-SE', {
timeZone: ZEITZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
return teile; // sv-SE liefert genau YYYY-MM-DD
}
/** Datum und Uhrzeit für die Anzeige, z. B. „10.09.2026, 08:15". */
export function alsZeitpunktText(zeitpunkt: Date | string | null | undefined): string {
if (!zeitpunkt) return '–';
const d = typeof zeitpunkt === 'string' ? new Date(zeitpunkt) : zeitpunkt;
return new Intl.DateTimeFormat('de-DE', {
timeZone: ZEITZONE,
dateStyle: 'short',
timeStyle: 'short',
}).format(d);
}
/** Datum für die Anzeige, z. B. „10.09.2026". */
export function alsDatumText(datum: string | null | undefined): string {
if (!datum) return '–';
const d = new Date(`${datum}T12:00:00Z`);
return new Intl.DateTimeFormat('de-DE', { timeZone: ZEITZONE, dateStyle: 'short' }).format(d);
}
/** Wochentagsname, z. B. „Montag". */
export function wochentagText(datum: string): string {
const d = new Date(`${datum}T12:00:00Z`);
return new Intl.DateTimeFormat('de-DE', { timeZone: ZEITZONE, weekday: 'long' }).format(d);
}
// --- Kalenderrechnung auf reinen Datumsstrings (UTC-Mittag als Anker) ---
function zuDate(datum: string): Date {
return new Date(`${datum}T12:00:00Z`);
}
function zuString(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** Tage addieren (auch negativ). */
export function plusTage(datum: string, tage: number): string {
const d = zuDate(datum);
d.setUTCDate(d.getUTCDate() + tage);
return zuString(d);
}
/** Montag der Woche, in der das Datum liegt. */
export function montagDerWoche(datum: string): string {
const d = zuDate(datum);
const wochentag = (d.getUTCDay() + 6) % 7; // Montag = 0
d.setUTCDate(d.getUTCDate() - wochentag);
return zuString(d);
}
/** Freitag der Woche, in der das Datum liegt. */
export function freitagDerWoche(datum: string): string {
return plusTage(montagDerWoche(datum), 4);
}
/** Kalenderwoche nach ISO 8601 (Woche 1 enthält den ersten Donnerstag). */
export function isoKalenderwoche(datum: string): number {
const d = zuDate(datum);
const wochentag = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - wochentag + 3); // Donnerstag dieser Woche
const donnerstag = d.getTime();
const jahresanfang = new Date(Date.UTC(d.getUTCFullYear(), 0, 4, 12, 0, 0));
const wochentagAnfang = (jahresanfang.getUTCDay() + 6) % 7;
jahresanfang.setUTCDate(jahresanfang.getUTCDate() - wochentagAnfang + 3);
return 1 + Math.round((donnerstag - jahresanfang.getTime()) / (7 * 24 * 3600 * 1000));
}
/** Kalenderjahr nach ISO 8601 – kann am Jahreswechsel vom Kalenderjahr abweichen. */
export function isoKalenderjahr(datum: string): number {
const d = zuDate(datum);
const wochentag = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - wochentag + 3);
return d.getUTCFullYear();
}
/** „KW 37 / 2026" */
export function kalenderwocheText(datum: string): string {
return `KW ${String(isoKalenderwoche(datum)).padStart(2, '0')} / ${isoKalenderjahr(datum)}`;
}
/** Die fünf Werktage (Mo–Fr) der Woche eines Datums. */
export function werktage(datum: string): string[] {
const montag = montagDerWoche(datum);
return [0, 1, 2, 3, 4].map((n) => plusTage(montag, n));
}
/** Zeitpunkt in der Zukunft, für Sitzungsablauf. */
export function inTagen(tage: number, bezug: Date = new Date()): string {
return new Date(bezug.getTime() + tage * 24 * 3600 * 1000).toISOString();
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
alsDatum,
freitagDerWoche,
isoKalenderjahr,
isoKalenderwoche,
kalenderwocheText,
montagDerWoche,
plusTage,
werktage,
} from './index';
describe('ISO-Kalenderwochen', () => {
it('rechnet gewöhnliche Wochen richtig', () => {
expect(isoKalenderwoche('2026-09-10')).toBe(37);
expect(isoKalenderjahr('2026-09-10')).toBe(2026);
});
it('rechnet den Jahreswechsel nach ISO 8601, nicht nach Kalenderjahr', () => {
// 1. Januar 2027 ist ein Freitag – gehört noch zur 53. Woche von 2026.
expect(isoKalenderwoche('2027-01-01')).toBe(53);
expect(isoKalenderjahr('2027-01-01')).toBe(2026);
// 31. Dezember 2019 ist ein Dienstag – gehört schon zur 1. Woche von 2020.
expect(isoKalenderwoche('2019-12-31')).toBe(1);
expect(isoKalenderjahr('2019-12-31')).toBe(2020);
// 4. Januar liegt per Definition immer in Woche 1.
expect(isoKalenderwoche('2021-01-04')).toBe(1);
expect(isoKalenderjahr('2021-01-04')).toBe(2021);
});
it('kennt Jahre mit 53 Wochen', () => {
expect(isoKalenderwoche('2020-12-31')).toBe(53);
expect(isoKalenderjahr('2020-12-31')).toBe(2020);
});
it('beschriftet zweistellig', () => {
expect(kalenderwocheText('2026-01-05')).toBe('KW 02 / 2026');
});
});
describe('Wochenrechnung', () => {
it('findet Montag und Freitag – auch wenn das Datum selbst ein Sonntag ist', () => {
expect(montagDerWoche('2026-09-10')).toBe('2026-09-07'); // Donnerstag
expect(montagDerWoche('2026-09-13')).toBe('2026-09-07'); // Sonntag
expect(montagDerWoche('2026-09-07')).toBe('2026-09-07'); // Montag selbst
expect(freitagDerWoche('2026-09-13')).toBe('2026-09-11');
});
it('liefert genau die fünf Werktage', () => {
expect(werktage('2026-09-10')).toEqual([
'2026-09-07',
'2026-09-08',
'2026-09-09',
'2026-09-10',
'2026-09-11',
]);
});
it('rechnet über Monats- und Jahresgrenzen', () => {
expect(plusTage('2026-02-28', 1)).toBe('2026-03-01');
expect(plusTage('2024-02-28', 1)).toBe('2024-02-29'); // Schaltjahr
expect(plusTage('2026-01-01', -1)).toBe('2025-12-31');
expect(plusTage('2026-12-31', 1)).toBe('2027-01-01');
});
});
describe('Zeitzone Europe/Berlin', () => {
it('ordnet einen Zeitpunkt dem richtigen Kalendertag zu', () => {
// 22:30 UTC am 9.9. ist in Berlin (Sommerzeit, UTC+2) schon der 10.9.
expect(alsDatum('2026-09-09T22:30:00.000Z')).toBe('2026-09-10');
// 22:30 UTC im Winter (UTC+1) ist noch der 9.12.
expect(alsDatum('2026-12-09T22:30:00.000Z')).toBe('2026-12-09');
// 23:30 UTC im Winter ist der 10.12.
expect(alsDatum('2026-12-09T23:30:00.000Z')).toBe('2026-12-10');
});
});
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"verbatimModuleSyntax": false,
"noUncheckedIndexedAccess": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "e2e"]
}
Executable
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Lern-Bar aktualisieren – für den Linux-Server, auf dem der Container läuft.
#
# ./update.sh Quelltext holen, neu bauen, neu starten
# ./update.sh --ohne-git nur neu bauen und neu starten
# ./update.sh --ohne-sicherung Sicherung überspringen (nicht empfohlen)
# ./update.sh --zweig main einen bestimmten Git-Zweig holen
#
# Ablauf: Sicherung → Quelltext holen → Abbild bauen → umschalten →
# Lebenszeichen prüfen. Klappt das Lebenszeichen nicht, wird automatisch auf
# das vorherige Abbild zurückgeschaltet.
# ---------------------------------------------------------------------------
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")"
DIENST="lernbar"
ABBILD="lernbar:lokal"
ZWEIG=""
MIT_GIT=1
MIT_SICHERUNG=1
WARTEZEIT=90
while [ $# -gt 0 ]; do
case "$1" in
--ohne-git) MIT_GIT=0 ;;
--ohne-sicherung) MIT_SICHERUNG=0 ;;
--zweig)
ZWEIG="${2:-}"
shift
;;
--hilfe | -h)
sed -n '2,18p' "$0"
exit 0
;;
*)
echo "Unbekannte Angabe: $1 (--hilfe zeigt die Möglichkeiten)" >&2
exit 1
;;
esac
shift
done
# --- Docker-Compose-Aufruf ermitteln ---------------------------------------
if docker compose version >/dev/null 2>&1; then
COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE="docker-compose"
else
echo "Weder 'docker compose' noch 'docker-compose' gefunden." >&2
exit 1
fi
meldung() { printf '\n\033[1;36m▸ %s\033[0m\n' "$1"; }
warnung() { printf '\033[1;33m! %s\033[0m\n' "$1"; }
fehler() { printf '\033[1;31m✗ %s\033[0m\n' "$1" >&2; }
fertig() { printf '\033[1;32m✓ %s\033[0m\n' "$1"; }
# --- 1. Sicherung ----------------------------------------------------------
if [ "$MIT_SICHERUNG" -eq 1 ]; then
meldung "Sicherung anlegen"
if $COMPOSE ps --status running --services 2>/dev/null | grep -qx "$DIENST"; then
ARCHIV="$($COMPOSE exec -T "$DIENST" /app/werkzeuge/sicherung.sh /daten/sicherungen | tail -1)"
fertig "Sicherung liegt im Volume unter ${ARCHIV}"
else
warnung "Container läuft nicht – Sicherung übersprungen."
fi
else
warnung "Sicherung auf Wunsch übersprungen."
fi
# --- 2. Quelltext holen ----------------------------------------------------
VORHER_STAND=""
if [ "$MIT_GIT" -eq 1 ]; then
if [ -d .git ]; then
meldung "Quelltext holen"
VORHER_STAND="$(git rev-parse HEAD)"
if [ -n "$(git status --porcelain)" ]; then
fehler "Im Arbeitsverzeichnis liegen Änderungen. Bitte erst aufräumen (git status)."
exit 1
fi
git fetch --prune origin
if [ -n "$ZWEIG" ]; then
git checkout "$ZWEIG"
fi
AKTUELLER_ZWEIG="$(git rev-parse --abbrev-ref HEAD)"
git merge --ff-only "origin/$AKTUELLER_ZWEIG"
fertig "Stand: $(git rev-parse --short HEAD) – $(git log -1 --pretty=%s)"
else
warnung "Kein Git-Repository – Quelltext bleibt, wie er ist."
fi
fi
# --- 3. Vorheriges Abbild sichern ------------------------------------------
ALTE_ID="$(docker image inspect "$ABBILD" --format '{{.Id}}' 2>/dev/null || true)"
if [ -n "$ALTE_ID" ]; then
docker tag "$ALTE_ID" "lernbar:vorher"
fi
# --- 4. Bauen --------------------------------------------------------------
meldung "Abbild bauen"
if ! $COMPOSE build --pull "$DIENST"; then
fehler "Der Bau ist fehlgeschlagen. Es läuft weiterhin die alte Fassung."
exit 1
fi
# --- 5. Umschalten ---------------------------------------------------------
meldung "Neu starten"
$COMPOSE up -d "$DIENST"
# --- 6. Lebenszeichen ------------------------------------------------------
meldung "Auf das Lebenszeichen warten (bis zu ${WARTEZEIT}s)"
GESUND=0
for _ in $(seq 1 "$WARTEZEIT"); do
ZUSTAND="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}kein-test{{end}}' "$DIENST" 2>/dev/null || echo unbekannt)"
case "$ZUSTAND" in
healthy | kein-test)
GESUND=1
break
;;
unhealthy)
break
;;
esac
sleep 1
done
if [ "$GESUND" -ne 1 ]; then
fehler "Die neue Fassung meldet sich nicht gesund. Ich schalte zurück."
docker logs --tail 60 "$DIENST" || true
if [ -n "$ALTE_ID" ]; then
docker tag "lernbar:vorher" "$ABBILD"
$COMPOSE up -d --no-build "$DIENST"
warnung "Zurückgeschaltet auf das vorherige Abbild."
fi
if [ -n "$VORHER_STAND" ]; then
warnung "Quelltext steht auf einem neueren Stand. Zurück mit: git reset --hard $VORHER_STAND"
fi
exit 1
fi
# --- 7. Aufräumen ----------------------------------------------------------
meldung "Aufräumen"
docker image prune -f --filter "label=org.opencontainers.image.title=lernbar" >/dev/null 2>&1 || true
docker image rm "lernbar:vorher" >/dev/null 2>&1 || true
fertig "Lern-Bar läuft in der neuen Fassung."
$COMPOSE ps "$DIENST"
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
globals: false,
},
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
});