feat: Sprachnotizen-API (T7 L2 Task 7)

Upload (Audio nur per Magic Bytes, versiegelt, 2 MiB), Text-Weg,
Liste, Abruf, Wiederholen, Verwerfen, alle verwerfen, Konfiguration und
Zähler unter /voice-captures. Rechte voice-captures:use plus
inspections:create (eigener Guard, API-Schlüssel ausgeschlossen),
Zähler nur mit system:manage; jede Abfrage auf den Besitzer begrenzt.
Grenzen je Benutzer unter Advisory-Lock (10 offen, 30 je Tag, 10 je
60 s), Wiederholen ebenfalls begrenzt und gedrosselt, Audio für QUEUED
nur unter 24 h. Verwerfen löscht Inhalt und Claim sofort. Keine Inhalte
in Log oder Audit. Wächter gegen Sprachinhalte in Suche, Export und
Berichten erweitert.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 10:10:54 +02:00
1 parent 19550d1394
commit 0b0bc3c6dc
21 files changed
+2843 -12

No files matched your search

+2
View File
@@ -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: [
@@ -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();
});
});
@@ -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<string> = 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;
}
@@ -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];
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
});
});
@@ -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<boolean> {
const user = context.switchToHttp().getRequest<RequestWithUser>().user;
if (!user) {
return false;
}
return this.permissionResolver.hasAny(user.id, ['inspections:create']);
}
}
@@ -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<Response>();
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).',
},
);
}
}
@@ -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();
});
});
@@ -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<string> = new Set(VOICE_ERROR_CODES);
const VOICE_FIELDS: ReadonlySet<string> = 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<string, unknown> {
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 };
}
@@ -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 },
});
});
});
@@ -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<string, number>;
byError: Record<string, number>;
tokens: { input: number; output: number };
byModel: Record<string, number>;
}
interface CountRow {
key: string;
count: number;
}
interface TokenRow {
input: bigint | number;
output: bigint | number;
}
function toRecord(rows: readonly CountRow[]): Record<string, number> {
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<VoiceCaptureStats> {
const since = Prisma.sql`"createdAt" > (now() AT TIME ZONE 'UTC') - interval '7 days'`;
const [byStatus, byError, byModel, tokens] = await Promise.all([
this.prisma.$queryRaw<CountRow[]>(Prisma.sql`
SELECT "status"::text AS "key", count(*)::int AS "count"
FROM "VoiceCapture" WHERE ${since} GROUP BY "status"`),
this.prisma.$queryRaw<CountRow[]>(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<CountRow[]>(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<TokenRow[]>(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),
};
}
}
@@ -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<T>(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<string[]>(REQUIRE_PERMISSION_KEY, name)).toEqual([
'system:manage',
]);
expect(metadata<unknown[] | undefined>(GUARDS_METADATA, name)).toBe(
undefined,
);
continue;
}
expect({
name,
permissions: metadata<string[]>(REQUIRE_PERMISSION_KEY, name),
guards: metadata<unknown[]>(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<boolean>(IDEMPOTENT_KEY, 'upload')).toBe(true);
expect(metadata<boolean>(IDEMPOTENT_KEY, 'submitText')).toBe(true);
expect(metadata<number>('THROTTLER:LIMITdefault', 'upload')).toBe(10);
expect(metadata<number>('THROTTLER:TTLdefault', 'upload')).toBe(60_000);
});
it('throttles retries to 5 a minute', () => {
expect(metadata<number>('THROTTLER:LIMITdefault', 'retry')).toBe(5);
expect(metadata<number>('THROTTLER:TTLdefault', 'retry')).toBe(60_000);
});
});
@@ -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<VoiceCaptureListItem[]> {
return this.captures.list(actor.id, query.status);
}
@RequirePermission('voice-captures:use')
@UseGuards(InspectionCreateGuard)
@Get('config')
config(): Promise<VoiceClientConfig> {
return this.captures.config();
}
@RequirePermission('system:manage')
@Get('stats')
getStats(): Promise<VoiceCaptureStats> {
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<VoiceCaptureDetail> {
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<VoiceCaptureListItem> {
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<void> {
return this.captures.discard(actor.id, id);
}
}
@@ -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<T>(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<unknown>): Promise<HttpException> {
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<object>(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',
});
},
);
});
});
@@ -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<VoiceCaptureListItem[]> {
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<VoiceCaptureDetail> {
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<VoiceCaptureListItem> {
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<void> {
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<VoiceClientConfig> {
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<string> {
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<void> {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`voice-captures:${userId}`}, 0))`;
}
private async recentCounts(
tx: Prisma.TransactionClient,
userId: string,
): Promise<RecentCounts> {
const rows = await tx.$queryRaw<RecentCounts[]>(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<void> {
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<void> {
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<void> {
// 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,
);
}
}
+21
View File
@@ -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 {}