Web-Anwendung: Design-Tokens, Texte, Gesundheitsendpunkt
Design-Tokens aus docs/10-design-system.md als CSS-Variablen: Grundpalette, die sechs Klassifizierungsfarben, Eckenradius durchgehend 2 px, keine Schatten ausser dem Fokusring. Der Schwärzungsbalken mit dem einmaligen Durchwischen respektiert prefers-reduced-motion. Vier Tests halten die Tokens gegen das Dokument. IBM Plex selbst gehostet als woff2, latin und latin-ext getrennt über unicode-range, font-display: swap, immutable-Cache. Alle Oberflächentexte in lib/i18n/de.ts. Ein Test durchsucht app/ und components/ nach Umlauten im JSX und schlägt fehl, wenn dort deutscher Text steht. Er hat beim ersten Lauf gleich einen Verstoss gefunden. /api/health prüft Datenbank, Discord-Gateway und ClamAV. Die Gesamtbewertung fällt nur auf "down", wenn die Datenbank weg ist: die anderen Dienste haben eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll nicht den Web-Container als tot markieren. Strikte CSP mit Nonce, kein unsafe-inline für Skripte. Die erste Route liegt bei 100 kB JavaScript nach Kompression, Budget sind 120 kB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
51984f17d4
commit
6b0cd331bb
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Rechte-Enum aus docs/03-auth-und-rechte.md. RoleMapping.permissions ist in der
|
||||
* Datenbank ein String[], diese Liste ist die verbindliche Menge gültiger Werte
|
||||
* und die Grundlage der Kästchen im Adminbereich.
|
||||
*/
|
||||
export const PERMISSIONS = [
|
||||
'case.read',
|
||||
'case.create',
|
||||
'case.edit',
|
||||
'case.close',
|
||||
'case.reopen',
|
||||
'case.delete',
|
||||
'case.edit_closed',
|
||||
'entry.create',
|
||||
'entry.correct',
|
||||
'entry.edit_own',
|
||||
'entry.edit_any',
|
||||
'entry.retract',
|
||||
'personnel.read',
|
||||
'personnel.edit',
|
||||
'template.manage',
|
||||
'roles.manage',
|
||||
'audit.read',
|
||||
'export.approve',
|
||||
'admin',
|
||||
] as const
|
||||
|
||||
export type Permission = (typeof PERMISSIONS)[number]
|
||||
|
||||
/**
|
||||
* Diese drei sind im Auslieferungszustand keiner Rolle zugeordnet. Der Leiter
|
||||
* schaltet sie bewusst frei. Der Adminbereich weist beim Zuweisen darauf hin.
|
||||
*/
|
||||
export const OPT_IN_PERMISSIONS: readonly Permission[] = [
|
||||
'entry.edit_own',
|
||||
'entry.edit_any',
|
||||
'entry.retract',
|
||||
]
|
||||
|
||||
export function isPermission(value: string): value is Permission {
|
||||
return (PERMISSIONS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** Aufgelöste Rechte eines Nutzers für genau einen Request. */
|
||||
export type EffectivePermissions = {
|
||||
userId: string
|
||||
/** Höchste Stufe aller Rollen. */
|
||||
clearanceLevel: number
|
||||
/** Vereinigung aller Rollen. */
|
||||
permissions: Set<Permission>
|
||||
/** Aus BOOTSTRAP_ADMIN_IDS oder Recht 'admin'. */
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export function can(p: EffectivePermissions, permission: Permission): boolean {
|
||||
return p.isAdmin || p.permissions.has(permission)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createPrismaClient, type PrismaClient } from '@afis/db'
|
||||
|
||||
/**
|
||||
* Ein Client pro Prozess. Im Dev-Server ueberlebt er den Hot Reload ueber
|
||||
* globalThis, sonst reisst jede Aenderung eine neue Verbindung auf.
|
||||
* Der Pool liegt im Betrieb hinter pgbouncer im Transaktionsmodus.
|
||||
*/
|
||||
const globalForPrisma = globalThis as unknown as { afisPrisma?: PrismaClient }
|
||||
|
||||
export const db: PrismaClient = globalForPrisma.afisPrisma ?? createPrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.afisPrisma = db
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Umgebungsvariablen werden beim Start einmal geprueft. Fehlt etwas, soll der
|
||||
* Container sofort und mit klarer Meldung sterben statt spaeter beim ersten
|
||||
* Request. Die Werte stehen im .env-Reiter von Dockge, Vorlage ist .env.example.
|
||||
*/
|
||||
const schema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
|
||||
DATABASE_URL: z.string().min(1),
|
||||
DIRECT_DATABASE_URL: z.string().min(1).optional(),
|
||||
|
||||
APP_URL: z.url(),
|
||||
AUTH_SECRET: z.string().min(32),
|
||||
|
||||
DISCORD_CLIENT_ID: z.string().min(1),
|
||||
DISCORD_CLIENT_SECRET: z.string().min(1),
|
||||
DISCORD_BOT_TOKEN: z.string().min(1),
|
||||
DISCORD_GUILD_ID: z.string().min(1),
|
||||
|
||||
/// Notfallzugang. Diese Discord-IDs haben immer Stufe 5 und alle Rechte,
|
||||
/// unabhaengig vom Rollen-Mapping. Siehe docs/03-auth-und-rechte.md.
|
||||
BOOTSTRAP_ADMIN_IDS: z
|
||||
.string()
|
||||
.default('')
|
||||
.transform((v) => v.split(',').map((s) => s.trim()).filter(Boolean)),
|
||||
|
||||
FILES_DIR: z.string().default('/data/files'),
|
||||
CLAMAV_SOCKET: z.string().default('/var/run/clamav/clamd.sock'),
|
||||
PDF_SERVICE_URL: z.url().optional(),
|
||||
|
||||
TZ: z.string().default('Europe/Berlin'),
|
||||
})
|
||||
|
||||
export type Env = z.infer<typeof schema>
|
||||
|
||||
let cached: Env | null = null
|
||||
|
||||
export function getEnv(): Env {
|
||||
if (cached) return cached
|
||||
const parsed = schema.safeParse(process.env)
|
||||
if (!parsed.success) {
|
||||
const missing = parsed.error.issues
|
||||
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
||||
.join('\n ')
|
||||
throw new Error(`Konfiguration unvollständig:\n ${missing}`)
|
||||
}
|
||||
cached = parsed.data
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Nur fuer Tests, damit eine geaenderte Umgebung neu gelesen wird. */
|
||||
export function resetEnvCache(): void {
|
||||
cached = null
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { pingClamav } from '@/lib/health/clamav'
|
||||
|
||||
export type ComponentState = 'up' | 'degraded' | 'down'
|
||||
|
||||
export type HealthReport = {
|
||||
status: ComponentState
|
||||
checkedAt: string
|
||||
components: Record<'database' | 'discordGateway' | 'clamav', {
|
||||
status: ComponentState
|
||||
detail?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** Der Worker gilt als abgehaengt, wenn sein Lebenszeichen aelter ist. */
|
||||
const HEARTBEAT_MAX_AGE_MS = 3 * 60_000
|
||||
|
||||
async function checkDatabase(): Promise<{ status: ComponentState; detail?: string }> {
|
||||
try {
|
||||
await db.$queryRaw`SELECT 1`
|
||||
return { status: 'up' }
|
||||
} catch (error) {
|
||||
return { status: 'down', detail: error instanceof Error ? error.message : 'unbekannt' }
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDiscordGateway(): Promise<{ status: ComponentState; detail?: string }> {
|
||||
try {
|
||||
const beat = await db.serviceHeartbeat.findUnique({ where: { service: 'worker' } })
|
||||
if (!beat) return { status: 'down', detail: 'kein Lebenszeichen' }
|
||||
|
||||
const ageMs = Date.now() - beat.at.getTime()
|
||||
if (ageMs > HEARTBEAT_MAX_AGE_MS) {
|
||||
return { status: 'down', detail: `Lebenszeichen ${Math.round(ageMs / 1000)} s alt` }
|
||||
}
|
||||
if (beat.status !== 'up') {
|
||||
return { status: beat.status === 'degraded' ? 'degraded' : 'down', detail: beat.detail ?? undefined }
|
||||
}
|
||||
return { status: 'up' }
|
||||
} catch {
|
||||
return { status: 'down', detail: 'nicht abfragbar' }
|
||||
}
|
||||
}
|
||||
|
||||
async function checkClamav(): Promise<{ status: ComponentState; detail?: string }> {
|
||||
const ok = await pingClamav(getEnv().CLAMAV_SOCKET)
|
||||
return ok ? { status: 'up' } : { status: 'down', detail: 'clamd antwortet nicht' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt den Zustand der drei Abhaengigkeiten. Die Gesamtbewertung faellt
|
||||
* bewusst nur auf `down`, wenn die Datenbank weg ist: Worker, clamav und pdf
|
||||
* haben eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll
|
||||
* nicht den ganzen Web-Container als tot markieren. Sichtbar bleibt der Ausfall
|
||||
* trotzdem, in dieser Antwort und in Dockge am jeweiligen Dienst.
|
||||
*/
|
||||
export async function collectHealth(): Promise<HealthReport> {
|
||||
const [database, discordGateway, clamav] = await Promise.all([
|
||||
checkDatabase(),
|
||||
checkDiscordGateway(),
|
||||
checkClamav(),
|
||||
])
|
||||
|
||||
const status: ComponentState =
|
||||
database.status === 'down'
|
||||
? 'down'
|
||||
: discordGateway.status === 'up' && clamav.status === 'up'
|
||||
? 'up'
|
||||
: 'degraded'
|
||||
|
||||
return {
|
||||
status,
|
||||
checkedAt: new Date().toISOString(),
|
||||
components: { database, discordGateway, clamav },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import net from 'node:net'
|
||||
|
||||
/**
|
||||
* Fragt clamd ueber den Unix-Socket. Der Socket liegt im geteilten Volume
|
||||
* clamav_socket, siehe compose.yaml. Antwort auf zPING ist "PONG".
|
||||
*/
|
||||
export function pingClamav(socketPath: string, timeoutMs = 2000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const done = (ok: boolean) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
socket.destroy()
|
||||
resolve(ok)
|
||||
}
|
||||
|
||||
const socket = net.createConnection(socketPath)
|
||||
socket.setTimeout(timeoutMs)
|
||||
socket.on('connect', () => socket.write('zPING\0'))
|
||||
socket.on('data', (chunk) => done(chunk.toString().includes('PONG')))
|
||||
socket.on('timeout', () => done(false))
|
||||
socket.on('error', () => done(false))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Alle Oberflaechentexte. Nie deutsche Strings hart ins JSX, siehe CLAUDE.md.
|
||||
* Die Begriffe folgen dem Glossar in docs/01-produkt.md.
|
||||
*
|
||||
* Schreibweise nach docs/10-design-system.md:
|
||||
* - Aktive Verben, die sagen, was passiert: "Vermerk speichern", nicht "Absenden"
|
||||
* - Dieselbe Bezeichnung durch den ganzen Ablauf
|
||||
* - Fehler nennen Ursache und naechsten Schritt, ohne Entschuldigung
|
||||
* - Leere Zustaende sind eine Aufforderung
|
||||
* - Keine Ausrufezeichen, kein "bitte", kein "erfolgreich"
|
||||
*/
|
||||
export const de = {
|
||||
app: {
|
||||
name: 'AfiS-Aktensystem',
|
||||
shortName: 'AfiS',
|
||||
description: 'Fallakten, Personalakten und Ermittlungen der Abteilung für innere Sicherheit',
|
||||
},
|
||||
|
||||
nav: {
|
||||
dashboard: 'Übersicht',
|
||||
cases: 'Fallakten',
|
||||
personnel: 'Personalakten',
|
||||
search: 'Suche',
|
||||
admin: 'Verwaltung',
|
||||
skipToContent: 'Zum Inhalt springen',
|
||||
},
|
||||
|
||||
auth: {
|
||||
signIn: 'Mit Discord anmelden',
|
||||
signOut: 'Abmelden',
|
||||
signedInAs: 'Angemeldet als',
|
||||
notInGuild: {
|
||||
title: 'Du bist nicht auf dem AfiS-Discord',
|
||||
body: 'Die Anmeldung setzt eine Mitgliedschaft auf dem Discord-Server voraus. Wende dich an die Abteilungsleitung.',
|
||||
},
|
||||
sessionEnded: {
|
||||
title: 'Sitzung beendet',
|
||||
body: 'Deine Rollen haben sich geändert. Melde dich neu an.',
|
||||
},
|
||||
error: {
|
||||
title: 'Anmeldung nicht möglich',
|
||||
body: 'Der Anmeldevorgang wurde abgebrochen. Erneut versuchen.',
|
||||
},
|
||||
},
|
||||
|
||||
clearance: {
|
||||
label: 'Freigabestufe',
|
||||
classification: 'Klassifizierung',
|
||||
level: (n: number) => `Stufe ${n}`,
|
||||
names: {
|
||||
0: 'Offen',
|
||||
1: 'Intern',
|
||||
2: 'Vertraulich',
|
||||
3: 'Geheim',
|
||||
4: 'Streng geheim',
|
||||
5: 'Leitungssache',
|
||||
} as Record<number, string>,
|
||||
locked: 'Inhalt gesperrt',
|
||||
lockedHint: (n: number) => `Sichtbar ab Stufe ${n}`,
|
||||
redacted: 'Geschwärzt',
|
||||
redactedHint: (n: number) => `Geschwärzt, ab Stufe ${n} sichtbar`,
|
||||
},
|
||||
|
||||
case: {
|
||||
one: 'Fallakte',
|
||||
many: 'Fallakten',
|
||||
number: 'Aktenzeichen',
|
||||
title: 'Titel',
|
||||
type: 'Fallart',
|
||||
status: 'Status',
|
||||
dueAt: 'Wiedervorlage',
|
||||
outcome: 'Ergebnis',
|
||||
createdBy: 'Angelegt von',
|
||||
timeline: 'Verlauf',
|
||||
sections: 'Abschnitte',
|
||||
chat: 'Aktenchat',
|
||||
changes: 'Änderungen',
|
||||
participants: 'Beteiligte',
|
||||
links: 'Verknüpfungen',
|
||||
attachments: 'Anhänge',
|
||||
empty: 'Noch keine Fallakte. Erste Fallakte anlegen.',
|
||||
statusNames: {
|
||||
DRAFT: 'Entwurf',
|
||||
OPEN: 'Offen',
|
||||
DORMANT: 'Ruhend',
|
||||
REVIEW: 'In Prüfung',
|
||||
CLOSED: 'Abgeschlossen',
|
||||
ARCHIVED: 'Archiviert',
|
||||
SEALED: 'Versiegelt',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
entry: {
|
||||
one: 'Vermerk',
|
||||
many: 'Vermerke',
|
||||
create: 'Vermerk speichern',
|
||||
empty: 'Noch kein Vermerk. Ersten Vermerk anlegen.',
|
||||
edited: (v: number) => `bearbeitet · v${v}`,
|
||||
history: 'Änderungsverlauf',
|
||||
changeReason: 'Begründung',
|
||||
retracted: (date: string, person: string, reason: string) =>
|
||||
`Eintrag zurückgezogen am ${date} durch ${person} · Begründung: ${reason}`,
|
||||
types: {
|
||||
NOTE: 'Vermerk',
|
||||
INTERROGATION: 'Befragung',
|
||||
EVIDENCE: 'Beweismittel',
|
||||
STATUS_CHANGE: 'Statuswechsel',
|
||||
CORRECTION: 'Korrektur',
|
||||
SYSTEM: 'System',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
personnel: {
|
||||
one: 'Personalakte',
|
||||
many: 'Personalakten',
|
||||
serviceNumber: 'Dienstnummer',
|
||||
rank: 'Dienstgrad',
|
||||
department: 'Abteilung',
|
||||
empty: 'Noch keine Personalakte. Erste Personalakte anlegen.',
|
||||
},
|
||||
|
||||
common: {
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
undo: 'Rückgängig',
|
||||
retry: 'Erneut versuchen',
|
||||
loading: 'Wird geladen',
|
||||
offline: 'Keine Verbindung, wird nachgesendet',
|
||||
unknown: 'Unbekannt',
|
||||
},
|
||||
|
||||
errors: {
|
||||
notFound: {
|
||||
title: 'Nicht gefunden',
|
||||
body: 'Die Seite gibt es nicht, oder du darfst sie nicht sehen.',
|
||||
},
|
||||
unexpected: {
|
||||
title: 'Nicht geladen',
|
||||
body: 'Der Abruf ist fehlgeschlagen. Erneut versuchen.',
|
||||
},
|
||||
conflict: 'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.',
|
||||
},
|
||||
|
||||
health: {
|
||||
heading: 'Systemzustand',
|
||||
service: 'Dienst',
|
||||
state: 'Zustand',
|
||||
checkedAt: (when: string) => `Zustand der Dienste, abgefragt ${when}`,
|
||||
ok: 'Betriebsbereit',
|
||||
degraded: 'Eingeschränkt',
|
||||
down: 'Gestört',
|
||||
components: {
|
||||
database: 'Datenbank',
|
||||
discordGateway: 'Discord-Gateway',
|
||||
clamav: 'Virenprüfung',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
} as const
|
||||
|
||||
export type Messages = typeof de
|
||||
Reference in New Issue
Block a user