Rollenstruktur und englische Fachbegriffe festgelegt

Der Discord-Server hat drei Sorten Rollen, die Dokumente kannten nur eine:
Zugehörigkeit (AfiS, Personalabteilung) als Türsteher, Rangrollen für die
Rechte, Zugangsrollen für die Freigabestufe. Das bestehende RoleMapping trägt
das fast, es fehlt nur der Türsteher. Die beiden Felder dafür kommen mit
Phase 1, die Struktur ist in docs/13-entscheidungen.md festgehalten.

Die Freigabestufen heißen jetzt wie die Zugangsrollen: Unclassified,
Restricted Access, Confidential Access, Sensitive Access, Critical Operations,
Executive Oversight. Fachbegriffe der Domäne bleiben englisch, auch in einer
deutschen Oberfläche.

Die Bezeichnungen sind damit Daten in ClearanceLevelConfig, nicht mehr
Oberflächentext in lib/i18n/de.ts: sie sind im Adminbereich änderbar, und was
der Leiter ändern kann, gehört nicht in eine Übersetzungsdatei. StampMark
bekommt den Namen hereingereicht.

Vier neue Tests prüfen den Zustand, den der Seed herstellt, gegen die
Datenbank statt gegen eine Konstante im Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-01 18:50:07 +02:00
co-authored by Claude Opus 5
parent dfc47f91c4
commit dbca57cedf
8 changed files with 143 additions and 36 deletions
+5
View File
@@ -65,6 +65,11 @@ Vor dem Bau eines Bereichs immer das passende Dokument lesen:
- **Code, Datenbank, API**: Englisch. `Case`, `CaseEntry`, `PersonnelFile`, `ClearanceLevel`. - **Code, Datenbank, API**: Englisch. `Case`, `CaseEntry`, `PersonnelFile`, `ClearanceLevel`.
- **Oberfläche und Dokumentation**: Deutsch. Alle UI-Texte zentral in `lib/i18n/de.ts`, - **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. 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 ## Kommandos
+14 -10
View File
@@ -46,17 +46,21 @@ Verbindliche Zuordnung zwischen Fachbegriff und Code-Bezeichner.
## Freigabestufen ## Freigabestufen
| Stufe | Bezeichnung | Existenz für niedrigere Stufen | 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
| 0 | Offen | sichtbar | solche Rolle.
| 1 | Intern | sichtbar |
| 2 | Vertraulich | sichtbar, Inhalt gesperrt |
| 3 | Geheim | unsichtbar |
| 4 | Streng geheim | unsichtbar |
| 5 | Leitungssache | unsichtbar |
Die Spalte ganz rechts ist konfigurierbar (`ClearanceLevelConfig.hiddenBelow`), die Werte | Stufe | Bezeichnung | Zugangsrolle | Existenz für niedrigere Stufen |
oben sind der Auslieferungszustand. |---|---|---|---|
| 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 ## Nicht im Umfang
+47
View File
@@ -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 Vektorspalten je Stufe. Diese Entscheidung ist bewusst konservativ getroffen, weil ein Leck
hier schwerer zu bemerken ist als eine Lücke. 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 ## Prisma 7
Die Dokumente nennen nur „Prisma + PostgreSQL 16". Eingesetzt wird Prisma 7, die Die Dokumente nennen nur „Prisma + PostgreSQL 16". Eingesetzt wird Prisma 7, die
+13 -9
View File
@@ -11,17 +11,21 @@ import { createPrismaClient } from '../src/index.js'
const db = createPrismaClient() const db = createPrismaClient()
/** /**
* Auslieferungszustand nach docs/01-produkt.md. Ab Stufe 3 ist die Existenz * Auslieferungszustand. Ab Stufe 3 ist die Existenz einer Akte für niedrigere
* einer Akte für niedrigere Stufen verborgen, darunter nur der Inhalt gesperrt. * Stufen verborgen, darunter ist nur der Inhalt gesperrt. Der Leiter kann das
* Der Leiter kann das im Adminbereich ändern. * 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 = [ const CLEARANCE_LEVELS = [
{ level: 0, name: 'Offen', hiddenBelow: false, colorToken: 'stamp-0' }, { level: 0, name: 'Unclassified', hiddenBelow: false, colorToken: 'stamp-0' },
{ level: 1, name: 'Intern', hiddenBelow: false, colorToken: 'stamp-1' }, { level: 1, name: 'Restricted Access', hiddenBelow: false, colorToken: 'stamp-1' },
{ level: 2, name: 'Vertraulich', hiddenBelow: false, colorToken: 'stamp-2' }, { level: 2, name: 'Confidential Access', hiddenBelow: false, colorToken: 'stamp-2' },
{ level: 3, name: 'Geheim', hiddenBelow: true, colorToken: 'stamp-3' }, { level: 3, name: 'Sensitive Access', hiddenBelow: true, colorToken: 'stamp-3' },
{ level: 4, name: 'Streng geheim', hiddenBelow: true, colorToken: 'stamp-4' }, { level: 4, name: 'Critical Operations', hiddenBelow: true, colorToken: 'stamp-4' },
{ level: 5, name: 'Leitungssache', hiddenBelow: true, colorToken: 'stamp-5' }, { level: 5, name: 'Executive Oversight', hiddenBelow: true, colorToken: 'stamp-5' },
] ]
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -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)
})
})
+5 -4
View File
@@ -1,5 +1,3 @@
import { de } from '@/lib/i18n/de'
const STAMP_COLOR: Record<number, string> = { const STAMP_COLOR: Record<number, string> = {
0: 'var(--color-stamp-0)', 0: 'var(--color-stamp-0)',
1: 'var(--color-stamp-1)', 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 * Stempelmarke oben rechts am Objekt. Der Stempelcharakter kommt aus dem
* schmalen Schnitt, der Rahmenlinie und der Farbe, nicht aus Grossbuchstaben. * schmalen Schnitt, der Rahmenlinie und der Farbe, nicht aus Grossbuchstaben.
* Die Stufe steht zusaetzlich als Text, nicht nur als Farbe. * 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 ( return (
<span <span
className="inline-flex items-center gap-1.5 border px-1.5 py-0.5 font-condensed text-2xs" className="inline-flex items-center gap-1.5 border px-1.5 py-0.5 font-condensed text-2xs"
@@ -27,7 +28,7 @@ export function StampMark({ level }: { level: number }) {
<span aria-hidden className="font-mono"> <span aria-hidden className="font-mono">
{level} {level}
</span> </span>
{de.clearance.names[level] ?? de.common.unknown} {name}
</span> </span>
) )
} }
+3 -8
View File
@@ -47,14 +47,9 @@ export const de = {
label: 'Freigabestufe', label: 'Freigabestufe',
classification: 'Klassifizierung', classification: 'Klassifizierung',
level: (n: number) => `Stufe ${n}`, level: (n: number) => `Stufe ${n}`,
names: { // Die Bezeichnungen der Stufen stehen nicht hier, sondern in
0: 'Offen', // ClearanceLevelConfig: sie sind im Adminbereich änderbar und folgen den
1: 'Intern', // Zugangsrollen des Discord-Servers. Siehe docs/13-entscheidungen.md.
2: 'Vertraulich',
3: 'Geheim',
4: 'Streng geheim',
5: 'Leitungssache',
} as Record<number, string>,
locked: 'Inhalt gesperrt', locked: 'Inhalt gesperrt',
lockedHint: (n: number) => `Sichtbar ab Stufe ${n}`, lockedHint: (n: number) => `Sichtbar ab Stufe ${n}`,
redacted: 'Geschwärzt', redacted: 'Geschwärzt',
+4 -5
View File
@@ -4,11 +4,10 @@ import { join } from 'node:path'
import { de } from '@/lib/i18n/de' import { de } from '@/lib/i18n/de'
describe('Sprachkonvention', () => { describe('Sprachkonvention', () => {
it('kennt alle sechs Freigabestufen', () => { it('haelt die Stufenbezeichnungen aus den Texten heraus', () => {
for (let level = 0; level <= 5; level++) { // Sie stehen in ClearanceLevelConfig, sind im Adminbereich aenderbar und
expect(de.clearance.names[level]).toBeTypeOf('string') // folgen den Zugangsrollen des Discord-Servers.
} expect(de.clearance).not.toHaveProperty('names')
expect(de.clearance.names[3]).toBe('Geheim')
}) })
it('verzichtet auf Ausrufezeichen und Entschuldigungen', () => { it('verzichtet auf Ausrufezeichen und Entschuldigungen', () => {