diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e302c36 --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# AfiS-Aktensystem — Vorlage für den .env-Reiter in Dockge. +# Diese Datei enthält nur Platzhalter. Die echten Werte gehören nach +# /opt/stacks/afis/.env und niemals ins Repository. Siehe docs/11-betrieb.md. + +# --- Zeitzone ----------------------------------------------------------------- +TZ=Europe/Berlin + +# --- Datenbank ---------------------------------------------------------------- +# Wird von db, pgbouncer, web, worker und den Backups gelesen. +POSTGRES_PASSWORD=hier-ein-langes-zufaelliges-passwort + +# Die Anwendung spricht über pgbouncer im Transaktionsmodus, deshalb pgbouncer=true. +DATABASE_URL=postgresql://afis:hier-ein-langes-zufaelliges-passwort@pgbouncer:6432/afis?pgbouncer=true&connection_limit=1 +# Migrationen und LISTEN/NOTIFY brauchen eine Verbindung an pgbouncer vorbei. +DIRECT_DATABASE_URL=postgresql://afis:hier-ein-langes-zufaelliges-passwort@db:5432/afis + +# --- Anwendung ---------------------------------------------------------------- +APP_URL=https://afis.example.de +# 32+ Zeichen. Erzeugen mit: openssl rand -base64 48 +AUTH_SECRET=hier-ein-langes-zufaelliges-geheimnis-mit-mindestens-32-zeichen + +# --- Discord ------------------------------------------------------------------ +# Aus dem Developer Portal. Das Server Members Intent muss aktiviert sein, +# sonst liefert der Bot keine Rollen. Siehe docs/03-auth-und-rechte.md. +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_BOT_TOKEN= +DISCORD_GUILD_ID= +# Kanal, in den der Worker Start, Verbindungsabbruch und fehlgeschlagenen +# Voll-Abgleich meldet. Das ist die eigentliche Alarmierung. +DISCORD_ADMIN_CHANNEL_ID= + +# --- Notfallzugang ------------------------------------------------------------ +# Eine oder zwei Discord-IDs, kommagetrennt. Diese Nutzer haben immer Stufe 5 +# und alle Rechte, unabhängig vom Rollen-Mapping. Ohne das sperrt man sich beim +# ersten Fehler in der Rollenverwaltung selbst aus. +BOOTSTRAP_ADMIN_IDS= + +# --- Dateien und Prüfkette ---------------------------------------------------- +FILES_DIR=/data/files +CLAMAV_SOCKET=/var/run/clamav/clamd.sock +PDF_SERVICE_URL=http://pdf:3000 + +# --- Cloudflare Tunnel -------------------------------------------------------- +CLOUDFLARE_TUNNEL_TOKEN= + +# --- Auslagerungs-Backup ------------------------------------------------------ +RESTIC_REPOSITORY= +RESTIC_PASSWORD= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..987584b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: Prüfung + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + pruefen: + name: Lint, Typen, Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: afis + POSTGRES_USER: afis + POSTGRES_PASSWORD: afis + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U afis -d afis" + --health-interval 10s --health-timeout 5s --health-retries 5 + + env: + DATABASE_URL: postgresql://afis:afis@localhost:5432/afis + DIRECT_DATABASE_URL: postgresql://afis:afis@localhost:5432/afis + APP_URL: http://127.0.0.1:3100 + AUTH_SECRET: nur-fuer-die-pruefung-mindestens-32-zeichen-lang + DISCORD_CLIENT_ID: pruefung + DISCORD_CLIENT_SECRET: pruefung + DISCORD_BOT_TOKEN: pruefung + DISCORD_GUILD_ID: '0' + NEXT_TELEMETRY_DISABLED: '1' + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # Erzeugt den Prisma-Client nach packages/db/src/generated und übersetzt + # @afis/db. Muss vor lint, typecheck und test laufen. + - name: Datenbankpaket bauen + run: pnpm --filter @afis/db run build + + - name: Datenbank aufbauen + run: pnpm db:deploy + + - run: pnpm db:seed + + - run: pnpm lint + + - run: pnpm typecheck + + # Die Regeltests in packages/db laufen gegen die echte Postgres-Instanz. + - run: pnpm test + + - name: Playwright-Browser + run: pnpm --filter @afis/web exec playwright install --with-deps chromium + + # Baut die Anwendung und prüft sie über den Standalone-Server, also genau + # so, wie der Container sie startet. + - run: pnpm test:e2e + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: web/playwright-report/ + retention-days: 7 + + # Budget aus docs/09-performance-und-ux.md: erste Route unter 120 kB + # JavaScript nach Kompression. + - name: Bündelgröße prüfen + run: pnpm --filter @afis/web exec node ../scripts/check-bundle-budget.mjs + + images: + name: Images bauen + runs-on: ubuntu-latest + needs: pruefen + if: github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + dienst: [web, worker, pdf] + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + # Auf Pull Requests nur bauen, nicht schieben. Das Schieben macht + # .github/workflows/images.yml auf main. + - uses: docker/build-push-action@v6 + with: + context: . + file: docker/${{ matrix.dienst }}.Dockerfile + push: false + cache-from: type=gha,scope=${{ matrix.dienst }} + cache-to: type=gha,mode=max,scope=${{ matrix.dienst }} diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml new file mode 100644 index 0000000..53e5b86 --- /dev/null +++ b/.github/workflows/images.yml @@ -0,0 +1,62 @@ +name: Images + +# Baut die drei Images und schiebt sie in die GitHub Container Registry. +# Dockge zieht sie beim Update, siehe docs/11-betrieb.md. + +on: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + +jobs: + bauen: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + strategy: + fail-fast: false + matrix: + include: + - dienst: web + dockerfile: docker/web.Dockerfile + - dienst: worker + dockerfile: docker/worker.Dockerfile + - dienst: pdf + dockerfile: docker/pdf.Dockerfile + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/afis-${{ matrix.dienst }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.dienst }} + cache-to: type=gha,mode=max,scope=${{ matrix.dienst }} diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..da5ceed --- /dev/null +++ b/TASKS.md @@ -0,0 +1,119 @@ +# Bauplan + +Phasen in Reihenfolge abarbeiten. Am Ende jeder Phase muss +`pnpm lint && pnpm typecheck && pnpm test` durchlaufen und die Anwendung startbar sein. + +## Phase 0 — Gerüst + +- [x] Next.js 15 mit TypeScript strict, Tailwind, pnpm-Workspace mit `web/` und `worker/` +- [x] Prisma, Postgres im Compose, erste Migration +- [x] Design-Tokens aus `docs/10-design-system.md` als CSS-Variablen, IBM Plex selbst gehostet +- [x] `lib/i18n/de.ts` mit den ersten Texten, Konvention: keine deutschen Strings im JSX +- [x] Vitest und Playwright eingerichtet, ein rauchender Test je Ebene +- [x] `.env.example` gefüllt, `compose.yaml` aus `docs/11-betrieb.md` als Dockge-Stack + lauffähig (zunächst nur `web`, `db`, `pgbouncer`) +- [x] Dockerfiles für `web`, `worker` und `pdf`, GitHub-Actions-Workflow schiebt die Images + in die GitHub Container Registry +- [x] `/api/health` prüft Datenbank, Discord-Gateway und ClamAV, wird vom Healthcheck genutzt + +## Phase 1 — Anmeldung und Rechte + +Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. + +- [ ] Auth.js mit Discord-Provider, Scope `identify`, state und PKCE +- [ ] Bot-Client mit Bot-Token, Guild-Mitgliedschaftsprüfung im Callback +- [ ] Eigene Session in der Datenbank, Cookie-Einstellungen wie in `docs/03-auth-und-rechte.md` +- [ ] `worker/`: discord.js am Gateway, Ereignisse plus Voll-Abgleich alle 5 Minuten +- [ ] `pg_notify` bei Rollenänderung, Cache-Invalidierung in der Web-App +- [ ] `EffectivePermissions` mit prozesslokalem Cache +- [ ] `BOOTSTRAP_ADMIN_IDS` als Notfallzugang +- [ ] Adminseite Rollen-Mapping: Tabelle der Discord-Rollen, Stufe per Auswahl, + Rechte per Kästchen, Vorschau „Was sieht Stufe 2?" +- [ ] Sessions werden bei Rollenverlust oder Serveraustritt beendet +- [ ] Zwischenstand: eingeloggte Seite zeigt Name, Rollen, errechnete Stufe und Rechte +- [ ] Negativtests aus `docs/03-auth-und-rechte.md` grün +- [ ] `entry.edit_own`, `entry.edit_any`, `entry.retract` und `case.edit_closed` als Rechte + angelegt, im Auslieferungszustand keiner Rolle zugeordnet + +## Phase 2 — Akten + +- [ ] Datenmodell für `Case`, `CaseSection`, `CaseEntry`, Beteiligte, Ausschlüsse +- [ ] `lib/access/` mit Where-Fragmenten, überall verwendet, nirgends Nachfilterung +- [ ] Aktenzeichen-Vergabe kollisionsfrei über `CaseCounter` +- [ ] Fallliste mit Filtern, cursorbasiert, `loading.tsx` mit Skelett +- [ ] Aktenansicht: Kopfdaten sofort, Verlauf und Chat in eigenen Suspense-Grenzen +- [ ] Vermerk anlegen, optimistisch, mit clientseitiger UUIDv7 +- [ ] Statusübergänge mit Rechteprüfung und automatischem Systemeintrag +- [ ] Audit-Log für alle schreibenden Aktionen und Lesezugriffe ab Stufe 3 +- [ ] Nachträgliche Änderungen an Verlaufseinträgen nach `docs/05-akten-und-vorlagen.md`: + `CaseEntryVersion`, Karenzzeit, Pflichtbegründung, `bearbeitet · vN` mit Diff-Historie +- [ ] Zurückziehen mit Grabstein und Pflichtbegründung, kein hartes Löschen +- [ ] Postgres-Regeln gegen `DELETE` auf `CaseEntry` und `CaseEntryVersion` +- [ ] Änderungsübersicht als Reiter an der Akte +- [ ] Adminseite für `AppSetting`: Änderungsrichtlinie, Karenzzeit, Markierungsverhalten + +## Phase 3 — Vorlagen und Abschnitte + +- [ ] `CaseTemplate` mit Feldtypen aus `docs/05-akten-und-vorlagen.md` +- [ ] Vorlagen-Editor im Adminbereich, versioniert, mit Vorschau +- [ ] Abschnitte rendern und bearbeiten, Autosave lokal alle 2 Sekunden +- [ ] Versionierung mit Diff-Ansicht +- [ ] Konflikterkennung über mitgesendete Version, 409 mit Vergleichsansicht +- [ ] `minClearance` pro Feld, serverseitige Ersetzung durch Platzhalter +- [ ] Schwärzungs-Auszeichnung im Freitext, Parser in `lib/redaction.ts` +- [ ] Seed mit vier Startvorlagen + +## Phase 4 — Personalakten und Verknüpfungen + +- [ ] `PersonnelFile`, auch für Personen ohne Discord-Konto +- [ ] Beteiligte einer Akte mit Rolle +- [ ] Rückverweise auf der Personalakte, durch die Sichtbarkeitsprüfung gefiltert +- [ ] Verknüpfungen zwischen Akten +- [ ] Test: Stufe-1-Nutzer sieht auf einer Personalakte keinen Hinweis auf Stufe-3-Fälle + +## Phase 5 — Dateien + +- [ ] Upload-Endpunkt mit Quarantäne, Limits, Kontingent +- [ ] Typprüfung über Magic Bytes, Whitelist +- [ ] ClamAV-Container angebunden +- [ ] Neu-Kodierung mit sharp und qpdf, Vorschaubild und Blurhash +- [ ] Auslieferung über `/api/files/[id]` mit Rechteprüfung und harten Headern +- [ ] Upload-UI: lokale Vorschau, Fortschrittsbalken, Zustand „Wird geprüft" +- [ ] SSE-Ereignis `attachment.scanned` aktualisiert die Anzeige + +## Phase 6 — Chat und Echtzeit + +- [ ] SSE-Endpunkt mit `LISTEN/NOTIFY`, Heartbeat, Reconnect mit `Last-Event-ID` +- [ ] Aktenchat mit Antworten, Reaktionen, Bearbeiten, Zurückziehen +- [ ] Optimistisches Senden mit Dedupe über Client-ID +- [ ] Aktenzeichen-Erkennung mit serverseitig aufgelöster Vorschaukarte +- [ ] Ungelesen-Markierung und Trennlinie +- [ ] Freie Kanäle mit eigener Mindeststufe +- [ ] „Als Vermerk übernehmen" +- [ ] Discord-Benachrichtigungen über den Worker, ohne Inhalte + +## Phase 7 — Suche + +- [ ] `search_vector` mit Trigger und Gewichtung, GIN-Index +- [ ] Suchseite mit Filtern, Debounce, AbortController, gedimmte alte Treffer +- [ ] Sichtbarkeitsprüfung in derselben Abfrage, auch für Facettenzähler +- [ ] Kommandopalette auf Strg+K +- [ ] Gespeicherte Ansichten + +## Phase 8 — Export + +- [ ] Druckansicht `/faelle/[caseNumber]/druck` mit `@media print` +- [ ] Playwright-Container erzeugt PDF, Ergebnis gecacht +- [ ] Schwärzung und Wasserzeichen im Export +- [ ] Freigabeverfahren ab Stufe 3 +- [ ] ZIP-Sammelexport und JSON-Vollexport für Admins + +## Phase 9 — Schliff + +- [ ] Dashboard: meine Fälle, Fristen, ungelesene Chats, letzte Aktivität +- [ ] Beobachtungsliste +- [ ] Helles Thema +- [ ] Mobile Ansicht durchgehen, besonders Akte und Chat +- [ ] Web-Vitals-Erfassung und Anzeige im Adminbereich +- [ ] Bundle-Analyse, Budget aus `docs/09-performance-und-ux.md` einhalten +- [ ] Backup-Wiederherstellung einmal von Hand testen diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..7492cfc --- /dev/null +++ b/compose.yaml @@ -0,0 +1,200 @@ +# AfiS-Aktensystem — ein einziger Dockge-Stack. +# Ablage: /opt/stacks/afis/compose.yaml, .env im eigenen Dockge-Reiter. +# Begründung der Entscheidungen in docs/11-betrieb.md: +# - keine Einmal- und keine Init-Container, Dockge zeigt beendete Container nicht +# - Migrationen laufen im Startkommando von `web` +# - jeder Dienst hat einen Healthcheck, das ist in Dockge die Statusanzeige +# - kein Reverse Proxy: TLS und Kompression macht der Cloudflare Tunnel +# +# DEIN-USER durch den eigenen GitHub-Namen ersetzen. + +x-logging: &logging + logging: + driver: json-file + options: { max-size: '10m', max-file: '3' } + +services: + web: + <<: *logging + image: ghcr.io/DEIN-USER/afis-web:latest + restart: unless-stopped + # Die Migration läuft im Startkommando, nicht in einem Init-Container. + # Die Prisma-CLI liegt im Image unter /app/migrator, es wird nichts nachgeladen. + command: sh -c "cd /app/migrator && node node_modules/prisma/build/index.js migrate deploy && cd /app && node web/server.js" + env_file: .env + environment: + NODE_ENV: production + PORT: '3000' + HOSTNAME: '0.0.0.0' + volumes: + - files:/data/files + - clamav_socket:/var/run/clamav + ports: + - '3000:3000' # nur im LAN, von außen kommt der Tunnel + depends_on: + pgbouncer: { condition: service_healthy } + clamav: { condition: service_healthy } + healthcheck: + test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/api/health'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + + worker: + <<: *logging + image: ghcr.io/DEIN-USER/afis-worker:latest + restart: unless-stopped + env_file: .env + depends_on: + pgbouncer: { condition: service_healthy } + healthcheck: + test: ['CMD', 'node', 'healthcheck.js'] # prüft Gateway-Verbindung + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + db: + <<: *logging + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: afis + POSTGRES_USER: afis + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + TZ: ${TZ} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U afis -d afis'] + interval: 10s + timeout: 5s + retries: 5 + + pgbouncer: + <<: *logging + image: edoburu/pgbouncer:latest + restart: unless-stopped + environment: + DB_HOST: db + DB_USER: afis + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: afis + POOL_MODE: transaction + MAX_CLIENT_CONN: '100' + DEFAULT_POOL_SIZE: '20' + AUTH_TYPE: scram-sha-256 + depends_on: + db: { condition: service_healthy } + healthcheck: + test: ['CMD-SHELL', 'pg_isready -h 127.0.0.1 -p 6432 -U afis'] + interval: 15s + timeout: 5s + retries: 5 + + clamav: + <<: *logging + image: clamav/clamav:stable + restart: unless-stopped + environment: + CLAMAV_NO_MILTERD: 'true' + FRESHCLAM_CHECKS: '2' + volumes: + - clamav_db:/var/lib/clamav + - clamav_socket:/var/run/clamav + - files:/data/files + healthcheck: + test: ['CMD-SHELL', 'clamdscan --ping 1 || exit 1'] + interval: 60s + timeout: 15s + retries: 3 + start_period: 300s # erster Signaturdownload dauert + + pdf: + <<: *logging + image: ghcr.io/DEIN-USER/afis-pdf:latest # Playwright-Basis + restart: unless-stopped + environment: + PORT: '3000' + APP_URL: ${APP_URL} + healthcheck: + test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/health'] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + cloudflared: + <<: *logging + image: cloudflare/cloudflared:latest + restart: unless-stopped + command: tunnel --no-autoupdate run + environment: + TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN} + depends_on: + web: { condition: service_healthy } + healthcheck: + test: ['CMD', 'cloudflared', '--version'] + interval: 60s + timeout: 10s + retries: 3 + start_period: 20s + + db-backup: + <<: *logging + image: prodrigestivill/postgres-backup-local:16 + restart: unless-stopped + environment: + POSTGRES_HOST: db + POSTGRES_DB: afis + POSTGRES_USER: afis + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_EXTRA_OPTS: '-Fc' + SCHEDULE: '0 15 4 * * *' # täglich 04:15 + BACKUP_KEEP_DAYS: '7' + BACKUP_KEEP_WEEKS: '4' + BACKUP_KEEP_MONTHS: '6' + HEALTHCHECK_PORT: '8080' + TZ: ${TZ} + volumes: + - /mnt/backup/afis/db:/backups + depends_on: + db: { condition: service_healthy } + healthcheck: + test: ['CMD-SHELL', 'wget -qO- http://localhost:8080/ || exit 1'] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + offsite-backup: + <<: *logging + image: mazzolino/restic:latest + restart: unless-stopped + environment: + RUN_ON_STARTUP: 'false' + BACKUP_CRON: '0 30 4 * * *' # 15 Minuten nach dem Dump + RESTIC_REPOSITORY: ${RESTIC_REPOSITORY} + RESTIC_PASSWORD: ${RESTIC_PASSWORD} + RESTIC_BACKUP_SOURCES: /data/db /data/files + RESTIC_FORGET_ARGS: '--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune' + TZ: ${TZ} + volumes: + - /mnt/backup/afis/db:/data/db:ro + - files:/data/files:ro + # Kein HTTP-Endpunkt im Image. Geprüft wird, dass der Cron-Dauerläufer noch + # steht und restic aufrufbar ist. Ohne Healthcheck wäre der Dienst in Dockge + # nur "läuft", auch wenn er seit Wochen nichts mehr sichert. + healthcheck: + test: ['CMD-SHELL', 'pgrep -f go-crond >/dev/null && restic version >/dev/null || exit 1'] + interval: 300s + timeout: 15s + retries: 3 + start_period: 30s + +volumes: + pgdata: + files: + clamav_db: + clamav_socket: diff --git a/docker/pdf.Dockerfile b/docker/pdf.Dockerfile new file mode 100644 index 0000000..44ec98d --- /dev/null +++ b/docker/pdf.Dockerfile @@ -0,0 +1,39 @@ +# Image für den Dienst `pdf`. Playwright-Basis mit vorinstalliertem Chromium, +# rendert die Druckansicht der Akte. Nur im Stack-Netz erreichbar. + +ARG PLAYWRIGHT_VERSION=v1.62.1-noble +ARG PNPM_VERSION=9.15.4 + +FROM mcr.microsoft.com/playwright:${PLAYWRIGHT_VERSION} AS build +ARG PNPM_VERSION +RUN npm install -g pnpm@${PNPM_VERSION} +WORKDIR /repo + +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml .npmrc tsconfig.base.json ./ +COPY packages/db/package.json packages/db/ +COPY web/package.json web/ +COPY worker/package.json worker/ +COPY pdf/package.json pdf/ + +RUN pnpm install --frozen-lockfile --filter @afis/pdf... + +COPY pdf/ pdf/ +RUN pnpm --filter @afis/pdf build && \ + pnpm --filter @afis/pdf --prod --no-optional deploy /bundle && \ + cp -r pdf/dist /bundle/dist + +FROM mcr.microsoft.com/playwright:${PLAYWRIGHT_VERSION} AS runtime +WORKDIR /app + +ENV NODE_ENV=production \ + PORT=3000 + +COPY --from=build --chown=pwuser:pwuser /bundle ./ + +USER pwuser +EXPOSE 3000 + +HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \ + CMD wget -qO- http://localhost:3000/health || exit 1 + +CMD ["node", "dist/index.js"] diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile new file mode 100644 index 0000000..cf076d4 --- /dev/null +++ b/docker/web.Dockerfile @@ -0,0 +1,81 @@ +# Image für den Dienst `web`. Standalone-Build, siehe web/next.config.ts. +# +# Die Migration läuft im Startkommando des Containers, nicht hier und nicht in +# einem Init-Container: Dockge zeigt beendete Container nicht dauerhaft an. +# Begründung in docs/11-betrieb.md. +# +# Dafür liegt eine eigenständige Prisma-CLI unter /app/migrator im Image. Sie +# beim Start aus dem Netz zu ziehen wäre der falsche Weg: der Container startet +# dann nur, wenn die Registry gerade erreichbar ist. + +ARG NODE_VERSION=22-alpine +ARG PNPM_VERSION=9.15.4 +ARG PRISMA_VERSION=7.10.0 + +# --- Abhängigkeiten ----------------------------------------------------------- +FROM node:${NODE_VERSION} AS deps +ARG PNPM_VERSION +RUN npm install -g pnpm@${PNPM_VERSION} +WORKDIR /repo + +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml .npmrc ./ +COPY packages/db/package.json packages/db/ +COPY web/package.json web/ +COPY worker/package.json worker/ +COPY pdf/package.json pdf/ + +RUN pnpm install --frozen-lockfile --filter @afis/web... --filter @afis/db... + +# --- Bauen -------------------------------------------------------------------- +FROM node:${NODE_VERSION} AS build +ARG PRISMA_VERSION +ARG PNPM_VERSION +RUN npm install -g pnpm@${PNPM_VERSION} +WORKDIR /repo + +COPY --from=deps /repo/node_modules ./node_modules +COPY --from=deps /repo/packages/db/node_modules ./packages/db/node_modules +COPY --from=deps /repo/web/node_modules ./web/node_modules +COPY . . + +# Prisma-Client erzeugen und @afis/db nach dist übersetzen. +RUN pnpm --filter @afis/db build + +ENV NEXT_TELEMETRY_DISABLED=1 +RUN pnpm --filter @afis/web build + +# Eigenständige Migrations-CLI mit heruntergeladener Schema-Engine. +RUN mkdir -p /migrator && cd /migrator && \ + npm install --no-package-lock --no-audit --no-fund "prisma@${PRISMA_VERSION}" && \ + cp -r /repo/packages/db/prisma /migrator/prisma && \ + cp /repo/packages/db/prisma.config.ts /migrator/prisma.config.ts + +# --- Laufzeit ----------------------------------------------------------------- +FROM node:${NODE_VERSION} AS runtime +WORKDIR /app + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 + +RUN apk add --no-cache wget openssl && \ + addgroup -g 1001 -S afis && \ + adduser -u 1001 -S afis -G afis + +# Standalone-Ausgabe: server.js plus die tatsächlich benutzten node_modules. +COPY --from=build --chown=afis:afis /repo/web/.next/standalone/ ./ +COPY --from=build --chown=afis:afis /repo/web/.next/static ./web/.next/static +COPY --from=build --chown=afis:afis /repo/web/public ./web/public + +# Migrations-CLI samt Schema, Migrationsordner und Engine. +COPY --from=build --chown=afis:afis /migrator ./migrator + +USER afis +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD wget -qO- http://localhost:3000/api/health || exit 1 + +# Das Startkommando setzt der Stack (Migration + Server), siehe compose.yaml. +CMD ["node", "web/server.js"] diff --git a/docker/worker.Dockerfile b/docker/worker.Dockerfile new file mode 100644 index 0000000..d9000f2 --- /dev/null +++ b/docker/worker.Dockerfile @@ -0,0 +1,51 @@ +# Image für den Dienst `worker`. Hängt am Discord-Gateway und hält die +# Guild-Mitgliedschaft in Postgres aktuell, siehe docs/02-architektur.md. + +ARG NODE_VERSION=22-alpine +ARG PNPM_VERSION=9.15.4 + +FROM node:${NODE_VERSION} AS deps +ARG PNPM_VERSION +RUN npm install -g pnpm@${PNPM_VERSION} +WORKDIR /repo + +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml .npmrc ./ +COPY packages/db/package.json packages/db/ +COPY web/package.json web/ +COPY worker/package.json worker/ +COPY pdf/package.json pdf/ + +RUN pnpm install --frozen-lockfile --filter @afis/worker... --filter @afis/db... + +FROM node:${NODE_VERSION} AS build +ARG PNPM_VERSION +RUN npm install -g pnpm@${PNPM_VERSION} +WORKDIR /repo + +COPY --from=deps /repo/node_modules ./node_modules +COPY --from=deps /repo/packages/db/node_modules ./packages/db/node_modules +COPY --from=deps /repo/worker/node_modules ./worker/node_modules +COPY . . + +RUN pnpm --filter @afis/db build && pnpm --filter @afis/worker build && \ + pnpm --filter @afis/worker --prod --no-optional deploy /bundle && \ + cp -r worker/dist /bundle/dist && \ + cp worker/healthcheck.js /bundle/healthcheck.js + +FROM node:${NODE_VERSION} AS runtime +WORKDIR /app + +ENV NODE_ENV=production + +RUN apk add --no-cache openssl && \ + addgroup -g 1001 -S afis && \ + adduser -u 1001 -S afis -G afis + +COPY --from=build --chown=afis:afis /bundle ./ + +USER afis + +HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \ + CMD node healthcheck.js || exit 1 + +CMD ["node", "dist/index.js"] diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs new file mode 100644 index 0000000..6dac165 --- /dev/null +++ b/scripts/check-bundle-budget.mjs @@ -0,0 +1,57 @@ +import { readFileSync, existsSync } from 'node:fs' +import path from 'node:path' +import { gzipSync } from 'node:zlib' + +/** + * Bündel-Budget aus docs/09-performance-und-ux.md: die erste Route muss unter + * 120 kB JavaScript nach Kompression bleiben. + * + * Gemessen wird je Route die Vereinigung ihrer JavaScript-Chunks aus + * app-build-manifest.json, gzip-komprimiert. Das entspricht dem, was Next + * selbst als "First Load JS" ausweist. Cloudflare liefert Brotli aus, das ist + * noch etwas kleiner, gzip ist damit die vorsichtigere Schätzung. + */ +const BUDGET_BYTES = 120 * 1024 + +const appDir = process.cwd() +const manifestPath = path.join(appDir, '.next', 'app-build-manifest.json') + +if (!existsSync(manifestPath)) { + console.error('Kein Build gefunden. Zuerst `pnpm --filter @afis/web run build` laufen lassen.') + process.exit(1) +} + +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) +const routes = Object.entries(manifest.pages ?? {}) + +if (routes.length === 0) { + console.error('Keine Routen im Manifest gefunden.') + process.exit(1) +} + +const measured = routes + .map(([route, files]) => { + let bytes = 0 + for (const file of new Set(files)) { + if (!file.endsWith('.js')) continue + const full = path.join(appDir, '.next', file) + if (!existsSync(full)) continue + bytes += gzipSync(readFileSync(full)).byteLength + } + return { route, bytes } + }) + .sort((a, b) => b.bytes - a.bytes) + +const kb = (n) => `${(n / 1024).toFixed(1)} kB` + +for (const { route, bytes } of measured) { + console.log(`${bytes > BUDGET_BYTES ? '✗' : '✓'} ${route.padEnd(28)} ${kb(bytes)}`) +} + +const worst = measured[0] +console.log(`\nGrößte Route: ${worst.route} mit ${kb(worst.bytes)}, Budget ${kb(BUDGET_BYTES)}`) + +if (worst.bytes > BUDGET_BYTES) { + console.error(`Budget überschritten um ${kb(worst.bytes - BUDGET_BYTES)}.`) + process.exit(1) +}