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