Phase 0: Fundament der Feuerwehr-Verwaltungsplattform
Legt den kompletten Unterbau an, auf dem die Fachmodule aufsetzen. Betrieb: - docker-compose.yml mit Traefik, App, Worker, PostgreSQL 17 + PostGIS, Redis, MinIO und Gotenberg; Entwicklungs-Override ohne Traefik, dafuer mit Mailpit und Adminer - Mehrstufiges Dockerfile: eigener Benutzer, tini als PID 1, Healthcheck - .env.example mit einem Kommentar zu jeder Variable Mandantentrennung: - Row Level Security auf allen mandantenpflichtigen Tabellen - Zwei getrennte Datenbankverbindungen: florian_app mit aktiver RLS fuer die Anwendung, Eigentuemer-Zugang nur fuer Migration, Seed und Anmeldung - fuerOrganisation() als einziger Weg zu Fachdaten Nachvollziehbarkeit: - AuditLog append-only per Trigger; UPDATE ist dauerhaft gesperrt, DELETE nur im gekennzeichneten Aufbewahrungslauf - Geheimnisse werden vor dem Schreiben entfernt Anmeldung: - OIDC gegen ein extern betriebenes authentik, Rollen aus Gruppen nur als Vorbelegung beim ersten Login - Lokaler E-Mail/Passwort-Login als Rueckfallweg - Terminal per PIN und RFID mit zwei Schranken (Geraet, dann Person) Weiteres: - lib/authz.ts mit 9 Rollen und 38 Einzelrechten - lib/storage.ts (MinIO), lib/audit.ts, lib/warteschlange.ts (BullMQ) - Integrationen fuer Mail, Telegram und LLM hinter abschaltbaren Interfaces - tRPC v11, Basis-Layout mit Seitenleiste und dunkler Darstellung - Seed mit einer Beispielwehr: 16 Personen, 3 Fahrzeuge, 40 Geraete - 32 Vitest-Tests (davon 8 gegen eine echte Datenbank), 10 Playwright-Tests - README, Anleitung mit Screenshots, Roadmap, Aufwandsschaetzung, Devlog, Versionierungsregeln und Changelog Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Erzeugt die Bilder für docs/ANLEITUNG.md.
|
||||
*
|
||||
* Läuft gegen die laufende Anwendung, meldet sich mit dem Seed-Administrator an
|
||||
* und legt die Bilder unter docs/screenshots ab — jeweils in heller und dunkler
|
||||
* Darstellung, damit die Anleitung zu beiden Einstellungen passt.
|
||||
*
|
||||
* docker compose up -d
|
||||
* npm run dev
|
||||
* npm run screenshots
|
||||
*
|
||||
* Die Bilder werden mit eingecheckt: die Anleitung soll auch dann lesbar sein,
|
||||
* wenn gerade kein Stack läuft.
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Page } from "@playwright/test";
|
||||
|
||||
const BASIS = process.env.E2E_BASIS_URL ?? "http://localhost:3100";
|
||||
const ZIEL = path.join(process.cwd(), "docs", "screenshots");
|
||||
|
||||
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
|
||||
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
||||
|
||||
interface Aufnahme {
|
||||
name: string;
|
||||
pfad: string;
|
||||
beschreibung: string;
|
||||
/** true, wenn vorher angemeldet werden muss. */
|
||||
angemeldet: boolean;
|
||||
}
|
||||
|
||||
const AUFNAHMEN: Aufnahme[] = [
|
||||
{
|
||||
name: "01-anmeldung",
|
||||
pfad: "/anmelden",
|
||||
beschreibung: "Anmeldeseite mit lokalem Login",
|
||||
angemeldet: false,
|
||||
},
|
||||
{
|
||||
name: "02-uebersicht",
|
||||
pfad: "/",
|
||||
beschreibung: "Übersicht nach der Anmeldung",
|
||||
angemeldet: true,
|
||||
},
|
||||
];
|
||||
|
||||
async function anmelden(seite: Page): Promise<void> {
|
||||
await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" });
|
||||
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||
await seite.getByRole("button", { name: "Anmelden" }).click();
|
||||
await seite.waitForURL(`${BASIS}/`, { timeout: 20_000 });
|
||||
await seite.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await mkdir(ZIEL, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
for (const darstellung of ["light", "dark"] as const) {
|
||||
const kontext = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
colorScheme: darstellung,
|
||||
});
|
||||
|
||||
const seite = await kontext.newPage();
|
||||
let istAngemeldet = false;
|
||||
|
||||
for (const aufnahme of AUFNAHMEN) {
|
||||
if (aufnahme.angemeldet && !istAngemeldet) {
|
||||
await anmelden(seite);
|
||||
istAngemeldet = true;
|
||||
}
|
||||
|
||||
await seite.goto(`${BASIS}${aufnahme.pfad}`, { waitUntil: "networkidle" });
|
||||
// Kurz warten, damit Übergänge und Symbole fertig gezeichnet sind.
|
||||
await seite.waitForTimeout(400);
|
||||
|
||||
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
|
||||
await seite.screenshot({ path: datei, fullPage: true });
|
||||
console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`);
|
||||
}
|
||||
|
||||
await kontext.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.info("[screenshots] Fertig.");
|
||||
}
|
||||
|
||||
main().catch((fehler) => {
|
||||
console.error("[screenshots] Abbruch:", fehler);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user