import { usePrisma } from "../utils/prisma"; // ─── Types ──────────────────────────────────────────────────────────────────── export type ProtectionSource = | "vpn" | "mdm" | "cooldown_disable" | "client" | "system"; // ─── Ingestion ──────────────────────────────────────────────────────────────── /** * Returns the most recent ProtectionStateLog entry for a user, or null if * no events have been recorded yet. */ export async function getLastProtectionEvent(userId: string) { const db = usePrisma(); return db.protectionStateLog.findFirst({ where: { userId }, orderBy: { occurredAt: "desc" }, }); } /** * Appends a new protection-state transition for a user. * Caller is responsible for deduplication (check getLastProtectionEvent first). */ export async function appendProtectionEvent( userId: string, active: boolean, source: ProtectionSource, occurredAt?: Date, ) { const db = usePrisma(); return db.protectionStateLog.create({ data: { userId, active, source, occurredAt: occurredAt ?? new Date(), }, }); } /** * Writes a protection-state transition only if it differs from the last * known state for this user. Returns the new row if written, null if deduped. */ export async function appendProtectionEventDeduped( userId: string, active: boolean, source: ProtectionSource, occurredAt?: Date, ): Promise<{ id: string } | null> { const last = await getLastProtectionEvent(userId); if (last && last.active === active) { // No state change — skip write. return null; } return appendProtectionEvent(userId, active, source, occurredAt); } // ─── Coverage Compute ───────────────────────────────────────────────────────── export interface ProtectionCoverage { firstProtectionAt: string | null; // ISO-8601 or null protectedDays: number; unprotectedDays: number; currentStreakDays: number; longestStreakDays: number; } /** * Reads all protection events for a user and computes the coverage/streak * metrics at read-time (no cron needed). * * Algorithm summary: * 1. Fetch all events ordered by occurredAt asc. * 2. firstProtectionAt = occurredAt of first active:true event. * If none → return all-zero response. * 3. Build intervals: [event[i].occurredAt, event[i+1].occurredAt) for each * segment, last segment ends at now(). * 4. For each UTC calendar day in [firstProtectionAt..today], compute total * unprotected minutes. If > 6h (360min) → day counts as UNPROTECTED. * 5. currentStreakDays = consecutive protected days running up to today. * 6. longestStreakDays = longest such run in the full history. */ export async function computeProtectionCoverage( userId: string, ): Promise { const db = usePrisma(); const events = await db.protectionStateLog.findMany({ where: { userId }, orderBy: { occurredAt: "asc" }, select: { active: true, occurredAt: true }, }); // Find first active:true event. const firstActiveEvent = events.find((e) => e.active); if (!firstActiveEvent) { return { firstProtectionAt: null, protectedDays: 0, unprotectedDays: 0, currentStreakDays: 0, longestStreakDays: 0, }; } const firstProtectionAt = firstActiveEvent.occurredAt; const now = new Date(); // ─── Build timeline of (state, from, to) intervals ─────────────────────── // Each event transitions the state FROM that moment forward. // We start from the first active event (ignore any earlier inactive events // before the user ever activated protection — they don't count in the window). // // The state at firstProtectionAt is "active:true" (that's the first event). // We replay all events from that point on. // Find the index of the first active event. const startIdx = events.indexOf(firstActiveEvent); const relevantEvents = events.slice(startIdx); interface Interval { active: boolean; from: Date; to: Date; } const intervals: Interval[] = []; for (let i = 0; i < relevantEvents.length; i++) { const from = relevantEvents[i].occurredAt; const to = i + 1 < relevantEvents.length ? relevantEvents[i + 1].occurredAt : now; intervals.push({ active: relevantEvents[i].active, from, to }); } // ─── Enumerate UTC calendar days from firstProtectionAt to today ───────── // Normalise to UTC date boundaries. const startDay = utcDayStart(firstProtectionAt); const todayDay = utcDayStart(now); // Map each day to total unprotected minutes within that day. const days: boolean[] = []; // true = protected, false = unprotected for ( let day = new Date(startDay); day <= todayDay; day = new Date(day.getTime() + 86400_000) ) { const dayEnd = new Date(day.getTime() + 86400_000); let unprotectedMs = 0; for (const iv of intervals) { if (iv.active) continue; // Protected interval, doesn't add unprotected time. // Overlap of this interval with [day, dayEnd) const overlapStart = Math.max(iv.from.getTime(), day.getTime()); const overlapEnd = Math.min(iv.to.getTime(), dayEnd.getTime()); if (overlapEnd > overlapStart) { unprotectedMs += overlapEnd - overlapStart; } } const unprotectedMinutes = unprotectedMs / 60_000; // Day is UNPROTECTED only if total unprotected time > 6h (360 min). days.push(unprotectedMinutes <= 360); } // ─── Compute metrics ───────────────────────────────────────────────────── const protectedDays = days.filter(Boolean).length; const totalDays = days.length; const unprotectedDays = totalDays - protectedDays; // currentStreakDays: consecutive protected days from the END of the array. let currentStreakDays = 0; for (let i = days.length - 1; i >= 0; i--) { if (days[i]) { currentStreakDays++; } else { break; } } // longestStreakDays: longest consecutive run of protected days. let longestStreakDays = 0; let runLength = 0; for (const d of days) { if (d) { runLength++; if (runLength > longestStreakDays) longestStreakDays = runLength; } else { runLength = 0; } } return { firstProtectionAt: firstProtectionAt.toISOString(), protectedDays, unprotectedDays, currentStreakDays, longestStreakDays, }; } // ─── Helpers ────────────────────────────────────────────────────────────────── /** Returns midnight UTC for the given date (floor to day boundary). */ function utcDayStart(date: Date): Date { return new Date( Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), ); }