Freigabe-Workflow + Aufgaben-Modul + Dashboard-Kachel
Konzept (abgestimmt): Beim Einreichen wählt der Einreicher einen konkreten Freigeber; die Freigabe/Ablehnung erfolgt im neuen Aufgaben-Modul (Vier-Augen). - Modell: generisches Task + TaskComment (Migration inkl. RLS; Task/TaskComment in TENANT_MODELS). Erster Typ policy_approval. Neues Modul „tasks" (lib/modules, Nav, i18n) — für Bestands-Tenants per Default aktiv. - policies.ts: submitForApproval(code, formData) verlangt einen Freigeber (aktiver Nutzer mit policy:approve, ≠ Einreicher), setzt IN_FREIGABE und erzeugt eine Aufgabe; Resubmit schließt alte offene Aufgaben. Alte approvePolicy/rejectPolicy entfernt (wandern ins Aufgaben-Modul). - actions/tasks.ts: approveTask/rejectTask (nur zugewiesener Freigeber, ≠ Einreicher; Richtlinie → FREIGEGEBEN bzw. zurück auf ENTWURF) und commentTask (nur Beteiligte); Kommentare/Statuswechsel historisiert, Audit. - /tasks: Aufgaben des Nutzers (offen/erledigt) mit Freigeben/Ablehnen (Grund erforderlich)/Kommentieren + Verlauf. Richtlinien-Editor: Freigeber-Dropdown beim Einreichen, „Zur Freigabe bei …" + Link zur Aufgabe (keine Inline-Freigabe mehr). - Dashboard: Kachel „N offene Aufgabe(n)" verlinkt auf /tasks. - Seed: zweiter ISB „Bea Approver" als Freigeber (ermöglicht den Vier-Augen-Flow). Browser-verifiziert: R08 eingereicht (Freigeber Bea) → Aufgabe; Dashboard-Kachel bei Bea; Freigeben in /tasks → R08 FREIGEGEBEN, Task DONE, Kommentar-Historie (submit+approve). tsc + lint + build + Guard-Check grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"risks": "Risikoanalyse",
|
"risks": "Risikoanalyse",
|
||||||
"soa": "SoA & Controls",
|
"soa": "SoA & Controls",
|
||||||
"measures": "Maßnahmen",
|
"measures": "Maßnahmen",
|
||||||
|
"tasks": "Aufgaben",
|
||||||
"incidents": "Vorfälle",
|
"incidents": "Vorfälle",
|
||||||
"policies": "Richtlinien",
|
"policies": "Richtlinien",
|
||||||
"chat": "ISMS-Chat",
|
"chat": "ISMS-Chat",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"risks": "Risk analysis",
|
"risks": "Risk analysis",
|
||||||
"soa": "SoA & controls",
|
"soa": "SoA & controls",
|
||||||
"measures": "Measures",
|
"measures": "Measures",
|
||||||
|
"tasks": "Tasks",
|
||||||
"incidents": "Incidents",
|
"incidents": "Incidents",
|
||||||
"policies": "Policies",
|
"policies": "Policies",
|
||||||
"chat": "ISMS chat",
|
"chat": "ISMS chat",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tasks" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'OPEN',
|
||||||
|
"entity_type" TEXT,
|
||||||
|
"entity_id" TEXT,
|
||||||
|
"entity_ref" TEXT,
|
||||||
|
"assignee_id" TEXT,
|
||||||
|
"created_by_id" TEXT,
|
||||||
|
"due_date" TIMESTAMP(3),
|
||||||
|
"resolved_by_id" TEXT,
|
||||||
|
"resolved_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "tasks_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "task_comments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"task_id" TEXT NOT NULL,
|
||||||
|
"author_id" TEXT,
|
||||||
|
"kind" TEXT NOT NULL DEFAULT 'comment',
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "task_comments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tasks_tenant_id_status_idx" ON "tasks"("tenant_id", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tasks_tenant_id_assignee_id_status_idx" ON "tasks"("tenant_id", "assignee_id", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "task_comments_tenant_id_task_id_idx" ON "task_comments"("tenant_id", "task_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "task_comments" ADD CONSTRAINT "task_comments_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- RLS für die neuen mandantenbezogenen Tabellen (zweite Verteidigungslinie).
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['tasks','task_comments'] LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
|
||||||
|
EXECUTE format('CREATE POLICY tenant_isolation ON %I USING (tenant_id = current_setting(''app.tenant_id'', true))', t);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@@ -746,6 +746,47 @@ model AuditLog {
|
|||||||
@@map("audit_logs")
|
@@map("audit_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generisches Aufgaben-/Freigabe-Modell (erweiterbar). Erster Typ: policy_approval
|
||||||
|
// (Richtlinien-Freigabe an eine konkrete Person). Kommentare/Statuswechsel historisiert.
|
||||||
|
model Task {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
type String // policy_approval | …
|
||||||
|
title String
|
||||||
|
status String @default("OPEN") // OPEN | DONE | REJECTED | CANCELLED
|
||||||
|
entityType String? @map("entity_type") // z. B. policy_document
|
||||||
|
entityId String? @map("entity_id")
|
||||||
|
entityRef String? @map("entity_ref") // Deep-Link-Referenz, z. B. Policy-Code
|
||||||
|
assigneeId String? @map("assignee_id") // wer bearbeiten/freigeben soll
|
||||||
|
createdById String? @map("created_by_id")
|
||||||
|
dueDate DateTime? @map("due_date")
|
||||||
|
resolvedById String? @map("resolved_by_id")
|
||||||
|
resolvedAt DateTime? @map("resolved_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
comments TaskComment[]
|
||||||
|
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([tenantId, assigneeId, status])
|
||||||
|
@@map("tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TaskComment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
taskId String @map("task_id")
|
||||||
|
authorId String? @map("author_id")
|
||||||
|
kind String @default("comment") // comment | approve | reject | submit
|
||||||
|
body String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tenantId, taskId])
|
||||||
|
@@map("task_comments")
|
||||||
|
}
|
||||||
|
|
||||||
// ── Richtlinien & Verfahren (VDA-ISA 2027 Vorlagenpaket) ──────────────────────
|
// ── Richtlinien & Verfahren (VDA-ISA 2027 Vorlagenpaket) ──────────────────────
|
||||||
// Dokumente werden aus ihrer echten Markdown-Vorlage gerendert; Variablen,
|
// Dokumente werden aus ihrer echten Markdown-Vorlage gerendert; Variablen,
|
||||||
// Feature-Flags und Baseline-Parameter sind die eine Pflegestelle (§7).
|
// Feature-Flags und Baseline-Parameter sind die eine Pflegestelle (§7).
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ async function main() {
|
|||||||
const passwordHash = await hash(DEMO_PASSWORD);
|
const passwordHash = await hash(DEMO_PASSWORD);
|
||||||
const demoUsers: { email: string; name: string; roles: string[] }[] = [
|
const demoUsers: { email: string; name: string; roles: string[] }[] = [
|
||||||
{ email: "admin@demo.example", name: "Anna Admin", roles: ["tenant-admin", "isb"] },
|
{ email: "admin@demo.example", name: "Anna Admin", roles: ["tenant-admin", "isb"] },
|
||||||
|
// Zweiter ISB als Freigeber — ermöglicht den Vier-Augen-Freigabe-Workflow (≠ Einreicher).
|
||||||
|
{ email: "bea.approver@demo.example", name: "Bea Approver", roles: ["isb"] },
|
||||||
{ email: "auditor@demo.example", name: "Axel Auditor", roles: ["auditor"] },
|
{ email: "auditor@demo.example", name: "Axel Auditor", roles: ["auditor"] },
|
||||||
{ email: "owner@demo.example", name: "Oskar Owner", roles: ["owner"] },
|
{ email: "owner@demo.example", name: "Oskar Owner", roles: ["owner"] },
|
||||||
{ email: "user@demo.example", name: "Ulla User", roles: ["user"] },
|
{ email: "user@demo.example", name: "Ulla User", roles: ["user"] },
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const ACTION_MODULE: Record<string, string> = {
|
|||||||
"processes.ts": "bia",
|
"processes.ts": "bia",
|
||||||
"risks.ts": "risk",
|
"risks.ts": "risk",
|
||||||
"measures.ts": "measures",
|
"measures.ts": "measures",
|
||||||
|
"tasks.ts": "tasks",
|
||||||
"suppliers.ts": "suppliers",
|
"suppliers.ts": "suppliers",
|
||||||
"services.ts": "suppliers",
|
"services.ts": "suppliers",
|
||||||
"policies.ts": "policies",
|
"policies.ts": "policies",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ListChecks } from "lucide-react";
|
||||||
import { getFormatter, getTranslations } from "next-intl/server";
|
import { getFormatter, getTranslations } from "next-intl/server";
|
||||||
import { requireSession } from "@/server/auth";
|
import { requireSession } from "@/server/auth";
|
||||||
import { dbForTenant } from "@/server/db";
|
import { dbForTenant } from "@/server/db";
|
||||||
@@ -9,11 +11,12 @@ export default async function DashboardPage() {
|
|||||||
const format = await getFormatter();
|
const format = await getFormatter();
|
||||||
|
|
||||||
const db = dbForTenant(session.user.tenantId);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
const [assetCount, processCount, criticalCount, openRisks, activities] = await Promise.all([
|
const [assetCount, processCount, criticalCount, openRisks, myOpenTasks, activities] = await Promise.all([
|
||||||
db.asset.count(),
|
db.asset.count(),
|
||||||
db.process.count(),
|
db.process.count(),
|
||||||
db.biaEntry.count({ where: { criticality: { gte: 3 } } }),
|
db.biaEntry.count({ where: { criticality: { gte: 3 } } }),
|
||||||
db.risk.count({ where: { status: { in: ["OPEN", "IN_TREATMENT"] } } }),
|
db.risk.count({ where: { status: { in: ["OPEN", "IN_TREATMENT"] } } }),
|
||||||
|
db.task.count({ where: { assigneeId: session.user.id, status: "OPEN" } }),
|
||||||
db.auditLog.findMany({
|
db.auditLog.findMany({
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 6,
|
take: 6,
|
||||||
@@ -44,6 +47,19 @@ export default async function DashboardPage() {
|
|||||||
{t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug })}
|
{t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{myOpenTasks > 0 && (
|
||||||
|
<Link href="/tasks" className="shadow-card mt-6 flex items-center justify-between rounded-xl border bg-card p-4 transition-colors hover:bg-muted/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="grid size-10 place-items-center rounded-lg bg-[rgba(90,169,230,0.16)] text-[var(--info)]"><ListChecks className="size-5" /></div>
|
||||||
|
<div>
|
||||||
|
<p className="font-heading text-sm font-semibold">{myOpenTasks} offene Aufgabe{myOpenTasks === 1 ? "" : "n"}</p>
|
||||||
|
<p className="text-[12px] text-muted-foreground">Freigaben, die auf Sie warten.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[13px] font-semibold text-[var(--primary)]">Zu den Aufgaben →</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
{kpis.map((kpi) => (
|
{kpis.map((kpi) => (
|
||||||
<Card key={kpi.label}>
|
<Card key={kpi.label}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
KanbanSquare,
|
KanbanSquare,
|
||||||
|
ListChecks,
|
||||||
Siren,
|
Siren,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
@@ -63,6 +64,7 @@ export default async function AppLayout({
|
|||||||
{ href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: true },
|
{ href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: true },
|
||||||
{ href: "/soa", label: t("soa"), icon: ClipboardCheck, enabled: false },
|
{ href: "/soa", label: t("soa"), icon: ClipboardCheck, enabled: false },
|
||||||
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: true },
|
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: true },
|
||||||
|
{ href: "/tasks", label: t("tasks"), icon: ListChecks, enabled: true },
|
||||||
{ href: "/incidents", label: t("incidents"), icon: Siren, enabled: false },
|
{ href: "/incidents", label: t("incidents"), icon: Siren, enabled: false },
|
||||||
{ href: "/policies", label: t("policies"), icon: BookOpenText, enabled: true },
|
{ href: "/policies", label: t("policies"), icon: BookOpenText, enabled: true },
|
||||||
{ href: "/chat", label: t("chat"), icon: MessagesSquare, enabled: false },
|
{ href: "/chat", label: t("chat"), icon: MessagesSquare, enabled: false },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ArrowLeft, Check, Send, X } from "lucide-react";
|
import { ArrowLeft, Send } from "lucide-react";
|
||||||
import { requireSession } from "@/server/auth";
|
import { requireSession } from "@/server/auth";
|
||||||
import { dbForTenant } from "@/server/db";
|
import { dbForTenant } from "@/server/db";
|
||||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||||
@@ -12,7 +12,7 @@ import { applyProtection, buildContext, effectiveLevel, renderPolicyHtml, splitP
|
|||||||
import { isCentralVariable } from "@/lib/policy-variables";
|
import { isCentralVariable } from "@/lib/policy-variables";
|
||||||
import { POLICY_STATUS_LABEL, POLICY_STATUS_TONE, POLICY_TYPE_LABEL } from "@/components/policy-modals";
|
import { POLICY_STATUS_LABEL, POLICY_STATUS_TONE, POLICY_TYPE_LABEL } from "@/components/policy-modals";
|
||||||
import { PolicyExpertEditor } from "@/components/policy-expert-editor";
|
import { PolicyExpertEditor } from "@/components/policy-expert-editor";
|
||||||
import { approvePolicy, rejectPolicy, submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies";
|
import { submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies";
|
||||||
|
|
||||||
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
|
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
|
||||||
|
|
||||||
@@ -59,6 +59,15 @@ export default async function PolicyEditPage({
|
|||||||
]);
|
]);
|
||||||
const userName = (id: string | null) => users.find((u) => u.id === id)?.name ?? id ?? "—";
|
const userName = (id: string | null) => users.find((u) => u.id === id)?.name ?? id ?? "—";
|
||||||
|
|
||||||
|
// Freigeber-Auswahl: aktive Nutzer mit Freigaberecht (≠ Einreicher).
|
||||||
|
const approvers = await db.user.findMany({
|
||||||
|
where: { status: "ACTIVE", id: { not: session.user.id }, userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "policy:approve" } } } } } } },
|
||||||
|
select: { id: true, name: true }, orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
const openTask = doc.status === "IN_FREIGABE"
|
||||||
|
? await db.task.findFirst({ where: { entityType: "policy_document", entityId: doc.id, status: "OPEN" }, select: { assigneeId: true } })
|
||||||
|
: null;
|
||||||
|
|
||||||
const usedKeys = docVariableKeys(doc.rawMarkdown, baseline);
|
const usedKeys = docVariableKeys(doc.rawMarkdown, baseline);
|
||||||
// Zentrale Variablen (Organisation/Rollen) sind nur in den Einstellungen pflegbar → hier ausblenden.
|
// Zentrale Variablen (Organisation/Rollen) sind nur in den Einstellungen pflegbar → hier ausblenden.
|
||||||
const docVars = variables.filter((v) => usedKeys.has(v.key) && v.kind !== "boolean" && !isCentralVariable(v));
|
const docVars = variables.filter((v) => usedKeys.has(v.key) && v.kind !== "boolean" && !isCentralVariable(v));
|
||||||
@@ -73,9 +82,6 @@ export default async function PolicyEditPage({
|
|||||||
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
|
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
|
||||||
const infoHtml = renderPolicyHtml(infoMd, ctx, { readMode: true, stripBaseline: false });
|
const infoHtml = renderPolicyHtml(infoMd, ctx, { readMode: true, stripBaseline: false });
|
||||||
|
|
||||||
const canApprove = hasPermission(session, "policy:approve");
|
|
||||||
const isSubmitter = doc.submittedBy === session.user.id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 p-6">
|
<main className="flex-1 p-6">
|
||||||
<Link href={`/policies/${code}`} className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
<Link href={`/policies/${code}`} className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
@@ -142,47 +148,39 @@ export default async function PolicyEditPage({
|
|||||||
|
|
||||||
{/* Rechte Leiste: Freigabe + Variablen */}
|
{/* Rechte Leiste: Freigabe + Variablen */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Freigabe-Workflow (Vier-Augen, §8) */}
|
{/* Freigabe-Workflow (Vier-Augen, §8) — Einreichen erzeugt eine Aufgabe */}
|
||||||
<div className="shadow-card rounded-2xl border bg-card p-4">
|
<div className="shadow-card rounded-2xl border bg-card p-4">
|
||||||
<p className="font-heading text-sm font-semibold">{t("approval")}</p>
|
<p className="font-heading text-sm font-semibold">{t("approval")}</p>
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{doc.status === "ENTWURF" && (
|
{(doc.status === "ENTWURF" || doc.status === "FREIGEGEBEN") && (
|
||||||
<form action={submitForApproval.bind(null, decoded)}>
|
approvers.length === 0 ? (
|
||||||
<Button type="submit" className="w-full justify-center"><Send className="size-4" /> {t("submitForApproval")}</Button>
|
<p className="rounded-lg border border-[var(--band-brd)] bg-[var(--band)] px-3 py-2 text-[12px] text-[var(--band-text)]">
|
||||||
|
Kein anderer Nutzer mit Freigaberecht vorhanden. Bitte zuerst einen Freigeber (Rolle mit „policy:approve“) anlegen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form action={submitForApproval.bind(null, decoded)} className="space-y-2">
|
||||||
|
{doc.status === "FREIGEGEBEN" && <p className="text-[12px] text-muted-foreground">{t("approvedBy")}: <b>{userName(doc.approvedBy)}</b></p>}
|
||||||
|
<label className="block text-[11.5px] text-muted-foreground">Freigeber wählen</label>
|
||||||
|
<select name="approverId" required defaultValue="" className="h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]">
|
||||||
|
<option value="" disabled>— Person auswählen —</option>
|
||||||
|
{approvers.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<Input name="note" placeholder="Notiz an den Freigeber (optional)" className="h-8" />
|
||||||
|
<Button type="submit" className="w-full justify-center">
|
||||||
|
<Send className="size-4" /> {doc.status === "FREIGEGEBEN" ? t("resubmit") : t("submitForApproval")}
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{doc.status === "IN_FREIGABE" && (
|
{doc.status === "IN_FREIGABE" && (
|
||||||
<>
|
<div className="space-y-1.5">
|
||||||
<p className="text-[12px] text-muted-foreground">{t("submittedBy")}: <b>{userName(doc.submittedBy)}</b></p>
|
<p className="text-[12px] text-muted-foreground">{t("submittedBy")}: <b>{userName(doc.submittedBy)}</b></p>
|
||||||
{canApprove && !isSubmitter ? (
|
<p className="text-[12px] text-muted-foreground">Zur Freigabe bei: <b>{userName(openTask?.assigneeId ?? null)}</b></p>
|
||||||
<>
|
<Link href="/tasks" className="inline-block text-[12px] font-semibold text-[var(--primary)] hover:underline">Zur Aufgabe →</Link>
|
||||||
<form action={approvePolicy.bind(null, decoded)}>
|
</div>
|
||||||
<Button type="submit" className="w-full justify-center"><Check className="size-4" /> {t("approve")}</Button>
|
|
||||||
</form>
|
|
||||||
<form action={rejectPolicy.bind(null, decoded)} className="space-y-2">
|
|
||||||
<Input name="reason" placeholder={t("rejectReason")} className="h-8" />
|
|
||||||
<Button type="submit" variant="outline" size="sm" className="w-full justify-center"><X className="size-4" /> {t("reject")}</Button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="rounded-lg border border-[var(--band-brd)] bg-[var(--band)] px-3 py-2 text-[12px] text-[var(--band-text)]">
|
|
||||||
{isSubmitter ? t("fourEyesSelf") : t("fourEyesNoRight")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{doc.status === "FREIGEGEBEN" && (
|
|
||||||
<>
|
|
||||||
<p className="text-[12px] text-muted-foreground">
|
|
||||||
{t("approvedBy")}: <b>{userName(doc.approvedBy)}</b>
|
|
||||||
</p>
|
|
||||||
<form action={submitForApproval.bind(null, decoded)}>
|
|
||||||
<Button type="submit" variant="outline" size="sm" className="w-full justify-center"><Send className="size-4" /> {t("resubmit")}</Button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-[11px] text-muted-foreground">{t("approvalNote")}</p>
|
<p className="mt-3 text-[11px] text-muted-foreground">Freigabe/Ablehnung erfolgen im Bereich „Aufgaben“ durch den gewählten Freigeber (Vier-Augen).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schutzbedarf / TISAX-Level — zentral gesteuert (nur Anzeige) */}
|
{/* Schutzbedarf / TISAX-Level — zentral gesteuert (nur Anzeige) */}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { requireModule } from "@/server/modules";
|
||||||
|
|
||||||
|
/** Serverseitige Modul-Durchsetzung für den Aufgaben-Bereich (§3.4). */
|
||||||
|
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
await requireModule("tasks");
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { dbForTenant } from "@/server/db";
|
||||||
|
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { approveTask, rejectTask, commentTask } from "@/server/actions/tasks";
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = { OPEN: "info", DONE: "ok", REJECTED: "warn", CANCELLED: "mut" };
|
||||||
|
const STATUS_LABEL: Record<string, string> = { OPEN: "Offen", DONE: "Freigegeben", REJECTED: "Abgelehnt", CANCELLED: "Zurückgezogen" };
|
||||||
|
const KIND_LABEL: Record<string, string> = { submit: "eingereicht", approve: "freigegeben", reject: "abgelehnt", comment: "Kommentar" };
|
||||||
|
|
||||||
|
interface TaskWithComments {
|
||||||
|
id: string; type: string; title: string; status: string;
|
||||||
|
entityType: string | null; entityRef: string | null;
|
||||||
|
assigneeId: string | null; createdById: string | null; createdAt: Date;
|
||||||
|
comments: { id: string; authorId: string | null; kind: string; body: string; createdAt: Date }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityHref(t: TaskWithComments): string | null {
|
||||||
|
return t.entityType === "policy_document" && t.entityRef ? `/policies/${t.entityRef}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCard({ task, me, name }: { task: TaskWithComments; me: string; name: (id: string | null) => string }) {
|
||||||
|
const isAssignee = task.assigneeId === me;
|
||||||
|
const isOpen = task.status === "OPEN";
|
||||||
|
const href = entityHref(task);
|
||||||
|
return (
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-heading text-sm font-semibold">
|
||||||
|
{href ? <Link href={href} className="hover:underline">{task.title}</Link> : task.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11.5px] text-muted-foreground">
|
||||||
|
Eingereicht von {name(task.createdById)} · Freigeber {name(task.assigneeId)} · {task.createdAt.toLocaleDateString("de-DE")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Pill tone={STATUS_TONE[task.status] ?? "mut"}>{STATUS_LABEL[task.status] ?? task.status}</Pill>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.comments.length > 0 && (
|
||||||
|
<ul className="mt-3 space-y-1.5 border-t pt-3">
|
||||||
|
{task.comments.map((c) => (
|
||||||
|
<li key={c.id} className="text-[12.5px]">
|
||||||
|
<span className="text-muted-foreground">{name(c.authorId)} · {KIND_LABEL[c.kind] ?? c.kind}:</span> {c.body}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && isAssignee && (
|
||||||
|
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
|
||||||
|
<form action={approveTask.bind(null, task.id)} className="flex gap-2">
|
||||||
|
<Input name="note" placeholder="Kommentar (optional)" className="h-8" />
|
||||||
|
<Button type="submit" size="sm"><Check className="size-4" /> Freigeben</Button>
|
||||||
|
</form>
|
||||||
|
<form action={rejectTask.bind(null, task.id)} className="flex gap-2">
|
||||||
|
<Input name="note" placeholder="Grund (erforderlich)" required className="h-8" />
|
||||||
|
<Button type="submit" variant="outline" size="sm"><X className="size-4" /> Ablehnen</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOpen && !isAssignee && (
|
||||||
|
<p className="mt-3 border-t pt-3 text-[11.5px] text-muted-foreground">Wartet auf Freigabe durch {name(task.assigneeId)}.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={commentTask.bind(null, task.id)} className="mt-2 flex gap-2">
|
||||||
|
<Input name="body" placeholder="Kommentar hinzufügen…" className="h-8" />
|
||||||
|
<Button type="submit" variant="ghost" size="sm">Kommentieren</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TasksPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
const db = dbForTenant(session.user.tenantId);
|
||||||
|
const me = session.user.id;
|
||||||
|
|
||||||
|
const [tasks, users] = await Promise.all([
|
||||||
|
db.task.findMany({
|
||||||
|
where: { OR: [{ assigneeId: me }, { createdById: me }] },
|
||||||
|
include: { comments: { orderBy: { createdAt: "asc" } } },
|
||||||
|
orderBy: [{ createdAt: "desc" }],
|
||||||
|
}),
|
||||||
|
db.user.findMany({ select: { id: true, name: true } }),
|
||||||
|
]);
|
||||||
|
const name = (id: string | null) => users.find((u) => u.id === id)?.name ?? "—";
|
||||||
|
|
||||||
|
const open = tasks.filter((t) => t.status === "OPEN");
|
||||||
|
const resolved = tasks.filter((t) => t.status !== "OPEN");
|
||||||
|
const myOpen = open.filter((t) => t.assigneeId === me);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
<PageHead crumb="Aufgaben" title="Aufgaben & Freigaben" sub={`${myOpen.length} offene Freigabe(n) für Sie · ${open.length} offen gesamt`} />
|
||||||
|
|
||||||
|
<section className="mt-4">
|
||||||
|
<h2 className="mb-3 font-heading text-sm font-semibold">Offen</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{open.length === 0 && <p className="text-sm text-muted-foreground">Keine offenen Aufgaben.</p>}
|
||||||
|
{open.map((t) => <TaskCard key={t.id} task={t} me={me} name={name} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{resolved.length > 0 && (
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="mb-3 font-heading text-sm font-semibold">Erledigt</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{resolved.map((t) => <TaskCard key={t.id} task={t} me={me} name={name} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const MODULES: ModuleDef[] = [
|
|||||||
{ key: "risk", name: "Risikoanalyse", href: "/risks", nav: "risks" },
|
{ key: "risk", name: "Risikoanalyse", href: "/risks", nav: "risks" },
|
||||||
{ key: "soa", name: "SoA & Controls", href: "/soa", nav: "soa" },
|
{ key: "soa", name: "SoA & Controls", href: "/soa", nav: "soa" },
|
||||||
{ key: "measures", name: "Maßnahmen / Reviews", href: "/measures", nav: "measures" },
|
{ key: "measures", name: "Maßnahmen / Reviews", href: "/measures", nav: "measures" },
|
||||||
|
{ key: "tasks", name: "Aufgaben & Freigaben", href: "/tasks", nav: "tasks" },
|
||||||
{ key: "incidents", name: "Vorfälle", href: "/incidents", nav: "incidents" },
|
{ key: "incidents", name: "Vorfälle", href: "/incidents", nav: "incidents" },
|
||||||
{ key: "policies", name: "Richtlinien & Verfahren", href: "/policies", nav: "policies" },
|
{ key: "policies", name: "Richtlinien & Verfahren", href: "/policies", nav: "policies" },
|
||||||
{ key: "suppliers", name: "Lieferanten- / IT-Service-Management", href: "/suppliers", nav: "suppliers" },
|
{ key: "suppliers", name: "Lieferanten- / IT-Service-Management", href: "/suppliers", nav: "suppliers" },
|
||||||
|
|||||||
@@ -209,40 +209,46 @@ export async function updateScopedVariables(code: string, formData: FormData) {
|
|||||||
redirect(`/policies/${encodeURIComponent(code)}/edit`);
|
redirect(`/policies/${encodeURIComponent(code)}/edit`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Freigabe-Workflow (Vier-Augen, §8) ── */
|
/* ── Freigabe-Workflow (Vier-Augen, §8) — Einreichen erzeugt eine Aufgabe ── */
|
||||||
|
|
||||||
export async function submitForApproval(code: string) {
|
/**
|
||||||
|
* Richtlinie zur Freigabe einreichen: der Einreicher wählt einen konkreten
|
||||||
|
* Freigeber (aktiver Nutzer mit policy:approve, ≠ Einreicher). Das Dokument geht
|
||||||
|
* auf IN_FREIGABE und es entsteht eine Aufgabe (policy_approval) für den Freigeber.
|
||||||
|
* Freigabe/Ablehnung erfolgen im Aufgaben-Modul (actions/tasks.ts).
|
||||||
|
*/
|
||||||
|
export async function submitForApproval(code: string, formData: FormData) {
|
||||||
const { session, db } = await guard();
|
const { session, db } = await guard();
|
||||||
|
const approverId = String(formData.get("approverId") ?? "").trim();
|
||||||
|
if (!approverId) throw new Error("Bitte einen Freigeber auswählen.");
|
||||||
|
if (approverId === session.user.id) throw new Error("Vier-Augen-Prinzip: Der Freigeber muss eine andere Person sein.");
|
||||||
|
|
||||||
const doc = await db.policyDocument.findFirst({ where: { code } });
|
const doc = await db.policyDocument.findFirst({ where: { code } });
|
||||||
if (!doc) throw new Error("Dokument nicht gefunden");
|
if (!doc) throw new Error("Dokument nicht gefunden");
|
||||||
|
|
||||||
|
const approver = await db.user.findFirst({
|
||||||
|
where: { id: approverId, status: "ACTIVE", userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "policy:approve" } } } } } } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!approver) throw new Error("Ungültiger Freigeber (kein aktiver Nutzer mit Freigaberecht).");
|
||||||
|
|
||||||
|
// Vorherige offene Freigabe-Aufgaben dieses Dokuments abschließen (Resubmit).
|
||||||
|
await db.task.updateMany({ where: { entityType: "policy_document", entityId: doc.id, status: "OPEN" }, data: { status: "CANCELLED" } });
|
||||||
|
|
||||||
await db.policyDocument.update({ where: { id: doc.id }, data: { status: "IN_FREIGABE", submittedBy: session.user.id, approvedBy: null, approvedAt: null } });
|
await db.policyDocument.update({ where: { id: doc.id }, data: { status: "IN_FREIGABE", submittedBy: session.user.id, approvedBy: null, approvedAt: null } });
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "IN_FREIGABE" } });
|
|
||||||
revalidatePath("/policies", "layout");
|
|
||||||
redirect(`/policies/${encodeURIComponent(code)}/edit`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function approvePolicy(code: string) {
|
const note = str(formData.get("note"));
|
||||||
const { session, db } = await guard("policy:approve");
|
const task = await db.task.create({
|
||||||
const doc = await db.policyDocument.findFirst({ where: { code } });
|
data: {
|
||||||
if (!doc) throw new Error("Dokument nicht gefunden");
|
tenantId: session.user.tenantId, type: "policy_approval",
|
||||||
if (doc.status !== "IN_FREIGABE") throw new Error("Dokument ist nicht in Freigabe");
|
title: `Freigabe: ${doc.code} — ${doc.title}`,
|
||||||
// Vier-Augen: Freigebender ≠ Einreichender
|
entityType: "policy_document", entityId: doc.id, entityRef: doc.code,
|
||||||
if (doc.submittedBy && doc.submittedBy === session.user.id) {
|
assigneeId: approver.id, createdById: session.user.id, status: "OPEN",
|
||||||
throw new Error("Vier-Augen-Prinzip: Freigabe muss durch eine andere Person als den Einreichenden erfolgen.");
|
comments: note ? { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "submit", body: note } } : undefined,
|
||||||
}
|
},
|
||||||
await db.policyDocument.update({ where: { id: doc.id }, data: { status: "FREIGEGEBEN", approvedBy: session.user.id, approvedAt: new Date() } });
|
});
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "FREIGEGEBEN" } });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "IN_FREIGABE", approverId: approver.id, taskId: task.id } });
|
||||||
revalidatePath("/policies", "layout");
|
|
||||||
redirect(`/policies/${encodeURIComponent(code)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rejectPolicy(code: string, formData: FormData) {
|
|
||||||
const { session, db } = await guard("policy:approve");
|
|
||||||
const doc = await db.policyDocument.findFirst({ where: { code } });
|
|
||||||
if (!doc) throw new Error("Dokument nicht gefunden");
|
|
||||||
const reason = str(formData.get("reason"));
|
|
||||||
await db.policyDocument.update({ where: { id: doc.id }, data: { status: "ENTWURF", submittedBy: null } });
|
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "ENTWURF", rejected: true, reason } });
|
|
||||||
revalidatePath("/policies", "layout");
|
revalidatePath("/policies", "layout");
|
||||||
|
revalidatePath("/tasks");
|
||||||
redirect(`/policies/${encodeURIComponent(code)}/edit`);
|
redirect(`/policies/${encodeURIComponent(code)}/edit`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufgaben-Modul (generisch). Erster Typ: policy_approval — Freigabe/Ablehnung
|
||||||
|
* einer Richtlinie durch den zugewiesenen Freigeber. Vier-Augen: der Freigeber
|
||||||
|
* ist eine andere Person als der Einreicher. Kommentare werden historisiert.
|
||||||
|
*/
|
||||||
|
const guard = moduleGuard("tasks");
|
||||||
|
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
|
||||||
|
|
||||||
|
/** Richtlinie freigeben (nur zugewiesener Freigeber, ≠ Einreicher). */
|
||||||
|
export async function approveTask(taskId: string, formData: FormData) {
|
||||||
|
const { session, db } = await guard("policy:approve");
|
||||||
|
const task = await db.task.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task || task.status !== "OPEN") throw new Error("Aufgabe ist nicht (mehr) offen.");
|
||||||
|
if (task.assigneeId !== session.user.id) throw new Error("Nur der zugewiesene Freigeber kann diese Aufgabe bearbeiten.");
|
||||||
|
if (task.createdById === session.user.id) throw new Error("Vier-Augen-Prinzip: nicht selbst freigeben.");
|
||||||
|
|
||||||
|
if (task.type === "policy_approval" && task.entityId) {
|
||||||
|
await db.policyDocument.update({ where: { id: task.entityId }, data: { status: "FREIGEGEBEN", approvedBy: session.user.id, approvedAt: new Date() } });
|
||||||
|
}
|
||||||
|
const note = str(formData.get("note"));
|
||||||
|
await db.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
status: "DONE", resolvedById: session.user.id, resolvedAt: new Date(),
|
||||||
|
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "approve", body: note || "Freigegeben." } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "DONE", approved: true } });
|
||||||
|
revalidatePath("/tasks");
|
||||||
|
revalidatePath("/policies", "layout");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Richtlinie ablehnen (mit Grund) — zurück auf ENTWURF. */
|
||||||
|
export async function rejectTask(taskId: string, formData: FormData) {
|
||||||
|
const { session, db } = await guard("policy:approve");
|
||||||
|
const task = await db.task.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task || task.status !== "OPEN") throw new Error("Aufgabe ist nicht (mehr) offen.");
|
||||||
|
if (task.assigneeId !== session.user.id) throw new Error("Nur der zugewiesene Freigeber kann diese Aufgabe bearbeiten.");
|
||||||
|
const reason = str(formData.get("note"));
|
||||||
|
if (!reason) throw new Error("Bitte einen Ablehnungsgrund angeben.");
|
||||||
|
|
||||||
|
if (task.type === "policy_approval" && task.entityId) {
|
||||||
|
await db.policyDocument.update({ where: { id: task.entityId }, data: { status: "ENTWURF", submittedBy: null } });
|
||||||
|
}
|
||||||
|
await db.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
status: "REJECTED", resolvedById: session.user.id, resolvedAt: new Date(),
|
||||||
|
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "reject", body: reason } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "REJECTED", reason } });
|
||||||
|
revalidatePath("/tasks");
|
||||||
|
revalidatePath("/policies", "layout");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kommentar zu einer Aufgabe (nur Beteiligte: Freigeber oder Einreicher). */
|
||||||
|
export async function commentTask(taskId: string, formData: FormData) {
|
||||||
|
const { session, db } = await guard();
|
||||||
|
const task = await db.task.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task) throw new Error("Aufgabe nicht gefunden.");
|
||||||
|
if (task.assigneeId !== session.user.id && task.createdById !== session.user.id) {
|
||||||
|
throw new Error("Nur Beteiligte können kommentieren.");
|
||||||
|
}
|
||||||
|
const body = str(formData.get("body"));
|
||||||
|
if (!body) return;
|
||||||
|
await db.taskComment.create({ data: { tenantId: session.user.tenantId, taskId, authorId: session.user.id, kind: "comment", body } });
|
||||||
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "task_comment", entityId: taskId });
|
||||||
|
revalidatePath("/tasks");
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ const TENANT_MODELS = new Set<string>([
|
|||||||
"User",
|
"User",
|
||||||
"Role",
|
"Role",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
|
"Task",
|
||||||
|
"TaskComment",
|
||||||
"TenantSettings",
|
"TenantSettings",
|
||||||
"TenantModule",
|
"TenantModule",
|
||||||
"Asset",
|
"Asset",
|
||||||
|
|||||||
Reference in New Issue
Block a user