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]>
This commit is contained in:
1 parent
e9c6604e3b
commit
1dc39f7658
35 files changed
+2886
-27
No files matched your search
@@ -0,0 +1,151 @@
|
||||
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')`;
|
||||
}
|
||||
Reference in new issue
Block a user