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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user