diff --git a/.env.example b/.env.example index 9c4ca07..6879553 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,8 @@ DISCORD_GUILD_ID= # Kanal, in den der Worker Start, Verbindungsabbruch und fehlgeschlagenen # Voll-Abgleich meldet. Das ist die eigentliche Alarmierung. DISCORD_ADMIN_CHANNEL_ID= +# Takt des Postausgangs als Sicherheitsnetz; angestossen wird er ueber LISTEN. +WORKER_OUTBOX_MS=30000 # --- Notfallzugang ------------------------------------------------------------ # Eine oder zwei Discord-IDs, kommagetrennt. Diese Nutzer haben immer Stufe 5 diff --git a/packages/db/prisma/migrations/20260903090000_chat_und_echtzeit/migration.sql b/packages/db/prisma/migrations/20260903090000_chat_und_echtzeit/migration.sql new file mode 100644 index 0000000..5bfe7d2 --- /dev/null +++ b/packages/db/prisma/migrations/20260903090000_chat_und_echtzeit/migration.sql @@ -0,0 +1,81 @@ +-- Phase 6: Chat und Echtzeit. + +-- =========================================================================== +-- 1. Erwähnungen an der Nachricht +-- +-- Beim Senden aufgelöst und gespeichert, nicht beim Lesen aus dem Text +-- geparst: der Worker braucht sie für die Benachrichtigungen, und er soll +-- den Text gar nicht erst sehen. +-- =========================================================================== + +ALTER TABLE "ChatMessage" + ADD COLUMN "mentions" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; + +CREATE INDEX "ChatMessage_mentions_idx" ON "ChatMessage" USING GIN ("mentions"); + +-- =========================================================================== +-- 2. Chatnachrichten sind zurückziehbar, nicht löschbar +-- +-- Dieselbe Zusicherung wie beim Verlaufseintrag, eine Stufe schwächer: der +-- Chat ist ausdrücklich kein Aktenbestandteil, aber eine Nachricht, auf die +-- jemand geantwortet hat, darf nicht spurlos verschwinden. +-- =========================================================================== + +CREATE RULE chat_no_delete AS ON DELETE TO "ChatMessage" DO INSTEAD NOTHING; + +-- Autor, Zeitpunkt und Ort einer Nachricht sind unveränderlich. Wer etwas +-- anderes sagen will, zieht zurück und schreibt neu. +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."channelId" IS DISTINCT FROM OLD."channelId" THEN + RAISE EXCEPTION 'authorId, createdAt und der Ort einer ChatMessage sind unveränderlich'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER chat_immutable_guard_trg + BEFORE UPDATE ON "ChatMessage" + FOR EACH ROW EXECUTE FUNCTION chat_immutable_guard(); + +-- =========================================================================== +-- 3. Benachrichtigungen +-- =========================================================================== + +-- NotifyMode und User.notifyMode gibt es seit dem Grundgeruest, sie werden hier +-- nur benutzt. Siehe docs/06-chat-und-realtime.md. + +CREATE TABLE "NotificationOutbox" ( + "id" BIGSERIAL NOT NULL, + "kind" TEXT NOT NULL, + "caseId" TEXT, + "channelId" TEXT, + "recipientId" TEXT, + "actorId" TEXT NOT NULL, + "meta" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sentAt" TIMESTAMP(3), + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + + CONSTRAINT "NotificationOutbox_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "NotificationOutbox_sentAt_createdAt_idx" + ON "NotificationOutbox"("sentAt", "createdAt"); +CREATE INDEX "NotificationOutbox_caseId_idx" ON "NotificationOutbox"("caseId"); + +-- Der Worker wartet nicht auf seinen Takt, wenn etwas anliegt. +CREATE OR REPLACE FUNCTION notify_outbox() RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify('notification_queued', NEW."id"::text); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER notification_outbox_notify + AFTER INSERT ON "NotificationOutbox" + FOR EACH ROW EXECUTE FUNCTION notify_outbox(); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 10960af..a611af7 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -41,10 +41,13 @@ model User { firstSeenAt DateTime @default(now()) lastSeenAt DateTime @default(now()) + /// Wie viel dieser Nutzer per Discord erfahren will. + /// Siehe docs/06-chat-und-realtime.md. notifyMode NotifyMode @default(OWN_CASES) roles UserRole[] sessions Session[] + personnelFiles PersonnelFile[] participations CaseParticipant[] assignments PersonnelAssignment[] @@ -55,9 +58,13 @@ model User { @@index([isActive, displayName]) } +/// Was ein Nutzer per Discord erfahren will. enum NotifyMode { + /// jede Aenderung in jeder Akte, die er sehen darf ALL + /// nur Akten, an denen er beteiligt ist oder die er beobachtet OWN_CASES + /// nur direkte Erwaehnungen MENTIONS OFF } @@ -720,6 +727,10 @@ model ChatMessage { withdrawnAt DateTime? /// Gesetzt, sobald die Nachricht als Vermerk uebernommen wurde. adoptedEntryId String? + /// Erwaehnte Nutzer, beim Senden aufgeloest. Steht hier und wird nicht beim + /// Lesen aus dem Text geparst, weil der Worker sie fuer die + /// Benachrichtigungen braucht, ohne den Text zu kennen. + mentions String[] @default([]) createdAt DateTime @default(now()) case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) @@ -732,6 +743,7 @@ model ChatMessage { @@index([channelId, createdAt(sort: Desc)]) @@index([authorId]) @@index([replyToId]) + @@index([mentions], type: Gin) } /// Kleiner fester Satz an Reaktionen, keine freie Emoji-Auswahl. @@ -766,6 +778,35 @@ model Watch { } +/// Was die Web-App dem Worker zum Verschicken uebergibt. +/// +/// Eine Tabelle statt eines direkten Aufrufs, weil der Worker ein eigener +/// Prozess ist und laufen oder auch gerade neu starten kann. Ein Eintrag, der +/// nicht rausging, bleibt liegen und wird beim naechsten Durchlauf abgeholt. +/// Kein Einmal-Container, siehe CLAUDE.md, harte Regel 8. +/// +/// **Hier steht nie ein Inhalt.** Nur Kennungen und die Art des Ereignisses; +/// den Text baut der Worker daraus, und Discord kennt unsere Klassifizierung +/// nicht. Siehe docs/06-chat-und-realtime.md. +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. + recipientId String? + actorId String + /// Nur Kennungen und Zahlen, nie Text aus einer Akte. + meta Json? + createdAt DateTime @default(now()) + sentAt DateTime? + attempts Int @default(0) + lastError String? + + @@index([sentAt, createdAt]) + @@index([caseId]) +} + /// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker /// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen /// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bab958..4d77974 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,9 +106,6 @@ importers: sharp: specifier: 0.34.4 version: 0.34.4 - uuidv7: - specifier: 1.0.2 - version: 1.0.2 zod: specifier: ^4.0.0 version: 4.5.4 @@ -3477,10 +3474,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - uuidv7@1.0.2: - resolution: {integrity: sha512-8JQkH4ooXnm1JCIhqTMbtmdnYEn6oKukBxHn1Ic9878jMkL7daTI7anTExfY18VRCX7tcdn5quzvCb6EWrR8PA==} - hasBin: true - valibot@1.4.2: resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: @@ -7090,8 +7083,6 @@ snapshots: dependencies: punycode: 2.3.1 - uuidv7@1.0.2: {} - valibot@1.4.2(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 diff --git a/web/app/(app)/chat/actions.ts b/web/app/(app)/chat/actions.ts new file mode 100644 index 0000000..7fa3ec2 --- /dev/null +++ b/web/app/(app)/chat/actions.ts @@ -0,0 +1,128 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { requireUser } from '@/lib/auth/current' +import { AccessError } from '@/lib/errors' +import { + adoptAsEntry, + editMessage, + markRead, + sendMessage, + toggleReaction, + withdrawMessage, +} from '@/lib/chat/service' +import { REACTIONS } from '@/lib/chat/terms' +import { de } from '@/lib/i18n/de' + +export type ActionResult = { ok: true } | { ok: false; error: string } + +function toMessage(error: unknown): string { + if (error instanceof AccessError) { + return de.actionErrors[error.code] ?? de.actionErrors.default! + } + console.error('[chat] unerwartet', error) + return de.actionErrors.default! +} + +const scope = z.union([ + z.object({ kind: z.literal('case'), caseNumber: z.string().min(1) }), + z.object({ kind: z.literal('channel'), key: z.string().min(1) }), +]) + +const sendSchema = z.object({ + id: z.string().min(1).max(64), + scope, + body: z.string().min(1).max(4000), + replyToId: z.string().max(64).nullish(), +}) + +export async function sendMessageAction(input: unknown): Promise { + const user = await requireUser() + const parsed = sendSchema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.actionErrors['message-empty']! } + + try { + await sendMessage(user.permissions, parsed.data) + return { ok: true } + } catch (error) { + return { ok: false, error: toMessage(error) } + } +} + +const editSchema = z.object({ id: z.string().min(1), body: z.string().min(1).max(4000) }) + +export async function editMessageAction(input: unknown): Promise { + const user = await requireUser() + const parsed = editSchema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.actionErrors['message-empty']! } + + try { + await editMessage(user.permissions, parsed.data) + return { ok: true } + } catch (error) { + return { ok: false, error: toMessage(error) } + } +} + +const idSchema = z.object({ id: z.string().min(1) }) + +export async function withdrawMessageAction(input: unknown): Promise { + const user = await requireUser() + const parsed = idSchema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.actionErrors.default! } + + try { + await withdrawMessage(user.permissions, parsed.data.id) + return { ok: true } + } catch (error) { + return { ok: false, error: toMessage(error) } + } +} + +const reactionSchema = z.object({ id: z.string().min(1), key: z.enum(REACTIONS) }) + +export async function toggleReactionAction(input: unknown): Promise { + const user = await requireUser() + const parsed = reactionSchema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.actionErrors.default! } + + try { + await toggleReaction(user.permissions, parsed.data) + return { ok: true } + } catch (error) { + return { ok: false, error: toMessage(error) } + } +} + +/** + * „Als Vermerk übernehmen". Danach wird die Akte neu geladen: der Vermerk + * gehört in den Verlauf, und der steht auf derselben Seite. + */ +export async function adoptAsEntryAction(input: unknown): Promise { + const user = await requireUser() + const parsed = idSchema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.actionErrors.default! } + + try { + await adoptAsEntry(user.permissions, parsed.data.id) + revalidatePath('/faelle', 'layout') + return { ok: true } + } catch (error) { + return { ok: false, error: toMessage(error) } + } +} + +const readSchema = z.object({ + scopeType: z.enum(['case', 'channel']), + scopeId: z.string().min(1), +}) + +export async function markReadAction(input: unknown): Promise { + const user = await requireUser() + const parsed = readSchema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.actionErrors.default! } + + await markRead(user.permissions, parsed.data.scopeType, parsed.data.scopeId) + return { ok: true } +} diff --git a/web/app/(app)/dokumente/[documentNumber]/page.tsx b/web/app/(app)/dokumente/[documentNumber]/page.tsx index 3f4a802..8fceb71 100644 --- a/web/app/(app)/dokumente/[documentNumber]/page.tsx +++ b/web/app/(app)/dokumente/[documentNumber]/page.tsx @@ -47,6 +47,7 @@ async function Body({ title: string layout: string createdAt: Date + caseId: string | null } permissions: EffectivePermissions canEdit: boolean @@ -69,6 +70,7 @@ async function Body({ sections={sections} canEdit={canEdit} documentNumber={document.documentNumber} + topic={document.caseId ? `case:${document.caseId}` : `user:${permissions.userId}`} photoUrls={photoUrls} /> ) @@ -108,11 +110,13 @@ async function Body({ async function AttachmentBlock({ documentId, documentNumber, + topic, permissions, canUpload, }: { documentId: string documentNumber: string + topic: string permissions: EffectivePermissions canUpload: boolean }) { @@ -120,6 +124,7 @@ async function AttachmentBlock({ return ( diff --git a/web/app/(app)/einstellungen/NotifyForm.tsx b/web/app/(app)/einstellungen/NotifyForm.tsx new file mode 100644 index 0000000..521dcb7 --- /dev/null +++ b/web/app/(app)/einstellungen/NotifyForm.tsx @@ -0,0 +1,46 @@ +'use client' + +import { useActionState } from 'react' +import { notifyText } from '@/lib/i18n/shell' +import { saveNotifyModeAction, type ActionResult } from './actions' + +const MODES = ['ALL', 'OWN_CASES', 'MENTIONS', 'OFF'] as const + +export function NotifyForm({ current }: { current: string }) { + const [state, action, pending] = useActionState( + saveNotifyModeAction, + null, + ) + + return ( +
+
+ {notifyText.mode} +
+ {MODES.map((mode) => ( + + ))} +
+
+ +
+ + {state?.ok ? ( + {notifyText.saved} + ) : null} + {state && !state.ok ? ( + {state.error} + ) : null} +
+
+ ) +} diff --git a/web/app/(app)/einstellungen/actions.ts b/web/app/(app)/einstellungen/actions.ts new file mode 100644 index 0000000..4bac63a --- /dev/null +++ b/web/app/(app)/einstellungen/actions.ts @@ -0,0 +1,28 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { db } from '@/lib/db' +import { requireUser } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' + +export type ActionResult = { ok: true } | { ok: false; error: string } + +const schema = z.object({ notifyMode: z.enum(['ALL', 'OWN_CASES', 'MENTIONS', 'OFF']) }) + +/** Jeder stellt nur für sich selbst ein — die Kennung kommt aus der Sitzung. */ +export async function saveNotifyModeAction( + _prev: ActionResult | null, + formData: FormData, +): Promise { + const user = await requireUser() + const parsed = schema.safeParse(Object.fromEntries(formData)) + if (!parsed.success) return { ok: false, error: de.actionErrors.default! } + + await db.user.update({ + where: { id: user.id }, + data: { notifyMode: parsed.data.notifyMode }, + }) + revalidatePath('/einstellungen') + return { ok: true } +} diff --git a/web/app/(app)/einstellungen/page.tsx b/web/app/(app)/einstellungen/page.tsx new file mode 100644 index 0000000..e2e3b00 --- /dev/null +++ b/web/app/(app)/einstellungen/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from 'next' +import { db } from '@/lib/db' +import { requireUser } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' +import { NotifyForm } from './NotifyForm' + +export const metadata: Metadata = { title: de.notify.heading } +export const dynamic = 'force-dynamic' + +export default async function SettingsPage() { + const user = await requireUser() + const eigen = await db.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { notifyMode: true }, + }) + + return ( +
+

{de.notify.heading}

+

{de.notify.intro}

+ +
+ ) +} diff --git a/web/app/(app)/faelle/[caseNumber]/CaseLinks.tsx b/web/app/(app)/faelle/[caseNumber]/CaseLinks.tsx index 41846e7..6df303b 100644 --- a/web/app/(app)/faelle/[caseNumber]/CaseLinks.tsx +++ b/web/app/(app)/faelle/[caseNumber]/CaseLinks.tsx @@ -3,10 +3,10 @@ import { useState, useTransition } from 'react' import Link from 'next/link' import { linkText } from '@/lib/i18n/cases' -import { commonText } from '@/lib/i18n/common' import { LINK_KINDS } from '@/lib/cases/terms' -import { linkCasesAction, unlinkCasesAction } from '../actions' +import { AddPanel, PanelSelect } from '@/components/AddPanel' import { classificationEdgeStyle } from '@/components/StampMark' +import { linkCasesAction, unlinkCasesAction } from '../actions' type Verknuepfung = { kind: string @@ -29,10 +29,9 @@ export function CaseLinks({ links: Verknuepfung[] canEdit: boolean }) { - const [open, setOpen] = useState(false) const [target, setTarget] = useState('') const [kind, setKind] = useState('RELATED') - const [error, setError] = useState(null) + const [fehler, setFehler] = useState(null) const [pending, startTransition] = useTransition() return ( @@ -65,12 +64,12 @@ export function CaseLinks({ disabled={pending} onClick={() => startTransition(async () => { - const result = await unlinkCasesAction({ + const ergebnis = await unlinkCasesAction({ fromCaseNumber: caseNumber, toCaseNumber: link.other.caseNumber, kind: link.kind, }) - if (!result.ok) setError(result.error) + if (!ergebnis.ok) setFehler(ergebnis.error) }) } className="font-condensed text-2xs text-text-dim" @@ -83,70 +82,37 @@ export function CaseLinks({ )} - {error ?

{error}

: null} + {fehler ?

{fehler}

: null} {canEdit ? ( - open ? ( -
- - setTarget(event.target.value)} - placeholder={linkText.target} - aria-label={linkText.target} - className="border border-line bg-raised px-2 py-1 font-mono text-2xs" - /> - - -
- ) : ( - - ) + { + const ergebnis = await linkCasesAction({ + fromCaseNumber: caseNumber, + toCaseNumber: target.trim(), + kind, + }) + if (ergebnis.ok) setTarget('') + return ergebnis + }} + > + + {LINK_KINDS.map((value) => ( + + ))} + + setTarget(event.target.value)} + placeholder={linkText.target} + aria-label={linkText.target} + className="border border-line bg-raised px-2 py-1 font-mono text-2xs" + /> + ) : null} ) diff --git a/web/app/(app)/faelle/[caseNumber]/EntryComposer.tsx b/web/app/(app)/faelle/[caseNumber]/EntryComposer.tsx index e2c8ff4..eb7158d 100644 --- a/web/app/(app)/faelle/[caseNumber]/EntryComposer.tsx +++ b/web/app/(app)/faelle/[caseNumber]/EntryComposer.tsx @@ -1,7 +1,7 @@ 'use client' import { useOptimistic, useRef, useState, useTransition } from 'react' -import { uuidv7 } from 'uuidv7' +import { uuidv7 } from '@/lib/uuidv7' import { caseText, entryText } from '@/lib/i18n/cases' import { addEntryAction } from '../actions' diff --git a/web/app/(app)/faelle/[caseNumber]/Participants.tsx b/web/app/(app)/faelle/[caseNumber]/Participants.tsx index c1a6e17..4ee4a6f 100644 --- a/web/app/(app)/faelle/[caseNumber]/Participants.tsx +++ b/web/app/(app)/faelle/[caseNumber]/Participants.tsx @@ -2,9 +2,10 @@ import { useState, useTransition } from 'react' import Link from 'next/link' -import { participantText } from '@/lib/i18n/cases' import { commonText } from '@/lib/i18n/common' +import { participantText } from '@/lib/i18n/cases' import { PARTICIPANT_ROLES } from '@/lib/cases/terms' +import { AddPanel, PanelSelect } from '@/components/AddPanel' import { addParticipantAction, removeParticipantAction } from '../actions' type Beteiligter = { @@ -35,31 +36,11 @@ export function Participants({ members: { id: string; displayName: string }[] files: { id: string; displayName: string }[] }) { - const [open, setOpen] = useState(false) const [role, setRole] = useState('INVESTIGATOR') const [target, setTarget] = useState('') - const [error, setError] = useState(null) + const [fehler, setFehler] = useState(null) const [pending, startTransition] = useTransition() - function add() { - if (!target) return - setError(null) - const [art, id] = target.split(':') - startTransition(async () => { - const result = await addParticipantAction({ - caseNumber, - role, - ...(art === 'user' ? { userId: id } : { personnelFileId: id }), - }) - if (result.ok) { - setTarget('') - setOpen(false) - } else { - setError(result.error) - } - }) - } - return (
{participants.length === 0 ? ( @@ -89,11 +70,11 @@ export function Participants({ disabled={pending} onClick={() => startTransition(async () => { - const result = await removeParticipantAction({ + const ergebnis = await removeParticipantAction({ caseNumber, participantId: person.id, }) - if (!result.ok) setError(result.error) + if (!ergebnis.ok) setFehler(ergebnis.error) }) } className="font-condensed text-2xs text-text-dim" @@ -106,75 +87,49 @@ export function Participants({ )} - {error ?

{error}

: null} + {fehler ?

{fehler}

: null} {canEdit ? ( - open ? ( -
-

- {participantText.hint} -

-
- - - - -
-
- ) : ( - - ) + { + const [art, id] = target.split(':') + const ergebnis = await addParticipantAction({ + caseNumber, + role, + ...(art === 'user' ? { userId: id } : { personnelFileId: id }), + }) + if (ergebnis.ok) setTarget('') + return ergebnis + }} + > + + {PARTICIPANT_ROLES.map((value) => ( + + ))} + + + + + {members.map((member) => ( + + ))} + + + {files.map((file) => ( + + ))} + + + ) : null}
) diff --git a/web/app/(app)/faelle/[caseNumber]/page.tsx b/web/app/(app)/faelle/[caseNumber]/page.tsx index f2e582f..378f54b 100644 --- a/web/app/(app)/faelle/[caseNumber]/page.tsx +++ b/web/app/(app)/faelle/[caseNumber]/page.tsx @@ -25,6 +25,8 @@ import { visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { DocumentList } from '@/components/documents/DocumentList' import { NewDocument } from '@/components/documents/NewDocument' import { Attachments } from '@/components/files/Attachments' +import { ChatLazy } from '@/components/chat/ChatLazy' +import { loadChatView } from '@/lib/chat/load' import { listAttachments } from '@/lib/files/service' import { toAttachmentViews } from '@/lib/files/view' import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle' @@ -214,6 +216,7 @@ async function Relations({
+ ) +} + async function SectionBlock({ caseId, caseNumber, @@ -373,6 +406,20 @@ export default async function CasePage({
+
+ + + + + + } + > + + +
+