diff --git a/packages/db/prisma/migrations/20260903230000_chat_in_der_akte/migration.sql b/packages/db/prisma/migrations/20260903230000_chat_in_der_akte/migration.sql new file mode 100644 index 0000000..2602af8 --- /dev/null +++ b/packages/db/prisma/migrations/20260903230000_chat_in_der_akte/migration.sql @@ -0,0 +1,61 @@ +-- Freie Kanäle entfallen, der Chat zieht in die Personalakte. +-- +-- Der Gedanke hinter dem Chat war von Anfang an, dass er dort steht, wo der +-- Vorgang steht. Ein eigener Reiter mit freien Räumen war der Rückweg nach +-- Discord: dort redet man über eine Akte, statt in ihr. Die Fallakte hatte +-- ihren Chat bereits, die Personalakte bekommt ihn hier. +-- +-- Die Kanäle werden mitsamt Tabelle entfernt. Das ist unumkehrbar; zum +-- Zeitpunkt dieser Migration stand in keinem Kanal eine Nachricht. + +-- Die Nachrichtentabelle ist gegen DELETE gesperrt. Für das einmalige +-- Aufräumen wird die Regel abgenommen und danach wieder gesetzt — sie ist die +-- Zusicherung, dass niemand im Betrieb Nachrichten verschwinden lässt. +DROP RULE chat_no_delete ON "ChatMessage"; + +DELETE FROM "ChatReaction" + WHERE "messageId" IN (SELECT "id" FROM "ChatMessage" WHERE "channelId" IS NOT NULL); +DELETE FROM "ChatMessage" WHERE "channelId" IS NOT NULL; + +CREATE RULE chat_no_delete AS ON DELETE TO "ChatMessage" DO INSTEAD NOTHING; + +DELETE FROM "ReadMarker" WHERE "scopeType" = 'channel'; +DELETE FROM "NotificationOutbox" WHERE "channelId" IS NOT NULL; + +-- Der Ort einer Nachricht heisst jetzt anders. +DROP INDEX IF EXISTS "ChatMessage_channelId_createdAt_idx"; +ALTER TABLE "ChatMessage" DROP CONSTRAINT IF EXISTS "ChatMessage_channelId_fkey"; +ALTER TABLE "ChatMessage" DROP COLUMN "channelId"; +ALTER TABLE "ChatMessage" ADD COLUMN "personnelFileId" TEXT; + +ALTER TABLE "ChatMessage" + ADD CONSTRAINT "ChatMessage_personnelFileId_fkey" + FOREIGN KEY ("personnelFileId") REFERENCES "PersonnelFile"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX "ChatMessage_personnelFileId_createdAt_idx" + ON "ChatMessage"("personnelFileId", "createdAt" DESC); + +-- Eine Nachricht gehört zu genau einem Ort. Vorher hing das an der +-- Anwendung; als CHECK kann es keine Abkürzung mehr umgehen. +ALTER TABLE "ChatMessage" + ADD CONSTRAINT "ChatMessage_genau_ein_ort" + CHECK (num_nonnulls("caseId", "personnelFileId") = 1); + +-- Der Wächter über Autor, Zeitpunkt und Ort kennt den neuen Namen. +CREATE OR REPLACE FUNCTION chat_immutable_guard() RETURNS trigger AS $$ +BEGIN + IF NEW."authorId" IS DISTINCT FROM OLD."authorId" + OR NEW."createdAt" IS DISTINCT FROM OLD."createdAt" + OR NEW."caseId" IS DISTINCT FROM OLD."caseId" + OR NEW."personnelFileId" IS DISTINCT FROM OLD."personnelFileId" THEN + RAISE EXCEPTION 'authorId, createdAt und der Ort einer ChatMessage sind unveränderlich'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +ALTER TABLE "NotificationOutbox" DROP COLUMN "channelId"; +ALTER TABLE "NotificationOutbox" ADD COLUMN "personnelFileId" TEXT; + +DROP TABLE "Channel"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f743662..eb31e44 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -484,6 +484,7 @@ model PersonnelFile { searchVector Unsupported("tsvector")? user User? @relation(fields: [userId], references: [id]) + messages ChatMessage[] participants CaseParticipant[] documents Document[] assignments PersonnelAssignment[] @@ -703,25 +704,14 @@ enum ScanStatus { // --------------------------------------------------------------------------- /// Frei angelegter Raum fuer allgemeine Absprachen, mit eigener Mindeststufe. -model Channel { - id String @id @default(uuid(7)) - key String @unique - name String - description String? - minClearance Int @default(0) - isArchived Boolean @default(false) - createdById String - createdAt DateTime @default(now()) - - messages ChatMessage[] - - @@index([isArchived, name]) -} - +/// Eine Chatnachricht haengt immer an genau einer Akte: an einer Fallakte +/// oder an einer Personalakte. Freie Kanaele gab es einmal; sie sind +/// entfallen, weil das Gespraech dorthin gehoert, wo der Vorgang steht. +/// Siehe docs/13-entscheidungen.md. model ChatMessage { - id String @id // clientseitig erzeugt, UUIDv7 - caseId String? - channelId String? + id String @id // clientseitig erzeugt, UUIDv7 + caseId String? + personnelFileId String? authorId String body String replyToId String? @@ -735,14 +725,14 @@ model ChatMessage { mentions String[] @default([]) createdAt DateTime @default(now()) - case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) - channel Channel? @relation(fields: [channelId], references: [id], onDelete: Cascade) + case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) + personnelFile PersonnelFile? @relation(fields: [personnelFileId], references: [id], onDelete: Cascade) replyTo ChatMessage? @relation("replies", fields: [replyToId], references: [id]) replies ChatMessage[] @relation("replies") reactions ChatReaction[] @@index([caseId, createdAt(sort: Desc)]) - @@index([channelId, createdAt(sort: Desc)]) + @@index([personnelFileId, createdAt(sort: Desc)]) @@index([authorId]) @@index([replyToId]) @@index([mentions], type: Gin) @@ -761,7 +751,7 @@ model ChatReaction { model ReadMarker { userId String - scopeType String // "case" | "channel" + scopeType String // "case" | "personnel" scopeId String lastReadAt DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -822,9 +812,9 @@ model RecentView { model NotificationOutbox { id BigInt @id @default(autoincrement()) kind String // "entry.new", "chat.new", "chat.mention", "case.status" - caseId String? - channelId String? - /// Leer heisst: in den allgemeinen Kanal. Sonst als Direktnachricht. + caseId String? + personnelFileId String? + /// Leer heisst: in den allgemeinen Discord-Kanal. Sonst als Direktnachricht. recipientId String? actorId String /// Nur Kennungen und Zahlen, nie Text aus einer Akte. diff --git a/web/app/(app)/admin/layout.tsx b/web/app/(app)/admin/layout.tsx new file mode 100644 index 0000000..e3c1ff0 --- /dev/null +++ b/web/app/(app)/admin/layout.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react' +import { notFound } from 'next/navigation' +import { requireUser } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' +import { adminSections, hasAdminAccess } from './sections' +import { Tabs } from '@/components/Tabs' + +export const dynamic = 'force-dynamic' + +/** + * Kopf und Leiste der Verwaltung. + * + * Wie bei der Fallakte sind die Bereiche **eigene Seiten** unter einem + * gemeinsamen Layout, keine umgeschalteten Blöcke. Wer gar keinen Bereich + * sehen darf, bekommt 404 — die Verwaltung ist für ihn nicht vorhanden. + */ +export default async function AdminLayout({ children }: { children: ReactNode }) { + const user = await requireUser() + if (!hasAdminAccess(user.permissions)) notFound() + + const bereiche = adminSections(user.permissions) + + return ( +
+
+

{de.admin.heading}

+

{de.admin.intro}

+
+ + ({ href: bereich.href, label: bereich.label }))} /> + +
{children}
+
+ ) +} diff --git a/web/app/(app)/admin/page.tsx b/web/app/(app)/admin/page.tsx new file mode 100644 index 0000000..8bf6985 --- /dev/null +++ b/web/app/(app)/admin/page.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { requireUser } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' +import { adminSections } from './sections' + +export const metadata: Metadata = { title: de.admin.heading } +export const dynamic = 'force-dynamic' + +/** + * Die Startseite der Verwaltung: was es gibt und wofür es da ist. + * + * Kein Weiterleiten auf den ersten Bereich. Wer die Verwaltung öffnet, weiss + * nicht immer, wo das Gesuchte liegt — eine Liste mit einem Satz je Bereich + * beantwortet das schneller als das Durchklicken der Leiste. + */ +export default async function AdminIndexPage() { + const user = await requireUser() + const bereiche = adminSections(user.permissions) + + return ( + + ) +} diff --git a/web/app/(app)/admin/richtlinie/PolicyForm.tsx b/web/app/(app)/admin/richtlinie/PolicyForm.tsx index 6162a49..ad758bd 100644 --- a/web/app/(app)/admin/richtlinie/PolicyForm.tsx +++ b/web/app/(app)/admin/richtlinie/PolicyForm.tsx @@ -74,7 +74,7 @@ export function PolicyForm({ setGrace(Number(event.target.value)) setSaved(false) }} - className="mt-1.5 block w-24 border border-line bg-raised px-3 py-2 font-mono text-sm disabled:text-text-dim" + className="mt-1.5 block w-24 field font-mono disabled:text-text-dim" /> @@ -95,7 +95,7 @@ export function PolicyForm({ type="button" onClick={submit} disabled={pending} - className="border border-line bg-raised px-4 py-2 font-condensed text-xs text-accent disabled:text-text-dim" + className="btn btn-primary" > {pending ? caseText.saving : settingsText.save} diff --git a/web/app/(app)/admin/richtlinie/page.tsx b/web/app/(app)/admin/richtlinie/page.tsx index 6e78957..90c0d91 100644 --- a/web/app/(app)/admin/richtlinie/page.tsx +++ b/web/app/(app)/admin/richtlinie/page.tsx @@ -13,8 +13,8 @@ export default async function PolicyPage() { const setting = await db.appSetting.findUnique({ where: { id: 1 } }) return ( -
-

{de.settings.heading}

+
+

{de.settings.heading}

{de.settings.intro}

-
+ ) } diff --git a/web/app/(app)/admin/rollen/RoleRow.tsx b/web/app/(app)/admin/rollen/RoleRow.tsx index 065ceff..c4458d6 100644 --- a/web/app/(app)/admin/rollen/RoleRow.tsx +++ b/web/app/(app)/admin/rollen/RoleRow.tsx @@ -91,7 +91,7 @@ export function RoleRow({ setCategory(event.target.value) setSaved(false) }} - className="border border-line bg-raised px-2 py-1" + className="field" > {CATEGORIES.map((value) => (