diff --git a/packages/db/prisma/migrations/20260902120000_aktenzeichen_praefix/migration.sql b/packages/db/prisma/migrations/20260902120000_aktenzeichen_praefix/migration.sql new file mode 100644 index 0000000..c8afc88 --- /dev/null +++ b/packages/db/prisma/migrations/20260902120000_aktenzeichen_praefix/migration.sql @@ -0,0 +1,20 @@ +-- Aktenzeichen-Muster je Vorlage, siehe docs/13-entscheidungen.md. +-- +-- Die Abteilung führt gewachsene Präfixe wie "AKT-PA" mit fünfstelliger +-- Nummer. Das Muster gehört deshalb zur Vorlage, nicht ins Programm. + +ALTER TABLE "CaseTemplate" + ADD COLUMN "numberPrefix" TEXT NOT NULL DEFAULT 'AfiS', + ADD COLUMN "numberPadding" INTEGER NOT NULL DEFAULT 4; + +-- Der Nummernkreis läuft ab jetzt je Präfix und Jahr. Bestehende Zähler +-- gehören zum bisherigen Präfix "AfiS" und werden nicht zurückgesetzt: +-- eine erneut vergebene Nummer wäre ein doppeltes Aktenzeichen. +ALTER TABLE "CaseCounter" ADD COLUMN "prefix" TEXT NOT NULL DEFAULT 'AfiS'; + +ALTER TABLE "CaseCounter" DROP CONSTRAINT "CaseCounter_pkey"; +ALTER TABLE "CaseCounter" ADD CONSTRAINT "CaseCounter_pkey" PRIMARY KEY ("prefix", "year"); + +-- Der Vorgabewert hat nur die vorhandenen Zeilen umgestellt; neue Zeilen +-- nennen ihr Präfix ausdrücklich. +ALTER TABLE "CaseCounter" ALTER COLUMN "prefix" DROP DEFAULT; diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index 78cb1ae..d954f10 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -36,7 +36,13 @@ const CLEARANCE_LEVELS = [ * mit allen Feldtypen baut Phase 3, hier steht nur, was eine Akte zum Laufen * braucht. */ -const TEMPLATES = [ +const TEMPLATES: { + key: string + name: string + description: string + numberPrefix?: string + numberPadding?: number +}[] = [ { key: 'korruptionsverdacht', name: 'Korruptionsverdacht', @@ -96,18 +102,32 @@ async function main(): Promise { for (const template of TEMPLATES) { await db.caseTemplate.upsert({ where: { key_version: { key: template.key, version: 1 } }, - create: { ...template, version: 1, fields: TEMPLATE_FIELDS, isActive: true }, + create: { + ...template, + numberPrefix: template.numberPrefix ?? 'AfiS', + numberPadding: template.numberPadding ?? 4, + version: 1, + fields: TEMPLATE_FIELDS, + isActive: true, + }, + // Muster und Felder bleiben unangetastet: was der Leiter im + // Vorlagen-Editor eingestellt hat, überschreibt der Seed nicht. update: { name: template.name, description: template.description }, }) } console.log(`[seed] ${TEMPLATES.length} Startvorlagen`) + // Ein Nummernkreis je Präfix und Jahr. Angelegt wird er beim ersten Fall + // von selbst; hier steht er nur, damit ein frischer Bestand nachvollziehbar + // bei null beginnt. const year = new Date().getFullYear() - await db.caseCounter.upsert({ - where: { year }, - create: { year, lastNumber: 0 }, - update: {}, - }) + for (const prefix of [...new Set(TEMPLATES.map((t) => t.numberPrefix ?? 'AfiS'))]) { + await db.caseCounter.upsert({ + where: { prefix_year: { prefix, year } }, + create: { prefix, year, lastNumber: 0 }, + update: {}, + }) + } console.log(`[seed] Aktenzeichen-Zähler für ${year}`) // entry.edit_own, entry.edit_any und entry.retract sind bewusst keiner Rolle diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..3e060e7 --- /dev/null +++ b/start.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# +# Startet die Anwendung für die lokale Entwicklung: Datenbank, Worker, Web. +# +# Das hier ist ausdrücklich **nicht** der Betrieb. Im Betrieb läuft alles als +# ein Dockge-Stack, siehe docs/11-betrieb.md. Dieses Skript ist für den Fall, +# dass der Stack gerade nicht läuft und man am Code arbeiten will. +# +# ./start.sh Datenbank, Worker und Web starten +# ./start.sh --ohne-worker nur Datenbank und Web (kein Discord nötig) +# ./start.sh --nur-db nur die Datenbank hochfahren +# +# Beenden mit Strg+C, das nimmt alle Kindprozesse mit. + +set -euo pipefail +cd "$(dirname "$0")" + +DB_CONTAINER="afis-dev-db" +DB_PORT="${AFIS_DB_PORT:-5434}" +DB_IMAGE="postgres:16-alpine" + +MIT_WORKER=1 +NUR_DB=0 +for arg in "$@"; do + case "$arg" in + --ohne-worker) MIT_WORKER=0 ;; + --nur-db) NUR_DB=1 ;; + -h|--help) awk 'NR>1 && /^#/ { sub(/^# ?/, ""); print; next } NR>1 { exit }' "$0"; exit 0 ;; + *) echo "Unbekannte Option: $arg" >&2; exit 2 ;; + esac +done + +info() { printf '\033[36m▸\033[0m %s\n' "$*"; } +warn() { printf '\033[33m!\033[0m %s\n' "$*"; } +fehler() { printf '\033[31m✗\033[0m %s\n' "$*" >&2; exit 1; } + +# --- Node aus nvm holen, falls es nicht schon im PATH liegt ------------------- +if ! command -v node >/dev/null 2>&1; then + if [ -s "$HOME/.nvm/nvm.sh" ]; then + # shellcheck disable=SC1091 + . "$HOME/.nvm/nvm.sh" >/dev/null + nvm use --lts >/dev/null 2>&1 || nvm use node >/dev/null 2>&1 || true + fi +fi +command -v node >/dev/null 2>&1 || fehler "Node nicht gefunden. Node 22 installieren oder nvm laden." + +if ! command -v pnpm >/dev/null 2>&1; then + corepack enable >/dev/null 2>&1 || true +fi +command -v pnpm >/dev/null 2>&1 || fehler "pnpm nicht gefunden. 'corepack enable' ausführen." + +command -v docker >/dev/null 2>&1 || fehler "Docker nicht gefunden, die Datenbank läuft im Container." + +[ -f .env ] || fehler ".env fehlt. Von .env.example abschreiben und die Discord-Werte eintragen." + +# --- Datenbank --------------------------------------------------------------- +if [ -n "$(docker ps -q -f "name=^${DB_CONTAINER}$")" ]; then + info "Datenbank läuft bereits" +elif [ -n "$(docker ps -aq -f "name=^${DB_CONTAINER}$")" ]; then + info "Datenbank starten" + docker start "$DB_CONTAINER" >/dev/null +else + info "Datenbank anlegen (Port ${DB_PORT})" + docker run -d --name "$DB_CONTAINER" \ + -e POSTGRES_DB=afis -e POSTGRES_USER=afis -e POSTGRES_PASSWORD=afis \ + -p "${DB_PORT}:5432" "$DB_IMAGE" >/dev/null \ + || fehler "Container nicht gestartet. Läuft schon etwas auf Port ${DB_PORT}?" +fi + +printf ' warte auf Postgres' +for _ in $(seq 1 60); do + if docker exec "$DB_CONTAINER" pg_isready -U afis -d afis >/dev/null 2>&1; then + printf ' bereit\n' + break + fi + printf '.' + sleep 1 +done +docker exec "$DB_CONTAINER" pg_isready -U afis -d afis >/dev/null 2>&1 \ + || fehler "Postgres antwortet nicht." + +# --- Schema und Grundzustand ------------------------------------------------- +info "Prisma-Client erzeugen" +pnpm --filter @afis/db run build >/dev/null + +info "Migrationen anwenden" +pnpm db:deploy >/dev/null + +info "Grundzustand einspielen" +pnpm db:seed >/dev/null + +if [ "$NUR_DB" = "1" ]; then + info "Datenbank läuft auf localhost:${DB_PORT}. Beenden mit: docker stop ${DB_CONTAINER}" + exit 0 +fi + +# --- Kindprozesse ------------------------------------------------------------ +PIDS=() +aufraeumen() { + echo + info "Beenden" + for pid in "${PIDS[@]:-}"; do + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null || true + info "Die Datenbank läuft weiter. Anhalten mit: docker stop ${DB_CONTAINER}" +} +trap aufraeumen INT TERM EXIT + +if [ "$MIT_WORKER" = "1" ]; then + info "Worker starten (Discord-Rollenabgleich)" + pnpm worker:dev 2>&1 | sed 's/^/[worker] /' & + PIDS+=($!) +fi + +info "Web starten" +pnpm dev 2>&1 | sed 's/^/[web] /' & +PIDS+=($!) + +echo +info "Anwendung unter http://localhost:3000" +[ "$MIT_WORKER" = "1" ] || warn "Ohne Worker: Rollen werden nicht abgeglichen, /api/health meldet das Gateway als gestört" +echo + +wait diff --git a/web/lib/cases/caseNumber.ts b/web/lib/cases/caseNumber.ts index f4da9f9..a1c2158 100644 --- a/web/lib/cases/caseNumber.ts +++ b/web/lib/cases/caseNumber.ts @@ -1,40 +1,57 @@ import type { Prisma } from '@afis/db' -/** `AfiS-2026-0042`, siehe Glossar in docs/01-produkt.md. */ -export function formatCaseNumber(year: number, number: number): string { - return `AfiS-${year}-${String(number).padStart(4, '0')}` +/** + * Aktenzeichen. Das Muster gehört zur Vorlage, nicht ins Programm: die + * Abteilung führt gewachsene Präfixe wie `AKT-PA-2026-62843` neben dem + * `AfiS-2026-0042` aus dem Glossar. Siehe docs/13-entscheidungen.md. + */ +export type NumberPattern = { prefix: string; padding: number } + +export const DEFAULT_PATTERN: NumberPattern = { prefix: 'AfiS', padding: 4 } + +export function formatCaseNumber( + year: number, + number: number, + pattern: NumberPattern = DEFAULT_PATTERN, +): string { + return `${pattern.prefix}-${year}-${String(number).padStart(pattern.padding, '0')}` } -const PATTERN = /^AfiS-(\d{4})-(\d{4})$/ +/** + * Ein Präfix darf Bindestriche enthalten (`AKT-PA`). Zerlegt wird deshalb von + * hinten: die letzten beiden Abschnitte sind Jahr und laufende Nummer, alles + * davor ist das Präfix. + */ +const PATTERN = /^([A-Za-z][A-Za-z0-9-]*)-(\d{4})-(\d+)$/ -export function parseCaseNumber(value: string): { year: number; number: number } | null { +export function parseCaseNumber( + value: string, +): { prefix: string; year: number; number: number } | null { const match = PATTERN.exec(value.trim()) if (!match) return null - return { year: Number(match[1]), number: Number(match[2]) } + return { prefix: match[1]!, year: Number(match[2]), number: Number(match[3]) } } /** Erkennt Aktenzeichen im Fliesstext, für die Vorschaukarten im Chat. */ -export const CASE_NUMBER_GLOBAL = /AfiS-\d{4}-\d{4}/g +export const CASE_NUMBER_GLOBAL = /\b[A-Za-z][A-Za-z0-9-]*-\d{4}-\d{3,8}\b/g /** - * Vergibt das nächste Aktenzeichen des Jahres. + * Vergibt das nächste Aktenzeichen im Nummernkreis dieses Präfixes und Jahres. * - * Läuft mit `SELECT … FOR UPDATE` in **derselben** Transaktion wie das Anlegen - * des Falls. Zwei gleichzeitige Anlagen warten dadurch aufeinander, statt - * dieselbe Nummer zu ziehen oder eine Lücke zu hinterlassen. - * Siehe docs/04-datenmodell.md. - * - * Eine Sequenz pro Jahr wäre einfacher, müsste aber jedes Jahr von Hand - * angelegt werden — und der Abteilungsleiter ist ausdrücklich nicht technisch. + * `INSERT … ON CONFLICT DO UPDATE` sperrt die Zeile für die Dauer der + * Transaktion. Zwei gleichzeitige Anlagen warten dadurch aufeinander, statt + * dieselbe Nummer zu ziehen. Läuft in **derselben** Transaktion wie das + * Anlegen des Falls, siehe docs/04-datenmodell.md. */ export async function nextCaseNumber( tx: Prisma.TransactionClient, + pattern: NumberPattern = DEFAULT_PATTERN, year = new Date().getFullYear(), ): Promise { const rows = await tx.$queryRaw<{ lastNumber: number }[]>` - INSERT INTO "CaseCounter" ("year", "lastNumber") - VALUES (${year}, 1) - ON CONFLICT ("year") DO UPDATE + INSERT INTO "CaseCounter" ("prefix", "year", "lastNumber") + VALUES (${pattern.prefix}, ${year}, 1) + ON CONFLICT ("prefix", "year") DO UPDATE SET "lastNumber" = "CaseCounter"."lastNumber" + 1 RETURNING "lastNumber" ` @@ -44,5 +61,5 @@ export async function nextCaseNumber( throw new Error('Aktenzeichen konnte nicht vergeben werden') } - return formatCaseNumber(year, lastNumber) + return formatCaseNumber(year, lastNumber, pattern) } diff --git a/web/lib/cases/service.ts b/web/lib/cases/service.ts index 933a7ab..9b7d201 100644 --- a/web/lib/cases/service.ts +++ b/web/lib/cases/service.ts @@ -56,12 +56,16 @@ export async function createCase( const template = await db.caseTemplate.findFirst({ where: { key: input.templateKey, isActive: true }, orderBy: { version: 'desc' }, - select: { id: true, key: true }, + select: { id: true, key: true, numberPrefix: true, numberPadding: true }, }) if (!template) throw new AccessError('template-missing') return db.$transaction(async (tx) => { - const caseNumber = await nextCaseNumber(tx) + // Das Muster kommt aus der Vorlage, jede Fallart hat ihren Nummernkreis. + const caseNumber = await nextCaseNumber(tx, { + prefix: template.numberPrefix, + padding: template.numberPadding, + }) const created = await tx.case.create({ data: { diff --git a/web/tests/schreibgrenzen.test.ts b/web/tests/schreibgrenzen.test.ts index f4b7f19..0e1e0c0 100644 --- a/web/tests/schreibgrenzen.test.ts +++ b/web/tests/schreibgrenzen.test.ts @@ -89,6 +89,51 @@ suite('Schreibgrenzen der Freigabestufe', () => { expect(new Set(nummern).size).toBe(nummern.length) }) + it('führt je Präfix einen eigenen Nummernkreis', async () => { + // Die Abteilung nutzt gewachsene Präfixe wie AKT-PA neben dem AfiS aus dem + // Glossar. Zwei Fallarten dürfen sich dabei nicht in die Quere kommen. + const { createCase } = await import('@/lib/cases/service') + await db.caseTemplate.create({ + data: { + key: `${marke}-personal`, + version: 1, + name: 'Personalvorgang', + fields: [], + numberPrefix: 'AKT-PA', + numberPadding: 5, + }, + }) + + const ersteA = await createCase(perms(5, VOLL), { + title: 'Personalvorgang eins', + classification: 1, + templateKey: `${marke}-personal`, + }) + const zweiteA = await createCase(perms(5, VOLL), { + title: 'Personalvorgang zwei', + classification: 1, + templateKey: `${marke}-personal`, + }) + + const jahr = new Date().getFullYear() + expect(ersteA.caseNumber).toMatch(new RegExp(`^AKT-PA-${jahr}-\\d{5}$`)) + expect(zweiteA.caseNumber).toMatch(new RegExp(`^AKT-PA-${jahr}-\\d{5}$`)) + + const { parseCaseNumber } = await import('@/lib/cases/caseNumber') + const eins = parseCaseNumber(ersteA.caseNumber) + const zwei = parseCaseNumber(zweiteA.caseNumber) + expect(eins?.prefix).toBe('AKT-PA') + expect(zwei!.number).toBe(eins!.number + 1) + + // Der AfiS-Kreis läuft davon unberührt weiter. + const afis = await createCase(perms(5, VOLL), { + title: 'Ermittlung', + classification: 1, + templateKey: `${marke}-vorlage`, + }) + expect(parseCaseNumber(afis.caseNumber)?.prefix).toBe('AfiS') + }) + it('gibt einer zu niedrigen Stufe nicht einmal die Existenz preis', async () => { const { createCase, loadVisibleCase } = await import('@/lib/cases/service') const geheim = await createCase(perms(5, VOLL), {