From 927aae85804a946883a2e8a56016fa39dae1e96d Mon Sep 17 00:00:00 2001 From: v0 Date: Fri, 31 Jul 2026 13:34:58 +0000 Subject: [PATCH] feat: add new legal document and database schema for module L compliance Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com> --- docs/legal/avv-template.md | 207 ++ prisma/db/custom.db | Bin 0 -> 2359296 bytes .../migration.sql | 3222 +++++++++++++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 109 +- scripts/generate-data-model.ts | 178 + scripts/generate-privacy-notice.ts | 150 + scripts/generate-ropa.ts | 215 ++ scripts/retention-period-defaults.json | 194 + scripts/run-retention-job.ts | 273 ++ src/app/admin/compliance-overview/page.tsx | 204 ++ src/app/admin/compliance/layout.tsx | 26 + src/app/admin/compliance/page.tsx | 83 + .../v1/compliance/deletions/approve/route.ts | 117 + .../api/v1/compliance/module-access/route.ts | 30 + .../compliance/onboarding/complete/route.ts | 134 + .../v1/compliance/onboarding/step1/route.ts | 79 + src/app/api/v1/compliance/status/route.ts | 26 + src/app/api/v1/gdpr/erasure/route.ts | 167 + src/app/api/v1/gdpr/export/route.ts | 85 + src/components/compliance-wizard.tsx | 536 +++ src/lib/audit.ts | 134 + src/lib/auth.ts | 90 + src/lib/compliance.ts | 304 ++ 24 files changed, 6565 insertions(+), 1 deletion(-) create mode 100644 docs/legal/avv-template.md create mode 100644 prisma/db/custom.db create mode 100644 prisma/migrations/20260731132904_add_module_l_compliance/migration.sql create mode 100644 prisma/migrations/migration_lock.toml mode change 100755 => 100644 prisma/schema.prisma create mode 100644 scripts/generate-data-model.ts create mode 100644 scripts/generate-privacy-notice.ts create mode 100644 scripts/generate-ropa.ts create mode 100644 scripts/retention-period-defaults.json create mode 100644 scripts/run-retention-job.ts create mode 100644 src/app/admin/compliance-overview/page.tsx create mode 100644 src/app/admin/compliance/layout.tsx create mode 100644 src/app/admin/compliance/page.tsx create mode 100644 src/app/api/v1/compliance/deletions/approve/route.ts create mode 100644 src/app/api/v1/compliance/module-access/route.ts create mode 100644 src/app/api/v1/compliance/onboarding/complete/route.ts create mode 100644 src/app/api/v1/compliance/onboarding/step1/route.ts create mode 100644 src/app/api/v1/compliance/status/route.ts create mode 100644 src/app/api/v1/gdpr/erasure/route.ts create mode 100644 src/app/api/v1/gdpr/export/route.ts create mode 100644 src/components/compliance-wizard.tsx mode change 100755 => 100644 src/lib/audit.ts mode change 100755 => 100644 src/lib/auth.ts create mode 100644 src/lib/compliance.ts diff --git a/docs/legal/avv-template.md b/docs/legal/avv-template.md new file mode 100644 index 0000000..ac6f1db --- /dev/null +++ b/docs/legal/avv-template.md @@ -0,0 +1,207 @@ +# Auftragsverarbeitungsvertrag (AVV) +# Data Processor Agreement (Art. 28 GDPR) + +**HINWEIS**: This is a template only. Your school must have its legal counsel review and customize this agreement before signing with any third-party data processor (e.g., if you outsource hosting). + +**For self-hosted deployments**: This agreement is NOT required. It only applies if your school contracts with an external party to operate the platform. + +--- + +## 1. Parties + +**Controller (Auftraggeber):** +- School Name: ________________________ +- Address: ________________________ +- Legal Representative: ________________________ + +**Processor (Auftragnehmer):** +- Service Provider Name: ________________________ +- Address: ________________________ +- Contact: ________________________ + +--- + +## 2. Scope of Processing + +The Processor shall process the following personal data on behalf of the Controller: + +- Student records (names, birth dates, assessments, grades) +- Teacher information and activity logs +- Parent contact information +- Learning progress and behavioral records +- Exam monitoring logs +- Communication/messaging data + +**Purpose**: Operation and maintenance of the ExaMetra school management platform + +--- + +## 3. Duration + +This agreement is effective from: ________________________ +Term: ________________________ +Renewal/Termination terms: ________________________ + +--- + +## 4. Responsibilities of the Processor + +The Processor shall: + +4.1 **Data Minimization** +- Process only the data categories strictly necessary for the purpose +- Not use data for any other purpose without written consent from the Controller + +4.2 **Security** +- Implement appropriate technical and organizational measures: + - Encryption of data at rest and in transit (TLS 1.2+, AES-256) + - Role-based access control (RBAC) + - Regular security audits and penetration testing + - Incident response procedures + - Staff training on data protection + +4.3 **Sub-processors** +- Not engage sub-processors without prior written authorization from the Controller +- Ensure sub-processors are bound by equivalent data protection obligations +- Maintain a current list of authorized sub-processors + +4.4 **Data Subject Rights** +- Assist the Controller in fulfilling data subject requests (access, deletion, portability) +- Respond to requests within 10 business days + +4.5 **Audit & Inspection** +- Allow the Controller and/or its DPO to audit processing activities +- Provide auditing certificates (ISO 27001, SOC 2) upon request +- Maintain documentation of processing activities + +4.6 **Confidentiality** +- Ensure all staff with access to personal data are bound by confidentiality +- Disclose data only to authorized individuals for the defined purpose + +--- + +## 5. Responsibilities of the Controller + +The Controller shall: + +5.1 **Authorization** +- Ensure processing is lawful under applicable law (GDPR, Schulgesetz) +- Obtain parental/student consent where required (Art. 8 GDPR for minors) + +5.2 **Instructions** +- Provide written instructions for all processing activities +- Prohibit any processing outside the defined scope + +5.3 **Data Quality** +- Ensure data is accurate and up-to-date +- Obtain informed consent from data subjects (or parents for minors) + +--- + +## 6. Data Breach Notification + +6.1 **Processor Notification** +If the Processor discovers a data breach or suspected unauthorized processing, it shall notify the Controller within **24 hours** with: +- Description of the breach +- Categories and approximate number of affected individuals +- Likely consequences +- Measures taken or proposed to address the breach + +6.2 **Controller's Reporting Duty** +The Controller is responsible for notifying the relevant supervisory authority (Landesbeauftragte) and affected individuals, as required by GDPR Art. 33-34. + +--- + +## 7. Data Deletion & Return + +Upon termination of this agreement, the Processor shall, at the Controller's choice: + +- Delete all personal data and existing copies (unless retention is required by law) +- Return all personal data in a structured, commonly used, machine-readable format + +Deletion shall be completed within **30 days** of termination and certified in writing. + +--- + +## 8. Data Transfer & Location + +8.1 **Processing Location** +Data shall be processed exclusively within the **European Union/EEA**. + +8.2 **International Transfers** +Any transfer of data outside the EU/EEA requires: +- Prior written consent from the Controller +- Compliance with GDPR Chapter 5 (adequacy decisions or appropriate safeguards) +- Standard Contractual Clauses (SCCs) if required + +--- + +## 9. Term & Termination + +9.1 **Term** +This agreement remains effective for the duration of the service contract. + +9.2 **Termination** +Either party may terminate with **30 days written notice** if the other party materially breaches this agreement and fails to remedy within 15 days. + +9.3 **Data Handling Upon Termination** +Upon termination, the Processor shall delete or return all personal data as directed by the Controller (Section 7). + +--- + +## 10. Liability + +The Processor shall indemnify the Controller for: +- Breaches of this agreement or applicable data protection law +- Unauthorized processing or disclosure of data +- Failure to implement required security measures + +Liability is limited to direct damages and shall not exceed annual fees under the service contract. + +--- + +## 11. Signatures + +**Controller (School)** + +Name: ________________________ +Title: ________________________ +Signature: ________________________ +Date: ________________________ + +**Processor (Service Provider)** + +Name: ________________________ +Title: ________________________ +Signature: ________________________ +Date: ________________________ + +--- + +## Appendix A: Data Categories & Retention Periods + +| Data Category | Retention Period | Legal Basis | +|---|---|---| +| Student grades & assessments | 1-2 years | Schulgesetz | +| Final reports | Permanent | Schulgesetz | +| Disciplinary records | 3-10 years | Varies by Bundesland | +| Audit logs | 1-2 years | GDPR compliance | + +--- + +## Appendix B: Authorized Sub-Processors + +The Processor is authorized to engage the following sub-processors: + +1. Cloud Infrastructure: ________________________ (unless otherwise specified) +2. Backup & Disaster Recovery: ________________________ +3. Security Monitoring: ________________________ + +**Note**: The Processor shall notify the Controller of any new sub-processors at least 30 days in advance, giving the Controller the right to object. + +--- + +**Generated**: ISO 8601 timestamp +**Source**: ExaMetra Module L - Compliance Framework + +**REMINDER**: This is a template. Consult legal counsel to customize for your jurisdiction and service provider. diff --git a/prisma/db/custom.db b/prisma/db/custom.db new file mode 100644 index 0000000000000000000000000000000000000000..7902c7655fcbdaa9f7977a508e42b82d3713bf99 GIT binary patch literal 2359296 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

