diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index b7f85ad..72e674d 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -64,6 +64,7 @@ import { HiveModule } from './hive/hive.module'; import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module'; import { ComplianceModule } from './compliance/compliance.module'; import { ReadinessModule } from './compliance/readiness/readiness.module'; +import { VoiceModule } from './voice/voice.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsModule } from './auth/permissions/permissions.module'; @@ -140,6 +141,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce HiveModule, ComplianceModule, ReadinessModule, + VoiceModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/common/file-signature.spec.ts b/ApiCore-Backend/src/common/file-signature.spec.ts index 5c1c7f9..54f2e17 100644 --- a/ApiCore-Backend/src/common/file-signature.spec.ts +++ b/ApiCore-Backend/src/common/file-signature.spec.ts @@ -1,4 +1,4 @@ -import { matchesFileSignature } from './file-signature'; +import { detectAudioType, matchesFileSignature } from './file-signature'; describe('matchesFileSignature', () => { it('accepts a buffer starting with the PNG signature', () => { @@ -47,3 +47,63 @@ describe('matchesFileSignature', () => { ); }); }); + +describe('detectAudioType', () => { + it('knows EBML, OggS and ftyp and nothing else', () => { + const webm = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42]); + const ogg = Buffer.concat([Buffer.from('OggS', 'ascii'), Buffer.alloc(8)]); + const mp4 = Buffer.concat([ + Buffer.from([0x00, 0x00, 0x00, 0x20]), + Buffer.from('ftypM4A ', 'ascii'), + ]); + expect(detectAudioType(webm)).toBe('audio/webm'); + expect(detectAudioType(ogg)).toBe('audio/ogg'); + expect(detectAudioType(mp4)).toBe('audio/mp4'); + expect( + detectAudioType(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a])), + ).toBeNull(); + expect(detectAudioType(Buffer.from('RIFF....WAVE', 'ascii'))).toBeNull(); + expect(detectAudioType(Buffer.from('%PDF-1.7', 'ascii'))).toBeNull(); + }); + + it.each(['heic', 'avif', 'mif1', 'qt ', '3gp4'])( + 'rejects an ftyp file with the brand %s', + (brand) => { + const file = Buffer.concat([ + Buffer.from([0, 0, 0, 0x20]), + Buffer.from(`ftyp${brand}`, 'latin1'), + ]); + expect(detectAudioType(file)).toBeNull(); + }, + ); + + it.each(['M4A ', 'isom', 'iso2', 'iso6', 'mp41', 'mp42', 'dash'])( + 'accepts the audio brand %s', + (brand) => { + const file = Buffer.concat([ + Buffer.from([0, 0, 0, 0x20]), + Buffer.from(`ftyp${brand}`, 'latin1'), + ]); + expect(detectAudioType(file)).toBe('audio/mp4'); + }, + ); + + it('rejects buffers that are too short for any signature', () => { + expect(detectAudioType(Buffer.alloc(0))).toBeNull(); + expect(detectAudioType(Buffer.from([0x1a, 0x45, 0xdf]))).toBeNull(); + expect( + detectAudioType(Buffer.from('OggS', 'ascii').subarray(0, 3)), + ).toBeNull(); + expect( + detectAudioType(Buffer.from([0, 0, 0, 0x20, 0x66, 0x74])), + ).toBeNull(); + }); + + it('does not take a signature from the middle of the buffer', () => { + const late = Buffer.concat([ + Buffer.alloc(16), + Buffer.from('OggS', 'ascii'), + ]); + expect(detectAudioType(late)).toBeNull(); + }); +}); diff --git a/ApiCore-Backend/src/common/file-signature.ts b/ApiCore-Backend/src/common/file-signature.ts index accf381..a596173 100644 --- a/ApiCore-Backend/src/common/file-signature.ts +++ b/ApiCore-Backend/src/common/file-signature.ts @@ -33,3 +33,47 @@ export function matchesFileSignature( const check = SIGNATURE_CHECKS[declaredMimeType]; return check ? check(buffer) : true; } + +export type VoiceAudioMime = 'audio/webm' | 'audio/ogg' | 'audio/mp4'; + +// Major brands of MP4/M4A audio; HEIC, AVIF and other ISO-BMFF files are not. +const MP4_AUDIO_BRANDS: ReadonlySet = new Set([ + 'M4A ', + 'isom', + 'iso2', + 'iso3', + 'iso4', + 'iso5', + 'iso6', + 'mp41', + 'mp42', + 'dash', +]); + +const EBML_MAGIC = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]); + +/** + * Names the audio container by its first bytes - strict, unlike + * matchesFileSignature: anything unknown is null, and the declared type of + * the upload plays no part. EBML (WebM/Matroska), "OggS" and "ftyp" at byte 4 + * (MP4/M4A) are the containers MediaRecorder produces. + */ +export function detectAudioType(buffer: Buffer): VoiceAudioMime | null { + if (buffer.length >= 4 && buffer.subarray(0, 4).equals(EBML_MAGIC)) { + return 'audio/webm'; + } + if ( + buffer.length >= 4 && + buffer.subarray(0, 4).toString('latin1') === 'OggS' + ) { + return 'audio/ogg'; + } + if ( + buffer.length >= 12 && + buffer.subarray(4, 8).toString('latin1') === 'ftyp' && + MP4_AUDIO_BRANDS.has(buffer.subarray(8, 12).toString('latin1')) + ) { + return 'audio/mp4'; + } + return null; +} diff --git a/ApiCore-Backend/src/common/worker-wakeup.ts b/ApiCore-Backend/src/common/worker-wakeup.ts index 72ecdce..f7a0129 100644 --- a/ApiCore-Backend/src/common/worker-wakeup.ts +++ b/ApiCore-Backend/src/common/worker-wakeup.ts @@ -6,6 +6,7 @@ export const WAKE_CHANNELS = { calendarSync: 'calendar_sync', webhookDeliveries: 'webhook_delivery', notificationDeliveries: 'notification_delivery', + voiceExtraction: 'voice_extraction', } as const; export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS]; diff --git a/ApiCore-Backend/src/voice/captures/dto/list-voice-captures.query.ts b/ApiCore-Backend/src/voice/captures/dto/list-voice-captures.query.ts new file mode 100644 index 0000000..cc210df --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/dto/list-voice-captures.query.ts @@ -0,0 +1,10 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { VoiceCaptureStatus } from '../../../../generated/prisma'; + +export class ListVoiceCapturesQuery { + @ApiPropertyOptional({ enum: VoiceCaptureStatus }) + @IsOptional() + @IsEnum(VoiceCaptureStatus) + status?: VoiceCaptureStatus; +} diff --git a/ApiCore-Backend/src/voice/captures/dto/submit-voice-text.dto.ts b/ApiCore-Backend/src/voice/captures/dto/submit-voice-text.dto.ts new file mode 100644 index 0000000..deaca42 --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/dto/submit-voice-text.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Matches, MaxLength } from 'class-validator'; +import { + DAY_PATTERN, + ID_PATTERN, + MAX_ID_LENGTH, +} from './upload-voice-capture.dto'; + +/** Rough bound before normalisation; the exact 20-4000 rule runs after it. */ +export const MAX_RAW_TEXT_LENGTH = 8000; + +export class SubmitVoiceTextDto { + @ApiProperty({ description: 'Das Volk der Durchsicht (lebend)' }) + @IsString() + @MaxLength(MAX_ID_LENGTH) + @Matches(ID_PATTERN) + colonyId!: string; + + @ApiProperty({ + description: 'Datum der Durchsicht (YYYY-MM-DD, höchstens 7 Tage zurück)', + }) + @IsString() + @Matches(DAY_PATTERN) + inspectionDate!: string; + + @ApiProperty({ description: 'Diktierter Text, 20 bis 4000 Zeichen' }) + @IsString() + @MaxLength(MAX_RAW_TEXT_LENGTH) + text!: string; +} diff --git a/ApiCore-Backend/src/voice/captures/dto/upload-voice-capture.dto.ts b/ApiCore-Backend/src/voice/captures/dto/upload-voice-capture.dto.ts new file mode 100644 index 0000000..4ce0a4f --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/dto/upload-voice-capture.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsString, Matches, Max, MaxLength, Min } from 'class-validator'; + +export const MAX_ID_LENGTH = 64; +export const ID_PATTERN = /^[A-Za-z0-9_-]+$/; +export const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +export const MAX_DURATION_SEC = 240; + +/** The text fields beside the `audio` part of the multipart upload. */ +export class UploadVoiceCaptureDto { + @ApiProperty({ description: 'Das Volk der Durchsicht (lebend)' }) + @IsString() + @MaxLength(MAX_ID_LENGTH) + @Matches(ID_PATTERN) + colonyId!: string; + + @ApiProperty({ + description: 'Datum der Durchsicht (YYYY-MM-DD, höchstens 7 Tage zurück)', + }) + @IsString() + @Matches(DAY_PATTERN) + inspectionDate!: string; + + @ApiProperty({ description: 'Vom Gerät gemeldete Dauer in Sekunden' }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(MAX_DURATION_SEC) + durationSec!: number; +} diff --git a/ApiCore-Backend/src/voice/captures/inspection-create.guard.spec.ts b/ApiCore-Backend/src/voice/captures/inspection-create.guard.spec.ts new file mode 100644 index 0000000..d6f7865 --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/inspection-create.guard.spec.ts @@ -0,0 +1,29 @@ +import type { ExecutionContext } from '@nestjs/common'; +import { InspectionCreateGuard } from './inspection-create.guard'; + +function contextOf(user: { id: string } | undefined): ExecutionContext { + return { + switchToHttp: () => ({ getRequest: () => ({ user }) }), + } as unknown as ExecutionContext; +} + +describe('InspectionCreateGuard', () => { + it('asks for inspections:create for the signed-in person', async () => { + const hasAny = jest.fn().mockResolvedValue(true); + const guard = new InspectionCreateGuard({ hasAny } as never); + await expect(guard.canActivate(contextOf({ id: 'u1' }))).resolves.toBe( + true, + ); + expect(hasAny).toHaveBeenCalledWith('u1', ['inspections:create']); + }); + + it('denies a person without the right and a request without a person', async () => { + const hasAny = jest.fn().mockResolvedValue(false); + const guard = new InspectionCreateGuard({ hasAny } as never); + await expect(guard.canActivate(contextOf({ id: 'u1' }))).resolves.toBe( + false, + ); + await expect(guard.canActivate(contextOf(undefined))).resolves.toBe(false); + expect(hasAny).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ApiCore-Backend/src/voice/captures/inspection-create.guard.ts b/ApiCore-Backend/src/voice/captures/inspection-create.guard.ts new file mode 100644 index 0000000..13d22c1 --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/inspection-create.guard.ts @@ -0,0 +1,26 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { PermissionResolverService } from '../../auth/permissions/permission-resolver.service'; +import type { AuthenticatedUser } from '../../auth/types/authenticated-user'; + +interface RequestWithUser { + user?: AuthenticatedUser; +} + +/** + * Voice notes end in an inspection, so every voice endpoint also needs + * inspections:create (spec 3) on top of voice-captures:use. @RequirePermission + * is "any of", so the second right is its own guard. A request without a + * signed-in person (an API key) never passes. + */ +@Injectable() +export class InspectionCreateGuard implements CanActivate { + constructor(private readonly permissionResolver: PermissionResolverService) {} + + async canActivate(context: ExecutionContext): Promise { + const user = context.switchToHttp().getRequest().user; + if (!user) { + return false; + } + return this.permissionResolver.hasAny(user.id, ['inspections:create']); + } +} diff --git a/ApiCore-Backend/src/voice/captures/upload-too-large.filter.ts b/ApiCore-Backend/src/voice/captures/upload-too-large.filter.ts new file mode 100644 index 0000000..924ba5c --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/upload-too-large.filter.ts @@ -0,0 +1,31 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpStatus, + PayloadTooLargeException, +} from '@nestjs/common'; +import type { Response } from 'express'; +import { VoiceErrorCode } from '../voice-error-codes'; + +/** + * Multer's own "file too large" reaches Nest as a bare 413. The voice upload + * answers with the fixed code instead (TOO_LARGE), like the service does. + */ +@Catch(PayloadTooLargeException) +export class UploadTooLargeFilter implements ExceptionFilter { + catch(exception: PayloadTooLargeException, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + const body = exception.getResponse(); + const hasCode = typeof body === 'object' && body !== null && 'code' in body; + response.status(HttpStatus.PAYLOAD_TOO_LARGE).json( + hasCode + ? body + : { + statusCode: HttpStatus.PAYLOAD_TOO_LARGE, + code: 'TOO_LARGE' satisfies VoiceErrorCode, + message: 'Die Aufnahme ist zu groß (höchstens 2 MB).', + }, + ); + } +} diff --git a/ApiCore-Backend/src/voice/captures/voice-capture-view.spec.ts b/ApiCore-Backend/src/voice/captures/voice-capture-view.spec.ts new file mode 100644 index 0000000..5249b3f --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/voice-capture-view.spec.ts @@ -0,0 +1,85 @@ +import { toListItem, toSuggestionPayloadView } from './voice-capture-view'; + +describe('toListItem', () => { + const row = { + id: 'c1', + colonyId: 'k1', + colony: { code: 'V-001' }, + status: 'READY' as const, + source: 'AUDIO' as const, + createdAt: new Date('2026-10-16T08:00:00.000Z'), + lastErrorCode: null, + contentExpiresAt: new Date('2026-10-23T08:00:00.000Z'), + }; + + it('gives ids, state and dates and no content field', () => { + const item = toListItem(row); + expect(item).toEqual({ + id: 'c1', + colonyId: 'k1', + colonyName: 'V-001', + status: 'READY', + source: 'AUDIO', + createdAt: '2026-10-16T08:00:00.000Z', + lastErrorCode: null, + contentExpiresAt: '2026-10-23T08:00:00.000Z', + }); + expect(Object.keys(item)).not.toEqual( + expect.arrayContaining(['transcript', 'suggestions']), + ); + }); + + it('shows only a known error code', () => { + expect( + toListItem({ ...row, lastErrorCode: 'NO_SPEECH' }).lastErrorCode, + ).toBe('NO_SPEECH'); + expect( + toListItem({ ...row, lastErrorCode: 'free text from somewhere' }) + .lastErrorCode, + ).toBeNull(); + }); +}); + +describe('toSuggestionPayloadView', () => { + it('keeps well-formed suggestions and drops the rest', () => { + const view = toSuggestionPayloadView({ + suggestions: [ + { + field: 'queenSeen', + value: true, + quote: 'Königin gesehen', + corrected: false, + truncated: false, + }, + { field: 'weather', value: 'sonnig', quote: 'sonnig' }, + { field: 'notes', value: { x: 1 }, quote: 'x' }, + 'nonsense', + { + field: 'broodFrames', + value: 4, + quote: 'vier Brutwaben', + previous: { value: 3, quote: 'drei' }, + }, + ], + unclear: ['Etwas zum Futter', 7], + dropped: 2, + }); + expect(view?.suggestions.map((each) => each.field)).toEqual([ + 'queenSeen', + 'broodFrames', + ]); + expect(view?.suggestions[1]).toMatchObject({ + corrected: false, + truncated: false, + previous: { value: 3, quote: 'drei' }, + }); + expect(view?.unclear).toEqual(['Etwas zum Futter']); + expect(view?.dropped).toBe(2); + }); + + it('returns null for anything that is not a suggestion payload', () => { + expect(toSuggestionPayloadView(null)).toBeNull(); + expect(toSuggestionPayloadView('text')).toBeNull(); + expect(toSuggestionPayloadView({ suggestions: 'x' })).toBeNull(); + }); +}); diff --git a/ApiCore-Backend/src/voice/captures/voice-capture-view.ts b/ApiCore-Backend/src/voice/captures/voice-capture-view.ts new file mode 100644 index 0000000..ea2a8d7 --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/voice-capture-view.ts @@ -0,0 +1,158 @@ +import { + VoiceCaptureSource, + VoiceCaptureStatus, +} from '../../../generated/prisma'; +import { + INSPECTION_VOICE_FIELDS, + InspectionVoiceField, +} from '../core/inspection-field-specs'; +import { VOICE_ERROR_CODES, VoiceErrorCode } from '../voice-error-codes'; + +export interface VoiceCaptureListItem { + id: string; + colonyId: string; + colonyName: string; + status: VoiceCaptureStatus; + source: VoiceCaptureSource; + createdAt: string; + lastErrorCode: VoiceErrorCode | null; + contentExpiresAt: string; +} + +export type VoiceSuggestionValue = number | boolean | string; + +export interface VoiceSuggestionView { + field: InspectionVoiceField; + value: VoiceSuggestionValue; + quote: string; + corrected: boolean; + truncated: boolean; + previous?: { value: VoiceSuggestionValue; quote: string }; +} + +export interface VoiceCaptureDetail extends VoiceCaptureListItem { + inspectionDate: string; + transcript: string | null; + suggestions: VoiceSuggestionView[] | null; + unclear: string[]; + dropped: number; +} + +export interface VoiceClientConfig { + recordingEnabled: boolean; + extractionEnabled: boolean; + model: string; + retention: { audioHours: 24; contentDays: 7; rowDays: 90 }; +} + +/** The columns a list item is built from; never a content column. */ +export interface VoiceCaptureListRow { + id: string; + colonyId: string; + colony: { code: string }; + status: VoiceCaptureStatus; + source: VoiceCaptureSource; + createdAt: Date; + lastErrorCode: string | null; + contentExpiresAt: Date; +} + +const ERROR_CODES: ReadonlySet = new Set(VOICE_ERROR_CODES); +const VOICE_FIELDS: ReadonlySet = new Set(INSPECTION_VOICE_FIELDS); + +/** Only a known code leaves the service; anything else shows as no code. */ +function knownCode(code: string | null): VoiceErrorCode | null { + return code !== null && ERROR_CODES.has(code) + ? (code as VoiceErrorCode) + : null; +} + +export function toListItem(row: VoiceCaptureListRow): VoiceCaptureListItem { + return { + id: row.id, + colonyId: row.colonyId, + colonyName: row.colony.code, + status: row.status, + source: row.source, + createdAt: row.createdAt.toISOString(), + lastErrorCode: knownCode(row.lastErrorCode), + contentExpiresAt: row.contentExpiresAt.toISOString(), + }; +} + +/** The calendar day of a @db.Date value (stored as midnight UTC). */ +export function dayOf(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function isValue(value: unknown): value is VoiceSuggestionValue { + return ( + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function toSuggestion(raw: unknown): VoiceSuggestionView | null { + if (!isRecord(raw)) { + return null; + } + const { field, value, quote, corrected, truncated, previous } = raw; + if ( + typeof field !== 'string' || + !VOICE_FIELDS.has(field) || + !isValue(value) || + typeof quote !== 'string' + ) { + return null; + } + const view: VoiceSuggestionView = { + field: field as InspectionVoiceField, + value, + quote, + corrected: corrected === true, + truncated: truncated === true, + }; + if (isRecord(previous) && isValue(previous.value)) { + view.previous = { + value: previous.value, + quote: typeof previous.quote === 'string' ? previous.quote : '', + }; + } + return view; +} + +export interface SuggestionPayloadView { + suggestions: VoiceSuggestionView[]; + unclear: string[]; + dropped: number; +} + +/** + * Reads the opened `suggestionEnc` payload (`{ suggestions, unclear, dropped, + * ... }`, as validateSuggestions returns it). Tolerant on purpose: an entry + * without the shape is left out, never passed on, and a payload that is not + * an object gives null. + */ +export function toSuggestionPayloadView( + payload: unknown, +): SuggestionPayloadView | null { + if (!isRecord(payload) || !Array.isArray(payload.suggestions)) { + return null; + } + const suggestions = payload.suggestions + .map(toSuggestion) + .filter((each): each is VoiceSuggestionView => each !== null); + const unclear = Array.isArray(payload.unclear) + ? payload.unclear.filter((each): each is string => typeof each === 'string') + : []; + const dropped = + typeof payload.dropped === 'number' && Number.isFinite(payload.dropped) + ? payload.dropped + : 0; + return { suggestions, unclear, dropped }; +} diff --git a/ApiCore-Backend/src/voice/captures/voice-captures-stats.service.spec.ts b/ApiCore-Backend/src/voice/captures/voice-captures-stats.service.spec.ts new file mode 100644 index 0000000..3bd7730 --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/voice-captures-stats.service.spec.ts @@ -0,0 +1,44 @@ +import { Prisma } from '../../../generated/prisma'; +import { VoiceCapturesStatsService } from './voice-captures-stats.service'; + +describe('VoiceCapturesStatsService', () => { + it('has counters only - no user ids, no capture ids - on the database clock', async () => { + const queryRaw = jest + .fn() + .mockResolvedValueOnce([{ key: 'READY', count: 3 }]) + .mockResolvedValueOnce([{ key: 'NO_SPEECH', count: 1 }]) + .mockResolvedValueOnce([{ key: 'claude-sonnet-5-5', count: 2 }]) + .mockResolvedValueOnce([{ input: 1200n, output: 300n }]); + const service = new VoiceCapturesStatsService({ + $queryRaw: queryRaw, + } as never); + + const result = await service.stats(); + + expect(result).toEqual({ + byStatus: { READY: 3 }, + byError: { NO_SPEECH: 1 }, + byModel: { 'claude-sonnet-5-5': 2 }, + tokens: { input: 1200, output: 300 }, + }); + const sql = (queryRaw.mock.calls as Prisma.Sql[][]) + .map((call) => call[0].sql) + .join('\n'); + expect(sql).not.toContain('ownerUserId'); + expect(sql).not.toContain('"id"'); + expect(sql).toContain("now() AT TIME ZONE 'UTC'"); + }); + + it('answers zeros for an empty week', async () => { + const queryRaw = jest.fn().mockResolvedValue([]); + const service = new VoiceCapturesStatsService({ + $queryRaw: queryRaw, + } as never); + await expect(service.stats()).resolves.toEqual({ + byStatus: {}, + byError: {}, + byModel: {}, + tokens: { input: 0, output: 0 }, + }); + }); +}); diff --git a/ApiCore-Backend/src/voice/captures/voice-captures-stats.service.ts b/ApiCore-Backend/src/voice/captures/voice-captures-stats.service.ts new file mode 100644 index 0000000..5a1c4d1 --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/voice-captures-stats.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '../../../generated/prisma'; +import { PrismaService } from '../../prisma/prisma.service'; + +export interface VoiceCaptureStats { + byStatus: Record; + byError: Record; + tokens: { input: number; output: number }; + byModel: Record; +} + +interface CountRow { + key: string; + count: number; +} + +interface TokenRow { + input: bigint | number; + output: bigint | number; +} + +function toRecord(rows: readonly CountRow[]): Record { + return Object.fromEntries(rows.map((row) => [row.key, Number(row.count)])); +} + +/** + * Counters of the last seven days for the system area: per status, per error + * code, per model and the token sums. Aggregates only - no user, no capture + * id, no content. The window runs on the database clock. + */ +@Injectable() +export class VoiceCapturesStatsService { + constructor(private readonly prisma: PrismaService) {} + + async stats(): Promise { + const since = Prisma.sql`"createdAt" > (now() AT TIME ZONE 'UTC') - interval '7 days'`; + const [byStatus, byError, byModel, tokens] = await Promise.all([ + this.prisma.$queryRaw(Prisma.sql` + SELECT "status"::text AS "key", count(*)::int AS "count" + FROM "VoiceCapture" WHERE ${since} GROUP BY "status"`), + this.prisma.$queryRaw(Prisma.sql` + SELECT "lastErrorCode" AS "key", count(*)::int AS "count" + FROM "VoiceCapture" + WHERE ${since} AND "lastErrorCode" IS NOT NULL + GROUP BY "lastErrorCode"`), + this.prisma.$queryRaw(Prisma.sql` + SELECT "model" AS "key", count(*)::int AS "count" + FROM "VoiceCapture" + WHERE ${since} AND "model" IS NOT NULL + GROUP BY "model"`), + this.prisma.$queryRaw(Prisma.sql` + SELECT COALESCE(sum("inputTokens"), 0) AS "input", + COALESCE(sum("outputTokens"), 0) AS "output" + FROM "VoiceCapture" WHERE ${since}`), + ]); + return { + byStatus: toRecord(byStatus), + byError: toRecord(byError), + tokens: { + input: Number(tokens[0]?.input ?? 0), + output: Number(tokens[0]?.output ?? 0), + }, + byModel: toRecord(byModel), + }; + } +} diff --git a/ApiCore-Backend/src/voice/captures/voice-captures.controller.spec.ts b/ApiCore-Backend/src/voice/captures/voice-captures.controller.spec.ts new file mode 100644 index 0000000..8fc954d --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/voice-captures.controller.spec.ts @@ -0,0 +1,69 @@ +import { GUARDS_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { REQUIRE_PERMISSION_KEY } from '../../auth/decorators/require-permission.decorator'; +import { IDEMPOTENT_KEY } from '../../common/idempotency/idempotent.decorator'; +import { InspectionCreateGuard } from './inspection-create.guard'; +import { VoiceCapturesController } from './voice-captures.controller'; + +const prototype = VoiceCapturesController.prototype as unknown as Record< + string, + (...args: never[]) => unknown +>; + +function routeHandlers(): string[] { + return Object.getOwnPropertyNames(prototype).filter( + (name) => + name !== 'constructor' && + Reflect.getMetadata(PATH_METADATA, prototype[name]) !== undefined, + ); +} + +function metadata(key: string, name: string): T { + return Reflect.getMetadata(key, prototype[name]) as T; +} + +describe('VoiceCapturesController', () => { + it('needs voice-captures:use plus inspections:create on every route but the counters', () => { + const handlers = routeHandlers(); + expect(handlers).toHaveLength(9); + for (const name of handlers) { + if (name === 'getStats') { + expect(metadata(REQUIRE_PERMISSION_KEY, name)).toEqual([ + 'system:manage', + ]); + expect(metadata(GUARDS_METADATA, name)).toBe( + undefined, + ); + continue; + } + expect({ + name, + permissions: metadata(REQUIRE_PERMISSION_KEY, name), + guards: metadata(GUARDS_METADATA, name), + }).toEqual({ + name, + permissions: ['voice-captures:use'], + guards: [InspectionCreateGuard], + }); + } + }); + + it('registers the static paths before :id', () => { + const order = routeHandlers(); + const idRoute = order.indexOf('get'); + for (const name of ['config', 'getStats', 'discardAll', 'submitText']) { + expect(order.indexOf(name)).toBeLessThan(idRoute); + } + }); + + it('makes the upload and the text way idempotent and throttles the upload to 10 a minute', () => { + expect(metadata(IDEMPOTENT_KEY, 'upload')).toBe(true); + expect(metadata(IDEMPOTENT_KEY, 'submitText')).toBe(true); + expect(metadata('THROTTLER:LIMITdefault', 'upload')).toBe(10); + expect(metadata('THROTTLER:TTLdefault', 'upload')).toBe(60_000); + }); + + it('throttles retries to 5 a minute', () => { + expect(metadata('THROTTLER:LIMITdefault', 'retry')).toBe(5); + expect(metadata('THROTTLER:TTLdefault', 'retry')).toBe(60_000); + }); +}); diff --git a/ApiCore-Backend/src/voice/captures/voice-captures.controller.ts b/ApiCore-Backend/src/voice/captures/voice-captures.controller.ts new file mode 100644 index 0000000..2213d14 --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/voice-captures.controller.ts @@ -0,0 +1,172 @@ +import { + BadRequestException, + Body, + Controller, + Get, + HttpCode, + Param, + Post, + Query, + UploadedFile, + UseFilters, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { RequirePermission } from '../../auth/decorators/require-permission.decorator'; +import type { AuthenticatedUser } from '../../auth/types/authenticated-user'; +import { Idempotent } from '../../common/idempotency/idempotent.decorator'; +import { ListVoiceCapturesQuery } from './dto/list-voice-captures.query'; +import { SubmitVoiceTextDto } from './dto/submit-voice-text.dto'; +import { UploadVoiceCaptureDto } from './dto/upload-voice-capture.dto'; +import { InspectionCreateGuard } from './inspection-create.guard'; +import { UploadTooLargeFilter } from './upload-too-large.filter'; +import { + VoiceCapturesStatsService, + VoiceCaptureStats, +} from './voice-captures-stats.service'; +import { + MAX_AUDIO_BYTES, + VoiceCapturesService, +} from './voice-captures.service'; +import { + VoiceCaptureDetail, + VoiceCaptureListItem, + VoiceClientConfig, +} from './voice-capture-view'; + +/** Per client network; the per-user limit (10 new notes in 60 s) lives in the service. */ +const UPLOAD_THROTTLE = { default: { limit: 10, ttl: 60_000 } }; +/** + * A retry spends an extraction from the shared daily budget. The schema has + * no column for a per-note retry count (and must not change here), so the + * brake is a rate limit per client network instead. + */ +const RETRY_THROTTLE = { default: { limit: 5, ttl: 60_000 } }; + +/** + * Voice notes of the signed-in person (spec 5). Every route except the + * counters needs voice-captures:use and inspections:create, and every service + * call carries the caller's id: a foreign id is a 404. The static paths + * (config, stats, text, discard-all) come before ":id". The global no-store + * header applies to every answer. + */ +@ApiTags('voice-captures') +@ApiBearerAuth() +@Controller('voice-captures') +export class VoiceCapturesController { + constructor( + private readonly captures: VoiceCapturesService, + private readonly stats: VoiceCapturesStatsService, + ) {} + + @RequirePermission('voice-captures:use') + @UseGuards(InspectionCreateGuard) + @Idempotent() + @Throttle(UPLOAD_THROTTLE) + @UseFilters(UploadTooLargeFilter) + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FileInterceptor('audio', { + // No dest and no storage: multer keeps the file in memory, nothing on disk. + limits: { + fileSize: MAX_AUDIO_BYTES, + files: 1, + fields: 4, + fieldSize: 256, + parts: 5, + }, + }), + ) + @Post() + upload( + @UploadedFile() file: Express.Multer.File | undefined, + @Body() dto: UploadVoiceCaptureDto, + @CurrentUser() actor: AuthenticatedUser, + ): Promise<{ id: string; status: 'QUEUED' }> { + if (!file) { + throw new BadRequestException('Es wurde kein Ton hochgeladen.'); + } + return this.captures.upload(actor.id, file, dto); + } + + @RequirePermission('voice-captures:use') + @UseGuards(InspectionCreateGuard) + @Idempotent() + @Post('text') + submitText( + @Body() dto: SubmitVoiceTextDto, + @CurrentUser() actor: AuthenticatedUser, + ): Promise<{ id: string; status: 'TRANSCRIBED' }> { + return this.captures.submitText(actor.id, dto); + } + + @RequirePermission('voice-captures:use') + @UseGuards(InspectionCreateGuard) + @Get() + list( + @Query() query: ListVoiceCapturesQuery, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return this.captures.list(actor.id, query.status); + } + + @RequirePermission('voice-captures:use') + @UseGuards(InspectionCreateGuard) + @Get('config') + config(): Promise { + return this.captures.config(); + } + + @RequirePermission('system:manage') + @Get('stats') + getStats(): Promise { + return this.stats.stats(); + } + + @RequirePermission('voice-captures:use') + @UseGuards(InspectionCreateGuard) + @HttpCode(200) + @Post('discard-all') + discardAll( + @CurrentUser() actor: AuthenticatedUser, + ): Promise<{ discarded: number }> { + return this.captures.discardAll(actor.id); + } + + @RequirePermission('voice-captures:use') + @UseGuards(InspectionCreateGuard) + @Get(':id') + get( + @Param('id') id: string, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return this.captures.get(actor.id, id); + } + + @RequirePermission('voice-captures:use') + @UseGuards(InspectionCreateGuard) + @Throttle(RETRY_THROTTLE) + @HttpCode(200) + @Post(':id/retry') + retry( + @Param('id') id: string, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return this.captures.retry(actor.id, id); + } + + @RequirePermission('voice-captures:use') + @UseGuards(InspectionCreateGuard) + @HttpCode(204) + @Post(':id/discard') + discard( + @Param('id') id: string, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return this.captures.discard(actor.id, id); + } +} diff --git a/ApiCore-Backend/src/voice/captures/voice-captures.service.spec.ts b/ApiCore-Backend/src/voice/captures/voice-captures.service.spec.ts new file mode 100644 index 0000000..dfb9c6f --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/voice-captures.service.spec.ts @@ -0,0 +1,354 @@ +import { HttpException, Logger } from '@nestjs/common'; +import { openVoiceBytes, voiceAad } from '../core/voice-crypto'; +import { openTranscript } from '../core/transcript'; +import { VoiceCapturesService } from './voice-captures.service'; + +const WEBM = Buffer.concat([ + Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), + Buffer.from('opus-bytes-of-a-spoken-note'), +]); +const PNG = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from('not audio'), +]); +const SECRET_TEXT = + 'Koenigin gesehen, Stifte da, vier Brutwaben, ruhig, Futter niedrig'; + +function file(buffer: Buffer, mimetype = 'audio/webm') { + return { + buffer, + size: buffer.length, + mimetype, + } as unknown as Express.Multer.File; +} + +/** The first argument of a recorded call, typed by the caller. */ +function firstArg(mock: jest.Mock, call = 0): T { + return (mock.mock.calls as unknown[][])[call][0] as T; +} + +function codeOf(error: unknown): unknown { + return ( + (error as HttpException).getResponse() as { code?: string } | undefined + )?.code; +} + +async function failureOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error as HttpException; + } + throw new Error('expected the call to fail'); +} + +describe('VoiceCapturesService', () => { + const originalEnv = { ...process.env }; + let service: VoiceCapturesService; + let tx: { + $executeRaw: jest.Mock; + $queryRaw: jest.Mock; + colony: { findFirst: jest.Mock }; + voiceCapture: { + create: jest.Mock; + update: jest.Mock; + findFirst: jest.Mock; + }; + voiceCaptureAudio: { create: jest.Mock }; + }; + let prisma: { + $transaction: jest.Mock; + voiceCapture: { findFirst: jest.Mock; findMany: jest.Mock }; + }; + let auditLog: { record: jest.Mock }; + let counts: { open: number; today: number; minute: number }; + + beforeEach(() => { + jest.useFakeTimers({ now: new Date('2026-10-16T10:00:00.000Z') }); + process.env.JWT_ACCESS_SECRET = 'j'.repeat(40); + process.env.VOICE_ENCRYPTION_KEY = 'v'.repeat(40); + counts = { open: 0, today: 0, minute: 0 }; + const findFirst = jest.fn(); + tx = { + $executeRaw: jest.fn().mockResolvedValue(1), + $queryRaw: jest.fn().mockImplementation(() => Promise.resolve([counts])), + colony: { findFirst: jest.fn().mockResolvedValue({ id: 'colony-1' }) }, + voiceCapture: { + create: jest.fn().mockResolvedValue({ id: 'cap-1' }), + update: jest.fn().mockResolvedValue({ id: 'cap-1' }), + findFirst, + }, + voiceCaptureAudio: { create: jest.fn().mockResolvedValue({}) }, + }; + prisma = { + $transaction: jest + .fn() + .mockImplementation((fn: (client: typeof tx) => unknown) => fn(tx)), + voiceCapture: { findFirst, findMany: jest.fn() }, + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + service = new VoiceCapturesService(prisma as never, auditLog as never); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + process.env = { ...originalEnv }; + }); + + const uploadDto = { + colonyId: 'colony-1', + inspectionDate: '2026-10-16', + durationSec: 42, + }; + + describe('upload', () => { + it('stores the sealed audio under the capture id and answers QUEUED', async () => { + await expect( + service.upload('user-1', file(WEBM), uploadDto), + ).resolves.toEqual({ + id: 'cap-1', + status: 'QUEUED', + }); + const created = firstArg<{ data: object }>(tx.voiceCapture.create).data; + expect(created).toMatchObject({ + ownerUserId: 'user-1', + colonyId: 'colony-1', + source: 'AUDIO', + status: 'QUEUED', + audioMime: 'audio/webm', + audioBytes: WEBM.length, + durationSec: 42, + }); + const sealed = firstArg<{ + data: { captureId: string; dataEnc: Uint8Array }; + }>(tx.voiceCaptureAudio.create).data; + expect(sealed.captureId).toBe('cap-1'); + expect( + openVoiceBytes(Buffer.from(sealed.dataEnc), voiceAad('cap-1', 'audio')), + ).toEqual(WEBM); + expect(Buffer.from(sealed.dataEnc).equals(WEBM)).toBe(false); + }); + + it('takes the per-user advisory lock before it counts', async () => { + await service.upload('user-1', file(WEBM), uploadDto); + const lockOrder = [ + tx.$executeRaw.mock.invocationCallOrder[0], + tx.$queryRaw.mock.invocationCallOrder[0], + ]; + expect(lockOrder[0]).toBeLessThan(lockOrder[1]); + const strings = ( + tx.$executeRaw.mock.calls[0] as unknown[] + )[0] as string[]; + expect(strings.join('?')).toContain('pg_advisory_xact_lock'); + }); + + it('audits the creation without any content', async () => { + await service.upload('user-1', file(WEBM), uploadDto); + const entry = firstArg(auditLog.record); + expect(entry).toMatchObject({ + entityType: 'VoiceCapture', + entityId: 'cap-1', + action: 'CREATE', + userId: 'user-1', + }); + expect(JSON.stringify(entry)).not.toContain('opus-bytes'); + }); + + it('checks magic bytes, not the declared type', async () => { + await expect( + service.upload('user-1', file(WEBM, 'image/png'), uploadDto), + ).resolves.toMatchObject({ status: 'QUEUED' }); + expect( + firstArg<{ data: { audioMime: string } }>(tx.voiceCapture.create).data + .audioMime, + ).toBe('audio/webm'); + + const error = await failureOf( + service.upload('user-1', file(PNG, 'audio/webm'), uploadDto), + ); + expect(error.getStatus()).toBe(415); + expect(codeOf(error)).toBe('UNSUPPORTED_AUDIO'); + }); + + it('rejects a file above 2 MB with 413 TOO_LARGE', async () => { + const big = Buffer.concat([WEBM, Buffer.alloc(2_097_152)]); + const error = await failureOf( + service.upload('user-1', file(big), uploadDto), + ); + expect(error.getStatus()).toBe(413); + expect(codeOf(error)).toBe('TOO_LARGE'); + }); + + it('rejects a dead or unknown colony with 400', async () => { + tx.colony.findFirst.mockResolvedValue(null); + const error = await failureOf( + service.upload('user-1', file(WEBM), uploadDto), + ); + expect(error.getStatus()).toBe(400); + expect(tx.voiceCapture.create).not.toHaveBeenCalled(); + }); + + it.each([ + ['in the future', '2026-10-17'], + ['older than 7 days', '2026-10-08'], + ['not a day', '2026-02-30'], + ])('rejects an inspectionDate %s', async (_label, inspectionDate) => { + const error = await failureOf( + service.upload('user-1', file(WEBM), { ...uploadDto, inspectionDate }), + ); + expect(error.getStatus()).toBe(400); + }); + + it.each(['2026-10-16', '2026-10-09'])( + 'accepts the inspectionDate %s (today and seven days back, Berlin day)', + async (inspectionDate) => { + await expect( + service.upload('user-1', file(WEBM), { + ...uploadDto, + inspectionDate, + }), + ).resolves.toMatchObject({ status: 'QUEUED' }); + }, + ); + + it('limits open captures to ten', async () => { + counts.open = 10; + const error = await failureOf( + service.upload('user-1', file(WEBM), uploadDto), + ); + expect(error.getStatus()).toBe(429); + expect(codeOf(error)).toBe('TOO_MANY_OPEN'); + expect((error.getResponse() as { message: string }).message).toBe( + 'Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.', + ); + }); + + it('limits new captures to thirty per UTC day', async () => { + counts.today = 30; + const error = await failureOf( + service.upload('user-1', file(WEBM), uploadDto), + ); + expect(error.getStatus()).toBe(429); + expect(codeOf(error)).toBe('TOO_MANY_OPEN'); + }); + + it('limits ten new captures per minute per user', async () => { + counts.minute = 10; + const error = await failureOf( + service.upload('user-1', file(WEBM), uploadDto), + ); + expect(error.getStatus()).toBe(429); + expect(tx.voiceCapture.create).not.toHaveBeenCalled(); + }); + + it('counts only the caller and uses the database clock', async () => { + await service.upload('user-1', file(WEBM), uploadDto); + const query = (tx.$queryRaw.mock.calls[0] as unknown[])[0] as { + sql: string; + values: unknown[]; + }; + expect(query.values).toContain('user-1'); + expect(query.sql).toContain('now()'); + expect(query.sql).toContain('"ownerUserId"'); + }); + }); + + describe('submitText', () => { + const textDto = { + colonyId: 'colony-1', + inspectionDate: '2026-10-16', + text: SECRET_TEXT, + }; + + it('stores the sealed transcript and wakes the extraction loop', async () => { + await expect(service.submitText('user-1', textDto)).resolves.toEqual({ + id: 'cap-1', + status: 'TRANSCRIBED', + }); + expect( + firstArg<{ data: object }>(tx.voiceCapture.create).data, + ).toMatchObject({ + source: 'TEXT', + status: 'TRANSCRIBED', + }); + const stored = firstArg<{ data: { transcriptEnc: string } }>( + tx.voiceCapture.update, + ).data.transcriptEnc; + expect(stored).not.toContain('Koenigin'); + expect(openTranscript('cap-1', stored).text).toBe(SECRET_TEXT); + const wake = (tx.$executeRaw.mock.calls as unknown[][]).find((call) => + JSON.stringify(call).includes('pg_notify'), + ); + expect(wake).toBeDefined(); + expect(JSON.stringify(wake)).toContain('voice_extraction'); + }); + + it('normalises the text and enforces 20 to 4000 characters', async () => { + const short = await failureOf( + service.submitText('user-1', { ...textDto, text: 'zu kurz' }), + ); + expect(short.getStatus()).toBe(400); + const padded = await failureOf( + service.submitText('user-1', { + ...textDto, + text: `${'\u0000'.repeat(30)}kurz`, + }), + ); + expect(padded.getStatus()).toBe(400); + const long = await failureOf( + service.submitText('user-1', { ...textDto, text: 'a'.repeat(4001) }), + ); + expect(long.getStatus()).toBe(400); + await expect( + service.submitText('user-1', { ...textDto, text: 'a'.repeat(4000) }), + ).resolves.toMatchObject({ status: 'TRANSCRIBED' }); + }); + + it('shares the colony, date and limit rules with the upload', async () => { + counts.open = 10; + const error = await failureOf(service.submitText('user-1', textDto)); + expect(codeOf(error)).toBe('TOO_MANY_OPEN'); + }); + + it('never logs the transcript', async () => { + const spies = (['log', 'warn', 'error', 'debug', 'verbose'] as const).map( + (level) => + jest + .spyOn(Logger.prototype, level) + .mockImplementation(() => undefined), + ); + await service.submitText('user-1', textDto); + await failureOf( + service.submitText('user-1', { + ...textDto, + inspectionDate: '2026-10-30', + }), + ); + counts.open = 10; + await failureOf(service.submitText('user-1', textDto)); + const logged = JSON.stringify(spies.flatMap((spy) => spy.mock.calls)); + expect(logged).not.toContain('Koenigin'); + expect(JSON.stringify(auditLog.record.mock.calls)).not.toContain( + 'Koenigin', + ); + }); + }); + + describe('foreign ids', () => { + it.each(['get', 'retry', 'discard'] as const)( + 'rejects a foreign id with 404 on %s', + async (method) => { + tx.voiceCapture.findFirst.mockResolvedValue(null); + const error = await failureOf(service[method]('user-1', 'cap-9')); + expect(error.getStatus()).toBe(404); + expect( + firstArg<{ where: object }>(tx.voiceCapture.findFirst).where, + ).toEqual({ + id: 'cap-9', + ownerUserId: 'user-1', + }); + }, + ); + }); +}); diff --git a/ApiCore-Backend/src/voice/captures/voice-captures.service.ts b/ApiCore-Backend/src/voice/captures/voice-captures.service.ts new file mode 100644 index 0000000..7ed0402 --- /dev/null +++ b/ApiCore-Backend/src/voice/captures/voice-captures.service.ts @@ -0,0 +1,579 @@ +import { + BadRequestException, + ConflictException, + HttpException, + HttpStatus, + Injectable, + Logger, + NotFoundException, + PayloadTooLargeException, + UnsupportedMediaTypeException, +} from '@nestjs/common'; +import { Prisma, VoiceCaptureStatus } from '../../../generated/prisma'; +import { AuditLogService } from '../../audit/audit-log.service'; +import { LIVING_COLONY_WHERE } from '../../colonies/colony-status'; +import { addDays, berlinToday, dayToDate } from '../../common/dates'; +import { detectAudioType } from '../../common/file-signature'; +import { notifyWorker, WAKE_CHANNELS } from '../../common/worker-wakeup'; +import { PrismaService } from '../../prisma/prisma.service'; +import { lockRowForUpdate, lockRowsForUpdate } from '../../prisma/row-lock'; +import { openTranscript, sealTranscript } from '../core/transcript'; +import { openVoiceText, sealVoiceBytes, voiceAad } from '../core/voice-crypto'; +import { normalizeInputText } from '../core/voice-text'; +import { voiceConfig } from '../voice-config'; +import { VoiceErrorCode } from '../voice-error-codes'; +import { SubmitVoiceTextDto } from './dto/submit-voice-text.dto'; +import { UploadVoiceCaptureDto } from './dto/upload-voice-capture.dto'; +import { + dayOf, + toListItem, + toSuggestionPayloadView, + VoiceCaptureDetail, + VoiceCaptureListItem, + VoiceClientConfig, +} from './voice-capture-view'; + +export const MAX_AUDIO_BYTES = 2_097_152; +const MAX_OPEN_CAPTURES = 10; +const MAX_CAPTURES_PER_DAY = 30; +const MAX_CAPTURES_PER_MINUTE = 10; +const MAX_DAYS_BACK = 7; +const CONTENT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +const MIN_TEXT_CHARS = 20; +const MAX_TEXT_CHARS = 4000; +const LIST_LIMIT = 200; +/** An extraction that cannot run sits TRANSCRIBED with this code for this long. */ +const EXTRACTION_OFF_WINDOW = '1 hour'; + +const OPEN_STATUSES: readonly VoiceCaptureStatus[] = [ + 'QUEUED', + 'TRANSCRIBING', + 'TRANSCRIBED', + 'EXTRACTING', + 'READY', +]; +/** Statuses that can still hold content: discarding them wipes it. */ +const WIPEABLE_STATUSES: readonly VoiceCaptureStatus[] = [ + ...OPEN_STATUSES, + 'FAILED', +]; +/** Statuses whose transcript and suggestions the owner may read (spec 5). */ +const READABLE_STATUSES: readonly VoiceCaptureStatus[] = [ + 'READY', + 'TRANSCRIBED', + 'FAILED', +]; + +const TOO_MANY_OPEN_MESSAGE = + 'Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.'; + +const LIST_SELECT = { + id: true, + colonyId: true, + colony: { select: { code: true } }, + status: true, + source: true, + createdAt: true, + lastErrorCode: true, + contentExpiresAt: true, +} satisfies Prisma.VoiceCaptureSelect; + +interface NewCapture { + colonyId: string; + inspectionDay: string; + source: 'AUDIO' | 'TEXT'; + data: Pick< + Prisma.VoiceCaptureUncheckedCreateInput, + 'status' | 'audioMime' | 'audioBytes' | 'durationSec' + >; +} + +interface RecentCounts { + open: number; + today: number; + minute: number; +} + +function badRequest(message: string): BadRequestException { + return new BadRequestException(message); +} + +function gone(): ConflictException { + const code: VoiceErrorCode = 'VOICE_CAPTURE_GONE'; + return new ConflictException({ + statusCode: HttpStatus.CONFLICT, + code, + message: 'Die Sprachnotiz ist nicht mehr verfügbar.', + }); +} + +function tooManyOpen(): HttpException { + const code: VoiceErrorCode = 'TOO_MANY_OPEN'; + return new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + code, + message: TOO_MANY_OPEN_MESSAGE, + }, + HttpStatus.TOO_MANY_REQUESTS, + ); +} + +/** The inspection date must be today (Berlin) or at most seven days back. */ +function assertInspectionDay(day: string): void { + try { + dayToDate(day); + } catch { + throw badRequest('Das Datum der Durchsicht ist ungültig.'); + } + const today = berlinToday(); + if (day > today || day < addDays(today, -MAX_DAYS_BACK)) { + throw badRequest( + 'Das Datum der Durchsicht darf nicht in der Zukunft und höchstens 7 Tage zurück liegen.', + ); + } +} + +/** + * Voice notes of the caller. Content (audio, transcript, suggestions) lives + * only sealed, is opened only for its owner and never reaches a log, an audit + * entry or an event. Every lookup carries ownerUserId: a foreign id is 404. + */ +@Injectable() +export class VoiceCapturesService { + private readonly logger = new Logger(VoiceCapturesService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async upload( + userId: string, + file: Express.Multer.File, + dto: UploadVoiceCaptureDto, + ): Promise<{ id: string; status: 'QUEUED' }> { + if (file.size > MAX_AUDIO_BYTES || file.buffer.length > MAX_AUDIO_BYTES) { + throw new PayloadTooLargeException({ + statusCode: HttpStatus.PAYLOAD_TOO_LARGE, + code: 'TOO_LARGE' satisfies VoiceErrorCode, + message: 'Die Aufnahme ist zu groß (höchstens 2 MB).', + }); + } + // The bytes decide, never the declared Content-Type. + const audioMime = detectAudioType(file.buffer); + if (audioMime === null) { + throw new UnsupportedMediaTypeException({ + statusCode: HttpStatus.UNSUPPORTED_MEDIA_TYPE, + code: 'UNSUPPORTED_AUDIO' satisfies VoiceErrorCode, + message: 'Dieses Tonformat wird nicht unterstützt.', + }); + } + assertInspectionDay(dto.inspectionDate); + return this.prisma.$transaction(async (tx) => { + const id = await this.reserve(tx, userId, { + colonyId: dto.colonyId, + inspectionDay: dto.inspectionDate, + source: 'AUDIO', + data: { + status: 'QUEUED', + audioMime, + audioBytes: file.buffer.length, + durationSec: dto.durationSec, + }, + }); + await tx.voiceCaptureAudio.create({ + data: { + captureId: id, + // A copy typed as plain-ArrayBuffer bytes, which is what Prisma's Bytes takes. + dataEnc: new Uint8Array( + sealVoiceBytes(file.buffer, voiceAad(id, 'audio')), + ), + }, + }); + await this.auditCreated(tx, userId, id, 'AUDIO', 'QUEUED', dto); + return { id, status: 'QUEUED' as const }; + }); + } + + async submitText( + userId: string, + dto: SubmitVoiceTextDto, + ): Promise<{ id: string; status: 'TRANSCRIBED' }> { + const text = normalizeInputText(dto.text); + const length = Array.from(text).length; + if (length < MIN_TEXT_CHARS || length > MAX_TEXT_CHARS) { + throw badRequest( + `Der Text muss ${MIN_TEXT_CHARS} bis ${MAX_TEXT_CHARS} Zeichen lang sein.`, + ); + } + assertInspectionDay(dto.inspectionDate); + return this.prisma.$transaction(async (tx) => { + const id = await this.reserve(tx, userId, { + colonyId: dto.colonyId, + inspectionDay: dto.inspectionDate, + source: 'TEXT', + data: { status: 'TRANSCRIBED' }, + }); + await tx.voiceCapture.update({ + where: { id }, + data: { transcriptEnc: sealTranscript(id, { v: 1, text }) }, + }); + await this.auditCreated(tx, userId, id, 'TEXT', 'TRANSCRIBED', dto); + // The worker reads its work from the table; the wake-up carries nothing. + await notifyWorker(tx, WAKE_CHANNELS.voiceExtraction); + return { id, status: 'TRANSCRIBED' as const }; + }); + } + + async list( + userId: string, + status?: VoiceCaptureStatus, + ): Promise { + const rows = await this.prisma.voiceCapture.findMany({ + where: { ownerUserId: userId, ...(status ? { status } : {}) }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: LIST_LIMIT, + select: LIST_SELECT, + }); + return rows.map(toListItem); + } + + async get(userId: string, id: string): Promise { + const row = await this.prisma.voiceCapture.findFirst({ + where: { id, ownerUserId: userId }, + select: { + ...LIST_SELECT, + inspectionDate: true, + transcriptEnc: true, + suggestionEnc: true, + }, + }); + if (!row) { + throw new NotFoundException('Sprachnotiz nicht gefunden'); + } + const detail: VoiceCaptureDetail = { + ...toListItem(row), + inspectionDate: dayOf(row.inspectionDate), + transcript: null, + suggestions: null, + unclear: [], + dropped: 0, + }; + if (!READABLE_STATUSES.includes(row.status) || !row.transcriptEnc) { + return detail; + } + try { + detail.transcript = openTranscript(id, row.transcriptEnc).text; + if (row.suggestionEnc && row.status === 'READY') { + const payload = toSuggestionPayloadView( + JSON.parse( + openVoiceText(row.suggestionEnc, voiceAad(id, 'suggestion')), + ), + ); + if (payload) { + detail.suggestions = payload.suggestions; + detail.unclear = payload.unclear; + detail.dropped = payload.dropped; + } + } + } catch { + // Opening or parsing failed. The cause may quote content: log the id only. + this.logger.warn(`Voice capture ${id}: content could not be read`); + detail.transcript = null; + detail.suggestions = null; + } + return detail; + } + + /** FAILED with content left and time left goes back to its waiting state. */ + async retry(userId: string, id: string): Promise { + return this.prisma.$transaction(async (tx) => { + // Same lock order as a new note: the user's advisory lock, then the row. + await this.lockUser(tx, userId); + await this.lockOwned(tx, userId, id); + const row = await tx.voiceCapture.findFirst({ + where: { id, ownerUserId: userId }, + select: { + status: true, + lastErrorCode: true, + transcriptEnc: true, + audio: { select: { captureId: true } }, + }, + }); + if (!row) { + throw new NotFoundException('Sprachnotiz nicht gefunden'); + } + if (row.status !== 'FAILED') { + throw new ConflictException( + 'Nur eine gescheiterte Sprachnotiz kann wiederholt werden.', + ); + } + const target = + row.transcriptEnc !== null + ? 'TRANSCRIBED' + : row.audio !== null + ? 'QUEUED' + : null; + if (target === null) { + throw gone(); + } + // A retry reopens a note: it counts against the open limit like a new one. + if ((await this.recentCounts(tx, userId)).open >= MAX_OPEN_CAPTURES) { + throw tooManyOpen(); + } + // Audio older than 24 h is past its limit even if the cleanup has not + // run yet. + const audioFresh = + target === 'QUEUED' + ? Prisma.sql`AND EXISTS ( + SELECT 1 FROM "VoiceCaptureAudio" a + WHERE a."captureId" = "VoiceCapture"."id" + AND a."createdAt" > (now() AT TIME ZONE 'UTC') - interval '24 hours')` + : Prisma.empty; + // The deadline is checked in SQL against the database clock; attempts + // restart at 0 so the note gets its full set of tries (spec 5). + const updated = await tx.$queryRaw<{ id: string }[]>(Prisma.sql` + UPDATE "VoiceCapture" + SET "status" = CAST(${target} AS "VoiceCaptureStatus"), + "attempts" = 0, + "availableAt" = now(), + "lastErrorCode" = NULL, + "claimedAt" = NULL, + "claimToken" = NULL, + "updatedAt" = now() + WHERE "id" = ${id} + AND "ownerUserId" = ${userId} + AND "status" = 'FAILED' + AND "contentExpiresAt" > now() ${audioFresh} + RETURNING "id"`); + if (updated.length === 0) { + throw gone(); + } + if (target === 'TRANSCRIBED') { + await notifyWorker(tx, WAKE_CHANNELS.voiceExtraction); + } + await this.auditLog.record( + { + entityType: 'VoiceCapture', + entityId: id, + action: 'UPDATE', + userId, + beforeData: { status: 'FAILED', lastErrorCode: row.lastErrorCode }, + afterData: { status: target }, + }, + tx, + ); + const fresh = await tx.voiceCapture.findFirstOrThrow({ + where: { id, ownerUserId: userId }, + select: LIST_SELECT, + }); + return toListItem(fresh); + }); + } + + /** Idempotent: a note without content left (done, discarded, expired) stays as it is. */ + async discard(userId: string, id: string): Promise { + await this.prisma.$transaction(async (tx) => { + await this.lockOwned(tx, userId, id); + const row = await tx.voiceCapture.findFirst({ + where: { id, ownerUserId: userId }, + select: { status: true }, + }); + if (!row) { + throw new NotFoundException('Sprachnotiz nicht gefunden'); + } + if (!WIPEABLE_STATUSES.includes(row.status)) { + return; + } + await this.wipe(tx, userId, [{ id, status: row.status }]); + }); + } + + async discardAll(userId: string): Promise<{ discarded: number }> { + return this.prisma.$transaction(async (tx) => { + await this.lockUser(tx, userId); + const where = { + ownerUserId: userId, + status: { in: [...WIPEABLE_STATUSES] }, + } satisfies Prisma.VoiceCaptureWhereInput; + const candidates = await tx.voiceCapture.findMany({ + where, + select: { id: true }, + }); + await lockRowsForUpdate( + tx, + 'VoiceCapture', + candidates.map((each) => each.id), + ); + // Read again under the locks: a row may have been confirmed meanwhile. + const rows = await tx.voiceCapture.findMany({ + where, + orderBy: { id: 'asc' }, + select: { id: true, status: true }, + }); + await this.wipe(tx, userId, rows); + return { discarded: rows.length }; + }); + } + + async config(): Promise { + const settings = voiceConfig(); + // The API pod never holds the Anthropic key. It knows extraction is off + // only from the notes the worker parked with EXTRACT_DISABLED. + const parked = await this.prisma.$queryRaw<{ parked: number }[]>(Prisma.sql` + SELECT count(*)::int AS "parked" + FROM "VoiceCapture" + WHERE "status" = 'TRANSCRIBED' + AND "lastErrorCode" = 'EXTRACT_DISABLED' + AND "updatedAt" > (now() AT TIME ZONE 'UTC') - CAST(${EXTRACTION_OFF_WINDOW} AS interval)`); + return { + recordingEnabled: settings.recordingEnabled, + extractionEnabled: (parked[0]?.parked ?? 0) === 0, + model: settings.model, + retention: { audioHours: 24, contentDays: 7, rowDays: 90 }, + }; + } + + /** + * Common start of every new note, under the caller's advisory lock: a + * living colony and the three limits, then the row. Returns its id. + */ + private async reserve( + tx: Prisma.TransactionClient, + userId: string, + input: NewCapture, + ): Promise { + await this.lockUser(tx, userId); + const colony = await tx.colony.findFirst({ + where: { id: input.colonyId, ...LIVING_COLONY_WHERE }, + select: { id: true }, + }); + if (!colony) { + throw badRequest('Dieses Volk gibt es nicht mehr oder nicht.'); + } + const counts = await this.recentCounts(tx, userId); + if ( + counts.open >= MAX_OPEN_CAPTURES || + counts.today >= MAX_CAPTURES_PER_DAY || + counts.minute >= MAX_CAPTURES_PER_MINUTE + ) { + throw tooManyOpen(); + } + const created = await tx.voiceCapture.create({ + data: { + ownerUserId: userId, + colonyId: input.colonyId, + inspectionDate: dayToDate(input.inspectionDay), + source: input.source, + contentExpiresAt: new Date(Date.now() + CONTENT_RETENTION_MS), + ...input.data, + }, + select: { id: true }, + }); + return created.id; + } + + /** Serialises a user's new notes and bulk changes; all windows run on the database clock. */ + private async lockUser( + tx: Prisma.TransactionClient, + userId: string, + ): Promise { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`voice-captures:${userId}`}, 0))`; + } + + private async recentCounts( + tx: Prisma.TransactionClient, + userId: string, + ): Promise { + const rows = await tx.$queryRaw(Prisma.sql` + SELECT + (count(*) FILTER (WHERE "status" IN ('QUEUED', 'TRANSCRIBING', 'TRANSCRIBED', 'EXTRACTING', 'READY')))::int AS "open", + (count(*) FILTER (WHERE "createdAt" >= date_trunc('day', now() AT TIME ZONE 'UTC')))::int AS "today", + (count(*) FILTER (WHERE "createdAt" > (now() AT TIME ZONE 'UTC') - interval '60 seconds'))::int AS "minute" + FROM "VoiceCapture" + WHERE "ownerUserId" = ${userId}`); + return rows[0] ?? { open: 0, today: 0, minute: 0 }; + } + + /** Owner check first (404 for a foreign id), then the row lock. */ + private async lockOwned( + tx: Prisma.TransactionClient, + userId: string, + id: string, + ): Promise { + const owned = await tx.voiceCapture.findFirst({ + where: { id, ownerUserId: userId }, + select: { id: true }, + }); + if (!owned) { + throw new NotFoundException('Sprachnotiz nicht gefunden'); + } + await lockRowForUpdate(tx, 'VoiceCapture', id); + } + + /** Discards the rows and deletes every piece of their content, in this transaction. */ + private async wipe( + tx: Prisma.TransactionClient, + userId: string, + rows: readonly { id: string; status: VoiceCaptureStatus }[], + ): Promise { + if (rows.length === 0) { + return; + } + const ids = rows.map((each) => each.id); + await tx.voiceCaptureAudio.deleteMany({ + where: { captureId: { in: ids } }, + }); + // Clearing the claim makes a late result of a running job lose its compare-and-set. + await tx.$executeRaw(Prisma.sql` + UPDATE "VoiceCapture" + SET "status" = 'DISCARDED', + "transcriptEnc" = NULL, + "suggestionEnc" = NULL, + "claimedAt" = NULL, + "claimToken" = NULL, + "contentDeletedAt" = now(), + "updatedAt" = now() + WHERE "id" = ANY(${ids}) AND "ownerUserId" = ${userId}`); + for (const row of rows) { + await this.auditLog.record( + { + entityType: 'VoiceCapture', + entityId: row.id, + action: 'UPDATE', + userId, + beforeData: { status: row.status }, + afterData: { status: 'DISCARDED' }, + }, + tx, + ); + } + } + + private async auditCreated( + tx: Prisma.TransactionClient, + userId: string, + id: string, + source: 'AUDIO' | 'TEXT', + status: VoiceCaptureStatus, + dto: { colonyId: string; inspectionDate: string }, + ): Promise { + // Ids, kind and state only - never audio or text. + await this.auditLog.record( + { + entityType: 'VoiceCapture', + entityId: id, + action: 'CREATE', + userId, + afterData: { + colonyId: dto.colonyId, + inspectionDate: dto.inspectionDate, + source, + status, + }, + }, + tx, + ); + } +} diff --git a/ApiCore-Backend/src/voice/voice.module.ts b/ApiCore-Backend/src/voice/voice.module.ts new file mode 100644 index 0000000..1f111b6 --- /dev/null +++ b/ApiCore-Backend/src/voice/voice.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { InspectionCreateGuard } from './captures/inspection-create.guard'; +import { VoiceCapturesController } from './captures/voice-captures.controller'; +import { VoiceCapturesStatsService } from './captures/voice-captures-stats.service'; +import { VoiceCapturesService } from './captures/voice-captures.service'; + +/** + * The voice note API (T7). Audit, the permission resolver and the Prisma + * client come from global modules. Later tasks add the confirmation service + * (task 8) and the handlers (task 9). + */ +@Module({ + controllers: [VoiceCapturesController], + providers: [ + VoiceCapturesService, + VoiceCapturesStatsService, + InspectionCreateGuard, + ], + exports: [VoiceCapturesService], +}) +export class VoiceModule {} diff --git a/ApiCore-Backend/test/search.e2e-spec.ts b/ApiCore-Backend/test/search.e2e-spec.ts index 601c735..00328ea 100644 --- a/ApiCore-Backend/test/search.e2e-spec.ts +++ b/ApiCore-Backend/test/search.e2e-spec.ts @@ -5,6 +5,7 @@ import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; +import { sealTranscript } from '../src/voice/core/transcript'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; @@ -67,6 +68,9 @@ describe('Search and filters (e2e)', () => { }); afterAll(async () => { + await prisma.voiceCapture.deleteMany({ + where: { ownerUserId: readonlyId }, + }); await prisma.colony.deleteMany({ where: { id: { in: [colonyId, dissolvedColonyId] } }, }); @@ -88,6 +92,48 @@ describe('Search and filters (e2e)', () => { ).toBe(true); }); + it('returns nothing of a voice capture, neither by its text nor by its id', async () => { + const spoken = `Stifte${Date.now()}x`; + const capture = await prisma.voiceCapture.create({ + data: { + ownerUserId: readonlyId, + colonyId, + inspectionDate: new Date(), + source: 'TEXT', + status: 'TRANSCRIBED', + contentExpiresAt: new Date(Date.now() + 86_400_000), + }, + }); + await prisma.voiceCapture.update({ + where: { id: capture.id }, + data: { + transcriptEnc: sealTranscript(capture.id, { + v: 1, + text: `${spoken} Koenigin gesehen`, + }), + lastErrorCode: 'EXTRACT_DISABLED', + }, + }); + + // The colony code is found (so the colony is in play), the capture never. + for (const term of [spoken, capture.id, 'EXTRACT_DISABLED', uniqueCode]) { + const response = await request(app.getHttpServer()) + .get(`/api/v1/search?q=${term}`) + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + const results = response.body as SearchResultBody[]; + const text = JSON.stringify(results); + expect(text).not.toContain(capture.id); + expect(text).not.toContain(spoken); + for (const entry of results) { + expect(Object.keys(entry).sort()).toEqual(['id', 'label', 'type']); + expect(['Apiary', 'Colony', 'Queen', 'Task', 'HoneyBatch']).toContain( + entry.type, + ); + } + } + }); + it('filters colonies by status', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/colonies?status=DISSOLVED&apiaryId=${apiaryId}`) @@ -101,29 +147,103 @@ describe('Search and filters (e2e)', () => { describe('Voice capture content guard', () => { // VoiceCapture holds personal data that is deleted after at most 7 days. It // must never be reachable through search or any export/report builder. - const sourceRoots = ['search', 'export'].map((dir) => - path.resolve(__dirname, '../src', dir), - ); + const srcRoot = path.resolve(__dirname, '../src'); + + // Every place that builds a document or a list for the user or another + // system, by directory: nothing here may touch the voice tables. + const builderDirs = [ + 'search', + 'export', + 'erp-export', + 'compliance', + 'label-templates', + 'statistics', + 'dashboard', + 'public', + 'qr', + 'calendar', + 'calendar-sync', + 'webhooks', + 'shop-sync', + 'documents', + 'import', + ]; + + // Files that build a PDF, CSV or download wherever they live. Found by + // content, so a new report builder in any folder is covered without an + // edit here. src/voice itself and src/backup (which lists the voice models + // as excluded) are the only places allowed to name the model. + const builderMarkers = + /PDFDocument|pdf-lib|pdfkit|application\/pdf|text\/csv|spreadsheetml|Content-Disposition|toCsv|csv-stringify/; function sourceFiles(dir: string): string[] { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = path.join(dir, entry.name); if (entry.isDirectory()) return sourceFiles(full); - return entry.name.endsWith('.ts') ? [full] : []; + return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts') + ? [full] + : []; }); } - it('VoiceCapture appears in no search provider and no export', () => { - const files = sourceRoots.flatMap(sourceFiles); + function mentionsVoiceCapture(file: string): boolean { + return /voiceCapture|VoiceCapture/.test(fs.readFileSync(file, 'utf8')); + } + + function relative(file: string): string { + return path.relative(srcRoot, file).split(path.sep).join('/'); + } + + it('VoiceCapture appears in no search provider, export or report directory', () => { + const files = builderDirs.flatMap((dir) => + sourceFiles(path.join(srcRoot, dir)), + ); expect(files.length).toBeGreaterThan(0); for (const file of files) { expect({ - file: path.basename(file), - mentions: /voiceCapture|VoiceCapture/.test( - fs.readFileSync(file, 'utf8'), - ), - }).toEqual({ file: path.basename(file), mentions: false }); + file: relative(file), + mentions: mentionsVoiceCapture(file), + }).toEqual({ + file: relative(file), + mentions: false, + }); } }); + + it('VoiceCapture appears in no file that builds a PDF, CSV or download, wherever it lives', () => { + const builders = sourceFiles(srcRoot).filter((file) => { + const name = relative(file); + return ( + !name.startsWith('voice/') && + !name.startsWith('backup/') && + builderMarkers.test(fs.readFileSync(file, 'utf8')) + ); + }); + + // The scan must find the known builders, or it checks nothing. + expect(builders.map(relative)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^export\//), + expect.stringMatching(/^compliance\/pdf\//), + ]), + ); + for (const file of builders) { + expect({ + file: relative(file), + mentions: mentionsVoiceCapture(file), + }).toEqual({ + file: relative(file), + mentions: false, + }); + } + }); + + it('SearchService names no voice type, field or table', () => { + const service = fs.readFileSync( + path.join(srcRoot, 'search/search.service.ts'), + 'utf8', + ); + expect(service).not.toMatch(/voice|transcript|suggestionEnc/i); + }); }); diff --git a/ApiCore-Backend/test/voice-captures.e2e-spec.ts b/ApiCore-Backend/test/voice-captures.e2e-spec.ts new file mode 100644 index 0000000..63daeca --- /dev/null +++ b/ApiCore-Backend/test/voice-captures.e2e-spec.ts @@ -0,0 +1,899 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { Prisma } from '../generated/prisma'; +import { berlinToday } from '../src/common/dates'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { sealVoiceText, voiceAad } from '../src/voice/core/voice-crypto'; +import { sealTranscript } from '../src/voice/core/transcript'; +import { login } from './utils/api'; +import { createTestApp } from './utils/create-test-app'; + +const WEBM = Buffer.concat([ + Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), + Buffer.from('opus-bytes-of-a-spoken-note'), +]); +const PNG = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from('not audio'), +]); +const SPOKEN = + 'Koenigin gesehen, Stifte da, vier Brutwaben, ruhig, Futter niedrig, Honigraum kommt naechste Woche drauf'; + +interface CaptureBody { + id: string; + status: string; + colonyId?: string; + colonyName?: string; + lastErrorCode?: string | null; + transcript?: string | null; + suggestions?: { field: string; value: unknown; quote: string }[] | null; + unclear?: string[]; + dropped?: number; + code?: string; + message?: string; +} + +/** + * Runs in parallel with the other e2e suites on one database: everything + * here belongs to this file's own users (found by the suffix in the e-mail), + * every query is scoped to them, and afterAll deletes by suffix. + */ +describe('Voice captures (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const password = 'SuperSecret123!'; + const emailOf = (name: string) => `voice-e2e-${name}-${suffix}@example.com`; + const roleKey = `VOICE_E2E_ONLY_VOICE_${suffix}`; + const inspectionsRoleKey = `VOICE_E2E_ONLY_INSPECTIONS_${suffix}`; + const tokens: Record = {}; + const userIds: Record = {}; + let apiaryId: string; + let colonyId: string; + let deadColonyId: string; + let keyCounter = 0; + + const server = () => app.getHttpServer(); + const as = (name: string) => ({ Authorization: `Bearer ${tokens[name]}` }); + const nextKey = () => + `voice-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`; + const today = () => berlinToday(); + let addressCounter = 0; + const nextAddress = () => { + addressCounter += 1; + return `10.${Math.floor(addressCounter / 250)}.${addressCounter % 250}.7`; + }; + + function upload( + who: string, + options: { + bytes?: Buffer; + type?: string; + colony?: string; + date?: string; + duration?: string; + key?: string; + } = {}, + ) { + return ( + request(server()) + .post('/api/v1/voice-captures') + .set(as(who)) + // Every upload comes from its own address: the IP throttle (10 a minute) + // is not what these tests are about. + .set('X-Forwarded-For', nextAddress()) + .set('Idempotency-Key', options.key ?? nextKey()) + .field('colonyId', options.colony ?? colonyId) + .field('inspectionDate', options.date ?? today()) + .field('durationSec', options.duration ?? '30') + .attach('audio', options.bytes ?? WEBM, { + filename: 'note.webm', + contentType: options.type ?? 'audio/webm', + }) + ); + } + + function sendText(who: string, text = SPOKEN, extra: object = {}) { + return request(server()) + .post('/api/v1/voice-captures/text') + .set(as(who)) + .set('Idempotency-Key', nextKey()) + .send({ colonyId, inspectionDate: today(), text, ...extra }); + } + + async function createUser( + name: string, + role: { key?: string; id?: string }, + ): Promise { + const passwordHash = await bcrypt.hash(password, 12); + const user = await prisma.user.create({ + data: { + email: emailOf(name), + passwordHash, + name: `Voice E2E ${name}`, + role: { connect: role.key ? { key: role.key } : { id: role.id } }, + }, + }); + userIds[name] = user.id; + tokens[name] = (await login(app, emailOf(name), password)).accessToken; + } + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const onlyVoice = await prisma.role.create({ + data: { key: roleKey, name: 'Nur Sprache' }, + }); + const permission = await prisma.permission.findUniqueOrThrow({ + where: { key: 'voice-captures:use' }, + }); + await prisma.rolePermission.create({ + data: { roleId: onlyVoice.id, permissionId: permission.id }, + }); + + const onlyInspections = await prisma.role.create({ + data: { key: inspectionsRoleKey, name: 'Nur Durchsicht' }, + }); + const inspectionsPermission = await prisma.permission.findUniqueOrThrow({ + where: { key: 'inspections:create' }, + }); + await prisma.rolePermission.create({ + data: { + roleId: onlyInspections.id, + permissionId: inspectionsPermission.id, + }, + }); + + await createUser('alice', { key: 'BEEKEEPER' }); + await createUser('bob', { key: 'BEEKEEPER' }); + await createUser('limits', { key: 'BEEKEEPER' }); + await createUser('admin', { key: 'ADMIN' }); + await createUser('readonly', { key: 'READONLY' }); + await createUser('onlyvoice', { id: onlyVoice.id }); + await createUser('onlyinspections', { id: onlyInspections.id }); + + const apiary = await prisma.apiary.create({ + data: { name: `Voice E2E Stand ${suffix}` }, + }); + apiaryId = apiary.id; + colonyId = ( + await prisma.colony.create({ + data: { + code: `VOICE-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }) + ).id; + deadColonyId = ( + await prisma.colony.create({ + data: { + code: `VOICE-DEAD-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + status: 'DISSOLVED', + }, + }) + ).id; + }); + + afterAll(async () => { + // By suffix, so a failed setup leaks nothing. + const users = await prisma.user.findMany({ + where: { email: { contains: suffix } }, + select: { id: true }, + }); + const ids = users.map((user) => user.id); + await prisma.voiceCapture.deleteMany({ + where: { ownerUserId: { in: ids } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: ids }, entityType: 'VoiceCapture' }, + }); + await prisma.idempotencyRecord.deleteMany({ + where: { userId: { in: ids } }, + }); + await prisma.refreshToken.deleteMany({ where: { userId: { in: ids } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); + await prisma.role.deleteMany({ + where: { key: { in: [roleKey, inspectionsRoleKey] } }, + }); + await prisma.colony.deleteMany({ + where: { code: { in: [`VOICE-${suffix}`, `VOICE-DEAD-${suffix}`] } }, + }); + await prisma.apiary.deleteMany({ + where: { name: `Voice E2E Stand ${suffix}` }, + }); + await app.close(); + }); + + const ownRows = (name: string) => + prisma.voiceCapture.findMany({ where: { ownerUserId: userIds[name] } }); + + beforeEach(async () => { + // Only the rows of this file's own users; other suites are untouched. + await prisma.voiceCapture.deleteMany({ + where: { ownerUserId: { in: Object.values(userIds) } }, + }); + }); + + describe('rights', () => { + it('needs voice-captures:use AND inspections:create on every user endpoint', async () => { + const calls: [string, string][] = [ + ['get', '/api/v1/voice-captures'], + ['get', '/api/v1/voice-captures/config'], + ['get', '/api/v1/voice-captures/some-id'], + ['post', '/api/v1/voice-captures/some-id/retry'], + ['post', '/api/v1/voice-captures/some-id/discard'], + ['post', '/api/v1/voice-captures/discard-all'], + ]; + for (const who of ['readonly', 'onlyvoice', 'onlyinspections']) { + for (const [method, url] of calls) { + const response = await request(server()) + [method as 'get' | 'post'](url) + .set(as(who)); + expect({ who, url, status: response.status }).toEqual({ + who, + url, + status: 403, + }); + } + expect((await upload(who)).status).toBe(403); + expect((await sendText(who)).status).toBe(403); + } + }); + + it('lets only system:manage read the counters', async () => { + await request(server()) + .get('/api/v1/voice-captures/stats') + .set(as('alice')) + .expect(403); + const response = await request(server()) + .get('/api/v1/voice-captures/stats') + .set(as('admin')) + .expect(200); + expect(response.body).toEqual({ + byStatus: expect.any(Object) as unknown, + byError: expect.any(Object) as unknown, + tokens: { + input: expect.any(Number) as unknown, + output: expect.any(Number) as unknown, + }, + byModel: expect.any(Object) as unknown, + }); + }); + + it('answers no-store', async () => { + const response = await request(server()) + .get('/api/v1/voice-captures') + .set(as('alice')) + .expect(200); + expect(response.headers['cache-control']).toContain('no-store'); + }); + }); + + it('runs the database session in UTC, which the plain now() writes rely on', async () => { + const rows = await prisma.$queryRaw<{ TimeZone: string }[]>`SHOW TimeZone`; + expect(rows[0].TimeZone).toBe('UTC'); + }); + + describe('upload', () => { + it('stores the audio sealed and answers QUEUED', async () => { + const response = await upload('alice').expect(201); + expect(response.body).toEqual({ + id: expect.any(String) as unknown, + status: 'QUEUED', + }); + const id = (response.body as CaptureBody).id; + const row = await prisma.voiceCapture.findUniqueOrThrow({ + where: { id }, + include: { audio: true }, + }); + expect(row).toMatchObject({ + ownerUserId: userIds.alice, + colonyId, + source: 'AUDIO', + status: 'QUEUED', + audioMime: 'audio/webm', + audioBytes: WEBM.length, + durationSec: 30, + attempts: 0, + transcriptEnc: null, + }); + expect(row.contentExpiresAt.getTime()).toBeGreaterThan( + row.createdAt.getTime() + 6.9 * 86_400_000, + ); + expect(row.audio).not.toBeNull(); + expect(Buffer.from(row.audio!.dataEnc).includes(WEBM)).toBe(false); + }); + + it('audits the creation without content', async () => { + const id = ((await upload('alice').expect(201)).body as CaptureBody).id; + const entries = await prisma.auditLog.findMany({ + where: { entityType: 'VoiceCapture', entityId: id }, + }); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + action: 'CREATE', + userId: userIds.alice, + }); + expect(JSON.stringify(entries[0].afterData)).not.toContain('opus'); + }); + + it('is idempotent for a repeated key', async () => { + const key = nextKey(); + const first = await upload('alice', { key }).expect(201); + const second = await upload('alice', { key }).expect(201); + expect((second.body as CaptureBody).id).toBe( + (first.body as CaptureBody).id, + ); + expect(await ownRows('alice')).toHaveLength(1); + }); + + it('takes WebM bytes with a wrong Content-Type and refuses PNG bytes as audio', async () => { + const ok = await upload('alice', { type: 'image/png' }).expect(201); + const id = (ok.body as CaptureBody).id; + const row = await prisma.voiceCapture.findUniqueOrThrow({ + where: { id }, + }); + expect(row.audioMime).toBe('audio/webm'); + + const bad = await upload('alice', { + bytes: PNG, + type: 'audio/webm', + }).expect(415); + expect((bad.body as CaptureBody).code).toBe('UNSUPPORTED_AUDIO'); + }); + + it('refuses a file above 2 MB with 413 TOO_LARGE', async () => { + const big = Buffer.concat([WEBM, Buffer.alloc(2_097_152)]); + const response = await upload('alice', { bytes: big }).expect(413); + expect((response.body as CaptureBody).code).toBe('TOO_LARGE'); + expect(await ownRows('alice')).toHaveLength(0); + }); + + it('refuses a request without audio, a dead colony and bad dates', async () => { + await request(server()) + .post('/api/v1/voice-captures') + .set(as('alice')) + .field('colonyId', colonyId) + .field('inspectionDate', today()) + .field('durationSec', '30') + .expect(400); + await upload('alice', { colony: deadColonyId }).expect(400); + await upload('alice', { colony: 'does-not-exist' }).expect(400); + await upload('alice', { date: '2099-01-01' }).expect(400); + await upload('alice', { date: '2020-01-01' }).expect(400); + await upload('alice', { duration: '999' }).expect(400); + expect(await ownRows('alice')).toHaveLength(0); + }); + }); + + describe('text', () => { + it('stores the sealed text as TRANSCRIBED and gives it back to the owner only', async () => { + const created = await sendText('alice').expect(201); + const id = (created.body as CaptureBody).id; + expect(created.body).toEqual({ id, status: 'TRANSCRIBED' }); + const row = await prisma.voiceCapture.findUniqueOrThrow({ + where: { id }, + }); + expect(row.source).toBe('TEXT'); + expect(row.transcriptEnc).not.toBeNull(); + expect(row.transcriptEnc).not.toContain('Koenigin'); + + const detail = await request(server()) + .get(`/api/v1/voice-captures/${id}`) + .set(as('alice')) + .expect(200); + expect(detail.body).toMatchObject({ + id, + status: 'TRANSCRIBED', + transcript: SPOKEN, + suggestions: null, + colonyName: `VOICE-${suffix}`, + inspectionDate: today(), + }); + }); + + it('refuses a text outside 20 to 4000 characters', async () => { + await sendText('alice', 'zu kurz').expect(400); + await sendText('alice', 'a'.repeat(4001)).expect(400); + await sendText('alice', 'a'.repeat(4000)).expect(201); + }); + }); + + describe('limits', () => { + it('allows ten open notes and answers the eleventh with 429 TOO_MANY_OPEN', async () => { + for (let index = 0; index < 10; index += 1) { + await sendText('limits').expect(201); + } + const response = await sendText('limits').expect(429); + expect(response.body).toMatchObject({ + code: 'TOO_MANY_OPEN', + message: + 'Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.', + }); + expect(await ownRows('limits')).toHaveLength(10); + }); + + it('counts per user: another person is not blocked', async () => { + for (let index = 0; index < 10; index += 1) { + await sendText('limits').expect(201); + } + await sendText('alice').expect(201); + }); + + it('does not count finished notes as open, but counts them per day and minute', async () => { + for (let index = 0; index < 10; index += 1) { + const id = ((await sendText('limits').expect(201)).body as CaptureBody) + .id; + await request(server()) + .post(`/api/v1/voice-captures/${id}/discard`) + .set(as('limits')) + .expect(204); + } + // No note is open any more, but ten were created within the minute. + await sendText('limits').expect(429); + }); + + it('limits new notes to thirty per UTC day', async () => { + const startOfUtcDay = new Date().setUTCHours(0, 0, 0, 0); + const createdAt = new Date(Math.max(Date.now() - 90_000, startOfUtcDay)); + await prisma.voiceCapture.createMany({ + data: Array.from({ length: 30 }, () => ({ + ownerUserId: userIds.limits, + colonyId, + inspectionDate: new Date(`${today()}T00:00:00.000Z`), + source: 'TEXT' as const, + status: 'DISCARDED' as const, + contentExpiresAt: new Date(Date.now() + 86_400_000), + createdAt, + })), + }); + const response = await sendText('limits').expect(429); + expect((response.body as CaptureBody).code).toBe('TOO_MANY_OPEN'); + }); + }); + + describe('list and get', () => { + it('lists only the own notes, newest first, without content', async () => { + const first = ((await sendText('alice').expect(201)).body as CaptureBody) + .id; + const second = ((await upload('alice').expect(201)).body as CaptureBody) + .id; + await sendText('bob').expect(201); + + const response = await request(server()) + .get('/api/v1/voice-captures') + .set(as('alice')) + .expect(200); + const items = response.body as CaptureBody[]; + expect(items.map((item) => item.id)).toEqual([second, first]); + for (const item of items) { + expect(item).not.toHaveProperty('transcript'); + expect(item).not.toHaveProperty('suggestions'); + expect(item.colonyName).toBe(`VOICE-${suffix}`); + } + expect(JSON.stringify(items)).not.toContain('Koenigin'); + }); + + it('filters by status and refuses an unknown status', async () => { + await sendText('alice').expect(201); + await upload('alice').expect(201); + const queued = await request(server()) + .get('/api/v1/voice-captures?status=QUEUED') + .set(as('alice')) + .expect(200); + expect((queued.body as CaptureBody[]).map((item) => item.status)).toEqual( + ['QUEUED'], + ); + await request(server()) + .get('/api/v1/voice-captures?status=NOPE') + .set(as('alice')) + .expect(400); + }); + + it('serves config before the :id route', async () => { + const response = await request(server()) + .get('/api/v1/voice-captures/config') + .set(as('alice')) + .expect(200); + expect(response.body).toEqual({ + recordingEnabled: expect.any(Boolean) as unknown, + extractionEnabled: expect.any(Boolean) as unknown, + model: expect.stringMatching(/^claude-/) as unknown, + retention: { audioHours: 24, contentDays: 7, rowDays: 90 }, + }); + }); + + it('reports extraction as off while a note is parked with EXTRACT_DISABLED', async () => { + await sendText('alice').expect(201); + await prisma.voiceCapture.updateMany({ + where: { ownerUserId: userIds.alice }, + data: { lastErrorCode: 'EXTRACT_DISABLED' }, + }); + const response = await request(server()) + .get('/api/v1/voice-captures/config') + .set(as('alice')) + .expect(200); + expect( + (response.body as { extractionEnabled: boolean }).extractionEnabled, + ).toBe(false); + }); + + it('shows suggestions of a READY note and hides content while it is being worked on', async () => { + const id = ((await sendText('alice').expect(201)).body as CaptureBody).id; + const payload = { + suggestions: [ + { + field: 'queenSeen', + value: true, + quote: 'Koenigin gesehen', + corrected: false, + truncated: false, + }, + { + field: 'broodFrames', + value: 4, + quote: 'vier Brutwaben', + corrected: false, + truncated: false, + }, + ], + unclear: ['Etwas zum Futter'], + dropped: 1, + truncated: 0, + }; + await prisma.voiceCapture.update({ + where: { id }, + data: { + status: 'READY', + suggestionEnc: sealVoiceText( + JSON.stringify(payload), + voiceAad(id, 'suggestion'), + ), + }, + }); + const ready = await request(server()) + .get(`/api/v1/voice-captures/${id}`) + .set(as('alice')) + .expect(200); + expect(ready.body).toMatchObject({ + status: 'READY', + transcript: SPOKEN, + unclear: ['Etwas zum Futter'], + dropped: 1, + }); + expect((ready.body as CaptureBody).suggestions).toEqual([ + { + field: 'queenSeen', + value: true, + quote: 'Koenigin gesehen', + corrected: false, + truncated: false, + }, + { + field: 'broodFrames', + value: 4, + quote: 'vier Brutwaben', + corrected: false, + truncated: false, + }, + ]); + + await prisma.voiceCapture.update({ + where: { id }, + data: { status: 'EXTRACTING' }, + }); + const working = await request(server()) + .get(`/api/v1/voice-captures/${id}`) + .set(as('alice')) + .expect(200); + expect(working.body).toMatchObject({ + transcript: null, + suggestions: null, + }); + }); + }); + + describe('foreign ids', () => { + it('answers 404 to another person on get, retry and discard, and changes nothing', async () => { + const id = ((await sendText('alice').expect(201)).body as CaptureBody).id; + await request(server()) + .get(`/api/v1/voice-captures/${id}`) + .set(as('bob')) + .expect(404); + await request(server()) + .post(`/api/v1/voice-captures/${id}/retry`) + .set('X-Forwarded-For', nextAddress()) + .set(as('bob')) + .expect(404); + await request(server()) + .post(`/api/v1/voice-captures/${id}/discard`) + .set(as('bob')) + .expect(404); + const row = await prisma.voiceCapture.findUniqueOrThrow({ + where: { id }, + }); + expect(row.status).toBe('TRANSCRIBED'); + expect(row.transcriptEnc).not.toBeNull(); + await request(server()) + .get('/api/v1/voice-captures/never-existed') + .set(as('alice')) + .expect(404); + }); + }); + + describe('retry', () => { + async function failed( + who: string, + data: Prisma.VoiceCaptureUncheckedUpdateInput, + options: { audio?: boolean; text?: boolean } = {}, + ): Promise { + const { audio = true, text = false } = options; + const id = ((await upload(who).expect(201)).body as CaptureBody).id; + if (!audio) { + await prisma.voiceCaptureAudio.deleteMany({ where: { captureId: id } }); + } + await prisma.voiceCapture.update({ + where: { id }, + data: { + status: 'FAILED', + attempts: 3, + ...(text + ? { transcriptEnc: sealTranscript(id, { v: 1, text: SPOKEN }) } + : {}), + ...data, + }, + }); + return id; + } + + it('puts a failed transcription back to QUEUED with attempts 0', async () => { + const id = await failed('alice', { lastErrorCode: 'TRANSCRIBE_FAILED' }); + const response = await request(server()) + .post(`/api/v1/voice-captures/${id}/retry`) + .set('X-Forwarded-For', nextAddress()) + .set(as('alice')) + .expect(200); + expect(response.body).toMatchObject({ + id, + status: 'QUEUED', + lastErrorCode: null, + }); + const row = await prisma.voiceCapture.findUniqueOrThrow({ + where: { id }, + }); + expect(row).toMatchObject({ + status: 'QUEUED', + attempts: 0, + lastErrorCode: null, + claimToken: null, + }); + }); + + it('puts a failed extraction with text back to TRANSCRIBED with attempts 0', async () => { + const id = await failed( + 'alice', + { lastErrorCode: 'EXTRACT_FAILED' }, + { audio: false, text: true }, + ); + const response = await request(server()) + .post(`/api/v1/voice-captures/${id}/retry`) + .set('X-Forwarded-For', nextAddress()) + .set(as('alice')) + .expect(200); + expect((response.body as CaptureBody).status).toBe('TRANSCRIBED'); + const row = await prisma.voiceCapture.findUniqueOrThrow({ + where: { id }, + }); + expect(row.attempts).toBe(0); + }); + + it('counts a retry against the open limit', async () => { + const id = await failed( + 'limits', + { lastErrorCode: 'EXTRACT_FAILED' }, + { audio: false, text: true }, + ); + // Rows made directly: ten API calls would also hit the per-minute limit. + await prisma.voiceCapture.createMany({ + data: Array.from({ length: 10 }, () => ({ + ownerUserId: userIds.limits, + colonyId, + inspectionDate: new Date(`${today()}T00:00:00.000Z`), + source: 'TEXT' as const, + status: 'TRANSCRIBED' as const, + contentExpiresAt: new Date(Date.now() + 86_400_000), + })), + }); + const response = await request(server()) + .post(`/api/v1/voice-captures/${id}/retry`) + .set('X-Forwarded-For', nextAddress()) + .set(as('limits')) + .expect(429); + expect((response.body as CaptureBody).code).toBe('TOO_MANY_OPEN'); + const row = await prisma.voiceCapture.findUniqueOrThrow({ + where: { id }, + }); + expect(row.status).toBe('FAILED'); + }); + + it('refuses to retry a transcription whose audio is older than 24 hours', async () => { + const id = await failed('alice', { lastErrorCode: 'TRANSCRIBE_FAILED' }); + await prisma.voiceCaptureAudio.update({ + where: { captureId: id }, + data: { createdAt: new Date(Date.now() - 25 * 3_600_000) }, + }); + const response = await request(server()) + .post(`/api/v1/voice-captures/${id}/retry`) + .set('X-Forwarded-For', nextAddress()) + .set(as('alice')) + .expect(409); + expect((response.body as CaptureBody).code).toBe('VOICE_CAPTURE_GONE'); + }); + + it('refuses a failed note without content, an expired one and one that is not failed', async () => { + const noContent = await failed( + 'alice', + { lastErrorCode: 'NO_SPEECH' }, + { audio: false }, + ); + const gone = await request(server()) + .post(`/api/v1/voice-captures/${noContent}/retry`) + .set('X-Forwarded-For', nextAddress()) + .set(as('alice')) + .expect(409); + expect((gone.body as CaptureBody).code).toBe('VOICE_CAPTURE_GONE'); + + const expired = await failed('alice', { + lastErrorCode: 'TRANSCRIBE_FAILED', + contentExpiresAt: new Date(Date.now() - 60_000), + }); + const late = await request(server()) + .post(`/api/v1/voice-captures/${expired}/retry`) + .set('X-Forwarded-For', nextAddress()) + .set(as('alice')) + .expect(409); + expect((late.body as CaptureBody).code).toBe('VOICE_CAPTURE_GONE'); + + const queued = ((await upload('alice').expect(201)).body as CaptureBody) + .id; + await request(server()) + .post(`/api/v1/voice-captures/${queued}/retry`) + .set('X-Forwarded-For', nextAddress()) + .set(as('alice')) + .expect(409); + }); + }); + + describe('discard', () => { + it('deletes audio, transcript and suggestion at once and is idempotent', async () => { + const audioId = ((await upload('alice').expect(201)).body as CaptureBody) + .id; + const textId = ((await sendText('alice').expect(201)).body as CaptureBody) + .id; + await prisma.voiceCapture.update({ + where: { id: textId }, + data: { + status: 'READY', + suggestionEnc: sealVoiceText('{"suggestions":[]}', 'x'), + }, + }); + for (const id of [audioId, textId]) { + await request(server()) + .post(`/api/v1/voice-captures/${id}/discard`) + .set(as('alice')) + .expect(204); + await request(server()) + .post(`/api/v1/voice-captures/${id}/discard`) + .set(as('alice')) + .expect(204); + } + const rows = await prisma.voiceCapture.findMany({ + where: { id: { in: [audioId, textId] } }, + include: { audio: true }, + }); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row).toMatchObject({ + status: 'DISCARDED', + transcriptEnc: null, + suggestionEnc: null, + audio: null, + claimToken: null, + }); + expect(row.contentDeletedAt).not.toBeNull(); + } + }); + + it('also stops a note a worker has claimed', async () => { + const id = ((await upload('alice').expect(201)).body as CaptureBody).id; + await prisma.voiceCapture.update({ + where: { id }, + data: { + status: 'TRANSCRIBING', + claimToken: 'token-of-a-running-job', + claimedAt: new Date(), + }, + }); + await request(server()) + .post(`/api/v1/voice-captures/${id}/discard`) + .set(as('alice')) + .expect(204); + const row = await prisma.voiceCapture.findUniqueOrThrow({ + where: { id }, + }); + expect(row).toMatchObject({ + status: 'DISCARDED', + claimToken: null, + claimedAt: null, + }); + }); + + it('leaves a confirmed note as it is', async () => { + const id = ((await sendText('alice').expect(201)).body as CaptureBody).id; + const inspection = await prisma.inspection.create({ + data: { colonyId, userId: userIds.alice, date: new Date() }, + }); + await prisma.voiceCapture.update({ + where: { id }, + data: { + status: 'CONFIRMED', + inspectionId: inspection.id, + transcriptEnc: null, + }, + }); + await request(server()) + .post(`/api/v1/voice-captures/${id}/discard`) + .set(as('alice')) + .expect(204); + const row = await prisma.voiceCapture.findUniqueOrThrow({ + where: { id }, + }); + expect(row.status).toBe('CONFIRMED'); + await prisma.voiceCapture.delete({ where: { id } }); + await prisma.inspection.delete({ where: { id: inspection.id } }); + }); + + it('discard-all touches only the caller and counts what it discarded', async () => { + const mine = [ + ((await sendText('alice').expect(201)).body as CaptureBody).id, + ((await upload('alice').expect(201)).body as CaptureBody).id, + ]; + const done = ((await sendText('alice').expect(201)).body as CaptureBody) + .id; + await prisma.voiceCapture.update({ + where: { id: done }, + data: { status: 'EXPIRED', transcriptEnc: null }, + }); + const others = ((await sendText('bob').expect(201)).body as CaptureBody) + .id; + + const response = await request(server()) + .post('/api/v1/voice-captures/discard-all') + .set(as('alice')) + .expect(200); + expect(response.body).toEqual({ discarded: 2 }); + + const rows = await prisma.voiceCapture.findMany({ + where: { id: { in: [...mine, done, others] } }, + include: { audio: true }, + }); + const byId = new Map(rows.map((row) => [row.id, row])); + for (const id of mine) { + expect(byId.get(id)).toMatchObject({ + status: 'DISCARDED', + transcriptEnc: null, + audio: null, + }); + } + expect(byId.get(done)?.status).toBe('EXPIRED'); + expect(byId.get(others)).toMatchObject({ status: 'TRANSCRIBED' }); + expect(byId.get(others)?.transcriptEnc).not.toBeNull(); + }); + }); +});