/** * 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 */ import { PrismaClient } from "@prisma/client"; import path from "node:path"; const prisma = new PrismaClient(); /** * 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 = []; 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) { // 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, dataCategory, expiryDate) { 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, entityType, expiryDate) { // 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 (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) { 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); }); } export { runRetentionJob, flagExpiredRecordsForSchool };