LTX 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 wfB*srAb `- ${p}`), + "", + "### Kategorien personenbezogener Daten", + ...ropa.dataCategories.map((c) => `- ${c}`), + "", + "### Kategorien von Empfängern", + ...ropa.recipients.map((r) => `- ${r}`), + "", + "### Speicherdauer", + `${ropa.retentionPeriod}`, + "", + "### Rechtsgrundlage", + `${ropa.legalBasis}`, + "", + "### Technische und organisatorische Maßnahmen", + ...ropa.securityMeasures.map((m) => `- ${m}`), + "" + ); + } + + lines.push( + "---", + "", + "## Betroffenenrechte (DSGVO Art. 12-22)", + "", + "Alle Personen haben das Recht auf:", + "- **Auskunft** (Art. 15): Zugang zu ihren Daten", + "- **Berichtigung** (Art. 16): Korrektur ungenauer Daten", + "- **Löschung** (Art. 17): Unter rechtlichen Bedingungen", + "- **Datenportabilität** (Art. 20): Export ihrer Daten", + "- **Widerspruch** (Art. 21): Gegen bestimmte Verarbeitungen", + "", + "Anträge richten Sie an den Datenschutzbeauftragten (DPO).", + "", + "## Datenschutzverletzungen", + "", + "Bei Verdacht auf eine Datenschutzverletzung kontaktieren Sie sofort den DPO und die zuständige Aufsichtsbehörde.", + "", + `**Generiert**: ${new Date().toISOString()}`, + "**Quelle**: ExaMetra Module L - Compliance Framework", + ]; + + const filePath = path.join( + outputPath, + "verzeichnis-verarbeitungstaetigkeiten.md" + ); + fs.writeFileSync(filePath, lines.join("\n"), "utf-8"); + console.log( + `[v0] Generated RoPA (Verzeichnis) at ${filePath}` + ); +} + +if (require.main === module) { + generateRopa(); +} diff --git a/scripts/retention-period-defaults.json b/scripts/retention-period-defaults.json new file mode 100644 index 0000000..cc47ed9 --- /dev/null +++ b/scripts/retention-period-defaults.json @@ -0,0 +1,194 @@ +{ + "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 + }, + "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 + }, + "HESSE": { + "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 + }, + "NORTH_RHINE_WESTPHALIA": { + "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 + }, + "LOWER_SAXONY": { + "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 + }, + "BREMEN": { + "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 + }, + "HAMBURG": { + "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 + }, + "RHINELAND_PALATINATE": { + "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 + }, + "SAARLAND": { + "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 + }, + "SAXONY": { + "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 + }, + "SAXONY_ANHALT": { + "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 + }, + "SCHLESWIG_HOLSTEIN": { + "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 + }, + "THURINGIA": { + "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 + }, + "MECKLENBURG_VORPOMMERN": { + "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 + }, + "BRANDENBURG": { + "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 + } +} diff --git a/scripts/run-retention-job.ts b/scripts/run-retention-job.ts new file mode 100644 index 0000000..02cb2fc --- /dev/null +++ b/scripts/run-retention-job.ts @@ -0,0 +1,273 @@ +/** + * Daily Retention Enforcement Job + * Flags records that have exceeded their retention period + * Called once per day; can be integrated with cron (e.g., GitHub Actions, node-schedule) + * + * Usage: npx ts-node scripts/run-retention-job.ts + */ + +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +interface FlaggedRecord { + entityType: string; + entityId: string; + dataCategory: string; + flaggedAt: Date; + reason: string; +} + +/** + * Check records for each school and flag those exceeding retention period + */ +async function runRetentionJob() { + console.log("[v0] Starting retention enforcement job..."); + const startTime = Date.now(); + + try { + // Get all schools + const schools = await prisma.school.findMany({ + select: { id: true, name: true }, + }); + + let totalFlagged = 0; + const report: { school: string; flagged: number }[] = []; + + for (const school of schools) { + const schoolFlagged = await flagExpiredRecordsForSchool(school.id); + totalFlagged += schoolFlagged; + report.push({ school: school.name, flagged: schoolFlagged }); + + console.log( + `[v0] School "${school.name}": ${schoolFlagged} records flagged` + ); + } + + console.log( + `[v0] Retention job completed: ${totalFlagged} total records flagged` + ); + console.log(`[v0] Duration: ${Date.now() - startTime}ms`); + + // Log retention job to audit + await prisma.auditLog.create({ + data: { + action: "RETENTION_JOB_EXECUTED", + entityType: "RetentionJob", + metadata: JSON.stringify({ + totalFlagged, + schoolsProcessed: schools.length, + timestamp: new Date().toISOString(), + report, + }), + }, + }); + + return { success: true, totalFlagged, report }; + } catch (error) { + console.error("[v0] Error in retention job:", error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +/** + * Flag expired records for a specific school + */ +async function flagExpiredRecordsForSchool(schoolId: string): Promise { + // Get all retention policies for this school + const policies = await prisma.dataRetentionPolicy.findMany({ + where: { schoolId }, + }); + + let flaggedCount = 0; + + for (const policy of policies) { + if (!policy.retentionPeriodMonths) continue; + + // Calculate expiry date + const expiryDate = new Date(); + expiryDate.setMonth(expiryDate.getMonth() - policy.retentionPeriodMonths); + + // Flag expired records based on category + const categoryFlagged = await flagRecordsByCategory( + schoolId, + policy.dataCategory, + expiryDate + ); + + flaggedCount += categoryFlagged; + } + + return flaggedCount; +} + +/** + * Flag records of a specific category that are older than expiryDate + */ +async function flagRecordsByCategory( + schoolId: string, + dataCategory: string, + expiryDate: Date +): Promise { + let flaggedCount = 0; + + try { + switch (dataCategory) { + case "LEARNING_PROGRESS": + flaggedCount += await flagRecordsByEntityType( + schoolId, + "LearningProgressEntry", + expiryDate + ); + break; + + case "ASSESSMENT_RESULT": + flaggedCount += await flagRecordsByEntityType( + schoolId, + "Assessment", + expiryDate + ); + break; + + case "FINAL_REPORT": + // Final reports should NOT be auto-flagged (legal requirement to keep permanently) + console.log( + `[v0] Skipping FINAL_REPORT for ${schoolId} (legally mandated retention)` + ); + break; + + case "DISCIPLINARY_RECORD": + flaggedCount += await flagRecordsByEntityType( + schoolId, + "DisciplinaryCase", + expiryDate + ); + break; + + case "EXAM_INCIDENT": + flaggedCount += await flagRecordsByEntityType( + schoolId, + "ExamIncidentEvent", + expiryDate + ); + break; + + case "MESSAGE": + flaggedCount += await flagRecordsByEntityType( + schoolId, + "ParentMessage", + expiryDate + ); + break; + + case "AI_TUTOR_LOG": + // Note: In production, this would reference actual AI tutor logs + console.log(`[v0] AI_TUTOR_LOG flagging not yet implemented`); + break; + + case "NOTEBOOK_PAGE": + flaggedCount += await flagRecordsByEntityType( + schoolId, + "Notebook", + expiryDate + ); + break; + + case "SIGNAGE_LOG": + // Note: Reference actual signage log entity + console.log(`[v0] SIGNAGE_LOG flagging not yet implemented`); + break; + + case "TEACHER_AUDIT_LOG": + flaggedCount += await flagRecordsByEntityType( + schoolId, + "AuditLog", + expiryDate + ); + break; + } + } catch (error) { + console.error( + `[v0] Error flagging ${dataCategory} records for school ${schoolId}:`, + error + ); + } + + return flaggedCount; +} + +/** + * Generic function to flag expired records by entity type + */ +async function flagRecordsByEntityType( + schoolId: string, + entityType: string, + expiryDate: Date +): Promise { + // Check if records of this type already have deletion flags + const alreadyFlagged = await prisma.deletionFlag.findMany({ + where: { + schoolId, + entityType, + deletedAt: null, // Not yet hard-deleted + approvedAt: null, // Not yet approved + }, + }); + + const alreadyFlaggedIds = new Set(alreadyFlagged.map((f) => f.entityId)); + + // Create new flags for records not yet flagged + let flaggedCount = 0; + + // This is a placeholder; in production, you'd query each entity type + // Example for LearningProgressEntry: + if (entityType === "LearningProgressEntry") { + const expiredRecords = await prisma.learningProgressEntry + .findMany({ + where: { + createdAt: { lt: expiryDate }, + // Filter by school if the entity has schoolId + }, + select: { id: true }, + take: 1000, // Limit to prevent huge queries + }) + .catch(() => []); + + for (const record of expiredRecords) { + if (!alreadyFlaggedIds.has(record.id)) { + await prisma.deletionFlag.create({ + data: { + schoolId, + entityType, + entityId: record.id, + dataCategory: "LEARNING_PROGRESS", + reason: "retention period expired", + }, + }); + flaggedCount++; + } + } + } + + return flaggedCount; +} + +// Run the job if this script is executed directly +if (require.main === module) { + runRetentionJob() + .then((result) => { + console.log( + `[v0] Job result:`, + JSON.stringify(result, null, 2) + ); + process.exit(0); + }) + .catch((error) => { + console.error("[v0] Job failed:", error); + process.exit(1); + }); +} + +module.exports = { runRetentionJob, flagExpiredRecordsForSchool }; diff --git a/src/app/admin/compliance-overview/page.tsx b/src/app/admin/compliance-overview/page.tsx new file mode 100644 index 0000000..0c60dde --- /dev/null +++ b/src/app/admin/compliance-overview/page.tsx @@ -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(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 ( +

+

Loading compliance status...

+
+ ); + } + + if (!data) { + return ( +
+ + +

Failed to load compliance dashboard

+
+
+
+ ); + } + + return ( +
+
+

Compliance Monitoring Dashboard

+

+ Track your school's data protection compliance status and document management +

+
+ + {/* Status Overview */} +
+ + +
+ {data.status?.onboardingCompleted ? ( + + ) : ( + + )} +
+

Onboarding Status

+

+ {data.status?.onboardingCompleted ? "Complete" : "Pending"} +

+
+
+
+
+ + + +
+ +
+

Data Retention Policies

+

+ {data.policiesConfigured} / {data.policiesReviewed} reviewed +

+
+
+
+
+ + + +
+ +
+

DPIA Records

+

{data.dpiaRecords.length}

+
+
+
+
+ + + +
+ +
+

Consultations

+

{data.consultations.length}

+
+
+
+
+
+ + {/* Data Retention Policies */} + + + Active Data Retention Policies + + Configured retention periods for each data category + + + +
+

{data.policiesConfigured} policies configured

+

Policies are centrally defined and reviewed by your DPO.

+
+
+
+ + {/* DPIA Records */} + {data.dpiaRecords.length > 0 && ( + + + DPIA Records + + Data Protection Impact Assessments for high-risk modules + + + +
+ {data.dpiaRecords.map((dpia: any) => ( +
+ + {dpia.moduleScope} + + + {dpia.approvedAt ? "✓ Approved" : "Pending review"} + +
+ ))} +
+
+
+ )} + + {/* Consultation Log */} + {data.consultations.length > 0 && ( + + + Consultation Log + + Staff and parent body consultations per module + + + +
+ {data.consultations.slice(0, 5).map((consult: any) => ( +
+
+

+ {consult.consultationType} +

+ {consult.moduleScope && ( +

{consult.moduleScope}

+ )} +
+ + {new Date(consult.acknowledgedAt).toLocaleDateString()} + +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/src/app/admin/compliance/layout.tsx b/src/app/admin/compliance/layout.tsx new file mode 100644 index 0000000..ba429f3 --- /dev/null +++ b/src/app/admin/compliance/layout.tsx @@ -0,0 +1,26 @@ +"use client"; + +import React from "react"; + +export default function ComplianceLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
+

+ Compliance & Data Protection Setup +

+

+ Configure your school's legal and data protection compliance + framework +

+
+ {children} +
+
+ ); +} diff --git a/src/app/admin/compliance/page.tsx b/src/app/admin/compliance/page.tsx new file mode 100644 index 0000000..dd57325 --- /dev/null +++ b/src/app/admin/compliance/page.tsx @@ -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(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 ( +
+ +
+ ); + } + + if (!schoolId) { + return ( +
+ + +

Unable to load compliance setup. Please ensure you are logged in.

+
+
+
+ ); + } + + return ( +
+ {isCompleted ? ( + + +

+ ✓ Compliance onboarding completed successfully! Your school is now ready to use ExaMetra. +

+ +
+
+ ) : ( + { + console.log("[v0] Wizard completed:", data); + setIsCompleted(true); + }} + /> + )} +
+ ); +} diff --git a/src/app/api/v1/compliance/deletions/approve/route.ts b/src/app/api/v1/compliance/deletions/approve/route.ts new file mode 100644 index 0000000..de7f211 --- /dev/null +++ b/src/app/api/v1/compliance/deletions/approve/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/v1/compliance/module-access/route.ts b/src/app/api/v1/compliance/module-access/route.ts new file mode 100644 index 0000000..76c3eb9 --- /dev/null +++ b/src/app/api/v1/compliance/module-access/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/v1/compliance/onboarding/complete/route.ts b/src/app/api/v1/compliance/onboarding/complete/route.ts new file mode 100644 index 0000000..f8301ca --- /dev/null +++ b/src/app/api/v1/compliance/onboarding/complete/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/v1/compliance/onboarding/step1/route.ts b/src/app/api/v1/compliance/onboarding/step1/route.ts new file mode 100644 index 0000000..d5a8d12 --- /dev/null +++ b/src/app/api/v1/compliance/onboarding/step1/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/v1/compliance/status/route.ts b/src/app/api/v1/compliance/status/route.ts new file mode 100644 index 0000000..46cc2bf --- /dev/null +++ b/src/app/api/v1/compliance/status/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/v1/gdpr/erasure/route.ts b/src/app/api/v1/gdpr/erasure/route.ts new file mode 100644 index 0000000..4ae1e85 --- /dev/null +++ b/src/app/api/v1/gdpr/erasure/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/v1/gdpr/export/route.ts b/src/app/api/v1/gdpr/export/route.ts new file mode 100644 index 0000000..0565144 --- /dev/null +++ b/src/app/api/v1/gdpr/export/route.ts @@ -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 } + ); + } +} diff --git a/src/components/compliance-wizard.tsx b/src/components/compliance-wizard.tsx new file mode 100644 index 0000000..1bc9f43 --- /dev/null +++ b/src/components/compliance-wizard.tsx @@ -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; + 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({ + 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 ( +
+ {/* Progress Indicator */} +
+ {[1, 2, 3, 4, 5].map((step) => ( +
+
= step + ? "bg-emerald-600 text-white" + : "bg-slate-200 text-slate-600" + }`} + > + {state.step > step ? ( + + ) : ( + step + )} +
+ {step < 5 && ( +
step ? "bg-emerald-600" : "bg-slate-200" + }`} + /> + )} +
+ ))} +
+ + {error && ( + + + +

{error}

+
+
+ )} + + {/* Step 1: School & Contact Info */} + {state.step === 1 && ( + + + School & Contact Information + + Provide your school's basic information and designated contacts + + + +
+ + + setState((prev) => ({ + ...prev, + schoolName: e.target.value, + })) + } + placeholder="e.g., Gymnasium Berlin" + /> +
+ +
+
+ + + setState((prev) => ({ ...prev, dpoName: e.target.value })) + } + placeholder="Full name" + /> +
+
+ + + setState((prev) => ({ ...prev, dpoEmail: e.target.value })) + } + placeholder="dpo@school.de" + /> +
+
+ +
+
+ + + setState((prev) => ({ + ...prev, + schultragerName: e.target.value, + })) + } + placeholder="e.g., City of Berlin" + /> +
+
+ + + setState((prev) => ({ + ...prev, + schultragerEmail: e.target.value, + })) + } + placeholder="admin@schultraeger.de" + /> +
+
+ +
+ + +
+
+
+ +
+
+ )} + + {/* Step 2: Retention Periods (coming in next iteration) */} + {state.step === 2 && ( + + + Data Retention Periods + + Configure retention periods for each data category (state-specific defaults provided) + + + +

+ Retention periods have been pre-seeded based on your state's regulations. + Your DPO should review and confirm these settings. +

+
+
+ + +
+
+ )} + + {/* Step 3: Consultations */} + {state.step === 3 && ( + + + Consultation Checklist + + Log consultations with staff and parent bodies per module + + + +
+ {MODULES.map((module) => ( +
+

{module.label}

+
+ + {!module.requiresDpia && ( + + )} + {module.requiresDpia && ( + + )} +
+
+ ))} +
+
+
+ + +
+
+ )} + + {/* Step 4: DPIA Gating */} + {state.step === 4 && ( + + + DPIA (Data Protection Impact Assessment) + + Upload completed DPIAs for high-risk modules before enabling them + + + + {MODULES.filter((m) => m.requiresDpia).map((module) => ( +
+

{module.label} DPIA

+

+ Upload your completed DPIA document (PDF) +

+ { + 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 && ( +

+ ✓ {state.dpiaModules[module.id].documentUrl} +

+ )} +
+ ))} +
+
+ + +
+
+ )} + + {/* Step 5: Summary & Complete */} + {state.step === 5 && ( + + + Compliance Setup Complete + + Review your configuration before finalizing + + + +
+

+ ✓ All compliance requirements have been configured. Your school is now ready for + production use. +

+
+
+

DPO: {state.dpoName} ({state.dpoEmail})

+

State: {state.bundesland}

+

Modules: {MODULES.length} configured

+
+
+
+ + +
+
+ )} +
+ ); +} diff --git a/src/lib/audit.ts b/src/lib/audit.ts old mode 100755 new mode 100644 index c53e97e..22e10fb --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -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 { + 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 +): Promise { + 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 +): Promise { + 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 { + 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 []; + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts old mode 100755 new mode 100644 index 24251d9..352a718 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -87,3 +87,93 @@ export async function clearSession(): Promise { 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 { + 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 { + 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 { + 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 { + 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 { + const isDPO = await isDPORole(userId); + return isDPO; +} diff --git a/src/lib/compliance.ts b/src/lib/compliance.ts new file mode 100644 index 0000000..a419991 --- /dev/null +++ b/src/lib/compliance.ts @@ -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 +> = { + 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 { + 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 { + 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 { + 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 { + 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 + ); +}