import { Prisma } from '../../generated/prisma'; import { PrismaService } from '../prisma/prisma.service'; import { recordVoiceCaptureFailed } from './voice-failure-hook'; /** Audio is kept until it is transcribed, 24 hours at most. */ export const VOICE_AUDIO_MAX_AGE_HOURS = 24; /** The row itself (no content) stays 90 days for the error and cost overview. */ export const VOICE_ROW_RETENTION_DAYS = 90; export interface VoiceRetentionCounts { audioExpired: number; contentExpired: number; inactiveOwnersWiped: number; } export interface VoiceRetentionOptions { /** * Extra condition on "VoiceCapture" columns, ANDed to every statement * (e.g. an owner filter in tests). Trusted SQL only. */ filter?: Prisma.Sql; } const OPEN_CONTENT = Prisma.sql`"status" NOT IN ('CONFIRMED', 'DISCARDED', 'EXPIRED')`; /** * Audio older than 24 hours goes, and a note that still waited for its * transcript (QUEUED, TRANSCRIBING) ends FAILED/AUDIO_EXPIRED; its claim is * cleared, so a job that still holds it loses the compare-and-set. A note in * another state keeps its status (FAILED keeps its own code). */ async function expireAudio( tx: Prisma.TransactionClient, filter: Prisma.Sql, ): Promise { const moved = await tx.$queryRaw<{ id: string }[]>(Prisma.sql` WITH old AS ( SELECT c."id" FROM "VoiceCaptureAudio" a JOIN "VoiceCapture" c ON c."id" = a."captureId" WHERE a."createdAt" < now() - interval '24 hours' ${filter} ORDER BY c."id" FOR UPDATE OF c ), gone AS ( DELETE FROM "VoiceCaptureAudio" WHERE "captureId" IN (SELECT "id" FROM old) ) UPDATE "VoiceCapture" SET "status" = 'FAILED', "lastErrorCode" = 'AUDIO_EXPIRED', "claimedAt" = NULL, "claimToken" = NULL, "updatedAt" = now() WHERE "id" IN (SELECT "id" FROM old) AND "status" IN ('QUEUED', 'TRANSCRIBING') RETURNING "id"`); for (const { id } of moved) { await recordVoiceCaptureFailed(tx, id, 'AUDIO_EXPIRED'); } return moved.length; } /** * Deletes audio, transcript and suggestions of the notes the `due` * condition selects and marks them EXPIRED. Locks the rows first, so a * confirm or discard in flight finishes before the wipe looks at the state. */ function wipeContent( tx: Prisma.TransactionClient, due: Prisma.Sql, filter: Prisma.Sql, ): Promise { return tx.$executeRaw(Prisma.sql` WITH due AS ( SELECT "id" FROM "VoiceCapture" WHERE "contentDeletedAt" IS NULL AND ${OPEN_CONTENT} AND ${due} ${filter} ORDER BY "id" FOR UPDATE ), gone AS ( DELETE FROM "VoiceCaptureAudio" WHERE "captureId" IN (SELECT "id" FROM due) ) UPDATE "VoiceCapture" SET "status" = 'EXPIRED', "transcriptEnc" = NULL, "suggestionEnc" = NULL, "contentDeletedAt" = now(), "claimedAt" = NULL, "claimToken" = NULL, "updatedAt" = now() WHERE "id" IN (SELECT "id" FROM due)`); } /** * The retention rules that must not wait for the daily cleanup: audio over * 24 hours, content past its 7 days (contentExpiresAt), content of * deactivated users. Pure SQL against the database clock, no key needed, so * every CronJob run (every 2 minutes) can do it - the cleanup job calls the * same function. Hooks and writes share one transaction. */ export function expireVoiceContent( db: PrismaService, options: VoiceRetentionOptions = {}, ): Promise { const filter = options.filter ? Prisma.sql`AND ${options.filter}` : Prisma.empty; return db.$transaction(async (tx) => { const audioExpired = await expireAudio(tx, filter); const contentExpired = await wipeContent( tx, Prisma.sql`"contentExpiresAt" < now()`, filter, ); const inactiveOwnersWiped = await wipeContent( tx, Prisma.sql`"ownerUserId" IN (SELECT "id" FROM "User" WHERE "isActive" = false)`, filter, ); return { audioExpired, contentExpired, inactiveOwnersWiped }; }); } /** * Removes successful voice-transcribe runs older than `hours` in which every * counter is 0 (no work): the CronJob runs every 2 minutes and would add * 1440 rows a day. Runs with work, and failed runs, stay with the normal * 90 days. `filter` narrows on "JobRun" columns (tests). */ export function deleteIdleVoiceRuns( db: PrismaService, hours: number, filter?: Prisma.Sql, ): Promise { return db.$executeRaw(Prisma.sql` DELETE FROM "JobRun" WHERE "job" = 'voice-transcribe' AND "status" = 'SUCCEEDED' AND "startedAt" < now() - (${hours} * interval '1 hour') AND "result" IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM jsonb_each_text("result"::jsonb) AS counter WHERE counter.value <> '0') ${filter ? Prisma.sql`AND ${filter}` : Prisma.empty}`); } /** Deletes rows (and, by cascade, any audio) older than 90 days. */ export function deleteOldVoiceRows(db: PrismaService): Promise { return db.$executeRaw` DELETE FROM "VoiceCapture" WHERE "createdAt" < now() - (${VOICE_ROW_RETENTION_DAYS} * interval '1 day')`; }