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,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