diff --git a/CLAUDE.md b/CLAUDE.md index 52f46e5..1df8773 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,11 @@ Vor dem Bau eines Bereichs immer das passende Dokument lesen: - **Code, Datenbank, API**: Englisch. `Case`, `CaseEntry`, `PersonnelFile`, `ClearanceLevel`. - **Oberfläche und Dokumentation**: Deutsch. Alle UI-Texte zentral in `lib/i18n/de.ts`, nie hart im JSX. Das Glossar in `docs/01-produkt.md` legt die Übersetzungen fest. +- **Fachbegriffe der Domäne bleiben englisch**, so wie sie auf dem Discord heißen: + Rollennamen, Dienstgrade und die Bezeichnungen der Freigabestufen (`Sensitive Access`, + nicht `Geheim`). Die Oberfläche ist deutsch, die Begriffe darin sind es nicht. Nicht + eindeutschen. Diese Namen sind Daten, keine Oberflächentexte: sie stehen in der + Datenbank und sind im Adminbereich änderbar, nicht in `lib/i18n/de.ts`. ## Kommandos diff --git a/docs/01-produkt.md b/docs/01-produkt.md index 03e7eaa..804c052 100644 --- a/docs/01-produkt.md +++ b/docs/01-produkt.md @@ -46,17 +46,21 @@ Verbindliche Zuordnung zwischen Fachbegriff und Code-Bezeichner. ## Freigabestufen -| Stufe | Bezeichnung | Existenz für niedrigere Stufen | -|---|---|---| -| 0 | Offen | sichtbar | -| 1 | Intern | sichtbar | -| 2 | Vertraulich | sichtbar, Inhalt gesperrt | -| 3 | Geheim | unsichtbar | -| 4 | Streng geheim | unsichtbar | -| 5 | Leitungssache | unsichtbar | +Die Bezeichnungen entsprechen den Zugangsrollen auf dem Discord-Server und bleiben deshalb +englisch. Stufe 0 hat keine eigene Zugangsrolle, sie gilt für jedes AfiS-Mitglied ohne eine +solche Rolle. -Die Spalte ganz rechts ist konfigurierbar (`ClearanceLevelConfig.hiddenBelow`), die Werte -oben sind der Auslieferungszustand. +| Stufe | Bezeichnung | Zugangsrolle | Existenz für niedrigere Stufen | +|---|---|---|---| +| 0 | Unclassified | — | sichtbar | +| 1 | Restricted Access | Restricted Access | sichtbar | +| 2 | Confidential Access | Confidential Access | sichtbar, Inhalt gesperrt | +| 3 | Sensitive Access | Sensitive Access | unsichtbar | +| 4 | Critical Operations | Critical Operations | unsichtbar | +| 5 | Executive Oversight | Executive Oversight | unsichtbar | + +Die Spalte ganz rechts ist konfigurierbar (`ClearanceLevelConfig.hiddenBelow`), ebenso die +Bezeichnung. Die Werte oben sind der Auslieferungszustand, den `pnpm db:seed` herstellt. ## Nicht im Umfang diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md index 810220d..d1a3086 100644 --- a/docs/13-entscheidungen.md +++ b/docs/13-entscheidungen.md @@ -55,6 +55,53 @@ nicht über die Volltextsuche. **Phase 7 muss das auflösen**, sinnvollerweise Vektorspalten je Stufe. Diese Entscheidung ist bewusst konservativ getroffen, weil ein Leck hier schwerer zu bemerken ist als eine Lücke. +## Rollen auf dem Discord + +Die Dokumente kennen genau eine Sorte Rolle: `RoleMapping` trägt Stufe **und** Rechte, die +Auflösung nimmt das Maximum der Stufen und die Vereinigung der Rechte. Auf dem Server gibt es +aber drei Sorten mit verschiedener Bedeutung: + +| Sorte | Beispiele | Wirkung | +|---|---|---| +| Zugehörigkeit | `AfiS`, `Personalabteilung` | Türsteher. Ohne eine davon kein Login | +| Rang | `Director of internal Security` … `Probationary Agent` | Bestimmt die **Rechte** | +| Zugang | `Executive Oversight` … `Restricted Access` | Bestimmt die **Freigabestufe** | + +Das bestehende Modell trägt das fast: Zugangsrollen bekommen eine Stufe und keine Rechte, +Rangrollen Rechte und Stufe 0, Maximum und Vereinigung ergeben das Richtige. Was fehlt, ist +der Türsteher. `RoleMapping` bekommt deshalb in Phase 1 zwei Felder: + +- `grantsMembership` — diese Rolle berechtigt zur Anmeldung +- `category` — nur für die Gruppierung im Adminbereich, damit dort drei Abschnitte stehen + statt einer Liste aus achtzehn Rollen + +Anmeldeprüfung damit: Mitglied der Guild **und** (eine Rolle mit `grantsMembership` **oder** +in `BOOTSTRAP_ADMIN_IDS`). Notfall-Admins umgehen die AfiS-Rolle, müssen aber Mitglied der +Guild bleiben — sonst bliebe die Hintertür auch nach einem Rauswurf offen. + +Rangrollen bekommen im Auslieferungszustand **keine** Rechte. Das Rollen-Mapping entsteht +vollständig im Adminbereich, nichts wird nach Rollennamen geraten. + +### Umzug vom Test- auf den echten Server + +Entwickelt wird gegen einen Test-Discord mit nachgebauten Rollen. Rollen-IDs sind pro Server +eindeutig, beim Umzug ist das gesamte `RoleMapping` deshalb wertlos. Das ist kein Fehler, +sondern eine Anforderung an Phase 1: die Adminseite muss ein neu erkanntes `DISCORD_GUILD_ID` +verkraften und beim Übernehmen anbieten, die alte Zuordnung **anhand der Rollennamen** +vorzuschlagen. `RoleMapping.label` wird dafür ohnehin aus Discord gespiegelt. + +## Sprache der Fachbegriffe + +`CLAUDE.md` verlangt eine deutsche Oberfläche. Die Begriffe der Domäne heißen auf dem Discord +aber englisch, und genau so werden sie im Rollenspiel gesprochen. Sie bleiben deshalb +englisch: Rollennamen, Dienstgrade und die Bezeichnungen der Freigabestufen. + +Aus `docs/01-produkt.md` wird damit `Sensitive Access` statt `Geheim`. Die Bezeichnungen sind +Daten in `ClearanceLevelConfig`, nicht Oberflächentexte in `lib/i18n/de.ts` — sie sind im +Adminbereich änderbar, und ein Text, den der Leiter ändern kann, gehört nicht in eine +Übersetzungsdatei. `lib/i18n/de.ts` behält nur das Gerüst („Freigabestufe", „Geschwärzt, +ab Stufe 4 sichtbar"). + ## Prisma 7 Die Dokumente nennen nur „Prisma + PostgreSQL 16". Eingesetzt wird Prisma 7, die diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index 50836a9..ea1f476 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -11,17 +11,21 @@ import { createPrismaClient } from '../src/index.js' const db = createPrismaClient() /** - * Auslieferungszustand nach docs/01-produkt.md. Ab Stufe 3 ist die Existenz - * einer Akte für niedrigere Stufen verborgen, darunter nur der Inhalt gesperrt. - * Der Leiter kann das im Adminbereich ändern. + * Auslieferungszustand. Ab Stufe 3 ist die Existenz einer Akte für niedrigere + * Stufen verborgen, darunter ist nur der Inhalt gesperrt. Der Leiter kann das + * im Adminbereich ändern. + * + * Die Namen entsprechen den Zugangsrollen auf dem Discord und bleiben deshalb + * englisch, siehe docs/13-entscheidungen.md. Stufe 0 hat keine eigene + * Zugangsrolle: sie gilt für jedes AfiS-Mitglied ohne eine solche Rolle. */ const CLEARANCE_LEVELS = [ - { level: 0, name: 'Offen', hiddenBelow: false, colorToken: 'stamp-0' }, - { level: 1, name: 'Intern', hiddenBelow: false, colorToken: 'stamp-1' }, - { level: 2, name: 'Vertraulich', hiddenBelow: false, colorToken: 'stamp-2' }, - { level: 3, name: 'Geheim', hiddenBelow: true, colorToken: 'stamp-3' }, - { level: 4, name: 'Streng geheim', hiddenBelow: true, colorToken: 'stamp-4' }, - { level: 5, name: 'Leitungssache', hiddenBelow: true, colorToken: 'stamp-5' }, + { level: 0, name: 'Unclassified', hiddenBelow: false, colorToken: 'stamp-0' }, + { level: 1, name: 'Restricted Access', hiddenBelow: false, colorToken: 'stamp-1' }, + { level: 2, name: 'Confidential Access', hiddenBelow: false, colorToken: 'stamp-2' }, + { level: 3, name: 'Sensitive Access', hiddenBelow: true, colorToken: 'stamp-3' }, + { level: 4, name: 'Critical Operations', hiddenBelow: true, colorToken: 'stamp-4' }, + { level: 5, name: 'Executive Oversight', hiddenBelow: true, colorToken: 'stamp-5' }, ] async function main(): Promise { diff --git a/packages/db/tests/auslieferungszustand.test.ts b/packages/db/tests/auslieferungszustand.test.ts new file mode 100644 index 0000000..9738c57 --- /dev/null +++ b/packages/db/tests/auslieferungszustand.test.ts @@ -0,0 +1,52 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createPrismaClient, type PrismaClient } from '../src/index.js' + +/** + * Prüft den Zustand, den `pnpm db:seed` herstellt. Das ist kein Testinhalt, + * sondern Konfiguration, ohne die die Anwendung nicht sinnvoll läuft. + */ +const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL +const suite = url ? describe : describe.skip + +let db: PrismaClient + +suite('Auslieferungszustand', () => { + beforeAll(() => { + db = createPrismaClient(url) + }) + + afterAll(async () => { + await db.$disconnect() + }) + + it('kennt sechs Freigabestufen, benannt nach den Zugangsrollen', async () => { + const levels = await db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }) + expect(levels.map((l) => l.level)).toEqual([0, 1, 2, 3, 4, 5]) + expect(levels.map((l) => l.name)).toEqual([ + 'Unclassified', + 'Restricted Access', + 'Confidential Access', + 'Sensitive Access', + 'Critical Operations', + 'Executive Oversight', + ]) + }) + + it('verbirgt die Existenz ab Stufe 3, darunter nicht', async () => { + const levels = await db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }) + expect(levels.map((l) => l.hiddenBelow)).toEqual([false, false, false, true, true, true]) + }) + + it('liefert die Änderungsrichtlinie mit Karenzzeit und ohne Markierung aus', async () => { + const setting = await db.appSetting.findUnique({ where: { id: 1 } }) + expect(setting?.entryEditPolicy).toBe('GRACE_WINDOW') + expect(setting?.entryEditGraceMinutes).toBe(15) + expect(setting?.entryEditMarkGrace).toBe(false) + }) + + it('ordnet im Auslieferungszustand keiner Rolle Rechte zu', async () => { + // entry.edit_own, entry.edit_any und entry.retract schaltet der Leiter + // bewusst frei. Siehe docs/03-auth-und-rechte.md. + expect(await db.roleMapping.count()).toBe(0) + }) +}) diff --git a/web/components/StampMark.tsx b/web/components/StampMark.tsx index 3fc76f9..8ec4161 100644 --- a/web/components/StampMark.tsx +++ b/web/components/StampMark.tsx @@ -1,5 +1,3 @@ -import { de } from '@/lib/i18n/de' - const STAMP_COLOR: Record = { 0: 'var(--color-stamp-0)', 1: 'var(--color-stamp-1)', @@ -17,8 +15,11 @@ export function stampColor(level: number): string { * Stempelmarke oben rechts am Objekt. Der Stempelcharakter kommt aus dem * schmalen Schnitt, der Rahmenlinie und der Farbe, nicht aus Grossbuchstaben. * Die Stufe steht zusaetzlich als Text, nicht nur als Farbe. + * + * `name` kommt aus ClearanceLevelConfig und ist im Adminbereich aenderbar, + * deshalb wird er hereingereicht statt aus lib/i18n/de.ts gelesen. */ -export function StampMark({ level }: { level: number }) { +export function StampMark({ level, name }: { level: number; name: string }) { return ( {level} - {de.clearance.names[level] ?? de.common.unknown} + {name} ) } diff --git a/web/lib/i18n/de.ts b/web/lib/i18n/de.ts index 3804fed..d0c864e 100644 --- a/web/lib/i18n/de.ts +++ b/web/lib/i18n/de.ts @@ -47,14 +47,9 @@ export const de = { 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, + // Die Bezeichnungen der Stufen stehen nicht hier, sondern in + // ClearanceLevelConfig: sie sind im Adminbereich änderbar und folgen den + // Zugangsrollen des Discord-Servers. Siehe docs/13-entscheidungen.md. locked: 'Inhalt gesperrt', lockedHint: (n: number) => `Sichtbar ab Stufe ${n}`, redacted: 'Geschwärzt', diff --git a/web/tests/i18n.test.ts b/web/tests/i18n.test.ts index 0283390..439ea87 100644 --- a/web/tests/i18n.test.ts +++ b/web/tests/i18n.test.ts @@ -4,11 +4,10 @@ import { join } from 'node:path' import { de } from '@/lib/i18n/de' describe('Sprachkonvention', () => { - it('kennt alle sechs Freigabestufen', () => { - for (let level = 0; level <= 5; level++) { - expect(de.clearance.names[level]).toBeTypeOf('string') - } - expect(de.clearance.names[3]).toBe('Geheim') + it('haelt die Stufenbezeichnungen aus den Texten heraus', () => { + // Sie stehen in ClearanceLevelConfig, sind im Adminbereich aenderbar und + // folgen den Zugangsrollen des Discord-Servers. + expect(de.clearance).not.toHaveProperty('names') }) it('verzichtet auf Ausrufezeichen und Entschuldigungen', () => {