Files
ApiCore/ApiCore-Backend/src/voice/voice-retention.ts
T
Nick SzaunigandClaude Opus 5.5 1dc39f7658
Backend Image / build-and-push (push) Successful in 6m51s
Frontend Image / build-and-push (push) Successful in 18m9s
feat: Sprachnotizen übernehmen, melden und abschreiben (T7 L2 Tasks 8, 9, 11)
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]>
2026-10-16 10:38:14 +02:00

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')`;
}