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,106 @@
|
||||
import { z } from "zod";
|
||||
import { router, oeffentlich, angemeldet, mitRecht } from "@/server/trpc";
|
||||
import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz";
|
||||
import { datenbankErreichbar } from "@/lib/db";
|
||||
import { speicherErreichbar } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* Wurzel-Router. Ab Phase 1 kommen hier die Fachbereiche als Unter-Router
|
||||
* dazu (stammdaten, wartung, atemschutz, einsatz, ausbildung, kasse).
|
||||
* Phase 0 liefert nur, was zum Prüfen des Fundaments nötig ist.
|
||||
*/
|
||||
|
||||
const systemRouter = router({
|
||||
/** Erreichbarkeit der Randsysteme. Ohne Anmeldung abrufbar. */
|
||||
status: oeffentlich.query(async () => {
|
||||
const [datenbank, speicher] = await Promise.all([
|
||||
datenbankErreichbar(),
|
||||
speicherErreichbar(),
|
||||
]);
|
||||
return {
|
||||
datenbank,
|
||||
speicher,
|
||||
zeitpunkt: new Date(),
|
||||
zeitzone: "Europe/Berlin",
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const sitzungRouter = router({
|
||||
/** Wer bin ich, welcher Wehr gehöre ich an, was darf ich? */
|
||||
ich: angemeldet.query(async ({ ctx }) => {
|
||||
const person = await ctx.db((db) =>
|
||||
db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId },
|
||||
select: {
|
||||
id: true,
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
email: true,
|
||||
dienstgrad: true,
|
||||
mitgliedsnummer: true,
|
||||
letzteAnmeldung: true,
|
||||
organisation: { select: { id: true, name: true, kurzname: true, slug: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
person,
|
||||
rollen: ctx.sitzung.rollen,
|
||||
rollenBezeichnung: ctx.sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]),
|
||||
rechte: [...rechteVon(ctx.sitzung.rollen)],
|
||||
anmeldeArt: ctx.sitzung.anmeldeArt,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const organisationRouter = router({
|
||||
/** Stammdaten der eigenen Wehr. */
|
||||
eigene: angemeldet.query(async ({ ctx }) =>
|
||||
ctx.db((db) =>
|
||||
db.organisation.findUnique({
|
||||
where: { id: ctx.sitzung.organisationId },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
/** Kennzahlen für die Startseite. */
|
||||
kennzahlen: angemeldet.query(async ({ ctx }) =>
|
||||
ctx.db(async (db) => {
|
||||
const [mitglieder, fahrzeuge, geraete, pruefungFaellig] = await Promise.all([
|
||||
db.person.count({ where: { aktiv: true, geloeschtAm: null } }),
|
||||
db.fahrzeug.count({ where: { aktiv: true, geloeschtAm: null } }),
|
||||
db.geraet.count({ where: { geloeschtAm: null } }),
|
||||
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
|
||||
]);
|
||||
return { mitglieder, fahrzeuge, geraete, pruefungFaellig };
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const auditRouter = router({
|
||||
/** Die letzten Einträge im Protokoll — nur mit dem entsprechenden Recht. */
|
||||
letzte: mitRecht("system.auditlog")
|
||||
.input(z.object({ anzahl: z.number().int().min(1).max(200).default(50) }).optional())
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.auditLog.findMany({
|
||||
orderBy: { zeitpunkt: "desc" },
|
||||
take: input?.anzahl ?? 50,
|
||||
include: {
|
||||
akteur: { select: { id: true, vorname: true, nachname: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
system: systemRouter,
|
||||
sitzung: sitzungRouter,
|
||||
organisation: organisationRouter,
|
||||
audit: auditRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
Reference in New Issue
Block a user