feat: add new legal document and database schema for module L compliance
Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com>
This commit is contained in:
24 files changed
+6565
-1
No files matched your search
@@ -0,0 +1,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
@@ -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
@@ -116,6 +116,12 @@ model School {
|
||||
substituteTeachers SubstituteTeacher[]
|
||||
teacherAbsences TeacherAbsence[]
|
||||
substitutionAssignments SubstitutionAssignment[]
|
||||
// Module L compliance relations
|
||||
dataRetentionPolicies DataRetentionPolicy[]
|
||||
dpiaRecords DpiaRecord[]
|
||||
complianceConsultations ComplianceConsultation[]
|
||||
complianceStatus SchoolComplianceStatus?
|
||||
deletionFlags DeletionFlag[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -125,10 +131,11 @@ model User {
|
||||
passwordHash String
|
||||
firstName 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")
|
||||
twoFactorSecret String?
|
||||
isDemo Boolean @default(false)
|
||||
isTeacher Boolean @default(false) // civil-servant status for Personalrat rights
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
@@ -207,6 +214,11 @@ model User {
|
||||
careerAppointments CareerAppointment[]
|
||||
substituteTeacher SubstituteTeacher?
|
||||
teacherAbsences TeacherAbsence[]
|
||||
// Module L compliance relations
|
||||
retentionPoliciesReviewed DataRetentionPolicy[] @relation("DataRetentionPolicyReviewer")
|
||||
dpiaRecordsApproved DpiaRecord[] @relation("DpiaRecordApprover")
|
||||
complianceConsultationsAcknowledged ComplianceConsultation[] @relation("ComplianceConsultationAcknowledger")
|
||||
deletionFlagsApproved DeletionFlag[] @relation("DeletionFlagApprover")
|
||||
}
|
||||
|
||||
model SchoolYear {
|
||||
@@ -651,9 +663,13 @@ model AuditLog {
|
||||
userAgent String?
|
||||
timestamp DateTime @default(now())
|
||||
metadata String? // JSON
|
||||
dataSubject String? // track which student (if applicable) for GDPR export scope
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
school School? @relation(fields: [schoolId], references: [id])
|
||||
|
||||
@@index([dataSubject])
|
||||
@@index([timestamp])
|
||||
}
|
||||
|
||||
model DataExportRequest {
|
||||
@@ -3216,3 +3232,94 @@ model GradeReport {
|
||||
@@index([schoolId])
|
||||
@@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])
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CheckCircle, AlertCircle, FileText, Users, Clock } from "lucide-react";
|
||||
|
||||
interface ComplianceData {
|
||||
status: any;
|
||||
policiesConfigured: number;
|
||||
policiesReviewed: number;
|
||||
dpiaRecords: any[];
|
||||
consultations: any[];
|
||||
}
|
||||
|
||||
export default function ComplianceDashboard() {
|
||||
const [data, setData] = useState<ComplianceData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/compliance/status");
|
||||
if (!response.ok) throw new Error("Failed to fetch compliance status");
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (error) {
|
||||
console.error("[v0] Error fetching compliance data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-slate-600">Loading compliance status...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-red-600">Failed to load compliance dashboard</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">Compliance Monitoring Dashboard</h1>
|
||||
<p className="text-slate-600 mt-2">
|
||||
Track your school's data protection compliance status and document management
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{data.status?.onboardingCompleted ? (
|
||||
<CheckCircle className="w-8 h-8 text-emerald-600" />
|
||||
) : (
|
||||
<AlertCircle className="w-8 h-8 text-yellow-600" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-slate-600">Onboarding Status</p>
|
||||
<p className="font-semibold text-slate-900">
|
||||
{data.status?.onboardingCompleted ? "Complete" : "Pending"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-600">Data Retention Policies</p>
|
||||
<p className="font-semibold text-slate-900">
|
||||
{data.policiesConfigured} / {data.policiesReviewed} reviewed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-purple-600" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-600">DPIA Records</p>
|
||||
<p className="font-semibold text-slate-900">{data.dpiaRecords.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-8 h-8 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-600">Consultations</p>
|
||||
<p className="font-semibold text-slate-900">{data.consultations.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Data Retention Policies */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Data Retention Policies</CardTitle>
|
||||
<CardDescription>
|
||||
Configured retention periods for each data category
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-slate-600">
|
||||
<p>{data.policiesConfigured} policies configured</p>
|
||||
<p className="mt-2">Policies are centrally defined and reviewed by your DPO.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* DPIA Records */}
|
||||
{data.dpiaRecords.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DPIA Records</CardTitle>
|
||||
<CardDescription>
|
||||
Data Protection Impact Assessments for high-risk modules
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{data.dpiaRecords.map((dpia: any) => (
|
||||
<div
|
||||
key={dpia.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
|
||||
>
|
||||
<span className="text-sm font-medium text-slate-900">
|
||||
{dpia.moduleScope}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{dpia.approvedAt ? "✓ Approved" : "Pending review"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Consultation Log */}
|
||||
{data.consultations.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Consultation Log</CardTitle>
|
||||
<CardDescription>
|
||||
Staff and parent body consultations per module
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{data.consultations.slice(0, 5).map((consult: any) => (
|
||||
<div
|
||||
key={consult.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{consult.consultationType}
|
||||
</p>
|
||||
{consult.moduleScope && (
|
||||
<p className="text-xs text-slate-500">{consult.moduleScope}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
{new Date(consult.acknowledgedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
export default function ComplianceLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-2">
|
||||
Compliance & Data Protection Setup
|
||||
</h1>
|
||||
<p className="text-lg text-slate-600">
|
||||
Configure your school's legal and data protection compliance
|
||||
framework
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ComplianceWizard } from "@/components/compliance-wizard";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function CompliancePage() {
|
||||
const router = useRouter();
|
||||
const [schoolId, setSchoolId] = useState<string | null>(null);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// In a real app, fetch the current user's schoolId from session/auth
|
||||
// For now, we'll get it from the URL or localStorage
|
||||
const fetchSchoolId = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/user/current");
|
||||
if (!response.ok) throw new Error("Failed to fetch user");
|
||||
const user = await response.json();
|
||||
if (user.schoolId) {
|
||||
setSchoolId(user.schoolId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] Error fetching school ID:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSchoolId();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schoolId) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-red-600">Unable to load compliance setup. Please ensure you are logged in.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isCompleted ? (
|
||||
<Card className="border-emerald-200 bg-emerald-50">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-emerald-800 mb-4">
|
||||
✓ Compliance onboarding completed successfully! Your school is now ready to use ExaMetra.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/admin/compliance-overview")}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700"
|
||||
>
|
||||
Go to Compliance Dashboard →
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ComplianceWizard
|
||||
schoolId={schoolId}
|
||||
onComplete={(data) => {
|
||||
console.log("[v0] Wizard completed:", data);
|
||||
setIsCompleted(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { canApproveDeletion } from "@/lib/auth";
|
||||
import { getSession } from "@/lib/auth";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* POST /api/v1/compliance/deletions/approve
|
||||
* DPO approves deletion of flagged records
|
||||
* Body: { flagIds: string[] }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Verify DPO role
|
||||
const session = await getSession();
|
||||
if (!session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const canApprove = await canApproveDeletion(session.userId);
|
||||
if (!canApprove) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only DPO can approve deletions" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { flagIds } = body;
|
||||
|
||||
if (!flagIds || !Array.isArray(flagIds)) {
|
||||
return NextResponse.json(
|
||||
{ error: "flagIds must be an array" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Approve the flags
|
||||
const approved = await prisma.deletionFlag.updateMany({
|
||||
where: {
|
||||
id: { in: flagIds },
|
||||
},
|
||||
data: {
|
||||
approvedByUserId: session.userId,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Log approval to audit
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
schoolId: session.user?.schoolId,
|
||||
userId: session.userId,
|
||||
action: "DELETION_FLAGS_APPROVED",
|
||||
entityType: "DeletionFlag",
|
||||
metadata: JSON.stringify({
|
||||
flagCount: approved.count,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
approvedCount: approved.count,
|
||||
message: `${approved.count} deletion(s) approved`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error approving deletions:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to approve deletions" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/compliance/deletions/pending
|
||||
* Get pending deletion flags awaiting DPO approval
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const schoolId = req.nextUrl.searchParams.get("schoolId");
|
||||
if (!schoolId) {
|
||||
return NextResponse.json(
|
||||
{ error: "schoolId parameter required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const pending = await prisma.deletionFlag.findMany({
|
||||
where: {
|
||||
schoolId,
|
||||
approvedAt: null, // Not yet approved
|
||||
deletedAt: null, // Not yet hard-deleted
|
||||
},
|
||||
orderBy: { flaggedAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
count: pending.length,
|
||||
deletionFlags: pending,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error fetching pending deletions:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch pending deletions" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { checkModuleAccess } from "@/lib/compliance";
|
||||
|
||||
/**
|
||||
* Check if a module can be accessed based on compliance status
|
||||
* GET /api/v1/compliance/module-access?schoolId=&module=AI_TUTOR
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const schoolId = req.nextUrl.searchParams.get("schoolId");
|
||||
const module = req.nextUrl.searchParams.get("module");
|
||||
|
||||
if (!schoolId || !module) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing schoolId or module parameter" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await checkModuleAccess(schoolId, module);
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("[v0] Error checking module access:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to check module access" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { schoolId, state } = body;
|
||||
|
||||
if (!schoolId || !state) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save DPIA records for required modules
|
||||
for (const [moduleId, dpiaData] of Object.entries(
|
||||
state.dpiaModules || {}
|
||||
)) {
|
||||
const dpia = dpiaData as any;
|
||||
if (dpia.completed) {
|
||||
// Check if record exists
|
||||
const existing = await prisma.dpiaRecord.findUnique({
|
||||
where: {
|
||||
schoolId_moduleScope: {
|
||||
schoolId,
|
||||
moduleScope: moduleId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.dpiaRecord.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
dataProcessingSummary: `DPIA for ${moduleId} completed`,
|
||||
completedAt: new Date(),
|
||||
documentUrl: dpia.documentUrl || `dpia-${moduleId}.pdf`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.dpiaRecord.create({
|
||||
data: {
|
||||
schoolId,
|
||||
moduleScope: moduleId,
|
||||
dataProcessingSummary: `DPIA for ${moduleId} completed`,
|
||||
completedAt: new Date(),
|
||||
documentUrl: dpia.documentUrl || `dpia-${moduleId}.pdf`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save consultation records
|
||||
for (const [moduleId, consultations] of Object.entries(
|
||||
state.moduleConsultations || {}
|
||||
)) {
|
||||
const consult = consultations as any;
|
||||
const consultationTypes = [];
|
||||
|
||||
if (consult.elternbeirat) {
|
||||
consultationTypes.push("ELTERNBEIRAT");
|
||||
}
|
||||
if (consult.schulkonferenz) {
|
||||
consultationTypes.push("SCHULKONFERENZ");
|
||||
}
|
||||
if (consult.personalrat) {
|
||||
consultationTypes.push("PERSONALRAT");
|
||||
}
|
||||
|
||||
for (const type of consultationTypes) {
|
||||
await prisma.complianceConsultation.create({
|
||||
data: {
|
||||
schoolId,
|
||||
consultationType: type,
|
||||
moduleScope: moduleId,
|
||||
acknowledgedByUserId: "system", // In production, get from current user
|
||||
acknowledgedAt: new Date(),
|
||||
notes: `Consultation acknowledged for ${moduleId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mark onboarding as completed
|
||||
await prisma.schoolComplianceStatus.update({
|
||||
where: { schoolId },
|
||||
data: {
|
||||
onboardingCompleted: true,
|
||||
moduleGates: JSON.stringify(
|
||||
Object.keys(state.dpiaModules || {}).reduce(
|
||||
(acc: any, moduleId) => {
|
||||
const dpia = (state.dpiaModules || {})[moduleId] as any;
|
||||
acc[moduleId] = {
|
||||
isApprovedForUse: dpia.completed,
|
||||
diaCompleted: dpia.completed,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
)
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// Log to audit trail
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
schoolId,
|
||||
action: "COMPLIANCE_ONBOARDING_COMPLETED",
|
||||
entityType: "SchoolComplianceStatus",
|
||||
entityId: schoolId,
|
||||
metadata: JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
modulesEnabled: Object.keys(state.dpiaModules || {}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Compliance onboarding completed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error completing compliance onboarding:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to complete compliance onboarding" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
ensureComplianceStatus,
|
||||
initializeRetentionPolicies,
|
||||
getRetentionDefaults
|
||||
} from "@/lib/compliance";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const {
|
||||
schoolId,
|
||||
dpoName,
|
||||
dpoEmail,
|
||||
schultragerName,
|
||||
schultragerEmail,
|
||||
bundesland,
|
||||
} = body;
|
||||
|
||||
if (!schoolId || !dpoName || !dpoEmail || !schultragerName || !schultragerEmail || !bundesland) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure compliance status exists
|
||||
const complianceStatus = await ensureComplianceStatus(schoolId);
|
||||
if (!complianceStatus) {
|
||||
throw new Error("Failed to create compliance status");
|
||||
}
|
||||
|
||||
// Update school compliance status with contact info
|
||||
await prisma.schoolComplianceStatus.update({
|
||||
where: { schoolId },
|
||||
data: {
|
||||
bundesland,
|
||||
dpoDpoContactEmail: dpoEmail,
|
||||
schultragerContactEmail: schultragerEmail,
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize retention policies for this Bundesland
|
||||
const policyInitialized = await initializeRetentionPolicies(schoolId, bundesland);
|
||||
if (!policyInitialized) {
|
||||
throw new Error("Failed to initialize retention policies");
|
||||
}
|
||||
|
||||
// Log to audit trail
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
schoolId,
|
||||
action: "COMPLIANCE_SETUP_STEP_1",
|
||||
entityType: "SchoolComplianceStatus",
|
||||
entityId: schoolId,
|
||||
metadata: JSON.stringify({
|
||||
dpoName,
|
||||
bundesland,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Step 1 saved successfully",
|
||||
retentionDefaults: getRetentionDefaults(bundesland),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error in compliance step 1:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to save compliance setup" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getComplianceStatus } from "@/lib/compliance";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// In production, get schoolId from session/auth
|
||||
const schoolId = req.nextUrl.searchParams.get("schoolId") || "default";
|
||||
|
||||
const status = await getComplianceStatus(schoolId);
|
||||
|
||||
if (!status) {
|
||||
return NextResponse.json(
|
||||
{ error: "School not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(status);
|
||||
} catch (error) {
|
||||
console.error("[v0] Error fetching compliance status:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch compliance status" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { logComplianceAudit } from "@/lib/audit";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* POST /api/v1/gdpr/erasure
|
||||
* Right to Erasure / Right to be Forgotten (Art. 17 GDPR)
|
||||
* User can request deletion of their data
|
||||
* Note: Some data (final grades) may be retained due to legal obligations
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { userId: requestedUserId, reason } = await req.json();
|
||||
|
||||
// Users can only request erasure of their own data
|
||||
if (session.userId !== requestedUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: "You can only request erasure of your own data" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: requestedUserId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Determine what can be deleted vs. retained
|
||||
const retained = {
|
||||
FINAL_REPORT: "Permanent legal record (Schulgesetz requirement)",
|
||||
DISCIPLINARY_RECORDS_WITH_CONSEQUENCES:
|
||||
"Retained per applicable retention policy",
|
||||
};
|
||||
|
||||
const erasedCategories = [
|
||||
"MESSAGES",
|
||||
"NOTEBOOKS",
|
||||
"LEARNING_PROGRESS_ENTRIES",
|
||||
"AI_TUTOR_LOGS",
|
||||
];
|
||||
|
||||
// Delete erasable data
|
||||
const deletionResults = {
|
||||
messagesDeleted: 0,
|
||||
notebooksDeleted: 0,
|
||||
learningProgressDeleted: 0,
|
||||
auditLogsDeleted: 0,
|
||||
};
|
||||
|
||||
// Delete messages
|
||||
const messageResult = await prisma.parentMessage.deleteMany({
|
||||
where: { OR: [{ senderId: requestedUserId }, { recipientId: requestedUserId }] },
|
||||
});
|
||||
deletionResults.messagesDeleted = messageResult.count;
|
||||
|
||||
// Delete notebooks (student-owned)
|
||||
if (user.student?.id) {
|
||||
const notebookResult = await prisma.notebook.deleteMany({
|
||||
where: { studentId: user.student.id },
|
||||
});
|
||||
deletionResults.notebooksDeleted = notebookResult.count;
|
||||
}
|
||||
|
||||
// Delete learning progress entries
|
||||
const lpResult = await prisma.learningProgressEntry.deleteMany({
|
||||
where: { userId: requestedUserId },
|
||||
});
|
||||
deletionResults.learningProgressDeleted = lpResult.count;
|
||||
|
||||
// Note: Audit logs are NOT deleted for audit trail integrity
|
||||
|
||||
// Log the erasure request
|
||||
await logComplianceAudit({
|
||||
userId: requestedUserId,
|
||||
schoolId: user.schoolId || undefined,
|
||||
action: "ERASURE_REQUEST_APPROVED",
|
||||
entityType: "User",
|
||||
entityId: requestedUserId,
|
||||
dataSubject: requestedUserId,
|
||||
metadata: {
|
||||
reason,
|
||||
erasedCategories,
|
||||
deletionResults,
|
||||
retainedCategories: retained,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Your erasure request has been processed",
|
||||
deleted: deletionResults,
|
||||
retained: retained,
|
||||
note: "Final grades and disciplinary records with consequences are retained per legal requirements. Contact your DPO for details.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error processing erasure request:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to process erasure request" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/gdpr/erasure/status
|
||||
* Check status of erasure requests for a user
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = req.nextUrl.searchParams.get("userId") || session.userId;
|
||||
|
||||
// Users can only check their own status
|
||||
if (session.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "You can only check your own erasure status" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get erasure-related audit logs
|
||||
const erasureLogs = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
dataSubject: userId,
|
||||
action: { contains: "ERASURE" },
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
timestamp: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
userId,
|
||||
recentRequests: erasureLogs.map((log) => ({
|
||||
...log,
|
||||
metadata: log.metadata ? JSON.parse(log.metadata) : null,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error checking erasure status:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to check erasure status" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { getAuditTrailForDataSubject } from "@/lib/audit";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* POST /api/v1/gdpr/export
|
||||
* Right of Access (Art. 15 GDPR)
|
||||
* User can request export of all their personal data
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { userId: requestedUserId } = await req.json();
|
||||
|
||||
// Users can only export their own data; admins can export any user's data
|
||||
const isAdmin = session.user?.role === "SCHOOL_ADMIN" || session.user?.role === "SUPER_ADMIN";
|
||||
if (!isAdmin && session.userId !== requestedUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: "You can only export your own data" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all personal data
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: requestedUserId },
|
||||
include: {
|
||||
student: true,
|
||||
learningProgressEntries: true,
|
||||
assessments: true,
|
||||
notebooks: true,
|
||||
parentMessagesSent: true,
|
||||
auditLogs: { take: 100 },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get audit trail for this user
|
||||
const auditTrail = await getAuditTrailForDataSubject(user.schoolId!, requestedUserId);
|
||||
|
||||
// Compile export data
|
||||
const exportData = {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
student: user.student || null,
|
||||
learningProgress: user.learningProgressEntries || [],
|
||||
assessments: user.assessments || [],
|
||||
notebooks: user.notebooks || [],
|
||||
messages: user.parentMessagesSent || [],
|
||||
auditTrail: auditTrail,
|
||||
exportedAt: new Date().toISOString(),
|
||||
retentionNote:
|
||||
"Some data (e.g., final grades) may be retained longer due to legal requirements. See your school's privacy notice.",
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: exportData,
|
||||
format: "json",
|
||||
message: "Your personal data export is ready",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[v0] Error exporting user data:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to export data" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'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'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
@@ -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<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
@@ -87,3 +87,93 @@ export async function clearSession(): Promise<void> {
|
||||
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<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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user