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
+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();
}
}