feat: add new legal document and database schema for module L compliance

Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com>
This commit is contained in:
v0andcetinmustafa83 committed 2026-07-31 13:34:58 +00:00
1 parent e6d82083d7
commit 927aae8580
24 files changed
+6565 -1

No files matched your search

Executable → Regular
+134
View File
@@ -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
View File
@@ -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;
}
+304
View File
@@ -0,0 +1,304 @@
/**
* Module L: Legal & Data Protection Compliance Utilities
* Provides compliance checks, DPIA gating, feature access control, and data retention helpers
*/
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// ─── Data Categories & Retention Mapping ──────────────────────────────────
export const DATA_CATEGORIES = {
LEARNING_PROGRESS: "LEARNING_PROGRESS",
ASSESSMENT_RESULT: "ASSESSMENT_RESULT",
FINAL_REPORT: "FINAL_REPORT",
DISCIPLINARY_RECORD: "DISCIPLINARY_RECORD",
EXAM_INCIDENT: "EXAM_INCIDENT",
MESSAGE: "MESSAGE",
AI_TUTOR_LOG: "AI_TUTOR_LOG",
NOTEBOOK_PAGE: "NOTEBOOK_PAGE",
SIGNAGE_LOG: "SIGNAGE_LOG",
TEACHER_AUDIT_LOG: "TEACHER_AUDIT_LOG",
} as const;
export const MODULE_DPIA_REQUIRED = ["AI_TUTOR", "EXAM_MODE"] as const;
export const BUNDESLAND_RETENTION_DEFAULTS: Record<
string,
Record<string, number>
> = {
BERLIN: {
LEARNING_PROGRESS: 12,
ASSESSMENT_RESULT: 12,
FINAL_REPORT: 30,
DISCIPLINARY_RECORD: 5,
EXAM_INCIDENT: 6,
MESSAGE: 12,
AI_TUTOR_LOG: 6,
NOTEBOOK_PAGE: 12,
SIGNAGE_LOG: 3,
TEACHER_AUDIT_LOG: 12,
},
BAYERN: {
LEARNING_PROGRESS: 12,
ASSESSMENT_RESULT: 12,
FINAL_REPORT: 35,
DISCIPLINARY_RECORD: 5,
EXAM_INCIDENT: 6,
MESSAGE: 12,
AI_TUTOR_LOG: 6,
NOTEBOOK_PAGE: 12,
SIGNAGE_LOG: 3,
TEACHER_AUDIT_LOG: 12,
},
// Add remaining 14 states with their specific retention rules
// For now, using Berlin as fallback
BADEN_WUERTTEMBERG: {
LEARNING_PROGRESS: 12,
ASSESSMENT_RESULT: 12,
FINAL_REPORT: 30,
DISCIPLINARY_RECORD: 5,
EXAM_INCIDENT: 6,
MESSAGE: 12,
AI_TUTOR_LOG: 6,
NOTEBOOK_PAGE: 12,
SIGNAGE_LOG: 3,
TEACHER_AUDIT_LOG: 12,
},
};
// ─── Compliance Status Checks ──────────────────────────────────────────────
export interface ComplianceCheckResult {
allowed: boolean;
reason?: string;
missingItems?: string[];
}
/**
* Check if a module can be accessed by a school
* Verifies: onboarding completion, DPIA presence (if required), and other gates
*/
export async function checkModuleAccess(
schoolId: string,
module: string
): Promise<ComplianceCheckResult> {
try {
const complianceStatus = await prisma.schoolComplianceStatus.findUnique({
where: { schoolId },
});
if (!complianceStatus?.onboardingCompleted) {
return {
allowed: false,
reason: "Compliance onboarding incomplete. Please complete the onboarding wizard first.",
};
}
// Check if module requires DPIA
if (MODULE_DPIA_REQUIRED.includes(module as any)) {
const dpiaRecord = await prisma.dpiaRecord.findUnique({
where: {
schoolId_moduleScope: {
schoolId,
moduleScope: module,
},
},
});
if (!dpiaRecord) {
return {
allowed: false,
reason: `DPIA documentation not found for ${module}. Please upload DPIA before enabling this module.`,
};
}
}
return { allowed: true };
} catch (error) {
console.error("[v0] Error checking module access:", error);
return {
allowed: false,
reason: "Error checking compliance status",
};
}
}
/**
* Get retention period for a data category
* Returns the months after which data should be flagged for deletion
*/
export async function getRetentionPeriod(
schoolId: string,
dataCategory: string
): Promise<number | null> {
try {
const policy = await prisma.dataRetentionPolicy.findFirst({
where: {
schoolId,
dataCategory,
},
});
return policy?.retentionPeriodMonths || null;
} catch (error) {
console.error("[v0] Error getting retention period:", error);
return null;
}
}
/**
* Flag data entities for deletion when retention period expires
* Called by daily retention job
*/
export async function flagExpiredRecords(schoolId: string): Promise<number> {
try {
const policies = await prisma.dataRetentionPolicy.findMany({
where: { schoolId },
});
let flaggedCount = 0;
for (const policy of policies) {
if (!policy.retentionPeriodMonths) continue;
const expiryDate = new Date();
expiryDate.setMonth(expiryDate.getMonth() - policy.retentionPeriodMonths);
// This is a placeholder for entity-specific logic
// In production, you'd loop over each entity type and flag accordingly
flaggedCount++;
}
return flaggedCount;
} catch (error) {
console.error("[v0] Error flagging expired records:", error);
return 0;
}
}
/**
* Check if user has DPO role
*/
export async function isDPO(userId: string): Promise<boolean> {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
});
return user?.role === "DPO";
} catch (error) {
console.error("[v0] Error checking DPO status:", error);
return false;
}
}
/**
* Get school compliance status summary
*/
export async function getComplianceStatus(schoolId: string) {
try {
const status = await prisma.schoolComplianceStatus.findUnique({
where: { schoolId },
});
const policies = await prisma.dataRetentionPolicy.findMany({
where: { schoolId },
});
const dpias = await prisma.dpiaRecord.findMany({
where: { schoolId },
});
const consultations = await prisma.complianceConsultation.findMany({
where: { schoolId },
});
return {
status,
policiesConfigured: policies.length,
policiesReviewed: policies.filter((p) => p.reviewedAt).length,
dpiaRecords: dpias,
consultations,
};
} catch (error) {
console.error("[v0] Error getting compliance status:", error);
return null;
}
}
/**
* Ensure school compliance status record exists
*/
export async function ensureComplianceStatus(schoolId: string) {
try {
const existing = await prisma.schoolComplianceStatus.findUnique({
where: { schoolId },
});
if (!existing) {
return await prisma.schoolComplianceStatus.create({
data: {
schoolId,
onboardingCompleted: false,
},
});
}
return existing;
} catch (error) {
console.error("[v0] Error ensuring compliance status:", error);
return null;
}
}
/**
* Initialize retention policies for a school based on Bundesland
*/
export async function initializeRetentionPolicies(
schoolId: string,
bundesland: string
) {
try {
const defaults =
BUNDESLAND_RETENTION_DEFAULTS[bundesland] ||
BUNDESLAND_RETENTION_DEFAULTS.BERLIN;
for (const [category, months] of Object.entries(defaults)) {
// Check if policy already exists
const existing = await prisma.dataRetentionPolicy.findFirst({
where: {
schoolId,
dataCategory: category,
},
});
if (!existing) {
await prisma.dataRetentionPolicy.create({
data: {
schoolId,
dataCategory: category,
retentionPeriodMonths: months,
isDefault: true,
},
});
}
}
return true;
} catch (error) {
console.error("[v0] Error initializing retention policies:", error);
return false;
}
}
/**
* Get Bundesland retention defaults
*/
export function getRetentionDefaults(bundesland: string) {
return (
BUNDESLAND_RETENTION_DEFAULTS[bundesland] ||
BUNDESLAND_RETENTION_DEFAULTS.BERLIN
);
}