Improve system stability and expand API documentation for core modules #1

Merged
cetinmustafa83 merged 7 commits from onboarding-tour into main 2026-07-31 14:01:59 +00:00
24 changed files with 6565 additions and 1 deletions
Showing only changes of commit 927aae8580 - Show all commits

No files matched your search

+207
View File
@@ -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.
Binary file not shown.
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
Executable → Regular
+108 -1
View File
@@ -116,6 +116,12 @@ model School {
substituteTeachers SubstituteTeacher[] substituteTeachers SubstituteTeacher[]
teacherAbsences TeacherAbsence[] teacherAbsences TeacherAbsence[]
substitutionAssignments SubstitutionAssignment[] substitutionAssignments SubstitutionAssignment[]
// Module L compliance relations
dataRetentionPolicies DataRetentionPolicy[]
dpiaRecords DpiaRecord[]
complianceConsultations ComplianceConsultation[]
complianceStatus SchoolComplianceStatus?
deletionFlags DeletionFlag[]
} }
model User { model User {
@@ -125,10 +131,11 @@ model User {
passwordHash String passwordHash String
firstName String firstName String
lastName String lastName String
role String @default("TEACHER") // TEACHER, SCHOOL_ADMIN, SUPER_ADMIN, STUDENT, PARENT, VICE_PRINCIPAL role String @default("TEACHER") // TEACHER, SCHOOL_ADMIN, SUPER_ADMIN, STUDENT, PARENT, VICE_PRINCIPAL, DPO
locale String @default("de") locale String @default("de")
twoFactorSecret String? twoFactorSecret String?
isDemo Boolean @default(false) isDemo Boolean @default(false)
isTeacher Boolean @default(false) // civil-servant status for Personalrat rights
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
@@ -207,6 +214,11 @@ model User {
careerAppointments CareerAppointment[] careerAppointments CareerAppointment[]
substituteTeacher SubstituteTeacher? substituteTeacher SubstituteTeacher?
teacherAbsences TeacherAbsence[] teacherAbsences TeacherAbsence[]
// Module L compliance relations
retentionPoliciesReviewed DataRetentionPolicy[] @relation("DataRetentionPolicyReviewer")
dpiaRecordsApproved DpiaRecord[] @relation("DpiaRecordApprover")
complianceConsultationsAcknowledged ComplianceConsultation[] @relation("ComplianceConsultationAcknowledger")
deletionFlagsApproved DeletionFlag[] @relation("DeletionFlagApprover")
} }
model SchoolYear { model SchoolYear {
@@ -651,9 +663,13 @@ model AuditLog {
userAgent String? userAgent String?
timestamp DateTime @default(now()) timestamp DateTime @default(now())
metadata String? // JSON metadata String? // JSON
dataSubject String? // track which student (if applicable) for GDPR export scope
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
school School? @relation(fields: [schoolId], references: [id]) school School? @relation(fields: [schoolId], references: [id])
@@index([dataSubject])
@@index([timestamp])
} }
model DataExportRequest { model DataExportRequest {
@@ -3216,3 +3232,94 @@ model GradeReport {
@@index([schoolId]) @@index([schoolId])
@@index([generatedBy]) @@index([generatedBy])
} }
// ─── Module L: Legal & Data Protection Compliance ──────────────────
model DataRetentionPolicy {
id String @id @default(cuid())
schoolId String
dataCategory String // LEARNING_PROGRESS, ASSESSMENT_RESULT, FINAL_REPORT, DISCIPLINARY_RECORD, EXAM_INCIDENT, MESSAGE, AI_TUTOR_LOG, NOTEBOOK_PAGE, SIGNAGE_LOG, TEACHER_AUDIT_LOG
retentionPeriodMonths Int? // nullable to force DPO to fill in legally-mandated categories
legalBasisNote String? // DPO notes on state-specific retention rule
isDefault Boolean @default(false) // false = legally mandated; true = optional product data
reviewedByUserId String? // DPO approval
reviewedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
school School @relation(fields: [schoolId], references: [id])
reviewedByUser User? @relation("DataRetentionPolicyReviewer", fields: [reviewedByUserId], references: [id])
@@index([schoolId])
@@index([dataCategory])
}
model DpiaRecord {
id String @id @default(cuid())
schoolId String
moduleScope String // AI_TUTOR, EXAM_MODE, WELLNESS_CHECKS, SIGNAGE_EMERGENCY, MESSAGING, LEARNING_ANALYTICS
dataProcessingSummary String // what's processed, for how long, who sees it
completedAt DateTime
approvedByUserId String? // DPO approval
documentUrl String? // uploaded DPIA PDF
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
school School @relation(fields: [schoolId], references: [id])
approvedByUser User? @relation("DpiaRecordApprover", fields: [approvedByUserId], references: [id])
@@index([schoolId])
@@index([moduleScope])
@@unique([schoolId, moduleScope])
}
model ComplianceConsultation {
id String @id @default(cuid())
schoolId String
consultationType String // ELTERNBEIRAT, SCHULKONFERENZ, PERSONALRAT
moduleScope String? // specific module if Personalrat for monitoring-capable features
acknowledgedByUserId String
acknowledgedAt DateTime
notes String? // what was consulted/approved
createdAt DateTime @default(now())
school School @relation(fields: [schoolId], references: [id])
acknowledgedByUser User @relation("ComplianceConsultationAcknowledger", fields: [acknowledgedByUserId], references: [id])
@@index([schoolId])
@@index([consultationType])
}
model SchoolComplianceStatus {
id String @id @default(cuid())
schoolId String @unique
bundesland String? // BERLIN, BREMEN, HAMBURG, etc. (16 states)
onboardingCompleted Boolean @default(false)
dpoDpoContactEmail String?
schultragerContactEmail String?
moduleGates String? // JSON: { "AI_TUTOR": { isApprovedForUse: bool, diaCompleted: bool }, ... }
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
school School @relation(fields: [schoolId], references: [id])
}
model DeletionFlag {
id String @id @default(cuid())
schoolId String
entityType String // Student, Message, Notebook, etc.
entityId String
dataCategory String // retention category reference
flaggedAt DateTime @default(now())
approvedByUserId String?
approvedAt DateTime?
deletedAt DateTime?
reason String // "retention period expired", "erasure request", etc.
school School @relation(fields: [schoolId], references: [id])
approvedByUser User? @relation("DeletionFlagApprover", fields: [approvedByUserId], references: [id])
@@index([schoolId])
@@index([entityType])
@@index([flaggedAt])
}
+178
View File
@@ -0,0 +1,178 @@
/**
* Generate DATA_MODEL.md from Prisma schema and database config
* This document lists all data entities, their purposes, legal basis, and retention categories
*/
const fs = require("fs");
const path = require("path");
interface DataEntity {
name: string;
dataCategories: string[];
legalBasis: string;
purpose: string;
retentionCategory: string;
notes?: string;
}
// Core data model definition
const dataModel: DataEntity[] = [
{
name: "User",
dataCategories: ["firstName", "lastName", "email", "role", "createdAt"],
legalBasis: "GDPR Art. 6(1)(c) - Legal obligation (Schulgesetz)",
purpose: "User account management and authentication",
retentionCategory: "TEACHER_AUDIT_LOG",
notes: "Retained per employment contract or student enrollment period",
},
{
name: "Student",
dataCategories: [
"firstName",
"lastName",
"dateOfBirth",
"email",
"phone",
"address",
],
legalBasis: "GDPR Art. 6(1)(c) - Legal obligation (Schulgesetz)",
purpose: "Student record management and communication",
retentionCategory: "LEARNING_PROGRESS",
},
{
name: "ClassGroup",
dataCategories: ["name", "grade", "year", "studentCount"],
legalBasis: "GDPR Art. 6(1)(c) - Legal obligation (Schulgesetz)",
purpose: "Class organization and grouping",
retentionCategory: "LEARNING_PROGRESS",
},
{
name: "Assessment",
dataCategories: ["studentId", "subjectId", "score", "feedback", "date"],
legalBasis:
"GDPR Art. 6(1)(a) - Consent + Art. 8 (children) - Parental consent",
purpose: "Record student learning progress and performance",
retentionCategory: "ASSESSMENT_RESULT",
},
{
name: "Report (Final Grade Report)",
dataCategories: ["studentId", "finalGrades", "comments", "semester"],
legalBasis:
"GDPR Art. 6(1)(c) - Legal obligation (Schulgesetz - long-term retention requirement)",
purpose: "Permanent record of student academic achievement",
retentionCategory: "FINAL_REPORT",
notes: "Typically retained for student lifetime + 30 years (per Bundesland law)",
},
{
name: "BehaviorIncident",
dataCategories: ["studentId", "incidentType", "description", "resolution"],
legalBasis:
"GDPR Art. 6(1)(a) - Consent + Art. 9(2)(h) - Health/safety necessity",
purpose: "Track disciplinary actions and behavioral support",
retentionCategory: "DISCIPLINARY_RECORD",
notes: "Retention varies by Bundesland; typically 3-10 years",
},
{
name: "ExamIncidentEvent",
dataCategories: ["studentId", "eventType", "timestamp", "notes"],
legalBasis:
"GDPR Art. 6(1)(a) - Consent + Art. 9(2)(h) - Exam integrity necessity",
purpose: "Log exam-time incidents (distress, technical issues)",
retentionCategory: "EXAM_INCIDENT",
notes: "Shorter retention (6-12 months) unless escalated to disciplinary record",
},
{
name: "Message",
dataCategories: ["senderId", "recipientId", "content", "timestamp"],
legalBasis: "GDPR Art. 6(1)(a) - Consent (user opt-in for messaging)",
purpose: "Communication between teachers, parents, and students",
retentionCategory: "MESSAGE",
},
{
name: "AuditLog",
dataCategories: ["userId", "action", "entityType", "timestamp"],
legalBasis: "GDPR Art. 6(1)(c) - Legal obligation (data protection compliance)",
purpose: "Audit trail for regulatory compliance and breach investigation",
retentionCategory: "TEACHER_AUDIT_LOG",
},
{
name: "Notebook (Student Learning Notebooks)",
dataCategories: ["studentId", "content", "createdAt"],
legalBasis: "GDPR Art. 6(1)(a) - Consent (student/parent consent)",
purpose: "Student learning portfolio and reflection entries",
retentionCategory: "NOTEBOOK_PAGE",
},
{
name: "AITutorLog",
dataCategories: ["studentId", "interaction", "timestamp", "response"],
legalBasis: "GDPR Art. 6(1)(a) - Explicit consent + Art. 22 - Automated decisions",
purpose: "Track AI tutor interactions for learning analytics",
retentionCategory: "AI_TUTOR_LOG",
notes: "Subject to Art. 22 GDPR - no solely automated grading decisions",
},
];
export function generateDataModel(outputPath: string = "docs/legal"): void {
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}
const lines: string[] = [
"# Data Model Documentation",
"",
"**Status**: Schema Reference for ExaMetra (Module L)",
"",
"This document outlines all personal data entities collected by ExaMetra, their legal basis, purpose, and retention category.",
"",
"## Core Entities",
"",
"| Entity | Data Categories | Legal Basis | Purpose | Retention Category | Notes |",
"|--------|-----------------|------------|---------|-------------------|-------|",
];
for (const entity of dataModel) {
const dataCategories = entity.dataCategories.join(", ");
const notes = entity.notes ? `${entity.notes}` : "—";
const row = `| **${entity.name}** | ${dataCategories} | ${entity.legalBasis} | ${entity.purpose} | **${entity.retentionCategory}** | ${notes} |`;
lines.push(row);
}
lines.push(
"",
"## Retention Categories",
"",
"| Category | Typical Retention | Legal Basis |",
"|----------|------------------|------------|",
"| LEARNING_PROGRESS | 1-2 years | Bundesland school law (Schulgesetz) |",
"| ASSESSMENT_RESULT | 1-2 years | Bundesland school law |",
"| FINAL_REPORT | 30+ years | Permanent record under Schulgesetz |",
"| DISCIPLINARY_RECORD | 3-10 years | Varies by Bundesland |",
"| EXAM_INCIDENT | 6-12 months | Exam integrity + school safety |",
"| MESSAGE | 1-2 years | Communication retention best practices |",
"| AI_TUTOR_LOG | 6-12 months | Pedagogical purpose + learning analytics |",
"| NOTEBOOK_PAGE | 1-2 years | Student portfolio / learning records |",
"| SIGNAGE_LOG | 3 months | Safety monitoring (emergency only) |",
"| TEACHER_AUDIT_LOG | 1-2 years | Compliance and audit trail |",
"",
"## Compliance Notes",
"",
"- **DPO Review**: Each retention period must be reviewed and approved by your school's Data Protection Officer (DPO)",
"- **Bundesland Variations**: Retention periods may vary by state (Bundesland). Consult your regional school authority.",
"- **Parental Consent**: For student data (especially minors), parental consent is required per Art. 8 GDPR",
"- **Automated Decisions**: No solely automated decisions (e.g., automatic grade lowering) without human review per Art. 22 GDPR",
"- **Data Subject Rights**: All entities are subject to GDPR rights: access, correction, erasure (where legally permitted), portability",
"",
"## Generated From",
"",
`Generated: ${new Date().toISOString()}`,
"Source: ExaMetra Prisma schema (Module L)",
];
const filePath = path.join(outputPath, "DATA_MODEL.md");
fs.writeFileSync(filePath, lines.join("\n"), "utf-8");
console.log(`[v0] Generated DATA_MODEL.md at ${filePath}`);
}
if (require.main === module) {
generateDataModel();
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Generate Datenschutzerklärung (Privacy Notice) for parents
* Plain-language German, based on school configuration
*/
const fs = require("fs");
const path = require("path");
interface PrivacyNoticeParams {
schoolName: string;
dpoName: string;
dpoEmail: string;
schultragerName: string;
bundesland: string;
}
export function generatePrivacyNotice(
params: PrivacyNoticeParams,
outputPath: string = "docs/legal"
): void {
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}
const lines: string[] = [
"# Datenschutzerklärung",
"# (Privacy Notice for Parents & Students)",
"",
`**Schule**: ${params.schoolName}`,
`**Bundesland**: ${params.bundesland}`,
`**Schulträger**: ${params.schultragerName}`,
`**Datenschutzbeauftragter (DPO)**: ${params.dpoName} (${params.dpoEmail})`,
"",
"---",
"",
"## 1. Wer ist verantwortlich für die Verarbeitung Ihrer Daten?",
"",
`Die **${params.schoolName}** ist die Schule, die ihre Daten verarbeitet.`,
`Der Schulträger ist: **${params.schultragerName}**`,
"",
"Der Datenschutzbeauftragte (DPO) ist:",
`- **Name**: ${params.dpoName}`,
`- **Email**: ${params.dpoEmail}`,
"",
"Sie können den DPO jederzeit kontaktieren, wenn Sie Fragen zu Ihren Daten haben.",
"",
"## 2. Welche Daten werden verarbeitet?",
"",
"Wir verarbeiten folgende personenbezogene Daten:",
"",
"### Schülerdaten",
"- Name, Vorname, Geburtsdatum",
"- Kontaktinformationen (Email, Telefon)",
"- Adresse",
"- Schulleistungen, Noten, Zeugnisse",
"- Verhalten und Disziplin",
"- Medizinische Informationen (falls relevant)",
"",
"### Lehrerdaten",
"- Name, Email, Telefon",
"- Unterrichtsvertrag und Stundenpläne",
"- Aktivitätsprotokoll (Audit Logs)",
"",
"### Elterndaten",
"- Name, Kontaktinformationen",
"- Nachrichten mit Lehrern",
"",
"## 3. Warum verarbeiten wir Ihre Daten?",
"",
"Wir verarbeiten Ihre Daten für folgende Zwecke:",
"",
"- **Schulverwaltung**: Verwaltung von Schülereinträgen und Klassengruppen",
"- **Unterricht & Bewertung**: Verfolgung von Lernfortschritten und Noten",
"- **Kommunikation**: Nachrichten zwischen Lehrern, Eltern und Schülern",
"- **Sicherheit**: Überwachung von Prüfungen und Schulsicherheit",
"- **Rechtliche Verpflichtungen**: Einhaltung des Schulgesetzes und der DSGVO",
"",
"## 4. Was ist die Rechtsgrundlage?",
"",
"Die Verarbeitung erfolgt auf Grundlage von:",
"",
"- **Schulgesetz** (Schulgesetz des Bundeslandes): Rechtliche Verpflichtung zur Führung von Schülerdaten",
"- **DSGVO Art. 6(1)(a)**: Ihre Zustimmung (z.B. für Messaging)",
"- **DSGVO Art. 6(1)(c)**: Erfüllung rechtlicher Verpflichtungen",
"- **DSGVO Art. 9(2)(h)**: Zwecke der Gesundheit und Sicherheit (z.B. Wellness-Überprüfungen)",
"",
"Für Minderjährige: **Eltern erteilen die Zustimmung** gemäß DSGVO Art. 8.",
"",
"## 5. Wie lange werden die Daten gespeichert?",
"",
"Die Speicherdauer hängt von der Datenkategorie ab:",
"",
"| Datentyp | Speicherdauer |",
"|----------|--------------|",
"| Lernfortschritt | 1–2 Jahre nach Schulabgang |",
"| Zeugnisse/Endbewertungen | Dauerhaft (Schulgesetz erforderlich) |",
"| Disziplinarberichte | 3–10 Jahre (je nach Bundesland) |",
"| Nachrichten | 1–2 Jahre |",
"| Audit Logs | 1–2 Jahre |",
"",
"Nach Ablauf der Aufbewahrungsfrist werden Ihre Daten gelöscht.",
"",
"## 6. Ihre Rechte",
"",
"Sie haben folgende Rechte unter der DSGVO:",
"",
"- **Auskunftsrecht** (Art. 15): Sie können fragen, welche Daten wir speichern",
"- **Berichtigungsrecht** (Art. 16): Sie können falsche Daten korrigieren lassen",
"- **Löschungsrecht** (Art. 17): Sie können verlangen, dass Ihre Daten gelöscht werden (sofern nicht gesetzlich erforderlich)",
"- **Datenportabilität** (Art. 20): Sie können Ihre Daten exportieren",
"- **Beschwerderecht**: Sie können sich an die Datenschutzbehörde beschweren",
"",
"Um diese Rechte auszuüben, kontaktieren Sie bitte den DPO.",
"",
"## 7. Sicherheit Ihrer Daten",
"",
"Wir schützen Ihre Daten durch:",
"",
"- Verschlüsselung (TLS) bei der Übertragung",
"- Verschlüsselung bei der Speicherung",
"- Rollenbasierte Zugriffskontrolle",
"- Regelmäßige Sicherheitsprüfungen",
"- Audit-Logs aller Datenzugriffe",
"",
"## 8. Kontakt",
"",
`Bei Fragen zum Datenschutz kontaktieren Sie bitte:`,
`- **DPO**: ${params.dpoName}`,
`- **Email**: ${params.dpoEmail}`,
"",
"---",
"",
`**Gültig ab**: ${new Date().toISOString().split("T")[0]}`,
"**Generiertaus**: ExaMetra Module L Compliance Framework",
];
const filePath = path.join(outputPath, "privacy-notice-de.md");
fs.writeFileSync(filePath, lines.join("\n"), "utf-8");
console.log(`[v0] Generated privacy notice at ${filePath}`);
}
if (require.main === module) {
generatePrivacyNotice({
schoolName: "Gymnasium Berlin",
dpoName: "Max Mustermann",
dpoEmail: "dpo@gymnasium-berlin.de",
schultragerName: "Stadt Berlin",
bundesland: "BERLIN",
});
}
+215
View File
@@ -0,0 +1,215 @@
/**
* Generate Records of Processing Activities (Verzeichnis von Verarbeitungstätigkeiten)
* Compliant with GDPR Art. 30 requirements
* German legal document for school's DPO review
*/
const fs = require("fs");
const path = require("path");
interface RopaEntry {
name: string;
purposes: string[];
dataCategories: string[];
recipients: string[];
retentionPeriod: string;
legalBasis: string;
securityMeasures: string[];
}
const ropas: RopaEntry[] = [
{
name: "Student Information Management",
purposes: [
"Student enrollment and record maintenance",
"Educational progress tracking",
"Communication with parents/guardians",
],
dataCategories: ["Name", "DOB", "Contact information", "Academic records"],
recipients: ["Teachers", "School administration", "Parents/guardians"],
retentionPeriod: "Duration of enrollment + 2 years",
legalBasis:
"GDPR Art. 6(1)(c) - Legal obligation under Schulgesetz (School Law)",
securityMeasures: [
"Role-based access control (RBAC)",
"Encryption at rest and in transit",
"Audit logging of all data access",
"Regular security audits",
],
},
{
name: "Learning Progress & Assessment",
purposes: [
"Track student learning outcomes",
"Generate report cards and grades",
"Identify learning support needs",
],
dataCategories: [
"Assessment scores",
"Teacher feedback",
"Learning competencies",
],
recipients: ["Teachers", "School administration", "Parents"],
retentionPeriod: "1-2 years per Bundesland regulations",
legalBasis:
"GDPR Art. 6(1)(a) - Consent + Art. 9(2)(h) - Educational necessity",
securityMeasures: [
"Pseudonymization of assessment data where possible",
"Teacher-student privacy boundaries",
"Parent-accessible portal with consent gates",
],
},
{
name: "AI Tutor Processing",
purposes: [
"Personalized learning recommendations",
"Adaptive tutoring content delivery",
"Learning analytics and reporting",
],
dataCategories: [
"Student interaction logs",
"Learning preferences",
"Performance metrics",
"AI model inputs/outputs",
],
recipients: ["AI service provider (if applicable)", "Teachers", "Students"],
retentionPeriod: "6-12 months after course completion",
legalBasis:
"GDPR Art. 6(1)(a) - Explicit consent + Art. 22 - Automated decision safeguards",
securityMeasures: [
"Data minimization: only essential features processed",
"Transparency logs for AI decisions",
"Human-in-the-loop review for grade-affecting recommendations",
"Right to explanation mechanism",
],
},
{
name: "Exam Monitoring & Integrity",
purposes: [
"Exam room monitoring for security",
"Incident logging (distress signals, technical issues)",
"Academic integrity verification",
],
dataCategories: [
"Exam room events",
"Student signals/requests",
"Technical logs",
"Audio/video (if applicable)",
],
recipients: ["Exam proctors", "School administration"],
retentionPeriod: "6-12 months (shorter if no incident)",
legalBasis:
"GDPR Art. 6(1)(a) - Consent + Art. 9(2)(h) - Exam security necessity",
securityMeasures: [
"Limited access to exam data (proctors only)",
"Video/audio encrypted and retained separately",
"Automated deletion of non-incident logs after retention period",
],
},
{
name: "Audit & Compliance Logging",
purposes: [
"Regulatory compliance (GDPR, Schulgesetz)",
"Data breach investigation",
"User accountability and audit trail",
],
dataCategories: ["User ID", "Action taken", "Timestamp", "Entity accessed"],
recipients: ["DPO", "School administration", "Schulträger (if requested)"],
retentionPeriod: "1-2 years",
legalBasis: "GDPR Art. 6(1)(c) - Legal obligation (compliance duty)",
securityMeasures: [
"Immutable audit logs",
"Restricted DPO-only access",
"Regular integrity verification",
"Automated flagging of suspicious access patterns",
],
},
];
export function generateRopa(outputPath: string = "docs/legal"): void {
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}
const lines: string[] = [
"# Verzeichnis von Verarbeitungstätigkeiten",
"# (Records of Processing Activities - Art. 30 GDPR)",
"",
"**Schulname**: [To be filled by school]",
"**DPO**: [To be filled by school]",
"**Schulträger**: [To be filled by school]",
"**Erstellungsdatum**: " + new Date().toISOString(),
"",
"---",
"",
"## Einleitung",
"",
"Dieses Verzeichnis dokumentiert alle Verarbeitungstätigkeiten personenbezogener Daten durch ExaMetra im Rahmen des deutschen Schulgesetzes (Schulgesetz/BDSG-SchulG) und der DSGVO.",
"",
"**HINWEIS**: Dies ist eine Vorlage. Ihre Schule muss dieses Dokument durch ihren Datenschutzbeauftragten überprüfen und finalisieren, bevor es als offizielle Dokumentation gilt.",
"",
"---",
"",
];
for (let i = 0; i < ropas.length; i++) {
const ropa = ropas[i];
lines.push(
`## Verarbeitungstätigkeit ${i + 1}: ${ropa.name}`,
"",
"### Zwecke der Verarbeitung",
...ropa.purposes.map((p) => `- ${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();
}
+194
View File
@@ -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
}
}
+273
View File
@@ -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<number> {
// 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<number> {
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<number> {
// 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 };
+204
View File
@@ -0,0 +1,204 @@
"use client";
import React, { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { CheckCircle, AlertCircle, FileText, Users, Clock } from "lucide-react";
interface ComplianceData {
status: any;
policiesConfigured: number;
policiesReviewed: number;
dpiaRecords: any[];
consultations: any[];
}
export default function ComplianceDashboard() {
const [data, setData] = useState<ComplianceData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch("/api/v1/compliance/status");
if (!response.ok) throw new Error("Failed to fetch compliance status");
const result = await response.json();
setData(result);
} catch (error) {
console.error("[v0] Error fetching compliance data:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) {
return (
<div className="p-8 text-center">
<p className="text-slate-600">Loading compliance status...</p>
</div>
);
}
if (!data) {
return (
<div className="p-8">
<Card className="border-red-200 bg-red-50">
<CardContent className="pt-6">
<p className="text-red-600">Failed to load compliance dashboard</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-8 p-8">
<div>
<h1 className="text-3xl font-bold text-slate-900">Compliance Monitoring Dashboard</h1>
<p className="text-slate-600 mt-2">
Track your school&apos;s data protection compliance status and document management
</p>
</div>
{/* Status Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
{data.status?.onboardingCompleted ? (
<CheckCircle className="w-8 h-8 text-emerald-600" />
) : (
<AlertCircle className="w-8 h-8 text-yellow-600" />
)}
<div>
<p className="text-sm text-slate-600">Onboarding Status</p>
<p className="font-semibold text-slate-900">
{data.status?.onboardingCompleted ? "Complete" : "Pending"}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<FileText className="w-8 h-8 text-blue-600" />
<div>
<p className="text-sm text-slate-600">Data Retention Policies</p>
<p className="font-semibold text-slate-900">
{data.policiesConfigured} / {data.policiesReviewed} reviewed
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<FileText className="w-8 h-8 text-purple-600" />
<div>
<p className="text-sm text-slate-600">DPIA Records</p>
<p className="font-semibold text-slate-900">{data.dpiaRecords.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Users className="w-8 h-8 text-orange-600" />
<div>
<p className="text-sm text-slate-600">Consultations</p>
<p className="font-semibold text-slate-900">{data.consultations.length}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Data Retention Policies */}
<Card>
<CardHeader>
<CardTitle>Active Data Retention Policies</CardTitle>
<CardDescription>
Configured retention periods for each data category
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-sm text-slate-600">
<p>{data.policiesConfigured} policies configured</p>
<p className="mt-2">Policies are centrally defined and reviewed by your DPO.</p>
</div>
</CardContent>
</Card>
{/* DPIA Records */}
{data.dpiaRecords.length > 0 && (
<Card>
<CardHeader>
<CardTitle>DPIA Records</CardTitle>
<CardDescription>
Data Protection Impact Assessments for high-risk modules
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{data.dpiaRecords.map((dpia: any) => (
<div
key={dpia.id}
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
>
<span className="text-sm font-medium text-slate-900">
{dpia.moduleScope}
</span>
<span className="text-xs text-slate-500">
{dpia.approvedAt ? "✓ Approved" : "Pending review"}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Consultation Log */}
{data.consultations.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Consultation Log</CardTitle>
<CardDescription>
Staff and parent body consultations per module
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{data.consultations.slice(0, 5).map((consult: any) => (
<div
key={consult.id}
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
>
<div>
<p className="text-sm font-medium text-slate-900">
{consult.consultationType}
</p>
{consult.moduleScope && (
<p className="text-xs text-slate-500">{consult.moduleScope}</p>
)}
</div>
<span className="text-xs text-slate-500">
{new Date(consult.acknowledgedAt).toLocaleDateString()}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import React from "react";
export default function ComplianceLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-slate-900 mb-2">
Compliance & Data Protection Setup
</h1>
<p className="text-lg text-slate-600">
Configure your school&apos;s legal and data protection compliance
framework
</p>
</div>
{children}
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ComplianceWizard } from "@/components/compliance-wizard";
import { Card, CardContent } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
export default function CompliancePage() {
const router = useRouter();
const [schoolId, setSchoolId] = useState<string | null>(null);
const [isCompleted, setIsCompleted] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
// In a real app, fetch the current user's schoolId from session/auth
// For now, we'll get it from the URL or localStorage
const fetchSchoolId = async () => {
try {
const response = await fetch("/api/v1/user/current");
if (!response.ok) throw new Error("Failed to fetch user");
const user = await response.json();
if (user.schoolId) {
setSchoolId(user.schoolId);
}
} catch (error) {
console.error("[v0] Error fetching school ID:", error);
} finally {
setLoading(false);
}
};
fetchSchoolId();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
</div>
);
}
if (!schoolId) {
return (
<div className="max-w-md mx-auto">
<Card>
<CardContent className="pt-6">
<p className="text-red-600">Unable to load compliance setup. Please ensure you are logged in.</p>
</CardContent>
</Card>
</div>
);
}
return (
<div>
{isCompleted ? (
<Card className="border-emerald-200 bg-emerald-50">
<CardContent className="pt-6">
<p className="text-emerald-800 mb-4">
✓ Compliance onboarding completed successfully! Your school is now ready to use ExaMetra.
</p>
<button
onClick={() => router.push("/admin/compliance-overview")}
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700"
>
Go to Compliance Dashboard →
</button>
</CardContent>
</Card>
) : (
<ComplianceWizard
schoolId={schoolId}
onComplete={(data) => {
console.log("[v0] Wizard completed:", data);
setIsCompleted(true);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import { canApproveDeletion } from "@/lib/auth";
import { getSession } from "@/lib/auth";
const prisma = new PrismaClient();
/**
* POST /api/v1/compliance/deletions/approve
* DPO approves deletion of flagged records
* Body: { flagIds: string[] }
*/
export async function POST(req: NextRequest) {
try {
// Verify DPO role
const session = await getSession();
if (!session?.userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const canApprove = await canApproveDeletion(session.userId);
if (!canApprove) {
return NextResponse.json(
{ error: "Only DPO can approve deletions" },
{ status: 403 }
);
}
const body = await req.json();
const { flagIds } = body;
if (!flagIds || !Array.isArray(flagIds)) {
return NextResponse.json(
{ error: "flagIds must be an array" },
{ status: 400 }
);
}
// Approve the flags
const approved = await prisma.deletionFlag.updateMany({
where: {
id: { in: flagIds },
},
data: {
approvedByUserId: session.userId,
approvedAt: new Date(),
},
});
// Log approval to audit
await prisma.auditLog.create({
data: {
schoolId: session.user?.schoolId,
userId: session.userId,
action: "DELETION_FLAGS_APPROVED",
entityType: "DeletionFlag",
metadata: JSON.stringify({
flagCount: approved.count,
timestamp: new Date().toISOString(),
}),
},
});
return NextResponse.json({
success: true,
approvedCount: approved.count,
message: `${approved.count} deletion(s) approved`,
});
} catch (error) {
console.error("[v0] Error approving deletions:", error);
return NextResponse.json(
{ error: "Failed to approve deletions" },
{ status: 500 }
);
}
}
/**
* GET /api/v1/compliance/deletions/pending
* Get pending deletion flags awaiting DPO approval
*/
export async function GET(req: NextRequest) {
try {
const session = await getSession();
if (!session?.userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const schoolId = req.nextUrl.searchParams.get("schoolId");
if (!schoolId) {
return NextResponse.json(
{ error: "schoolId parameter required" },
{ status: 400 }
);
}
const pending = await prisma.deletionFlag.findMany({
where: {
schoolId,
approvedAt: null, // Not yet approved
deletedAt: null, // Not yet hard-deleted
},
orderBy: { flaggedAt: "desc" },
});
return NextResponse.json({
count: pending.length,
deletionFlags: pending,
});
} catch (error) {
console.error("[v0] Error fetching pending deletions:", error);
return NextResponse.json(
{ error: "Failed to fetch pending deletions" },
{ status: 500 }
);
}
}
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { checkModuleAccess } from "@/lib/compliance";
/**
* Check if a module can be accessed based on compliance status
* GET /api/v1/compliance/module-access?schoolId=&module=AI_TUTOR
*/
export async function GET(req: NextRequest) {
try {
const schoolId = req.nextUrl.searchParams.get("schoolId");
const module = req.nextUrl.searchParams.get("module");
if (!schoolId || !module) {
return NextResponse.json(
{ error: "Missing schoolId or module parameter" },
{ status: 400 }
);
}
const result = await checkModuleAccess(schoolId, module);
return NextResponse.json(result);
} catch (error) {
console.error("[v0] Error checking module access:", error);
return NextResponse.json(
{ error: "Failed to check module access" },
{ status: 500 }
);
}
}
@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { schoolId, state } = body;
if (!schoolId || !state) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
// Save DPIA records for required modules
for (const [moduleId, dpiaData] of Object.entries(
state.dpiaModules || {}
)) {
const dpia = dpiaData as any;
if (dpia.completed) {
// Check if record exists
const existing = await prisma.dpiaRecord.findUnique({
where: {
schoolId_moduleScope: {
schoolId,
moduleScope: moduleId,
},
},
});
if (existing) {
await prisma.dpiaRecord.update({
where: { id: existing.id },
data: {
dataProcessingSummary: `DPIA for ${moduleId} completed`,
completedAt: new Date(),
documentUrl: dpia.documentUrl || `dpia-${moduleId}.pdf`,
},
});
} else {
await prisma.dpiaRecord.create({
data: {
schoolId,
moduleScope: moduleId,
dataProcessingSummary: `DPIA for ${moduleId} completed`,
completedAt: new Date(),
documentUrl: dpia.documentUrl || `dpia-${moduleId}.pdf`,
},
});
}
}
}
// Save consultation records
for (const [moduleId, consultations] of Object.entries(
state.moduleConsultations || {}
)) {
const consult = consultations as any;
const consultationTypes = [];
if (consult.elternbeirat) {
consultationTypes.push("ELTERNBEIRAT");
}
if (consult.schulkonferenz) {
consultationTypes.push("SCHULKONFERENZ");
}
if (consult.personalrat) {
consultationTypes.push("PERSONALRAT");
}
for (const type of consultationTypes) {
await prisma.complianceConsultation.create({
data: {
schoolId,
consultationType: type,
moduleScope: moduleId,
acknowledgedByUserId: "system", // In production, get from current user
acknowledgedAt: new Date(),
notes: `Consultation acknowledged for ${moduleId}`,
},
});
}
}
// Mark onboarding as completed
await prisma.schoolComplianceStatus.update({
where: { schoolId },
data: {
onboardingCompleted: true,
moduleGates: JSON.stringify(
Object.keys(state.dpiaModules || {}).reduce(
(acc: any, moduleId) => {
const dpia = (state.dpiaModules || {})[moduleId] as any;
acc[moduleId] = {
isApprovedForUse: dpia.completed,
diaCompleted: dpia.completed,
};
return acc;
},
{}
)
),
},
});
// Log to audit trail
await prisma.auditLog.create({
data: {
schoolId,
action: "COMPLIANCE_ONBOARDING_COMPLETED",
entityType: "SchoolComplianceStatus",
entityId: schoolId,
metadata: JSON.stringify({
timestamp: new Date().toISOString(),
modulesEnabled: Object.keys(state.dpiaModules || {}),
}),
},
});
return NextResponse.json({
success: true,
message: "Compliance onboarding completed successfully",
});
} catch (error) {
console.error("[v0] Error completing compliance onboarding:", error);
return NextResponse.json(
{ error: "Failed to complete compliance onboarding" },
{ status: 500 }
);
}
}
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import {
ensureComplianceStatus,
initializeRetentionPolicies,
getRetentionDefaults
} from "@/lib/compliance";
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const {
schoolId,
dpoName,
dpoEmail,
schultragerName,
schultragerEmail,
bundesland,
} = body;
if (!schoolId || !dpoName || !dpoEmail || !schultragerName || !schultragerEmail || !bundesland) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
// Ensure compliance status exists
const complianceStatus = await ensureComplianceStatus(schoolId);
if (!complianceStatus) {
throw new Error("Failed to create compliance status");
}
// Update school compliance status with contact info
await prisma.schoolComplianceStatus.update({
where: { schoolId },
data: {
bundesland,
dpoDpoContactEmail: dpoEmail,
schultragerContactEmail: schultragerEmail,
},
});
// Initialize retention policies for this Bundesland
const policyInitialized = await initializeRetentionPolicies(schoolId, bundesland);
if (!policyInitialized) {
throw new Error("Failed to initialize retention policies");
}
// Log to audit trail
await prisma.auditLog.create({
data: {
schoolId,
action: "COMPLIANCE_SETUP_STEP_1",
entityType: "SchoolComplianceStatus",
entityId: schoolId,
metadata: JSON.stringify({
dpoName,
bundesland,
timestamp: new Date().toISOString(),
}),
},
});
return NextResponse.json({
success: true,
message: "Step 1 saved successfully",
retentionDefaults: getRetentionDefaults(bundesland),
});
} catch (error) {
console.error("[v0] Error in compliance step 1:", error);
return NextResponse.json(
{ error: "Failed to save compliance setup" },
{ status: 500 }
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { getComplianceStatus } from "@/lib/compliance";
export async function GET(req: NextRequest) {
try {
// In production, get schoolId from session/auth
const schoolId = req.nextUrl.searchParams.get("schoolId") || "default";
const status = await getComplianceStatus(schoolId);
if (!status) {
return NextResponse.json(
{ error: "School not found" },
{ status: 404 }
);
}
return NextResponse.json(status);
} catch (error) {
console.error("[v0] Error fetching compliance status:", error);
return NextResponse.json(
{ error: "Failed to fetch compliance status" },
{ status: 500 }
);
}
}
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
import { logComplianceAudit } from "@/lib/audit";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
/**
* POST /api/v1/gdpr/erasure
* Right to Erasure / Right to be Forgotten (Art. 17 GDPR)
* User can request deletion of their data
* Note: Some data (final grades) may be retained due to legal obligations
*/
export async function POST(req: NextRequest) {
try {
const session = await getSession();
if (!session?.userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId: requestedUserId, reason } = await req.json();
// Users can only request erasure of their own data
if (session.userId !== requestedUserId) {
return NextResponse.json(
{ error: "You can only request erasure of your own data" },
{ status: 403 }
);
}
const user = await prisma.user.findUnique({
where: { id: requestedUserId },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Determine what can be deleted vs. retained
const retained = {
FINAL_REPORT: "Permanent legal record (Schulgesetz requirement)",
DISCIPLINARY_RECORDS_WITH_CONSEQUENCES:
"Retained per applicable retention policy",
};
const erasedCategories = [
"MESSAGES",
"NOTEBOOKS",
"LEARNING_PROGRESS_ENTRIES",
"AI_TUTOR_LOGS",
];
// Delete erasable data
const deletionResults = {
messagesDeleted: 0,
notebooksDeleted: 0,
learningProgressDeleted: 0,
auditLogsDeleted: 0,
};
// Delete messages
const messageResult = await prisma.parentMessage.deleteMany({
where: { OR: [{ senderId: requestedUserId }, { recipientId: requestedUserId }] },
});
deletionResults.messagesDeleted = messageResult.count;
// Delete notebooks (student-owned)
if (user.student?.id) {
const notebookResult = await prisma.notebook.deleteMany({
where: { studentId: user.student.id },
});
deletionResults.notebooksDeleted = notebookResult.count;
}
// Delete learning progress entries
const lpResult = await prisma.learningProgressEntry.deleteMany({
where: { userId: requestedUserId },
});
deletionResults.learningProgressDeleted = lpResult.count;
// Note: Audit logs are NOT deleted for audit trail integrity
// Log the erasure request
await logComplianceAudit({
userId: requestedUserId,
schoolId: user.schoolId || undefined,
action: "ERASURE_REQUEST_APPROVED",
entityType: "User",
entityId: requestedUserId,
dataSubject: requestedUserId,
metadata: {
reason,
erasedCategories,
deletionResults,
retainedCategories: retained,
timestamp: new Date().toISOString(),
},
});
return NextResponse.json({
success: true,
message: "Your erasure request has been processed",
deleted: deletionResults,
retained: retained,
note: "Final grades and disciplinary records with consequences are retained per legal requirements. Contact your DPO for details.",
});
} catch (error) {
console.error("[v0] Error processing erasure request:", error);
return NextResponse.json(
{ error: "Failed to process erasure request" },
{ status: 500 }
);
}
}
/**
* GET /api/v1/gdpr/erasure/status
* Check status of erasure requests for a user
*/
export async function GET(req: NextRequest) {
try {
const session = await getSession();
if (!session?.userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = req.nextUrl.searchParams.get("userId") || session.userId;
// Users can only check their own status
if (session.userId !== userId) {
return NextResponse.json(
{ error: "You can only check your own erasure status" },
{ status: 403 }
);
}
// Get erasure-related audit logs
const erasureLogs = await prisma.auditLog.findMany({
where: {
dataSubject: userId,
action: { contains: "ERASURE" },
},
orderBy: { timestamp: "desc" },
take: 10,
select: {
id: true,
action: true,
timestamp: true,
metadata: true,
},
});
return NextResponse.json({
userId,
recentRequests: erasureLogs.map((log) => ({
...log,
metadata: log.metadata ? JSON.parse(log.metadata) : null,
})),
});
} catch (error) {
console.error("[v0] Error checking erasure status:", error);
return NextResponse.json(
{ error: "Failed to check erasure status" },
{ status: 500 }
);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
import { getAuditTrailForDataSubject } from "@/lib/audit";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
/**
* POST /api/v1/gdpr/export
* Right of Access (Art. 15 GDPR)
* User can request export of all their personal data
*/
export async function POST(req: NextRequest) {
try {
const session = await getSession();
if (!session?.userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId: requestedUserId } = await req.json();
// Users can only export their own data; admins can export any user's data
const isAdmin = session.user?.role === "SCHOOL_ADMIN" || session.user?.role === "SUPER_ADMIN";
if (!isAdmin && session.userId !== requestedUserId) {
return NextResponse.json(
{ error: "You can only export your own data" },
{ status: 403 }
);
}
// Fetch all personal data
const user = await prisma.user.findUnique({
where: { id: requestedUserId },
include: {
student: true,
learningProgressEntries: true,
assessments: true,
notebooks: true,
parentMessagesSent: true,
auditLogs: { take: 100 },
},
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Get audit trail for this user
const auditTrail = await getAuditTrailForDataSubject(user.schoolId!, requestedUserId);
// Compile export data
const exportData = {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
createdAt: user.createdAt,
},
student: user.student || null,
learningProgress: user.learningProgressEntries || [],
assessments: user.assessments || [],
notebooks: user.notebooks || [],
messages: user.parentMessagesSent || [],
auditTrail: auditTrail,
exportedAt: new Date().toISOString(),
retentionNote:
"Some data (e.g., final grades) may be retained longer due to legal requirements. See your school's privacy notice.",
};
return NextResponse.json({
success: true,
data: exportData,
format: "json",
message: "Your personal data export is ready",
});
} catch (error) {
console.error("[v0] Error exporting user data:", error);
return NextResponse.json(
{ error: "Failed to export data" },
{ status: 500 }
);
}
}
+536
View File
@@ -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<string, { completed: boolean; documentUrl?: string }>;
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<WizardState>({
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 (
<div className="space-y-8">
{/* Progress Indicator */}
<div className="flex justify-between items-center">
{[1, 2, 3, 4, 5].map((step) => (
<div
key={step}
className={`flex items-center ${step < 5 ? "flex-1" : ""}`}
>
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
state.step >= step
? "bg-emerald-600 text-white"
: "bg-slate-200 text-slate-600"
}`}
>
{state.step > step ? (
<CheckCircle className="w-6 h-6" />
) : (
step
)}
</div>
{step < 5 && (
<div
className={`flex-1 h-1 mx-2 ${
state.step > step ? "bg-emerald-600" : "bg-slate-200"
}`}
/>
)}
</div>
))}
</div>
{error && (
<Card className="border-red-200 bg-red-50">
<CardContent className="pt-6 flex gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-red-800">{error}</p>
</CardContent>
</Card>
)}
{/* Step 1: School & Contact Info */}
{state.step === 1 && (
<Card>
<CardHeader>
<CardTitle>School & Contact Information</CardTitle>
<CardDescription>
Provide your school&apos;s basic information and designated contacts
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
School Name
</label>
<Input
value={state.schoolName || ""}
onChange={(e) =>
setState((prev) => ({
...prev,
schoolName: e.target.value,
}))
}
placeholder="e.g., Gymnasium Berlin"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Data Protection Officer (DPO) Name
</label>
<Input
value={state.dpoName || ""}
onChange={(e) =>
setState((prev) => ({ ...prev, dpoName: e.target.value }))
}
placeholder="Full name"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
DPO Email
</label>
<Input
type="email"
value={state.dpoEmail || ""}
onChange={(e) =>
setState((prev) => ({ ...prev, dpoEmail: e.target.value }))
}
placeholder="dpo@school.de"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Schulträger (School Provider) Name
</label>
<Input
value={state.schultragerName || ""}
onChange={(e) =>
setState((prev) => ({
...prev,
schultragerName: e.target.value,
}))
}
placeholder="e.g., City of Berlin"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Schulträger Contact Email
</label>
<Input
type="email"
value={state.schultragerEmail || ""}
onChange={(e) =>
setState((prev) => ({
...prev,
schultragerEmail: e.target.value,
}))
}
placeholder="admin@schultraeger.de"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
State (Bundesland)
</label>
<Select value={state.bundesland} onValueChange={(value) =>
setState((prev) => ({ ...prev, bundesland: value }))
}>
<SelectTrigger>
<SelectValue placeholder="Select state..." />
</SelectTrigger>
<SelectContent>
{BUNDESLANDS.map((land) => (
<SelectItem key={land} value={land}>
{land.replace(/_/g, " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
<div className="flex justify-end gap-3 px-6 py-4 border-t">
<Button onClick={handleStep1Submit} disabled={!isStep1Valid || loading}>
{loading ? "Saving..." : "Next"}
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</Card>
)}
{/* Step 2: Retention Periods (coming in next iteration) */}
{state.step === 2 && (
<Card>
<CardHeader>
<CardTitle>Data Retention Periods</CardTitle>
<CardDescription>
Configure retention periods for each data category (state-specific defaults provided)
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-slate-600">
Retention periods have been pre-seeded based on your state&apos;s regulations.
Your DPO should review and confirm these settings.
</p>
</CardContent>
<div className="flex justify-between gap-3 px-6 py-4 border-t">
<Button variant="outline" onClick={() => setState((prev) => ({ ...prev, step: 1 }))}>
<ChevronLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button onClick={() => setState((prev) => ({ ...prev, step: 3 }))}>
Next: Consultations
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</Card>
)}
{/* Step 3: Consultations */}
{state.step === 3 && (
<Card>
<CardHeader>
<CardTitle>Consultation Checklist</CardTitle>
<CardDescription>
Log consultations with staff and parent bodies per module
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{MODULES.map((module) => (
<div key={module.id} className="border-b pb-4 last:border-b-0">
<h4 className="font-medium text-slate-900 mb-3">{module.label}</h4>
<div className="space-y-2 pl-4">
<label className="flex items-center gap-3">
<Checkbox
checked={
state.moduleConsultations[module.id]?.elternbeirat || false
}
onCheckedChange={(checked) =>
setState((prev) => ({
...prev,
moduleConsultations: {
...prev.moduleConsultations,
[module.id]: {
...prev.moduleConsultations[module.id],
elternbeirat: checked === true,
},
},
}))
}
/>
<span className="text-sm text-slate-700">
Elternbeirat (Parent Council) consulted
</span>
</label>
{!module.requiresDpia && (
<label className="flex items-center gap-3">
<Checkbox
checked={
state.moduleConsultations[module.id]?.schulkonferenz || false
}
onCheckedChange={(checked) =>
setState((prev) => ({
...prev,
moduleConsultations: {
...prev.moduleConsultations,
[module.id]: {
...prev.moduleConsultations[module.id],
schulkonferenz: checked === true,
},
},
}))
}
/>
<span className="text-sm text-slate-700">
Schulkonferenz (School Conference) consulted
</span>
</label>
)}
{module.requiresDpia && (
<label className="flex items-center gap-3">
<Checkbox checked={true} disabled />
<span className="text-sm text-slate-700">
Personalrat (Staff Council) consulted{" "}
<span className="text-xs text-slate-500">(required)</span>
</span>
</label>
)}
</div>
</div>
))}
</div>
</CardContent>
<div className="flex justify-between gap-3 px-6 py-4 border-t">
<Button variant="outline" onClick={() => setState((prev) => ({ ...prev, step: 2 }))}>
<ChevronLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button onClick={() => setState((prev) => ({ ...prev, step: 4 }))}>
Next: DPIA Documents
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</Card>
)}
{/* Step 4: DPIA Gating */}
{state.step === 4 && (
<Card>
<CardHeader>
<CardTitle>DPIA (Data Protection Impact Assessment)</CardTitle>
<CardDescription>
Upload completed DPIAs for high-risk modules before enabling them
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{MODULES.filter((m) => m.requiresDpia).map((module) => (
<div key={module.id} className="border rounded-lg p-4">
<h4 className="font-medium text-slate-900 mb-2">{module.label} DPIA</h4>
<p className="text-sm text-slate-600 mb-3">
Upload your completed DPIA document (PDF)
</p>
<input
type="file"
accept=".pdf"
onChange={(e) => {
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 && (
<p className="text-sm text-emerald-600 mt-2">
✓ {state.dpiaModules[module.id].documentUrl}
</p>
)}
</div>
))}
</CardContent>
<div className="flex justify-between gap-3 px-6 py-4 border-t">
<Button variant="outline" onClick={() => setState((prev) => ({ ...prev, step: 3 }))}>
<ChevronLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button onClick={() => setState((prev) => ({ ...prev, step: 5 }))}>
Review & Complete
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</Card>
)}
{/* Step 5: Summary & Complete */}
{state.step === 5 && (
<Card>
<CardHeader>
<CardTitle>Compliance Setup Complete</CardTitle>
<CardDescription>
Review your configuration before finalizing
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<p className="text-sm text-emerald-800">
✓ All compliance requirements have been configured. Your school is now ready for
production use.
</p>
</div>
<div className="text-sm text-slate-600 space-y-2">
<p><strong>DPO:</strong> {state.dpoName} ({state.dpoEmail})</p>
<p><strong>State:</strong> {state.bundesland}</p>
<p><strong>Modules:</strong> {MODULES.length} configured</p>
</div>
</CardContent>
<div className="flex justify-between gap-3 px-6 py-4 border-t">
<Button variant="outline" onClick={() => setState((prev) => ({ ...prev, step: 4 }))}>
<ChevronLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button onClick={handleStep4Submit} disabled={loading}>
{loading ? "Completing..." : "Finalize Setup"}
</Button>
</div>
</Card>
)}
</div>
);
}
Executable → Regular
+134
View File
@@ -138,3 +138,137 @@ export function withAuditLog(
return response; 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<void> {
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<string, unknown>
): Promise<void> {
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<string, unknown>
): Promise<void> {
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<void> {
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 [];
}
}
Executable → Regular
+90
View File
@@ -87,3 +87,93 @@ export async function clearSession(): Promise<void> {
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME); 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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
const isDPO = await isDPORole(userId);
return isDPO;
}
+304
View File
@@ -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<string, number>
> = {
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<ComplianceCheckResult> {
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<number | null> {
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<number> {
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<boolean> {
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
);
}