Task 8: POST /inspections nimmt voiceCaptureId; die Notiz wird zuletzt in derselben Transaktion bestätigt (jeder offene Status, Ruling 1), Inhalt, Audio und Claim gelöscht; fremde oder geschlossene Notiz 409 VOICE_CAPTURE_GONE, anderes Volk 409 VOICE_CAPTURE_COLONY_MISMATCH; Wiederholung mit gleichem Schlüssel idempotent. Task 9: Ereignisse VoiceCaptureReady/VoiceCaptureFailed (nur IDs und Endcodes), Meldungen „Sprachnotiz für … ist fertig“ bzw. Fehlerhinweis mit nächstem Schritt, Betreiberhinweis bei abgeschalteter Auswertung; Handler auch im Worker registriert (sonst gingen die Ereignisse ungehört durch); keine Weiterleitung an Webhooks. Task 11: Job voice-transcribe mit Phasen fetch (Audio nur im Speicher entschlüsselt in ein RAM-Arbeitsverzeichnis 0700/0600) und ingest (Ergebnis gegen Claim geprüft, Transkript versiegelt, Audio gelöscht, Auswertung geweckt; Stille/zu lang enden FAILED); Ereignisse genau einmal in derselben Transaktion. Aufbewahrung: Audio 24 h, Inhalt bis contentExpiresAt, deaktivierte Besitzer, Zeilen 90 Tage; im täglichen Cleanup inkl. leerer Job-Läufe (Ruling 10). Gates: Backend lint, typecheck, 4471 Unit-Tests, build; e2e 103 Suites (einziger Fehler: Wetter-Test gegen echte API). Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
152 lines
5.0 KiB
TypeScript
152 lines
5.0 KiB
TypeScript
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<number> {
|
|
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<number> {
|
|
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<VoiceRetentionCounts> {
|
|
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<number> {
|
|
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<number> {
|
|
return db.$executeRaw`
|
|
DELETE FROM "VoiceCapture"
|
|
WHERE "createdAt" < now() - (${VOICE_ROW_RETENTION_DAYS} * interval '1 day')`;
|
|
}
|