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,111 @@
|
||||
# =============================================================================
|
||||
# FLORIAN — Mehrstufiger Build.
|
||||
# deps : Abhängigkeiten installieren (wird gecacht)
|
||||
# builder : Prisma-Client erzeugen, Next.js bauen, Worker transpilieren
|
||||
# runner : schlankes Laufzeit-Image, non-root, mit Healthcheck
|
||||
# =============================================================================
|
||||
|
||||
ARG NODE_VERSION=22.20.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stufe 1: Abhängigkeiten
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION}-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# openssl wird von Prisma benötigt, ca-certificates für ausgehendes TLS.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY package.json package-lock.json* .npmrc ./
|
||||
RUN npm ci --include=dev
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stufe 2: Build
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION}-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Prisma-Client aus dem Schema erzeugen — ohne Datenbankverbindung.
|
||||
RUN npx prisma generate
|
||||
|
||||
# Der Next.js-Build braucht formal gültige Variablen, verbindet sich aber nicht.
|
||||
# Die echten Werte kommen zur Laufzeit aus der Umgebung.
|
||||
RUN DATABASE_URL="postgresql://build:build@localhost:5432/build?schema=public" \
|
||||
DIRECT_DATABASE_URL="postgresql://build:build@localhost:5432/build?schema=public" \
|
||||
AUTH_SECRET="build_time_platzhalter_mindestens_32_zeichen" \
|
||||
KIOSK_DEVICE_SECRET="build_time_platzhalter" \
|
||||
SKIP_ENV_VALIDATION=1 \
|
||||
npm run build
|
||||
|
||||
# Worker separat nach CommonJS transpilieren, damit im Runtime-Image kein
|
||||
# TypeScript-Toolchain mehr nötig ist.
|
||||
RUN npx esbuild src/worker/index.ts \
|
||||
--bundle \
|
||||
--platform=node \
|
||||
--target=node22 \
|
||||
--format=cjs \
|
||||
--packages=external \
|
||||
--sourcemap \
|
||||
--outfile=dist/worker/index.js
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stufe 3: Laufzeit
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION}-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
# Eigener, unprivilegierter Benutzer. Läuft NICHT als root.
|
||||
RUN groupadd --system --gid 1001 florian \
|
||||
&& useradd --system --uid 1001 --gid florian --home /app --shell /usr/sbin/nologin florian
|
||||
|
||||
# Standalone-Ausgabe von Next.js: enthält nur die tatsächlich benötigten Module.
|
||||
COPY --from=builder --chown=florian:florian /app/.next/standalone ./
|
||||
COPY --from=builder --chown=florian:florian /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=florian:florian /app/public ./public
|
||||
|
||||
# Worker-Bundle und dessen Laufzeitabhängigkeiten.
|
||||
COPY --from=builder --chown=florian:florian /app/dist ./dist
|
||||
|
||||
# Prisma: Schema und Migrationen fürs Deployment, plus erzeugter Client.
|
||||
COPY --from=builder --chown=florian:florian /app/prisma ./prisma
|
||||
COPY --from=builder --chown=florian:florian /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder --chown=florian:florian /app/node_modules/@prisma ./node_modules/@prisma
|
||||
COPY --from=builder --chown=florian:florian /app/node_modules/prisma ./node_modules/prisma
|
||||
|
||||
COPY --chown=florian:florian docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY --chown=florian:florian docker/healthcheck.js /app/healthcheck.js
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
USER florian
|
||||
EXPOSE 3000
|
||||
|
||||
# Prüft den Endpunkt /api/health — der fragt Datenbank und Redis mit ab.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD node /app/healthcheck.js || exit 1
|
||||
|
||||
# tini als PID 1, damit Signale sauber durchgereicht werden.
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Einstiegspunkt für App- und Worker-Container.
|
||||
# Die App wendet vor dem Start ausstehende Migrationen an, der Worker nicht —
|
||||
# sonst würden zwei Prozesse gleichzeitig migrieren.
|
||||
# =============================================================================
|
||||
set -eu
|
||||
|
||||
log() {
|
||||
echo "[florian-entrypoint] $*"
|
||||
}
|
||||
|
||||
if [ "${ROLLE:-app}" = "app" ] && [ "${SKIP_MIGRATIONS:-false}" != "true" ]; then
|
||||
log "Wende ausstehende Datenbankmigrationen an ..."
|
||||
if node node_modules/prisma/build/index.js migrate deploy; then
|
||||
log "Migrationen abgeschlossen."
|
||||
else
|
||||
log "FEHLER: Migrationen fehlgeschlagen. Container wird beendet."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log "Rolle '${ROLLE:-app}' — Migrationen werden übersprungen."
|
||||
fi
|
||||
|
||||
log "Starte: $*"
|
||||
exec "$@"
|
||||
@@ -0,0 +1,25 @@
|
||||
// Healthcheck für den Docker-HEALTHCHECK. Bewusst ohne Abhängigkeiten,
|
||||
// damit er auch dann läuft, wenn die Anwendung selbst nicht startet.
|
||||
const http = require("node:http");
|
||||
|
||||
const port = process.env.PORT || "3000";
|
||||
|
||||
const anfrage = http.request(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/health",
|
||||
method: "GET",
|
||||
timeout: 4000,
|
||||
},
|
||||
(antwort) => {
|
||||
process.exit(antwort.statusCode === 200 ? 0 : 1);
|
||||
},
|
||||
);
|
||||
|
||||
anfrage.on("error", () => process.exit(1));
|
||||
anfrage.on("timeout", () => {
|
||||
anfrage.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
anfrage.end();
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# Läuft EINMALIG beim allerersten Start des Postgres-Containers (leeres
|
||||
# Datenverzeichnis). Richtet ein:
|
||||
# - die benötigten Erweiterungen (PostGIS, pgcrypto, citext, unaccent, pg_trgm)
|
||||
# - den Anwendungsbenutzer "florian_app" OHNE BYPASSRLS
|
||||
# - Vorgabeprivilegien, damit künftige Tabellen automatisch nutzbar sind
|
||||
#
|
||||
# Wichtig zum Verständnis der Mandantentrennung:
|
||||
# Der Eigentümer der Tabellen (POSTGRES_USER) umgeht Row Level Security —
|
||||
# das ist gewollt, damit Migrationen und das Seed-Skript arbeiten können.
|
||||
# Die Anwendung verbindet sich ausschließlich als "florian_app"; für den
|
||||
# greift RLS, und ohne gesetztes app.current_organisation sieht er nichts.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP_ROLE="florian_app"
|
||||
APP_PASSWORD="${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD muss gesetzt sein}"
|
||||
# Einfache Anführungszeichen im Passwort für SQL verdoppeln.
|
||||
APP_PASSWORD_SQL="${APP_PASSWORD//\'/\'\'}"
|
||||
|
||||
echo "[florian-init] Richte Erweiterungen und Anwendungsrolle ein ..."
|
||||
|
||||
# Erweiterungen. Reines SQL, keine Variablen nötig.
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'EOSQL'
|
||||
CREATE EXTENSION IF NOT EXISTS postgis; -- Koordinaten, Einsatzorte
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid(), digest()
|
||||
CREATE EXTENSION IF NOT EXISTS citext; -- E-Mail ohne Gross/Klein
|
||||
CREATE EXTENSION IF NOT EXISTS unaccent; -- Suche ueber Umlaute hinweg
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- unscharfe Namenssuche
|
||||
EOSQL
|
||||
|
||||
# Anwendungsrolle anlegen. Bewusst OHNE DO-Block: psql ersetzt seine Variablen
|
||||
# nicht innerhalb dollar-quotierter Zeichenketten. Stattdessen erzeugt die
|
||||
# Abfrage den CREATE-Befehl als Text, den \gexec dann ausführt — und zwar nur,
|
||||
# wenn die Rolle noch nicht existiert.
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
SELECT format(
|
||||
'CREATE ROLE %I LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS',
|
||||
'${APP_ROLE}', '${APP_PASSWORD_SQL}'
|
||||
)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${APP_ROLE}')
|
||||
\gexec
|
||||
|
||||
ALTER ROLE "${APP_ROLE}" PASSWORD '${APP_PASSWORD_SQL}';
|
||||
ALTER ROLE "${APP_ROLE}" SET timezone TO 'Europe/Berlin';
|
||||
EOSQL
|
||||
|
||||
# Rechtevergabe. Hier funktionieren psql-Variablen, weil kein DO-Block im Spiel
|
||||
# ist.
|
||||
psql -v ON_ERROR_STOP=1 \
|
||||
--username "$POSTGRES_USER" \
|
||||
--dbname "$POSTGRES_DB" \
|
||||
-v app_role="$APP_ROLE" \
|
||||
-v datenbank="$POSTGRES_DB" \
|
||||
-v owner="$POSTGRES_USER" <<-'EOSQL'
|
||||
GRANT CONNECT ON DATABASE :"datenbank" TO :"app_role";
|
||||
GRANT USAGE ON SCHEMA public TO :"app_role";
|
||||
|
||||
-- Rechte auf alles, was heute schon existiert (bei Erstinstallation leer).
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO :"app_role";
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO :"app_role";
|
||||
|
||||
-- Und auf alles, was der Eigentuemer kuenftig anlegt (Prisma-Migrationen).
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE :"owner" IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO :"app_role";
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE :"owner" IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO :"app_role";
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE :"owner" IN SCHEMA public
|
||||
GRANT EXECUTE ON FUNCTIONS TO :"app_role";
|
||||
|
||||
-- Die Anwendung darf keine Tabellen anlegen oder loeschen.
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||
REVOKE CREATE ON SCHEMA public FROM :"app_role";
|
||||
|
||||
ALTER DATABASE :"datenbank" SET timezone TO 'Europe/Berlin';
|
||||
EOSQL
|
||||
|
||||
echo "[florian-init] Anwendungsrolle '$APP_ROLE' eingerichtet (RLS wirksam)."
|
||||
Reference in New Issue
Block a user