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:
v0andcetinmustafa83 committed 2026-07-31 13:34:58 +00:00
1 parent e6d82083d7
commit 927aae8580
24 files changed
+6565 -1

No files matched your search

+204
View File
@@ -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&apos;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>
);
}
+26
View File
@@ -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&apos;s legal and data protection compliance
framework
</p>
</div>
{children}
</div>
</div>
);
}
+83
View File
@@ -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 }
);
}
}
+26
View File
@@ -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 }
);
}
}
+167
View File
@@ -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 }
);
}
}
+85
View File
@@ -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 }
);
}
}