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

@@ -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 }
);
}
}