feat: add new legal document and database schema for module L compliance
Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com>
This commit is contained in:
24 files changed
+6565
-1
No files matched your search
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CheckCircle, AlertCircle, FileText, Users, Clock } from "lucide-react";
|
||||
|
||||
interface ComplianceData {
|
||||
status: any;
|
||||
policiesConfigured: number;
|
||||
policiesReviewed: number;
|
||||
dpiaRecords: any[];
|
||||
consultations: any[];
|
||||
}
|
||||
|
||||
export default function ComplianceDashboard() {
|
||||
const [data, setData] = useState<ComplianceData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/compliance/status");
|
||||
if (!response.ok) throw new Error("Failed to fetch compliance status");
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (error) {
|
||||
console.error("[v0] Error fetching compliance data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-slate-600">Loading compliance status...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-red-600">Failed to load compliance dashboard</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">Compliance Monitoring Dashboard</h1>
|
||||
<p className="text-slate-600 mt-2">
|
||||
Track your school's data protection compliance status and document management
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{data.status?.onboardingCompleted ? (
|
||||
<CheckCircle className="w-8 h-8 text-emerald-600" />
|
||||
) : (
|
||||
<AlertCircle className="w-8 h-8 text-yellow-600" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-slate-600">Onboarding Status</p>
|
||||
<p className="font-semibold text-slate-900">
|
||||
{data.status?.onboardingCompleted ? "Complete" : "Pending"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-600">Data Retention Policies</p>
|
||||
<p className="font-semibold text-slate-900">
|
||||
{data.policiesConfigured} / {data.policiesReviewed} reviewed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-purple-600" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-600">DPIA Records</p>
|
||||
<p className="font-semibold text-slate-900">{data.dpiaRecords.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-8 h-8 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-600">Consultations</p>
|
||||
<p className="font-semibold text-slate-900">{data.consultations.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Data Retention Policies */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Data Retention Policies</CardTitle>
|
||||
<CardDescription>
|
||||
Configured retention periods for each data category
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-slate-600">
|
||||
<p>{data.policiesConfigured} policies configured</p>
|
||||
<p className="mt-2">Policies are centrally defined and reviewed by your DPO.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* DPIA Records */}
|
||||
{data.dpiaRecords.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DPIA Records</CardTitle>
|
||||
<CardDescription>
|
||||
Data Protection Impact Assessments for high-risk modules
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{data.dpiaRecords.map((dpia: any) => (
|
||||
<div
|
||||
key={dpia.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
|
||||
>
|
||||
<span className="text-sm font-medium text-slate-900">
|
||||
{dpia.moduleScope}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{dpia.approvedAt ? "✓ Approved" : "Pending review"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Consultation Log */}
|
||||
{data.consultations.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Consultation Log</CardTitle>
|
||||
<CardDescription>
|
||||
Staff and parent body consultations per module
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{data.consultations.slice(0, 5).map((consult: any) => (
|
||||
<div
|
||||
key={consult.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{consult.consultationType}
|
||||
</p>
|
||||
{consult.moduleScope && (
|
||||
<p className="text-xs text-slate-500">{consult.moduleScope}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
{new Date(consult.acknowledgedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
export default function ComplianceLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-2">
|
||||
Compliance & Data Protection Setup
|
||||
</h1>
|
||||
<p className="text-lg text-slate-600">
|
||||
Configure your school's legal and data protection compliance
|
||||
framework
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ComplianceWizard } from "@/components/compliance-wizard";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function CompliancePage() {
|
||||
const router = useRouter();
|
||||
const [schoolId, setSchoolId] = useState<string | null>(null);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// In a real app, fetch the current user's schoolId from session/auth
|
||||
// For now, we'll get it from the URL or localStorage
|
||||
const fetchSchoolId = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/user/current");
|
||||
if (!response.ok) throw new Error("Failed to fetch user");
|
||||
const user = await response.json();
|
||||
if (user.schoolId) {
|
||||
setSchoolId(user.schoolId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] Error fetching school ID:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSchoolId();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schoolId) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-red-600">Unable to load compliance setup. Please ensure you are logged in.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isCompleted ? (
|
||||
<Card className="border-emerald-200 bg-emerald-50">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-emerald-800 mb-4">
|
||||
✓ Compliance onboarding completed successfully! Your school is now ready to use ExaMetra.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/admin/compliance-overview")}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700"
|
||||
>
|
||||
Go to Compliance Dashboard →
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ComplianceWizard
|
||||
schoolId={schoolId}
|
||||
onComplete={(data) => {
|
||||
console.log("[v0] Wizard completed:", data);
|
||||
setIsCompleted(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { canApproveDeletion } from "@/lib/auth";
|
||||
import { getSession } from "@/lib/auth";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* POST /api/v1/compliance/deletions/approve
|
||||
* DPO approves deletion of flagged records
|
||||
* Body: { flagIds: string[] }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Verify DPO role
|
||||
const session = await getSession();
|
||||
if (!session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const canApprove = await canApproveDeletion(session.userId);
|
||||
if (!canApprove) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only DPO can approve deletions" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { flagIds } = body;
|
||||
|
||||
if (!flagIds || !Array.isArray(flagIds)) {
|
||||
return NextResponse.json(
|
||||
{ error: "flagIds must be an array" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Approve the flags
|
||||
const approved = await prisma.deletionFlag.updateMany({
|
||||
where: {
|
||||
id: { in: flagIds },
|
||||
},
|
||||
data: {
|
||||
approvedByUserId: session.userId,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Log approval to audit
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
schoolId: session.user?.schoolId,
|
||||
userId: session.userId,
|
||||
action: "DELETION_FLAGS_APPROVED",
|
||||
entityType: "DeletionFlag",
|
||||
metadata: JSON.stringify({
|
||||
flagCount: approved.count,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
approvedCount: approved.count,
|
||||
message: `${approved.count} deletion(s) approved`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error approving deletions:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to approve deletions" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/compliance/deletions/pending
|
||||
* Get pending deletion flags awaiting DPO approval
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const schoolId = req.nextUrl.searchParams.get("schoolId");
|
||||
if (!schoolId) {
|
||||
return NextResponse.json(
|
||||
{ error: "schoolId parameter required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const pending = await prisma.deletionFlag.findMany({
|
||||
where: {
|
||||
schoolId,
|
||||
approvedAt: null, // Not yet approved
|
||||
deletedAt: null, // Not yet hard-deleted
|
||||
},
|
||||
orderBy: { flaggedAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
count: pending.length,
|
||||
deletionFlags: pending,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error fetching pending deletions:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch pending deletions" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { checkModuleAccess } from "@/lib/compliance";
|
||||
|
||||
/**
|
||||
* Check if a module can be accessed based on compliance status
|
||||
* GET /api/v1/compliance/module-access?schoolId=&module=AI_TUTOR
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const schoolId = req.nextUrl.searchParams.get("schoolId");
|
||||
const module = req.nextUrl.searchParams.get("module");
|
||||
|
||||
if (!schoolId || !module) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing schoolId or module parameter" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await checkModuleAccess(schoolId, module);
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("[v0] Error checking module access:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to check module access" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { schoolId, state } = body;
|
||||
|
||||
if (!schoolId || !state) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save DPIA records for required modules
|
||||
for (const [moduleId, dpiaData] of Object.entries(
|
||||
state.dpiaModules || {}
|
||||
)) {
|
||||
const dpia = dpiaData as any;
|
||||
if (dpia.completed) {
|
||||
// Check if record exists
|
||||
const existing = await prisma.dpiaRecord.findUnique({
|
||||
where: {
|
||||
schoolId_moduleScope: {
|
||||
schoolId,
|
||||
moduleScope: moduleId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.dpiaRecord.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
dataProcessingSummary: `DPIA for ${moduleId} completed`,
|
||||
completedAt: new Date(),
|
||||
documentUrl: dpia.documentUrl || `dpia-${moduleId}.pdf`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.dpiaRecord.create({
|
||||
data: {
|
||||
schoolId,
|
||||
moduleScope: moduleId,
|
||||
dataProcessingSummary: `DPIA for ${moduleId} completed`,
|
||||
completedAt: new Date(),
|
||||
documentUrl: dpia.documentUrl || `dpia-${moduleId}.pdf`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save consultation records
|
||||
for (const [moduleId, consultations] of Object.entries(
|
||||
state.moduleConsultations || {}
|
||||
)) {
|
||||
const consult = consultations as any;
|
||||
const consultationTypes = [];
|
||||
|
||||
if (consult.elternbeirat) {
|
||||
consultationTypes.push("ELTERNBEIRAT");
|
||||
}
|
||||
if (consult.schulkonferenz) {
|
||||
consultationTypes.push("SCHULKONFERENZ");
|
||||
}
|
||||
if (consult.personalrat) {
|
||||
consultationTypes.push("PERSONALRAT");
|
||||
}
|
||||
|
||||
for (const type of consultationTypes) {
|
||||
await prisma.complianceConsultation.create({
|
||||
data: {
|
||||
schoolId,
|
||||
consultationType: type,
|
||||
moduleScope: moduleId,
|
||||
acknowledgedByUserId: "system", // In production, get from current user
|
||||
acknowledgedAt: new Date(),
|
||||
notes: `Consultation acknowledged for ${moduleId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mark onboarding as completed
|
||||
await prisma.schoolComplianceStatus.update({
|
||||
where: { schoolId },
|
||||
data: {
|
||||
onboardingCompleted: true,
|
||||
moduleGates: JSON.stringify(
|
||||
Object.keys(state.dpiaModules || {}).reduce(
|
||||
(acc: any, moduleId) => {
|
||||
const dpia = (state.dpiaModules || {})[moduleId] as any;
|
||||
acc[moduleId] = {
|
||||
isApprovedForUse: dpia.completed,
|
||||
diaCompleted: dpia.completed,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
)
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// Log to audit trail
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
schoolId,
|
||||
action: "COMPLIANCE_ONBOARDING_COMPLETED",
|
||||
entityType: "SchoolComplianceStatus",
|
||||
entityId: schoolId,
|
||||
metadata: JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
modulesEnabled: Object.keys(state.dpiaModules || {}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Compliance onboarding completed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error completing compliance onboarding:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to complete compliance onboarding" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
ensureComplianceStatus,
|
||||
initializeRetentionPolicies,
|
||||
getRetentionDefaults
|
||||
} from "@/lib/compliance";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const {
|
||||
schoolId,
|
||||
dpoName,
|
||||
dpoEmail,
|
||||
schultragerName,
|
||||
schultragerEmail,
|
||||
bundesland,
|
||||
} = body;
|
||||
|
||||
if (!schoolId || !dpoName || !dpoEmail || !schultragerName || !schultragerEmail || !bundesland) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure compliance status exists
|
||||
const complianceStatus = await ensureComplianceStatus(schoolId);
|
||||
if (!complianceStatus) {
|
||||
throw new Error("Failed to create compliance status");
|
||||
}
|
||||
|
||||
// Update school compliance status with contact info
|
||||
await prisma.schoolComplianceStatus.update({
|
||||
where: { schoolId },
|
||||
data: {
|
||||
bundesland,
|
||||
dpoDpoContactEmail: dpoEmail,
|
||||
schultragerContactEmail: schultragerEmail,
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize retention policies for this Bundesland
|
||||
const policyInitialized = await initializeRetentionPolicies(schoolId, bundesland);
|
||||
if (!policyInitialized) {
|
||||
throw new Error("Failed to initialize retention policies");
|
||||
}
|
||||
|
||||
// Log to audit trail
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
schoolId,
|
||||
action: "COMPLIANCE_SETUP_STEP_1",
|
||||
entityType: "SchoolComplianceStatus",
|
||||
entityId: schoolId,
|
||||
metadata: JSON.stringify({
|
||||
dpoName,
|
||||
bundesland,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Step 1 saved successfully",
|
||||
retentionDefaults: getRetentionDefaults(bundesland),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error in compliance step 1:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to save compliance setup" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getComplianceStatus } from "@/lib/compliance";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// In production, get schoolId from session/auth
|
||||
const schoolId = req.nextUrl.searchParams.get("schoolId") || "default";
|
||||
|
||||
const status = await getComplianceStatus(schoolId);
|
||||
|
||||
if (!status) {
|
||||
return NextResponse.json(
|
||||
{ error: "School not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(status);
|
||||
} catch (error) {
|
||||
console.error("[v0] Error fetching compliance status:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch compliance status" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { logComplianceAudit } from "@/lib/audit";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* POST /api/v1/gdpr/erasure
|
||||
* Right to Erasure / Right to be Forgotten (Art. 17 GDPR)
|
||||
* User can request deletion of their data
|
||||
* Note: Some data (final grades) may be retained due to legal obligations
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { userId: requestedUserId, reason } = await req.json();
|
||||
|
||||
// Users can only request erasure of their own data
|
||||
if (session.userId !== requestedUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: "You can only request erasure of your own data" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: requestedUserId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Determine what can be deleted vs. retained
|
||||
const retained = {
|
||||
FINAL_REPORT: "Permanent legal record (Schulgesetz requirement)",
|
||||
DISCIPLINARY_RECORDS_WITH_CONSEQUENCES:
|
||||
"Retained per applicable retention policy",
|
||||
};
|
||||
|
||||
const erasedCategories = [
|
||||
"MESSAGES",
|
||||
"NOTEBOOKS",
|
||||
"LEARNING_PROGRESS_ENTRIES",
|
||||
"AI_TUTOR_LOGS",
|
||||
];
|
||||
|
||||
// Delete erasable data
|
||||
const deletionResults = {
|
||||
messagesDeleted: 0,
|
||||
notebooksDeleted: 0,
|
||||
learningProgressDeleted: 0,
|
||||
auditLogsDeleted: 0,
|
||||
};
|
||||
|
||||
// Delete messages
|
||||
const messageResult = await prisma.parentMessage.deleteMany({
|
||||
where: { OR: [{ senderId: requestedUserId }, { recipientId: requestedUserId }] },
|
||||
});
|
||||
deletionResults.messagesDeleted = messageResult.count;
|
||||
|
||||
// Delete notebooks (student-owned)
|
||||
if (user.student?.id) {
|
||||
const notebookResult = await prisma.notebook.deleteMany({
|
||||
where: { studentId: user.student.id },
|
||||
});
|
||||
deletionResults.notebooksDeleted = notebookResult.count;
|
||||
}
|
||||
|
||||
// Delete learning progress entries
|
||||
const lpResult = await prisma.learningProgressEntry.deleteMany({
|
||||
where: { userId: requestedUserId },
|
||||
});
|
||||
deletionResults.learningProgressDeleted = lpResult.count;
|
||||
|
||||
// Note: Audit logs are NOT deleted for audit trail integrity
|
||||
|
||||
// Log the erasure request
|
||||
await logComplianceAudit({
|
||||
userId: requestedUserId,
|
||||
schoolId: user.schoolId || undefined,
|
||||
action: "ERASURE_REQUEST_APPROVED",
|
||||
entityType: "User",
|
||||
entityId: requestedUserId,
|
||||
dataSubject: requestedUserId,
|
||||
metadata: {
|
||||
reason,
|
||||
erasedCategories,
|
||||
deletionResults,
|
||||
retainedCategories: retained,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Your erasure request has been processed",
|
||||
deleted: deletionResults,
|
||||
retained: retained,
|
||||
note: "Final grades and disciplinary records with consequences are retained per legal requirements. Contact your DPO for details.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error processing erasure request:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to process erasure request" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/gdpr/erasure/status
|
||||
* Check status of erasure requests for a user
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = req.nextUrl.searchParams.get("userId") || session.userId;
|
||||
|
||||
// Users can only check their own status
|
||||
if (session.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "You can only check your own erasure status" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get erasure-related audit logs
|
||||
const erasureLogs = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
dataSubject: userId,
|
||||
action: { contains: "ERASURE" },
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
timestamp: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
userId,
|
||||
recentRequests: erasureLogs.map((log) => ({
|
||||
...log,
|
||||
metadata: log.metadata ? JSON.parse(log.metadata) : null,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error checking erasure status:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to check erasure status" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { getAuditTrailForDataSubject } from "@/lib/audit";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* POST /api/v1/gdpr/export
|
||||
* Right of Access (Art. 15 GDPR)
|
||||
* User can request export of all their personal data
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { userId: requestedUserId } = await req.json();
|
||||
|
||||
// Users can only export their own data; admins can export any user's data
|
||||
const isAdmin = session.user?.role === "SCHOOL_ADMIN" || session.user?.role === "SUPER_ADMIN";
|
||||
if (!isAdmin && session.userId !== requestedUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: "You can only export your own data" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all personal data
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: requestedUserId },
|
||||
include: {
|
||||
student: true,
|
||||
learningProgressEntries: true,
|
||||
assessments: true,
|
||||
notebooks: true,
|
||||
parentMessagesSent: true,
|
||||
auditLogs: { take: 100 },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get audit trail for this user
|
||||
const auditTrail = await getAuditTrailForDataSubject(user.schoolId!, requestedUserId);
|
||||
|
||||
// Compile export data
|
||||
const exportData = {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
student: user.student || null,
|
||||
learningProgress: user.learningProgressEntries || [],
|
||||
assessments: user.assessments || [],
|
||||
notebooks: user.notebooks || [],
|
||||
messages: user.parentMessagesSent || [],
|
||||
auditTrail: auditTrail,
|
||||
exportedAt: new Date().toISOString(),
|
||||
retentionNote:
|
||||
"Some data (e.g., final grades) may be retained longer due to legal requirements. See your school's privacy notice.",
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: exportData,
|
||||
format: "json",
|
||||
message: "Your personal data export is ready",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error exporting user data:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to export data" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AlertCircle, CheckCircle, ChevronRight, ChevronLeft } from "lucide-react";
|
||||
|
||||
const BUNDESLANDS = [
|
||||
"BERLIN",
|
||||
"BREMEN",
|
||||
"HAMBURG",
|
||||
"HESSE",
|
||||
"LOWER_SAXONY",
|
||||
"MECKLENBURG_VORPOMMERN",
|
||||
"NORTH_RHINE_WESTPHALIA",
|
||||
"RHINELAND_PALATINATE",
|
||||
"SAARLAND",
|
||||
"SAXONY",
|
||||
"SAXONY_ANHALT",
|
||||
"SCHLESWIG_HOLSTEIN",
|
||||
"THURINGIA",
|
||||
"BADEN_WUERTTEMBERG",
|
||||
"BAVARIA",
|
||||
"BRANDENBURG",
|
||||
];
|
||||
|
||||
const MODULES = [
|
||||
{ id: "AI_TUTOR", label: "AI Tutor", requiresDpia: true },
|
||||
{ id: "EXAM_MODE", label: "Exam Mode", requiresDpia: true },
|
||||
{ id: "WELLNESS_CHECKS", label: "Wellness Checks", requiresDpia: false },
|
||||
{ id: "SIGNAGE_EMERGENCY", label: "Signage & Emergency", requiresDpia: false },
|
||||
{ id: "MESSAGING", label: "Messaging", requiresDpia: false },
|
||||
{ id: "LEARNING_ANALYTICS", label: "Learning Analytics", requiresDpia: false },
|
||||
];
|
||||
|
||||
interface WizardState {
|
||||
step: number;
|
||||
schoolName?: string;
|
||||
dpoName?: string;
|
||||
dpoEmail?: string;
|
||||
schultragerName?: string;
|
||||
schultragerEmail?: string;
|
||||
bundesland?: string;
|
||||
consultationTypes: string[];
|
||||
dpiaModules: Record<string, { completed: boolean; documentUrl?: string }>;
|
||||
moduleConsultations: Record<
|
||||
string,
|
||||
{
|
||||
elternbeirat: boolean;
|
||||
personalrat: boolean;
|
||||
schulkonferenz: boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
interface ComplianceWizardProps {
|
||||
schoolId: string;
|
||||
onComplete?: (data: WizardState) => void;
|
||||
}
|
||||
|
||||
export function ComplianceWizard({ schoolId, onComplete }: ComplianceWizardProps) {
|
||||
const [state, setState] = useState<WizardState>({
|
||||
step: 1,
|
||||
consultationTypes: [],
|
||||
dpiaModules: MODULES.reduce(
|
||||
(acc, mod) => ({
|
||||
...acc,
|
||||
[mod.id]: { completed: false },
|
||||
}),
|
||||
{}
|
||||
),
|
||||
moduleConsultations: MODULES.reduce(
|
||||
(acc, mod) => ({
|
||||
...acc,
|
||||
[mod.id]: {
|
||||
elternbeirat: false,
|
||||
personalrat: mod.requiresDpia,
|
||||
schulkonferenz: false,
|
||||
},
|
||||
}),
|
||||
{}
|
||||
),
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isStep1Valid =
|
||||
state.schoolName &&
|
||||
state.dpoName &&
|
||||
state.dpoEmail &&
|
||||
state.schultragerName &&
|
||||
state.schultragerEmail &&
|
||||
state.bundesland;
|
||||
|
||||
const isStep2Valid = true; // Retention policies auto-seeded
|
||||
|
||||
const isStep3Valid = Object.keys(state.moduleConsultations).length > 0;
|
||||
|
||||
const isStep4Valid =
|
||||
MODULES.filter((m) => m.requiresDpia).every(
|
||||
(m) => state.dpiaModules[m.id]?.completed
|
||||
);
|
||||
|
||||
const handleStep1Submit = async () => {
|
||||
if (!isStep1Valid) {
|
||||
setError("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/v1/compliance/onboarding/step1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schoolId,
|
||||
dpoName: state.dpoName,
|
||||
dpoEmail: state.dpoEmail,
|
||||
schultragerName: state.schultragerName,
|
||||
schultragerEmail: state.schultragerEmail,
|
||||
bundesland: state.bundesland,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to save step 1");
|
||||
setState((prev) => ({ ...prev, step: 2 }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStep4Submit = async () => {
|
||||
if (!isStep4Valid) {
|
||||
setError("Please complete all required DPIAs");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/v1/compliance/onboarding/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schoolId,
|
||||
state,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to complete onboarding");
|
||||
onComplete?.(state);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Progress Indicator */}
|
||||
<div className="flex justify-between items-center">
|
||||
{[1, 2, 3, 4, 5].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`flex items-center ${step < 5 ? "flex-1" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
|
||||
state.step >= step
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{state.step > step ? (
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
) : (
|
||||
step
|
||||
)}
|
||||
</div>
|
||||
{step < 5 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 ${
|
||||
state.step > step ? "bg-emerald-600" : "bg-slate-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="pt-6 flex gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-800">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 1: School & Contact Info */}
|
||||
{state.step === 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>School & Contact Information</CardTitle>
|
||||
<CardDescription>
|
||||
Provide your school's basic information and designated contacts
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
School Name
|
||||
</label>
|
||||
<Input
|
||||
value={state.schoolName || ""}
|
||||
onChange={(e) =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
schoolName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g., Gymnasium Berlin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Data Protection Officer (DPO) Name
|
||||
</label>
|
||||
<Input
|
||||
value={state.dpoName || ""}
|
||||
onChange={(e) =>
|
||||
setState((prev) => ({ ...prev, dpoName: e.target.value }))
|
||||
}
|
||||
placeholder="Full name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
DPO Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={state.dpoEmail || ""}
|
||||
onChange={(e) =>
|
||||
setState((prev) => ({ ...prev, dpoEmail: e.target.value }))
|
||||
}
|
||||
placeholder="dpo@school.de"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Schulträger (School Provider) Name
|
||||
</label>
|
||||
<Input
|
||||
value={state.schultragerName || ""}
|
||||
onChange={(e) =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
schultragerName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g., City of Berlin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Schulträger Contact Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={state.schultragerEmail || ""}
|
||||
onChange={(e) =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
schultragerEmail: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="admin@schultraeger.de"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
State (Bundesland)
|
||||
</label>
|
||||
<Select value={state.bundesland} onValueChange={(value) =>
|
||||
setState((prev) => ({ ...prev, bundesland: value }))
|
||||
}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select state..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BUNDESLANDS.map((land) => (
|
||||
<SelectItem key={land} value={land}>
|
||||
{land.replace(/_/g, " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t">
|
||||
<Button onClick={handleStep1Submit} disabled={!isStep1Valid || loading}>
|
||||
{loading ? "Saving..." : "Next"}
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Retention Periods (coming in next iteration) */}
|
||||
{state.step === 2 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Data Retention Periods</CardTitle>
|
||||
<CardDescription>
|
||||
Configure retention periods for each data category (state-specific defaults provided)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-slate-600">
|
||||
Retention periods have been pre-seeded based on your state's regulations.
|
||||
Your DPO should review and confirm these settings.
|
||||
</p>
|
||||
</CardContent>
|
||||
<div className="flex justify-between gap-3 px-6 py-4 border-t">
|
||||
<Button variant="outline" onClick={() => setState((prev) => ({ ...prev, step: 1 }))}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={() => setState((prev) => ({ ...prev, step: 3 }))}>
|
||||
Next: Consultations
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Consultations */}
|
||||
{state.step === 3 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Consultation Checklist</CardTitle>
|
||||
<CardDescription>
|
||||
Log consultations with staff and parent bodies per module
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{MODULES.map((module) => (
|
||||
<div key={module.id} className="border-b pb-4 last:border-b-0">
|
||||
<h4 className="font-medium text-slate-900 mb-3">{module.label}</h4>
|
||||
<div className="space-y-2 pl-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={
|
||||
state.moduleConsultations[module.id]?.elternbeirat || false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
moduleConsultations: {
|
||||
...prev.moduleConsultations,
|
||||
[module.id]: {
|
||||
...prev.moduleConsultations[module.id],
|
||||
elternbeirat: checked === true,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
Elternbeirat (Parent Council) consulted
|
||||
</span>
|
||||
</label>
|
||||
{!module.requiresDpia && (
|
||||
<label className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={
|
||||
state.moduleConsultations[module.id]?.schulkonferenz || false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
moduleConsultations: {
|
||||
...prev.moduleConsultations,
|
||||
[module.id]: {
|
||||
...prev.moduleConsultations[module.id],
|
||||
schulkonferenz: checked === true,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
Schulkonferenz (School Conference) consulted
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{module.requiresDpia && (
|
||||
<label className="flex items-center gap-3">
|
||||
<Checkbox checked={true} disabled />
|
||||
<span className="text-sm text-slate-700">
|
||||
Personalrat (Staff Council) consulted{" "}
|
||||
<span className="text-xs text-slate-500">(required)</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="flex justify-between gap-3 px-6 py-4 border-t">
|
||||
<Button variant="outline" onClick={() => setState((prev) => ({ ...prev, step: 2 }))}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={() => setState((prev) => ({ ...prev, step: 4 }))}>
|
||||
Next: DPIA Documents
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 4: DPIA Gating */}
|
||||
{state.step === 4 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DPIA (Data Protection Impact Assessment)</CardTitle>
|
||||
<CardDescription>
|
||||
Upload completed DPIAs for high-risk modules before enabling them
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{MODULES.filter((m) => m.requiresDpia).map((module) => (
|
||||
<div key={module.id} className="border rounded-lg p-4">
|
||||
<h4 className="font-medium text-slate-900 mb-2">{module.label} DPIA</h4>
|
||||
<p className="text-sm text-slate-600 mb-3">
|
||||
Upload your completed DPIA document (PDF)
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
dpiaModules: {
|
||||
...prev.dpiaModules,
|
||||
[module.id]: {
|
||||
completed: true,
|
||||
documentUrl: e.target.files![0].name,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="block w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-emerald-50 file:text-emerald-700 hover:file:bg-emerald-100"
|
||||
/>
|
||||
{state.dpiaModules[module.id]?.documentUrl && (
|
||||
<p className="text-sm text-emerald-600 mt-2">
|
||||
✓ {state.dpiaModules[module.id].documentUrl}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
<div className="flex justify-between gap-3 px-6 py-4 border-t">
|
||||
<Button variant="outline" onClick={() => setState((prev) => ({ ...prev, step: 3 }))}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={() => setState((prev) => ({ ...prev, step: 5 }))}>
|
||||
Review & Complete
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 5: Summary & Complete */}
|
||||
{state.step === 5 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Compliance Setup Complete</CardTitle>
|
||||
<CardDescription>
|
||||
Review your configuration before finalizing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<p className="text-sm text-emerald-800">
|
||||
✓ All compliance requirements have been configured. Your school is now ready for
|
||||
production use.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-600 space-y-2">
|
||||
<p><strong>DPO:</strong> {state.dpoName} ({state.dpoEmail})</p>
|
||||
<p><strong>State:</strong> {state.bundesland}</p>
|
||||
<p><strong>Modules:</strong> {MODULES.length} configured</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="flex justify-between gap-3 px-6 py-4 border-t">
|
||||
<Button variant="outline" onClick={() => setState((prev) => ({ ...prev, step: 4 }))}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleStep4Submit} disabled={loading}>
|
||||
{loading ? "Completing..." : "Finalize Setup"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable → Regular
+134
@@ -138,3 +138,137 @@ export function withAuditLog(
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Module L: Compliance Audit Logging ────────────────────────────────
|
||||
|
||||
export interface ComplianceAuditParams extends AuditLogParams {
|
||||
dataSubject?: string; // Student ID if applicable (for GDPR export scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log compliance-specific actions (DPO approvals, policy changes, etc.)
|
||||
* Includes dataSubject field for tracking affected individuals
|
||||
*/
|
||||
export async function logComplianceAudit(params: ComplianceAuditParams): Promise<void> {
|
||||
try {
|
||||
let userId = params.userId;
|
||||
if (!userId) {
|
||||
const session = await getSession();
|
||||
userId = session?.user?.id ?? undefined;
|
||||
}
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: userId ?? null,
|
||||
schoolId: params.schoolId ?? null,
|
||||
action: params.action,
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId ?? null,
|
||||
changes: params.changes ? JSON.stringify(params.changes) : null,
|
||||
ipAddress: params.ipAddress ?? null,
|
||||
userAgent: params.userAgent ?? null,
|
||||
metadata: params.metadata ? JSON.stringify(params.metadata) : null,
|
||||
dataSubject: params.dataSubject ?? null, // Track affected individual
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Compliance audit log error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when a student's data is accessed (for audit trail)
|
||||
*/
|
||||
export async function logDataAccess(
|
||||
userId: string,
|
||||
studentId: string,
|
||||
action: string,
|
||||
schoolId?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await logComplianceAudit({
|
||||
userId,
|
||||
schoolId,
|
||||
action: `DATA_ACCESS_${action}`, // e.g., DATA_ACCESS_VIEW_GRADES
|
||||
entityType: "Student",
|
||||
entityId: studentId,
|
||||
dataSubject: studentId,
|
||||
metadata: {
|
||||
...metadata,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log DPO approval actions
|
||||
*/
|
||||
export async function logDPOApproval(
|
||||
dpoId: string,
|
||||
schoolId: string,
|
||||
approvalType: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await logComplianceAudit({
|
||||
userId: dpoId,
|
||||
schoolId,
|
||||
action: `DPO_APPROVAL_${approvalType}`,
|
||||
entityType: "ComplianceStatus",
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log policy updates
|
||||
*/
|
||||
export async function logPolicyUpdate(
|
||||
userId: string,
|
||||
schoolId: string,
|
||||
policyType: string,
|
||||
changes: AuditLogParams['changes'],
|
||||
): Promise<void> {
|
||||
await logComplianceAudit({
|
||||
userId,
|
||||
schoolId,
|
||||
action: "POLICY_UPDATE",
|
||||
entityType: policyType, // e.g., "DataRetentionPolicy"
|
||||
changes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query audit logs for a specific data subject (student)
|
||||
* Used for GDPR right-of-access requests
|
||||
*/
|
||||
export async function getAuditTrailForDataSubject(
|
||||
schoolId: string,
|
||||
studentId: string,
|
||||
limit: number = 100
|
||||
) {
|
||||
try {
|
||||
const logs = await db.auditLog.findMany({
|
||||
where: {
|
||||
schoolId,
|
||||
dataSubject: studentId,
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
entityType: true,
|
||||
userId: true,
|
||||
timestamp: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
return logs.map((log) => ({
|
||||
...log,
|
||||
metadata: log.metadata ? JSON.parse(log.metadata) : null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("[v0] Error querying audit trail:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Executable → Regular
+90
@@ -87,3 +87,93 @@ export async function clearSession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
}
|
||||
|
||||
// ─── Module L: Access Control & DPO Role ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if user has DPO (Data Protection Officer) role
|
||||
*/
|
||||
export async function isDPORole(userId: string): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionUserId = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionUserId || sessionUserId !== userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
return user?.role === 'DPO';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has admin role (SCHOOL_ADMIN or higher)
|
||||
*/
|
||||
export async function isAdminRole(userId: string): Promise<boolean> {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
return user?.role === 'SCHOOL_ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access compliance features (DPO or Admin)
|
||||
*/
|
||||
export async function canAccessCompliance(userId: string): Promise<boolean> {
|
||||
const isDPO = await isDPORole(userId);
|
||||
const isAdmin = await isAdminRole(userId);
|
||||
return isDPO || isAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user can perform compliance actions
|
||||
* Returns { allowed: boolean, reason?: string }
|
||||
*/
|
||||
export async function verifyComplianceAccess(
|
||||
userId: string
|
||||
): Promise<{ allowed: boolean; reason?: string }> {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId, deletedAt: null },
|
||||
select: { role: true, schoolId: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { allowed: false, reason: 'User not found' };
|
||||
}
|
||||
|
||||
if (user.role !== 'DPO' && user.role !== 'SCHOOL_ADMIN' && user.role !== 'SUPER_ADMIN') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Only DPO and administrators can access compliance features',
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.schoolId) {
|
||||
return { allowed: false, reason: 'User not associated with a school' };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can view audit logs
|
||||
* Only DPO and admins can view full audit logs
|
||||
*/
|
||||
export async function canViewAuditLogs(userId: string): Promise<boolean> {
|
||||
const canAccess = await canAccessCompliance(userId);
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can approve deletion flags
|
||||
* Only DPO can approve data deletion
|
||||
*/
|
||||
export async function canApproveDeletion(userId: string): Promise<boolean> {
|
||||
const isDPO = await isDPORole(userId);
|
||||
return isDPO;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Module L: Legal & Data Protection Compliance Utilities
|
||||
* Provides compliance checks, DPIA gating, feature access control, and data retention helpers
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// ─── Data Categories & Retention Mapping ──────────────────────────────────
|
||||
|
||||
export const DATA_CATEGORIES = {
|
||||
LEARNING_PROGRESS: "LEARNING_PROGRESS",
|
||||
ASSESSMENT_RESULT: "ASSESSMENT_RESULT",
|
||||
FINAL_REPORT: "FINAL_REPORT",
|
||||
DISCIPLINARY_RECORD: "DISCIPLINARY_RECORD",
|
||||
EXAM_INCIDENT: "EXAM_INCIDENT",
|
||||
MESSAGE: "MESSAGE",
|
||||
AI_TUTOR_LOG: "AI_TUTOR_LOG",
|
||||
NOTEBOOK_PAGE: "NOTEBOOK_PAGE",
|
||||
SIGNAGE_LOG: "SIGNAGE_LOG",
|
||||
TEACHER_AUDIT_LOG: "TEACHER_AUDIT_LOG",
|
||||
} as const;
|
||||
|
||||
export const MODULE_DPIA_REQUIRED = ["AI_TUTOR", "EXAM_MODE"] as const;
|
||||
|
||||
export const BUNDESLAND_RETENTION_DEFAULTS: Record<
|
||||
string,
|
||||
Record<string, number>
|
||||
> = {
|
||||
BERLIN: {
|
||||
LEARNING_PROGRESS: 12,
|
||||
ASSESSMENT_RESULT: 12,
|
||||
FINAL_REPORT: 30,
|
||||
DISCIPLINARY_RECORD: 5,
|
||||
EXAM_INCIDENT: 6,
|
||||
MESSAGE: 12,
|
||||
AI_TUTOR_LOG: 6,
|
||||
NOTEBOOK_PAGE: 12,
|
||||
SIGNAGE_LOG: 3,
|
||||
TEACHER_AUDIT_LOG: 12,
|
||||
},
|
||||
BAYERN: {
|
||||
LEARNING_PROGRESS: 12,
|
||||
ASSESSMENT_RESULT: 12,
|
||||
FINAL_REPORT: 35,
|
||||
DISCIPLINARY_RECORD: 5,
|
||||
EXAM_INCIDENT: 6,
|
||||
MESSAGE: 12,
|
||||
AI_TUTOR_LOG: 6,
|
||||
NOTEBOOK_PAGE: 12,
|
||||
SIGNAGE_LOG: 3,
|
||||
TEACHER_AUDIT_LOG: 12,
|
||||
},
|
||||
// Add remaining 14 states with their specific retention rules
|
||||
// For now, using Berlin as fallback
|
||||
BADEN_WUERTTEMBERG: {
|
||||
LEARNING_PROGRESS: 12,
|
||||
ASSESSMENT_RESULT: 12,
|
||||
FINAL_REPORT: 30,
|
||||
DISCIPLINARY_RECORD: 5,
|
||||
EXAM_INCIDENT: 6,
|
||||
MESSAGE: 12,
|
||||
AI_TUTOR_LOG: 6,
|
||||
NOTEBOOK_PAGE: 12,
|
||||
SIGNAGE_LOG: 3,
|
||||
TEACHER_AUDIT_LOG: 12,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Compliance Status Checks ──────────────────────────────────────────────
|
||||
|
||||
export interface ComplianceCheckResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
missingItems?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module can be accessed by a school
|
||||
* Verifies: onboarding completion, DPIA presence (if required), and other gates
|
||||
*/
|
||||
export async function checkModuleAccess(
|
||||
schoolId: string,
|
||||
module: string
|
||||
): Promise<ComplianceCheckResult> {
|
||||
try {
|
||||
const complianceStatus = await prisma.schoolComplianceStatus.findUnique({
|
||||
where: { schoolId },
|
||||
});
|
||||
|
||||
if (!complianceStatus?.onboardingCompleted) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "Compliance onboarding incomplete. Please complete the onboarding wizard first.",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if module requires DPIA
|
||||
if (MODULE_DPIA_REQUIRED.includes(module as any)) {
|
||||
const dpiaRecord = await prisma.dpiaRecord.findUnique({
|
||||
where: {
|
||||
schoolId_moduleScope: {
|
||||
schoolId,
|
||||
moduleScope: module,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!dpiaRecord) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `DPIA documentation not found for ${module}. Please upload DPIA before enabling this module.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
} catch (error) {
|
||||
console.error("[v0] Error checking module access:", error);
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "Error checking compliance status",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retention period for a data category
|
||||
* Returns the months after which data should be flagged for deletion
|
||||
*/
|
||||
export async function getRetentionPeriod(
|
||||
schoolId: string,
|
||||
dataCategory: string
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const policy = await prisma.dataRetentionPolicy.findFirst({
|
||||
where: {
|
||||
schoolId,
|
||||
dataCategory,
|
||||
},
|
||||
});
|
||||
|
||||
return policy?.retentionPeriodMonths || null;
|
||||
} catch (error) {
|
||||
console.error("[v0] Error getting retention period:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag data entities for deletion when retention period expires
|
||||
* Called by daily retention job
|
||||
*/
|
||||
export async function flagExpiredRecords(schoolId: string): Promise<number> {
|
||||
try {
|
||||
const policies = await prisma.dataRetentionPolicy.findMany({
|
||||
where: { schoolId },
|
||||
});
|
||||
|
||||
let flaggedCount = 0;
|
||||
|
||||
for (const policy of policies) {
|
||||
if (!policy.retentionPeriodMonths) continue;
|
||||
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setMonth(expiryDate.getMonth() - policy.retentionPeriodMonths);
|
||||
|
||||
// This is a placeholder for entity-specific logic
|
||||
// In production, you'd loop over each entity type and flag accordingly
|
||||
flaggedCount++;
|
||||
}
|
||||
|
||||
return flaggedCount;
|
||||
} catch (error) {
|
||||
console.error("[v0] Error flagging expired records:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has DPO role
|
||||
*/
|
||||
export async function isDPO(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
return user?.role === "DPO";
|
||||
} catch (error) {
|
||||
console.error("[v0] Error checking DPO status:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get school compliance status summary
|
||||
*/
|
||||
export async function getComplianceStatus(schoolId: string) {
|
||||
try {
|
||||
const status = await prisma.schoolComplianceStatus.findUnique({
|
||||
where: { schoolId },
|
||||
});
|
||||
|
||||
const policies = await prisma.dataRetentionPolicy.findMany({
|
||||
where: { schoolId },
|
||||
});
|
||||
|
||||
const dpias = await prisma.dpiaRecord.findMany({
|
||||
where: { schoolId },
|
||||
});
|
||||
|
||||
const consultations = await prisma.complianceConsultation.findMany({
|
||||
where: { schoolId },
|
||||
});
|
||||
|
||||
return {
|
||||
status,
|
||||
policiesConfigured: policies.length,
|
||||
policiesReviewed: policies.filter((p) => p.reviewedAt).length,
|
||||
dpiaRecords: dpias,
|
||||
consultations,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[v0] Error getting compliance status:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure school compliance status record exists
|
||||
*/
|
||||
export async function ensureComplianceStatus(schoolId: string) {
|
||||
try {
|
||||
const existing = await prisma.schoolComplianceStatus.findUnique({
|
||||
where: { schoolId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return await prisma.schoolComplianceStatus.create({
|
||||
data: {
|
||||
schoolId,
|
||||
onboardingCompleted: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return existing;
|
||||
} catch (error) {
|
||||
console.error("[v0] Error ensuring compliance status:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize retention policies for a school based on Bundesland
|
||||
*/
|
||||
export async function initializeRetentionPolicies(
|
||||
schoolId: string,
|
||||
bundesland: string
|
||||
) {
|
||||
try {
|
||||
const defaults =
|
||||
BUNDESLAND_RETENTION_DEFAULTS[bundesland] ||
|
||||
BUNDESLAND_RETENTION_DEFAULTS.BERLIN;
|
||||
|
||||
for (const [category, months] of Object.entries(defaults)) {
|
||||
// Check if policy already exists
|
||||
const existing = await prisma.dataRetentionPolicy.findFirst({
|
||||
where: {
|
||||
schoolId,
|
||||
dataCategory: category,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.dataRetentionPolicy.create({
|
||||
data: {
|
||||
schoolId,
|
||||
dataCategory: category,
|
||||
retentionPeriodMonths: months,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[v0] Error initializing retention policies:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Bundesland retention defaults
|
||||
*/
|
||||
export function getRetentionDefaults(bundesland: string) {
|
||||
return (
|
||||
BUNDESLAND_RETENTION_DEFAULTS[bundesland] ||
|
||||
BUNDESLAND_RETENTION_DEFAULTS.BERLIN
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user