Compare commits

...
7 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 1dc39f7658 feat: Sprachnotizen übernehmen, melden und abschreiben (T7 L2 Tasks 8, 9, 11)
Backend Image / build-and-push (push) Successful in 6m51s
Frontend Image / build-and-push (push) Successful in 18m9s
Task 8: POST /inspections nimmt voiceCaptureId; die Notiz wird zuletzt
in derselben Transaktion bestätigt (jeder offene Status, Ruling 1),
Inhalt, Audio und Claim gelöscht; fremde oder geschlossene Notiz 409
VOICE_CAPTURE_GONE, anderes Volk 409 VOICE_CAPTURE_COLONY_MISMATCH;
Wiederholung mit gleichem Schlüssel idempotent.

Task 9: Ereignisse VoiceCaptureReady/VoiceCaptureFailed (nur IDs und
Endcodes), Meldungen „Sprachnotiz für … ist fertig“ bzw. Fehlerhinweis
mit nächstem Schritt, Betreiberhinweis bei abgeschalteter Auswertung;
Handler auch im Worker registriert (sonst gingen die Ereignisse
ungehört durch); keine Weiterleitung an Webhooks.

Task 11: Job voice-transcribe mit Phasen fetch (Audio nur im Speicher
entschlüsselt in ein RAM-Arbeitsverzeichnis 0700/0600) und ingest
(Ergebnis gegen Claim geprüft, Transkript versiegelt, Audio gelöscht,
Auswertung geweckt; Stille/zu lang enden FAILED); Ereignisse genau
einmal in derselben Transaktion. Aufbewahrung: Audio 24 h, Inhalt bis
contentExpiresAt, deaktivierte Besitzer, Zeilen 90 Tage; im täglichen
Cleanup inkl. leerer Job-Läufe (Ruling 10).

Gates: Backend lint, typecheck, 4471 Unit-Tests, build; e2e 103 Suites
(einziger Fehler: Wetter-Test gegen echte API).

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:38:14 +02:00
Nick SzaunigandClaude Opus 5.5 e9c6604e3b docs: TASKS 3D-Budget geprüft
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:18:59 +02:00
Nick SzaunigandClaude Opus 5.5 2207bd2e67 ci: Größenbudget des 3D-Chunks im Frontend-Build prüfen
scripts/check-3d-budget.mjs misst den three.js-Chunk (Marker
WebGLRenderer und __THREE__) mit gzip -9 und bricht den Image-Build ab,
wenn er 320 kB übersteigt oder nicht gefunden wird. Heute 299 kB.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:18:58 +02:00
Nick SzaunigandClaude Opus 5.5 92c00b4453 docs: TASKS Beute L4 und Task 22 erledigt, T7-Stand, Rollout-Hinweis
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:16:47 +02:00
Nick SzaunigandClaude Opus 5.5 9f6ab4ea3f refactor: Beute-Frontend vereinfacht (findPart, subVec3, seededRandom)
Gemeinsame Helfer statt vier Kopien der Teilsuche, zwei Vektor-
Subtraktionen und zwei Zufallsgeneratoren; hasPartSheet an einer
Stelle; ungenutzte setOpacity und points-Ref entfernt. Verhalten
unverändert (1050 Tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:16:35 +02:00
Nick SzaunigandClaude Opus 5.5 0b0bc3c6dc 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]>
2026-10-16 10:10:54 +02:00
Nick SzaunigandClaude Opus 5.5 19550d1394 fix: Beute-Ziehen bricht bei Pinch ab, Rähmchen-Druck zieht keine Zarge
- zweiter Finger während Druck oder Ziehen bricht ab (kein Ablegen)
- Druck auf ein Rähmchen im Editor erreicht die Zarge nicht mehr
- Canvas-Rechteck bei Scroll neu gemessen, Motion-Ziele nur bei
  Abstand oder bewegtem Teil neu berechnet, toter Re-Export entfernt

Refs #21

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:06:46 +02:00
79 changed files with 5979 additions and 114 deletions

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 { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
import { ComplianceModule } from './compliance/compliance.module'; import { ComplianceModule } from './compliance/compliance.module';
import { ReadinessModule } from './compliance/readiness/readiness.module'; import { ReadinessModule } from './compliance/readiness/readiness.module';
import { VoiceModule } from './voice/voice.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module'; import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -140,6 +141,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
HiveModule, HiveModule,
ComplianceModule, ComplianceModule,
ReadinessModule, ReadinessModule,
VoiceModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [
@@ -1,4 +1,4 @@
import { matchesFileSignature } from './file-signature'; import { detectAudioType, matchesFileSignature } from './file-signature';
describe('matchesFileSignature', () => { describe('matchesFileSignature', () => {
it('accepts a buffer starting with the PNG signature', () => { 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]; const check = SIGNATURE_CHECKS[declaredMimeType];
return check ? check(buffer) : true; 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;
}
@@ -23,7 +23,9 @@ interface SourceFile {
const WEATHER_HOSTS = /open-meteo\.com/i; const WEATHER_HOSTS = /open-meteo\.com/i;
const TELEGRAM_HOST = /api\.telegram\.org/i; const TELEGRAM_HOST = /api\.telegram\.org/i;
const FETCH_USE = /(?<![\w$.])fetch\b(?!\s*:)|\bglobalThis\s*\.\s*fetch\b/; // `--phase=fetch` is the argument of the voice job, not a network call.
const FETCH_USE =
/(?<![\w$.])(?<!['"`]--phase=)fetch\b(?!\s*:)|\bglobalThis\s*\.\s*fetch\b/;
const NETWORK_PACKAGES = new Set([ const NETWORK_PACKAGES = new Set([
'http', 'http',
'https', 'https',
@@ -180,6 +182,11 @@ describe('network calls stay inside their loop directories', () => {
describe('with planted violations', () => { describe('with planted violations', () => {
it.each([ it.each([
['colonies/colonies.service.ts', 'await fetch(url);'], ['colonies/colonies.service.ts', 'await fetch(url);'],
[
'colonies/colonies.service.ts',
"const a = '--phase=fetch'; await fetch(url);",
],
['colonies/colonies.service.ts', 'const a = --phase=fetch;'],
['colonies/colonies.service.ts', 'const f = fetch;'], ['colonies/colonies.service.ts', 'const f = fetch;'],
['colonies/colonies.service.ts', 'await globalThis.fetch(url);'], ['colonies/colonies.service.ts', 'await globalThis.fetch(url);'],
['colonies/colonies.service.ts', "import https from 'node:https';"], ['colonies/colonies.service.ts', "import https from 'node:https';"],
@@ -248,6 +255,7 @@ describe('network calls stay inside their loop directories', () => {
['colonies/colonies.service.ts', '// fetch the data later\nconst a = 1;'], ['colonies/colonies.service.ts', '// fetch the data later\nconst a = 1;'],
['colonies/colonies.service.ts', "const scheme = 'https';"], ['colonies/colonies.service.ts', "const scheme = 'https';"],
['colonies/colonies.service.ts', 'const o = { fetch: 1 };'], ['colonies/colonies.service.ts', 'const o = { fetch: 1 };'],
['jobs/job-names.ts', "const a = ['--phase=fetch', '--phase=ingest'];"],
])('lets %s pass: %s', (path, source) => { ])('lets %s pass: %s', (path, source) => {
expect(violations([{ path, source }])).toEqual([]); expect(violations([{ path, source }])).toEqual([]);
}); });
@@ -6,6 +6,7 @@ export const WAKE_CHANNELS = {
calendarSync: 'calendar_sync', calendarSync: 'calendar_sync',
webhookDeliveries: 'webhook_delivery', webhookDeliveries: 'webhook_delivery',
notificationDeliveries: 'notification_delivery', notificationDeliveries: 'notification_delivery',
voiceExtraction: 'voice_extraction',
} as const; } as const;
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS]; export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
@@ -12,6 +12,21 @@ import {
TaskStatus, TaskStatus,
} from '../../generated/prisma'; } from '../../generated/prisma';
/**
* The codes a VoiceCaptureFailed event may carry: terminal failures of a
* note, plus EXTRACT_DISABLED (the operator notice). Waiting states
* (DAILY_LIMIT) and per-attempt codes (EXTRACT_RETRY) are no events.
*/
export type VoiceFailureEventCode =
| 'TRANSCRIBE_FAILED'
| 'NO_SPEECH'
| 'TOO_LONG'
| 'AUDIO_EXPIRED'
| 'EXTRACT_FAILED'
| 'EXTRACT_INVALID'
| 'REFUSED'
| 'EXTRACT_DISABLED';
export interface ColonyStateSnapshot { export interface ColonyStateSnapshot {
status: ColonyStatus; status: ColonyStatus;
healthStatus: ColonyCondition | null; healthStatus: ColonyCondition | null;
@@ -154,6 +169,12 @@ export interface DomainEventPayloads {
source: HiveChangeSource; source: HiveChangeSource;
inspectionId: string | null; inspectionId: string | null;
}; };
// T7, aggregate VoiceCapture: a voice note can be reviewed. Ids only, never
// the text, the quote or a suggestion. Not forwarded to webhooks.
VoiceCaptureReady: { captureId: string; colonyId: string };
// T7, aggregate VoiceCapture: a voice note failed for good. The fixed
// error code only, never content. Not forwarded to webhooks.
VoiceCaptureFailed: { captureId: string; code: VoiceFailureEventCode };
} }
export type DomainEventType = keyof DomainEventPayloads; export type DomainEventType = keyof DomainEventPayloads;
@@ -199,6 +220,8 @@ const CATALOG: Record<DomainEventType, true> = {
ApiaryChanged: true, ApiaryChanged: true,
TskReferenceDayReached: true, TskReferenceDayReached: true,
HiveChanged: true, HiveChanged: true,
VoiceCaptureReady: true,
VoiceCaptureFailed: true,
}; };
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze( export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
@@ -223,7 +246,8 @@ export type AggregateType =
| 'PhenologyReport' | 'PhenologyReport'
| 'WinterCheck' | 'WinterCheck'
| 'Apiary' | 'Apiary'
| 'Compliance'; | 'Compliance'
| 'VoiceCapture';
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> { export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
type: T; type: T;
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
describe('domain event catalog', () => { describe('domain event catalog', () => {
it('lists every event type exactly once', () => { it('lists every event type exactly once', () => {
expect(DOMAIN_EVENT_TYPES).toHaveLength(39); expect(DOMAIN_EVENT_TYPES).toHaveLength(41);
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length); expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual( expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@@ -207,6 +207,8 @@ describe('domain event catalog', () => {
'ApiaryChanged', 'ApiaryChanged',
'TskReferenceDayReached', 'TskReferenceDayReached',
'HiveChanged', 'HiveChanged',
'VoiceCaptureReady',
'VoiceCaptureFailed',
]), ]),
); );
}); });
@@ -12,6 +12,7 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Matches,
Max, Max,
Min, Min,
ValidateNested, ValidateNested,
@@ -37,6 +38,15 @@ export class InspectionHiveChangeDto {
} }
export class CreateInspectionDto { export class CreateInspectionDto {
@ApiPropertyOptional({
description:
'Sprachnotiz, die mit dem Speichern übernommen und gelöscht wird (gleiches Volk, gleicher Nutzer).',
})
@IsOptional()
@IsString()
@Matches(/^[A-Za-z0-9_-]{1,40}$/)
voiceCaptureId?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
colonyId!: string; colonyId!: string;
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TasksModule } from '../tasks/tasks.module'; import { TasksModule } from '../tasks/tasks.module';
import { ColoniesModule } from '../colonies/colonies.module'; import { ColoniesModule } from '../colonies/colonies.module';
import { HiveModule } from '../hive/hive.module'; import { HiveModule } from '../hive/hive.module';
import { VoiceConfirmationModule } from '../voice/captures/voice-confirmation.module';
import { InspectionsController } from './inspections.controller'; import { InspectionsController } from './inspections.controller';
import { ColonyInspectionsController } from './colony-inspections.controller'; import { ColonyInspectionsController } from './colony-inspections.controller';
import { InspectionsService } from './inspections.service'; import { InspectionsService } from './inspections.service';
@@ -12,7 +13,7 @@ import { InspectionsService } from './inspections.service';
* PermissionResolverService comes from the global resolver module. * PermissionResolverService comes from the global resolver module.
*/ */
@Module({ @Module({
imports: [TasksModule, ColoniesModule, HiveModule], imports: [TasksModule, ColoniesModule, HiveModule, VoiceConfirmationModule],
controllers: [InspectionsController, ColonyInspectionsController], controllers: [InspectionsController, ColonyInspectionsController],
providers: [InspectionsService], providers: [InspectionsService],
exports: [InspectionsService], exports: [InspectionsService],
@@ -8,6 +8,7 @@ import { ColoniesService } from '../colonies/colonies.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { HiveService } from '../hive/hive.service'; import { HiveService } from '../hive/hive.service';
import { Prisma } from '../../generated/prisma'; import { Prisma } from '../../generated/prisma';
import { VoiceConfirmationService } from '../voice/captures/voice-confirmation.service';
describe('InspectionsService', () => { describe('InspectionsService', () => {
let service: InspectionsService; let service: InspectionsService;
@@ -37,6 +38,7 @@ describe('InspectionsService', () => {
let colonies: { updateIn: jest.Mock }; let colonies: { updateIn: jest.Mock };
let resolver: { getPermissions: jest.Mock }; let resolver: { getPermissions: jest.Mock };
let hive: { applyChange: jest.Mock }; let hive: { applyChange: jest.Mock };
let voice: { confirmIn: jest.Mock };
const inspection = { const inspection = {
id: 'inspection-1', id: 'inspection-1',
@@ -77,6 +79,7 @@ describe('InspectionsService', () => {
getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])), getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])),
}; };
hive = { applyChange: jest.fn().mockResolvedValue({ version: 2 }) }; hive = { applyChange: jest.fn().mockResolvedValue({ version: 2 }) };
voice = { confirmIn: jest.fn().mockResolvedValue(undefined) };
service = new InspectionsService( service = new InspectionsService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
@@ -85,6 +88,7 @@ describe('InspectionsService', () => {
colonies as unknown as ColoniesService, colonies as unknown as ColoniesService,
resolver as unknown as PermissionResolverService, resolver as unknown as PermissionResolverService,
hive as unknown as HiveService, hive as unknown as HiveService,
voice as unknown as VoiceConfirmationService,
); );
}); });
@@ -99,6 +103,7 @@ describe('InspectionsService', () => {
ColoniesService, ColoniesService,
PermissionResolverService, PermissionResolverService,
HiveService, HiveService,
VoiceConfirmationService,
]); ]);
}); });
@@ -152,6 +157,74 @@ describe('InspectionsService', () => {
expect(tasks.createInTx).not.toHaveBeenCalled(); expect(tasks.createInTx).not.toHaveBeenCalled();
}); });
describe('voiceCaptureId', () => {
it('confirms the capture in the same transaction, after the inspection and its event, and never stores the id on the inspection', async () => {
tx.inspection.create.mockResolvedValue(inspection);
await service.create(
{ colonyId: 'colony-1', date: '2026-01-01', voiceCaptureId: 'cap-1' },
'user-1',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
const createArg = (
tx.inspection.create.mock.calls[0] as [{ data: object }]
)[0].data;
expect(createArg).not.toHaveProperty('voiceCaptureId');
expect(voice.confirmIn).toHaveBeenCalledWith(
tx,
{ kind: 'capture', id: 'cap-1' },
{
userId: 'user-1',
colonyId: 'colony-1',
inspectionId: 'inspection-1',
},
);
expect(voice.confirmIn.mock.invocationCallOrder[0]).toBeGreaterThan(
events.record.mock.invocationCallOrder[0],
);
});
it('confirms last, after a hive change (the capture lock comes last in the lock order)', async () => {
tx.inspection.create.mockResolvedValue(inspection);
await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
voiceCaptureId: 'cap-1',
hiveChange: { ops: [{}] } as never,
},
'user-1',
);
expect(voice.confirmIn.mock.invocationCallOrder[0]).toBeGreaterThan(
hive.applyChange.mock.invocationCallOrder[0],
);
});
it('lets the whole transaction fail when the capture cannot be confirmed', async () => {
tx.inspection.create.mockResolvedValue(inspection);
voice.confirmIn.mockRejectedValue(new Error('gone'));
await expect(
service.create(
{ colonyId: 'colony-1', date: '2026-01-01', voiceCaptureId: 'cap-1' },
'user-1',
),
).rejects.toThrow('gone');
});
it('does not touch the voice service without voiceCaptureId', async () => {
tx.inspection.create.mockResolvedValue(inspection);
await service.create(
{ colonyId: 'colony-1', date: '2026-01-01' },
'user-1',
);
expect(voice.confirmIn).not.toHaveBeenCalled();
});
});
describe('applyToColony', () => { describe('applyToColony', () => {
const saved = { const saved = {
...inspection, ...inspection,
@@ -25,6 +25,7 @@ import { optionalDate } from '../common/patch-values';
import { berlinDayOf } from '../common/dates'; import { berlinDayOf } from '../common/dates';
import type { HiveOp } from '../hive/hive-types'; import type { HiveOp } from '../hive/hive-types';
import { HiveService, HiveSetupView } from '../hive/hive.service'; import { HiveService, HiveSetupView } from '../hive/hive.service';
import { VoiceConfirmationService } from '../voice/captures/voice-confirmation.service';
export type InspectionWithTakeover = Inspection & { export type InspectionWithTakeover = Inspection & {
colonyTakeover: ColonyTakeover; colonyTakeover: ColonyTakeover;
@@ -51,6 +52,7 @@ export class InspectionsService {
private readonly colonies: ColoniesService, private readonly colonies: ColoniesService,
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
private readonly hiveService: HiveService, private readonly hiveService: HiveService,
private readonly voiceConfirmation: VoiceConfirmationService,
) {} ) {}
async findAll(): Promise<Inspection[]> { async findAll(): Promise<Inspection[]> {
@@ -72,7 +74,9 @@ export class InspectionsService {
dto: CreateInspectionDto, dto: CreateInspectionDto,
actingUserId: string, actingUserId: string,
): Promise<InspectionWithTakeover> { ): Promise<InspectionWithTakeover> {
const { applyToColony = [], hiveChange, ...data } = dto; // voiceCaptureId is not an Inspection column: it only triggers the
// confirmation of the note below.
const { applyToColony = [], hiveChange, voiceCaptureId, ...data } = dto;
// Resolved before the transaction (it may query the database), once for // Resolved before the transaction (it may query the database), once for
// both features: a takeover only applies with colonies:update, a hive // both features: a takeover only applies with colonies:update, a hive
// change is refused without it. // change is refused without it.
@@ -123,10 +127,8 @@ export class InspectionsService {
canUpdateColony, canUpdateColony,
actingUserId, actingUserId,
); );
if (!hiveChange) { const hive = hiveChange
return { ...inspection, colonyTakeover }; ? await this.hiveService.applyChange(
}
const hive = await this.hiveService.applyChange(
tx, tx,
dto.colonyId, dto.colonyId,
{ {
@@ -137,8 +139,24 @@ export class InspectionsService {
inspectionId: inspection.id, inspectionId: inspection.id,
}, },
actingUserId, actingUserId,
)
: undefined;
if (voiceCaptureId) {
// Last: VoiceCapture is the last table in the lock order. A note that
// cannot be confirmed fails the whole save.
await this.voiceConfirmation.confirmIn(
tx,
{ kind: 'capture', id: voiceCaptureId },
{
userId: actingUserId,
colonyId: inspection.colonyId,
inspectionId: inspection.id,
},
); );
return { ...inspection, colonyTakeover, hive }; }
return hive
? { ...inspection, colonyTakeover, hive }
: { ...inspection, colonyTakeover };
}); });
} }
@@ -1,6 +1,7 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service'; import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import * as voiceRetention from '../voice/voice-retention';
import { JobRunService } from '../system/job-run.service'; import { JobRunService } from '../system/job-run.service';
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config'; import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
import { import {
@@ -9,6 +10,7 @@ import {
FAILED_RETENTION_DAYS, FAILED_RETENTION_DAYS,
JOB_RUN_RETENTION_DAYS, JOB_RUN_RETENTION_DAYS,
JOB_RUN_STALE_HOURS, JOB_RUN_STALE_HOURS,
VOICE_IDLE_RUN_RETENTION_HOURS,
PENDING_DELIVERY_MAX_AGE_DAYS, PENDING_DELIVERY_MAX_AGE_DAYS,
WEBHOOK_DELIVERY_RETENTION_DAYS, WEBHOOK_DELIVERY_RETENTION_DAYS,
} from './cleanup.job'; } from './cleanup.job';
@@ -37,6 +39,13 @@ describe('CleanupJob', () => {
}; };
beforeEach(() => { beforeEach(() => {
jest.spyOn(voiceRetention, 'expireVoiceContent').mockResolvedValue({
audioExpired: 1,
contentExpired: 2,
inactiveOwnersWiped: 3,
});
jest.spyOn(voiceRetention, 'deleteOldVoiceRows').mockResolvedValue(4);
jest.spyOn(voiceRetention, 'deleteIdleVoiceRuns').mockResolvedValue(21);
let count = 10; let count = 10;
executeRaw = jest.fn<Promise<number>, RawCall>(() => executeRaw = jest.fn<Promise<number>, RawCall>(() =>
Promise.resolve(count++), Promise.resolve(count++),
@@ -62,6 +71,17 @@ describe('CleanupJob', () => {
expect(JOB_RUN_STALE_HOURS).toBe(6); expect(JOB_RUN_STALE_HOURS).toBe(6);
}); });
it('cleanup runs the voice retention and prunes idle voice-transcribe runs only', async () => {
await job.run();
expect(voiceRetention.expireVoiceContent).toHaveBeenCalledTimes(1);
expect(voiceRetention.deleteOldVoiceRows).toHaveBeenCalledTimes(1);
expect(voiceRetention.deleteIdleVoiceRuns).toHaveBeenCalledWith(
expect.anything(),
VOICE_IDLE_RUN_RETENTION_HOURS,
);
});
it('cleanup deletes weather days before January 1st four years back', async () => { it('cleanup deletes weather days before January 1st four years back', async () => {
await job.run(); await job.run();
@@ -94,6 +114,11 @@ describe('CleanupJob', () => {
'jobRuns.aborted': 1, 'jobRuns.aborted': 1,
'jobRuns.deleted': 19, 'jobRuns.deleted': 19,
weatherDays: 20, weatherDays: 20,
'voice.audioExpired': 1,
'voice.contentExpired': 2,
'voice.inactiveOwnersWiped': 3,
'voice.rowsDeleted': 4,
'jobRuns.voiceIdle': 21,
}); });
}); });
+23
View File
@@ -2,6 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service'; import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service'; import { JobRunService } from '../system/job-run.service';
import {
deleteIdleVoiceRuns,
deleteOldVoiceRows,
expireVoiceContent,
} from '../voice/voice-retention';
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config'; import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
import { Job, JobResult } from './job'; import { Job, JobResult } from './job';
import { JobName } from './job-names'; import { JobName } from './job-names';
@@ -18,6 +23,8 @@ export const JOB_RUN_RETENTION_DAYS = 90;
export const PENDING_DELIVERY_MAX_AGE_DAYS = 7; export const PENDING_DELIVERY_MAX_AGE_DAYS = 7;
/** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */ /** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */
export const JOB_RUN_STALE_HOURS = 6; export const JOB_RUN_STALE_HOURS = 6;
/** A voice-transcribe run (every 2 min) that did nothing is noise after a day. */
export const VOICE_IDLE_RUN_RETENTION_HOURS = 24;
/** /**
* Daily housekeeping (CronJob 04:00). Every age is compared against now(), * Daily housekeeping (CronJob 04:00). Every age is compared against now(),
@@ -100,6 +107,17 @@ export class CleanupJob implements Job {
DELETE FROM "ApiaryWeatherDay" DELETE FROM "ApiaryWeatherDay"
WHERE "day" < make_date( WHERE "day" < make_date(
extract(year FROM now() AT TIME ZONE 'Europe/Berlin')::int - ${WEATHER_RETENTION_YEARS}, 1, 1)`; extract(year FROM now() AT TIME ZONE 'Europe/Berlin')::int - ${WEATHER_RETENTION_YEARS}, 1, 1)`;
// The same retention rules the voice CronJob runs every 2 minutes (audio
// over 24 h, content over 7 days, deactivated users), plus the rows
// after 90 days.
const voiceRetention = await expireVoiceContent(this.prisma);
const voiceRowsDeleted = await deleteOldVoiceRows(this.prisma);
// 1440 runs a day, nearly all without work: keep only runs that did
// something (a counter above 0) or failed, for the usual 90 days.
const voiceIdleRuns = await deleteIdleVoiceRuns(
this.prisma,
VOICE_IDLE_RUN_RETENTION_HOURS,
);
return { return {
idempotencyRecords, idempotencyRecords,
consumedTokens, consumedTokens,
@@ -115,6 +133,11 @@ export class CleanupJob implements Job {
'jobRuns.aborted': jobRunsAborted, 'jobRuns.aborted': jobRunsAborted,
'jobRuns.deleted': jobRunsDeleted, 'jobRuns.deleted': jobRunsDeleted,
weatherDays, weatherDays,
'voice.audioExpired': voiceRetention.audioExpired,
'voice.contentExpired': voiceRetention.contentExpired,
'voice.inactiveOwnersWiped': voiceRetention.inactiveOwnersWiped,
'voice.rowsDeleted': voiceRowsDeleted,
'jobRuns.voiceIdle': voiceIdleRuns,
}; };
} }
} }
+6 -4
View File
@@ -28,12 +28,14 @@ describe('job names', () => {
).toEqual([]); ).toEqual([]);
}); });
it('only rekey accepts an argument, and only --dry-run', () => { it('only rekey and voice-transcribe accept arguments, and only their own', () => {
expect(Object.keys(JOB_ALLOWED_ARGS)).toEqual([...JOB_NAMES]); expect(Object.keys(JOB_ALLOWED_ARGS)).toEqual([...JOB_NAMES]);
for (const name of JOB_NAMES) { for (const name of JOB_NAMES) {
expect(JOB_ALLOWED_ARGS[name]).toEqual( const expected: Record<string, string[]> = {
name === 'rekey' ? ['--dry-run'] : [], rekey: ['--dry-run'],
); 'voice-transcribe': ['--phase=fetch', '--phase=ingest'],
};
expect(JOB_ALLOWED_ARGS[name]).toEqual(expected[name] ?? []);
} }
}); });
}); });
+4
View File
@@ -7,6 +7,7 @@ export const JOB_NAMES = [
'cleanup', 'cleanup',
'app-backup', 'app-backup',
'rekey', 'rekey',
'voice-transcribe',
] as const; ] as const;
export type JobName = (typeof JOB_NAMES)[number]; export type JobName = (typeof JOB_NAMES)[number];
@@ -18,6 +19,7 @@ export const JOB_LABELS_DE: Record<JobName, string> = {
cleanup: 'Aufräumen', cleanup: 'Aufräumen',
'app-backup': 'App-Backup', 'app-backup': 'App-Backup',
rekey: 'Schlüssel umstellen', rekey: 'Schlüssel umstellen',
'voice-transcribe': 'Sprachnotizen abschreiben',
}; };
/** /**
@@ -32,6 +34,7 @@ export const JOB_FAILURE_PERMISSION: Record<JobName, PermissionKey> = {
cleanup: 'system:manage', cleanup: 'system:manage',
'app-backup': 'backup:configure', 'app-backup': 'backup:configure',
rekey: 'system:manage', rekey: 'system:manage',
'voice-transcribe': 'system:manage',
}; };
/** /**
@@ -45,6 +48,7 @@ export const JOB_ALLOWED_ARGS: Record<JobName, readonly string[]> = {
cleanup: [], cleanup: [],
'app-backup': [], 'app-backup': [],
rekey: ['--dry-run'], rekey: ['--dry-run'],
'voice-transcribe': ['--phase=fetch', '--phase=ingest'],
}; };
export function isJobName(value: string): value is JobName { export function isJobName(value: string): value is JobName {
+2
View File
@@ -21,6 +21,7 @@ import { JobRegistry, JOBS } from './job-registry';
import { RekeyJob } from './rekey.job'; import { RekeyJob } from './rekey.job';
import { TimeEventsStep } from './steps/time-events.step'; import { TimeEventsStep } from './steps/time-events.step';
import { TaskTemplatesJob } from './task-templates.job'; import { TaskTemplatesJob } from './task-templates.job';
import { VoiceTranscribeJob } from './voice-transcribe.job';
const JOB_CLASSES = [ const JOB_CLASSES = [
DailyEvaluationJob, DailyEvaluationJob,
@@ -28,6 +29,7 @@ const JOB_CLASSES = [
CleanupJob, CleanupJob,
AppBackupJob, AppBackupJob,
RekeyJob, RekeyJob,
VoiceTranscribeJob,
]; ];
/** /**
@@ -0,0 +1,79 @@
import { mkdtemp, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PrismaService } from '../prisma/prisma.service';
import * as claims from '../voice/claims/voice-claims';
import * as retention from '../voice/voice-retention';
import { VoiceTranscribeJob } from './voice-transcribe.job';
describe('VoiceTranscribeJob', () => {
let dir: string;
let job: VoiceTranscribeJob;
let retentionSpy: jest.SpyInstance;
let staleSpy: jest.SpyInstance;
let claimSpy: jest.SpyInstance;
const tx = {};
const prisma = {
$transaction: (fn: (t: typeof tx) => Promise<unknown>) => fn(tx),
} as unknown as PrismaService;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'voice-job-'));
process.env.VOICE_WORK_DIR = dir;
retentionSpy = jest
.spyOn(retention, 'expireVoiceContent')
.mockResolvedValue({
audioExpired: 2,
contentExpired: 1,
inactiveOwnersWiped: 0,
});
staleSpy = jest
.spyOn(claims, 'releaseStaleVoiceClaims')
.mockResolvedValue([]);
claimSpy = jest.spyOn(claims, 'claimVoiceCaptures').mockResolvedValue([]);
job = new VoiceTranscribeJob(prisma);
});
afterEach(async () => {
delete process.env.VOICE_WORK_DIR;
jest.restoreAllMocks();
await rm(dir, { recursive: true, force: true });
});
it('is named voice-transcribe', () => {
expect(job.name).toBe('voice-transcribe');
});
it('refuses a missing or unknown phase', async () => {
await expect(job.run([])).rejects.toThrow(/exactly one phase argument/);
await expect(job.run(['--phase=whisper'])).rejects.toThrow(
/exactly one phase argument/,
);
await expect(job.run(['--phase=fetch', '--phase=ingest'])).rejects.toThrow(
/exactly one phase argument/,
);
});
it('fetch ends quickly without work and writes no manifest', async () => {
const result = await job.run(['--phase=fetch']);
expect(result).toMatchObject({
claimed: 0,
audioExpired: 2,
contentExpired: 1,
});
expect(retentionSpy).toHaveBeenCalledTimes(1);
expect(staleSpy).toHaveBeenCalledWith(tx, 'transcribe', {});
expect(claimSpy).toHaveBeenCalledWith(prisma, 'transcribe', {
limit: 5,
maxTotalSec: 600,
});
expect(await readdir(dir)).not.toContain('manifest.json');
await expect(readdir(path.join(dir, 'in'))).resolves.toEqual([]);
});
it('ingest without a manifest does nothing', async () => {
const result = await job.run(['--phase=ingest']);
expect(result).toEqual({ ingested: 0, failed: 0, retried: 0, lost: 0 });
});
});
@@ -0,0 +1,282 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { PrismaService } from '../prisma/prisma.service';
import {
claimVoiceCaptures,
releaseStaleVoiceClaims,
settleVoiceFailure,
settleVoiceSuccess,
VoiceClaim,
} from '../voice/claims/voice-claims';
import { sealTranscript } from '../voice/core/transcript';
import { openVoiceBytes, voiceAad } from '../voice/core/voice-crypto';
import {
clearWorkDir,
readManifest,
readResults,
WhisperResult,
workDirFromEnv,
writeInputs,
} from '../voice/transcribe/work-dir';
import { recordVoiceCaptureFailed } from '../voice/voice-failure-hook';
import { expireVoiceContent } from '../voice/voice-retention';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
/** Per run: at most 5 notes and 600 seconds of audio (spec 4.2). */
export const VOICE_FETCH_LIMIT = 5;
export const VOICE_FETCH_MAX_SEC = 600;
/** Whisper text shorter than this is no speech (spec 6.3). */
const MIN_TEXT_CHARS = 5;
const MIN_DURATION_SEC = 1;
const MAX_DURATION_SEC = 240;
type Phase = 'claim' | 'ingest';
type ItemOutcome = 'ingested' | 'failed' | 'retried' | 'lost';
export interface VoiceFetchOptions {
/** Extra condition on "VoiceCapture" (tests scope their runs to own rows). */
filter?: Prisma.Sql;
}
function parsePhase(args: readonly string[]): Phase {
if (args.length === 1 && args[0] === '--phase=fetch') return 'claim';
if (args.length === 1 && args[0] === '--phase=ingest') return 'ingest';
throw new Error('voice-transcribe needs exactly one phase argument');
}
/**
* CronJob `apicore-job-voice-transcribe`, the two Node halves around the
* Whisper container. `fetch` runs the retention rules, claims notes,
* decrypts their audio in memory and hands it over in the RAM work dir;
* `ingest` takes Whisper's results, seals the text, deletes the audio and
* wakes the worker - each note in one transaction, guarded by its claim
* token. Content is never logged: only ids, counts and fixed codes.
*/
@Injectable()
export class VoiceTranscribeJob implements Job {
private readonly logger = new Logger(VoiceTranscribeJob.name);
readonly name: JobName = 'voice-transcribe';
constructor(private readonly prisma: PrismaService) {}
async run(args: readonly string[]): Promise<JobResult> {
return parsePhase(args) === 'claim' ? this.runFetchPhase() : this.ingest();
}
async runFetchPhase(options: VoiceFetchOptions = {}): Promise<JobResult> {
const dir = workDirFromEnv();
await clearWorkDir(dir);
const filter = options.filter ? { filter: options.filter } : {};
const retention = await expireVoiceContent(this.prisma, filter);
const staleReleased = await this.releaseStale(filter);
const claims = await claimVoiceCaptures(this.prisma, 'transcribe', {
limit: VOICE_FETCH_LIMIT,
maxTotalSec: VOICE_FETCH_MAX_SEC,
...filter,
});
const inputs: { claim: VoiceClaim; audio: Buffer }[] = [];
for (const claim of claims) {
const audio = await this.decryptAudio(claim);
if (audio === null) {
await this.failAttempt(claim, 'TRANSCRIBE_FAILED');
} else {
inputs.push({ claim, audio });
}
}
if (inputs.length > 0) {
await writeInputs(dir, inputs);
}
return {
claimed: claims.length,
written: inputs.length,
staleReleased,
...retention,
};
}
async ingest(): Promise<JobResult> {
const dir = workDirFromEnv();
const counts: Record<ItemOutcome, number> = {
ingested: 0,
failed: 0,
retried: 0,
lost: 0,
};
try {
const manifest = await readManifest(dir);
if (manifest === null) {
return counts;
}
const results = await readResults(dir, manifest);
let errors = 0;
for (const item of manifest.items) {
try {
const outcome = await this.ingestOne(
item.captureId,
item.claimToken,
results.get(item.captureId) ?? null,
);
counts[outcome] += 1;
} catch (error: unknown) {
errors += 1;
// The message of a failed seal or query holds no content, but
// keep to the id anyway.
this.logger.error(
`Ingest failed for voice note ${item.captureId}: ${error instanceof Error ? error.name : 'error'}`,
);
}
}
if (errors > 0) {
throw new Error(
`voice ingest failed for ${errors} of ${manifest.items.length} notes (${JSON.stringify(counts)})`,
);
}
return counts;
} finally {
await clearWorkDir(dir);
}
}
private releaseStale(filter: VoiceFetchOptions): Promise<number> {
return this.prisma.$transaction(async (tx) => {
const released = await releaseStaleVoiceClaims(tx, 'transcribe', filter);
for (const { id, code } of released) {
if (code !== null) {
await recordVoiceCaptureFailed(tx, id, 'TRANSCRIBE_FAILED');
}
}
return released.length;
});
}
private async decryptAudio(claim: VoiceClaim): Promise<Buffer | null> {
const row = await this.prisma.voiceCaptureAudio.findUnique({
where: { captureId: claim.id },
});
if (!row) {
return null;
}
try {
return openVoiceBytes(
Buffer.from(row.dataEnc),
voiceAad(claim.id, 'audio'),
);
} catch {
this.logger.warn(`Audio of voice note ${claim.id} cannot be opened`);
return null;
}
}
private failAttempt(
claim: VoiceClaim,
code: 'TRANSCRIBE_FAILED',
): Promise<'retry' | 'failed' | 'lost'> {
return this.prisma.$transaction(async (tx) => {
const outcome = await settleVoiceFailure(tx, 'transcribe', claim, code);
if (outcome === 'failed') {
await recordVoiceCaptureFailed(tx, claim.id, code);
}
return outcome;
});
}
/** The claim as the database holds it now; null if it is gone. */
private async loadClaim(
captureId: string,
claimToken: string,
): Promise<VoiceClaim | null> {
const rows = await this.prisma.$queryRaw<VoiceClaim[]>(Prisma.sql`
SELECT "id", "claimToken", "attempts", "ownerUserId", "colonyId",
"audioMime", "durationSec"
FROM "VoiceCapture"
WHERE "id" = ${captureId} AND "claimToken" = ${claimToken}
AND "status" = 'TRANSCRIBING'`);
return rows[0] ?? null;
}
private async ingestOne(
captureId: string,
claimToken: string,
result: WhisperResult | null,
): Promise<ItemOutcome> {
const claim = await this.loadClaim(captureId, claimToken);
if (claim === null) {
return 'lost';
}
if (result === null || result.error === 'DECODE_FAILED') {
return this.outcomeOf(await this.failAttempt(claim, 'TRANSCRIBE_FAILED'));
}
const noSpeech =
result.error ??
(result.text.trim().length < MIN_TEXT_CHARS ? 'NO_SPEECH' : null);
if (noSpeech === 'NO_SPEECH' || noSpeech === 'TOO_LONG') {
return this.finishFailed(claim, noSpeech);
}
if (result.durationSec > MAX_DURATION_SEC) {
return this.finishFailed(claim, 'TOO_LONG');
}
return this.finishTranscribed(claim, result);
}
private outcomeOf(settled: 'retry' | 'failed' | 'lost'): ItemOutcome {
return settled === 'retry'
? 'retried'
: settled === 'failed'
? 'failed'
: 'lost';
}
/** Terminal failure of the note itself: FAILED with the code, audio gone. */
private finishFailed(
claim: VoiceClaim,
code: 'NO_SPEECH' | 'TOO_LONG',
): Promise<ItemOutcome> {
return this.prisma.$transaction(async (tx) => {
const changed = await tx.$executeRaw`
UPDATE "VoiceCapture"
SET "status" = 'FAILED', "lastErrorCode" = ${code},
"attempts" = "attempts" + 1, "updatedAt" = now(),
"claimedAt" = NULL, "claimToken" = NULL
WHERE "id" = ${claim.id} AND "status" = 'TRANSCRIBING'
AND "claimToken" = ${claim.claimToken}`;
if (changed !== 1) {
return 'lost';
}
await tx.voiceCaptureAudio.deleteMany({ where: { captureId: claim.id } });
await recordVoiceCaptureFailed(tx, claim.id, code);
return 'failed';
});
}
private finishTranscribed(
claim: VoiceClaim,
result: WhisperResult,
): Promise<ItemOutcome> {
const durationSec = Math.max(
MIN_DURATION_SEC,
Math.round(result.durationSec),
);
const sealed = sealTranscript(claim.id, {
v: 1,
text: result.text,
durationSec: result.durationSec,
segments: result.segments,
});
return this.prisma.$transaction(async (tx) => {
const settled = await settleVoiceSuccess(
tx,
'transcribe',
claim,
Prisma.sql`"transcriptEnc" = ${sealed}, "durationSec" = ${durationSec}`,
);
if (!settled) {
return 'lost';
}
await tx.voiceCaptureAudio.deleteMany({ where: { captureId: claim.id } });
await notifyWorker(tx, WAKE_CHANNELS.voiceExtraction);
return 'ingested';
});
}
}
@@ -1,6 +1,7 @@
import { ConflictException, NotFoundException } from '@nestjs/common'; import { ConflictException, NotFoundException } from '@nestjs/common';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { JOB_NAMES } from '../jobs/job-names';
import { JobRunService } from './job-run.service'; import { JobRunService } from './job-run.service';
import { SystemQueueService } from './system-queue.service'; import { SystemQueueService } from './system-queue.service';
@@ -67,7 +68,7 @@ describe('SystemQueueService', () => {
oldestPendingAgeSeconds: 42.5, oldestPendingAgeSeconds: 42.5,
}); });
expect(overview.webhookDeliveries).toEqual({ pending: 4, failed: 1 }); expect(overview.webhookDeliveries).toEqual({ pending: 4, failed: 1 });
expect(overview.jobs).toHaveLength(5); expect(overview.jobs).toHaveLength(JOB_NAMES.length);
const sql = sqlText(prisma.$queryRaw.mock.calls[0]); const sql = sqlText(prisma.$queryRaw.mock.calls[0]);
expect(sql).toContain('EXTRACT(EPOCH FROM now() - MIN("occurredAt")'); expect(sql).toContain('EXTRACT(EPOCH FROM now() - MIN("occurredAt")');
}); });
@@ -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,
);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { VoiceConfirmationService } from './voice-confirmation.service';
/**
* Only the confirmation step, so InspectionsModule can use it without
* pulling in the voice note API (controller, upload, limits). Audit comes
* from a global module. No network provider here.
*/
@Module({
providers: [VoiceConfirmationService],
exports: [VoiceConfirmationService],
})
export class VoiceConfirmationModule {}
@@ -0,0 +1,156 @@
import { HttpException } from '@nestjs/common';
import { VoiceConfirmationService } from './voice-confirmation.service';
const CTX = { userId: 'user-1', colonyId: 'colony-1', inspectionId: 'insp-1' };
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('VoiceConfirmationService', () => {
let service: VoiceConfirmationService;
let tx: {
$queryRaw: jest.Mock;
$executeRaw: jest.Mock;
voiceCapture: { findUnique: jest.Mock };
voiceCaptureAudio: { deleteMany: jest.Mock };
};
let auditLog: { record: jest.Mock };
let row: Record<string, unknown> | null;
let locked: boolean;
let order: string[];
beforeEach(() => {
order = [];
locked = true;
row = {
ownerUserId: 'user-1',
colonyId: 'colony-1',
status: 'READY',
};
tx = {
$queryRaw: jest
.fn()
.mockImplementation((query: { strings?: string[] }) => {
const text = (query.strings ?? []).join('?');
if (text.includes('FOR UPDATE')) {
order.push('lock');
return Promise.resolve(locked ? [{ id: 'cap-1' }] : []);
}
return Promise.resolve([]);
}),
$executeRaw: jest.fn().mockImplementation(() => {
order.push('update');
return Promise.resolve(1);
}),
voiceCapture: {
findUnique: jest.fn().mockImplementation(() => {
order.push('read');
return Promise.resolve(row);
}),
},
voiceCaptureAudio: {
deleteMany: jest.fn().mockImplementation(() => {
order.push('audio');
return Promise.resolve({ count: 1 });
}),
},
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new VoiceConfirmationService(auditLog as never);
});
const confirm = () =>
service.confirmIn(tx as never, { kind: 'capture', id: 'cap-1' }, CTX);
it('locks the capture, then wipes audio and content and confirms in the given transaction', async () => {
await confirm();
expect(order).toEqual(['lock', 'read', 'audio', 'update']);
expect(tx.voiceCaptureAudio.deleteMany).toHaveBeenCalledWith({
where: { captureId: 'cap-1' },
});
const sql = (
tx.$executeRaw.mock.calls[0] as [{ strings: string[] }]
)[0].strings.join('?');
expect(sql).toContain(`"status" = 'CONFIRMED'`);
expect(sql).toContain('"transcriptEnc" = NULL');
expect(sql).toContain('"suggestionEnc" = NULL');
expect(sql).toContain('"claimToken" = NULL');
expect(sql).toContain('"claimedAt" = NULL');
expect(sql).toContain('"contentDeletedAt" = now()');
});
it.each([
'QUEUED',
'TRANSCRIBING',
'TRANSCRIBED',
'EXTRACTING',
'READY',
'FAILED',
])('confirms a %s capture', async (status) => {
row = { ...row, status };
await expect(confirm()).resolves.toBeUndefined();
});
it.each(['CONFIRMED', 'DISCARDED', 'EXPIRED'])(
'answers 409 VOICE_CAPTURE_GONE for a %s capture',
async (status) => {
row = { ...row, status };
const error = await failureOf(confirm());
expect(error.getStatus()).toBe(409);
expect(codeOf(error)).toBe('VOICE_CAPTURE_GONE');
expect(tx.voiceCaptureAudio.deleteMany).not.toHaveBeenCalled();
},
);
it('answers 409 VOICE_CAPTURE_GONE for a missing capture', async () => {
locked = false;
row = null;
const error = await failureOf(confirm());
expect(error.getStatus()).toBe(409);
expect(codeOf(error)).toBe('VOICE_CAPTURE_GONE');
});
it('answers 409 VOICE_CAPTURE_GONE for a foreign capture without touching it', async () => {
row = { ...row, ownerUserId: 'someone-else' };
const error = await failureOf(confirm());
expect(codeOf(error)).toBe('VOICE_CAPTURE_GONE');
expect(tx.voiceCaptureAudio.deleteMany).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it('answers 409 VOICE_CAPTURE_COLONY_MISMATCH for another colony', async () => {
row = { ...row, colonyId: 'colony-2' };
const error = await failureOf(confirm());
expect(error.getStatus()).toBe(409);
expect(codeOf(error)).toBe('VOICE_CAPTURE_COLONY_MISMATCH');
expect(tx.voiceCaptureAudio.deleteMany).not.toHaveBeenCalled();
});
it('audits the status change without any content', async () => {
await confirm();
expect(auditLog.record).toHaveBeenCalledWith(
{
entityType: 'VoiceCapture',
entityId: 'cap-1',
action: 'UPDATE',
userId: 'user-1',
beforeData: { status: 'READY' },
afterData: { status: 'CONFIRMED', inspectionId: 'insp-1' },
},
tx,
);
});
});
@@ -0,0 +1,108 @@
import { ConflictException, HttpStatus, Injectable } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { AuditLogService } from '../../audit/audit-log.service';
import { lockRowForUpdate } from '../../prisma/row-lock';
import {
VOICE_CAPTURE_COLONY_MISMATCH,
type VoiceErrorCode,
} from '../voice-error-codes';
/** What a note is confirmed against. Part 4 adds `{ kind: 'session'; id }`. */
export type VoiceLink = { kind: 'capture'; id: string };
export interface VoiceConfirmContext {
userId: string;
colonyId: string;
inspectionId: string;
}
/** States in which saving an inspection can no longer take the note over. */
const CLOSED_STATUSES: ReadonlySet<string> = new Set([
'CONFIRMED',
'DISCARDED',
'EXPIRED',
]);
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 otherColony(): ConflictException {
return new ConflictException({
statusCode: HttpStatus.CONFLICT,
code: VOICE_CAPTURE_COLONY_MISMATCH,
message: 'Die Sprachnotiz gehört zu einem anderen Volk.',
});
}
/**
* Takes a voice note over when the inspection it led to is saved: status
* CONFIRMED, audio, transcript and suggestions gone, all in the caller's
* transaction. Anything that cannot be confirmed fails the whole save (spec
* 6.6). Ruling 1 (A1): any open state may be confirmed, so saving never leaves
* content behind; a running job loses its claim and can no longer settle.
*/
@Injectable()
export class VoiceConfirmationService {
constructor(private readonly auditLog: AuditLogService) {}
/**
* Call it last in the transaction: VoiceCapture is the last table in the
* lock order (row-lock.ts). A foreign or missing note is answered like a
* gone one (409, not 404) so the client can retry without the id.
*/
async confirmIn(
tx: Prisma.TransactionClient,
link: VoiceLink,
ctx: VoiceConfirmContext,
): Promise<void> {
const locked = await lockRowForUpdate(tx, 'VoiceCapture', link.id);
if (!locked) {
throw gone();
}
const row = await tx.voiceCapture.findUnique({
where: { id: link.id },
select: { ownerUserId: true, colonyId: true, status: true },
});
if (
!row ||
row.ownerUserId !== ctx.userId ||
CLOSED_STATUSES.has(row.status)
) {
throw gone();
}
if (row.colonyId !== ctx.colonyId) {
throw otherColony();
}
await tx.voiceCaptureAudio.deleteMany({ where: { captureId: link.id } });
// Raw, so contentDeletedAt and updatedAt come from the database clock;
// CONFIRMED and inspectionId are set together (CHECK constraint).
await tx.$executeRaw(Prisma.sql`
UPDATE "VoiceCapture"
SET "status" = 'CONFIRMED',
"inspectionId" = ${ctx.inspectionId},
"transcriptEnc" = NULL,
"suggestionEnc" = NULL,
"claimedAt" = NULL,
"claimToken" = NULL,
"contentDeletedAt" = now(),
"updatedAt" = now()
WHERE "id" = ${link.id}`);
await this.auditLog.record(
{
entityType: 'VoiceCapture',
entityId: link.id,
action: 'UPDATE',
userId: ctx.userId,
beforeData: { status: row.status },
afterData: { status: 'CONFIRMED', inspectionId: ctx.inspectionId },
},
tx,
);
}
}
@@ -0,0 +1,187 @@
import {
mkdtemp,
readFile,
readdir,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
MAX_INPUT_BYTES,
WhisperResult,
clearWorkDir,
readManifest,
readResults,
workDirFromEnv,
writeInputs,
} from './work-dir';
const CLAIM = (id: string) => ({
id,
claimToken: `token-${id}`,
attempts: 0,
ownerUserId: 'u1',
colonyId: 'c1',
audioMime: 'audio/webm',
durationSec: 12,
});
const result = (captureId: string, over: Partial<WhisperResult> = {}) => ({
v: 1,
captureId,
text: 'Volk ist ruhig',
durationSec: 11.5,
language: 'de',
languageProbability: 0.98,
segments: [{ start: 0, end: 2.5, text: 'Volk ist ruhig' }],
...over,
});
describe('voice work dir', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'voice-work-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
async function writeOut(id: string, body: unknown): Promise<void> {
await writeFile(
path.join(dir, 'out', `${id}.json`),
typeof body === 'string' ? body : JSON.stringify(body),
);
}
it('reads the work dir from VOICE_WORK_DIR and defaults to /work', () => {
expect(workDirFromEnv({})).toBe('/work');
expect(workDirFromEnv({ VOICE_WORK_DIR: '/tmp/x' })).toBe('/tmp/x');
});
it('writes one file per capture and a manifest named after ids only', async () => {
const manifest = await writeInputs(dir, [
{ claim: CLAIM('ck1'), audio: Buffer.from('AAA') },
{
claim: { ...CLAIM('ck2'), audioMime: 'audio/ogg', durationSec: null },
audio: Buffer.from('BBBB'),
},
]);
expect(manifest.items).toEqual([
{
captureId: 'ck1',
claimToken: 'token-ck1',
file: 'ck1.webm',
mime: 'audio/webm',
reportedSec: 12,
},
{
captureId: 'ck2',
claimToken: 'token-ck2',
file: 'ck2.ogg',
mime: 'audio/ogg',
reportedSec: null,
},
]);
expect((await readdir(path.join(dir, 'in'))).sort()).toEqual([
'ck1.webm',
'ck2.ogg',
'manifest.json',
]);
expect(await readFile(path.join(dir, 'in', 'ck1.webm'))).toEqual(
Buffer.from('AAA'),
);
expect(await readManifest(dir)).toEqual(manifest);
if (process.platform !== 'win32') {
expect((await stat(path.join(dir, 'in', 'ck1.webm'))).mode & 0o777).toBe(
0o600,
);
}
});
it('refuses audio that would not fit the memory volume', async () => {
const big = Buffer.alloc(MAX_INPUT_BYTES + 1);
await expect(
writeInputs(dir, [{ claim: CLAIM('ck1'), audio: big }]),
).rejects.toThrow(/too large/);
expect(await readdir(dir)).toEqual([]);
});
it('refuses an id that is not a plain id and an unknown audio type', async () => {
await expect(
writeInputs(dir, [{ claim: CLAIM('../evil'), audio: Buffer.from('x') }]),
).rejects.toThrow(/capture id/);
await expect(
writeInputs(dir, [
{
claim: { ...CLAIM('ck1'), audioMime: 'video/mp4' },
audio: Buffer.from('x'),
},
]),
).rejects.toThrow(/audio type/);
});
it('returns null without a manifest and for a broken one', async () => {
expect(await readManifest(dir)).toBeNull();
await writeInputs(dir, []);
await writeFile(
path.join(dir, 'in', 'manifest.json'),
'{"v":1,"items":[{"captureId":"../x"}]}',
);
expect(await readManifest(dir)).toBeNull();
await writeFile(path.join(dir, 'in', 'manifest.json'), 'not json');
expect(await readManifest(dir)).toBeNull();
});
it('reads a valid result and maps a missing or broken file to null', async () => {
const manifest = await writeInputs(dir, [
{ claim: CLAIM('ck1'), audio: Buffer.from('a') },
{ claim: CLAIM('ck2'), audio: Buffer.from('b') },
{ claim: CLAIM('ck3'), audio: Buffer.from('c') },
{ claim: CLAIM('ck4'), audio: Buffer.from('d') },
]);
await writeOut('ck1', result('ck1'));
await writeOut('ck3', '{"v":1,"captureId":"ck3","text":5}');
await writeOut('ck4', result('ck4', { error: 'NO_SPEECH', text: '' }));
const results = await readResults(dir, manifest);
expect(results.get('ck1')).toEqual(result('ck1'));
expect(results.get('ck2')).toBeNull();
expect(results.get('ck3')).toBeNull();
expect(results.get('ck4')?.error).toBe('NO_SPEECH');
});
it('rejects a result for another capture, a bad error and bad segments', async () => {
const manifest = await writeInputs(dir, [
{ claim: CLAIM('ck1'), audio: Buffer.from('a') },
{ claim: CLAIM('ck2'), audio: Buffer.from('b') },
{ claim: CLAIM('ck3'), audio: Buffer.from('c') },
]);
await writeOut('ck1', result('other'));
await writeOut('ck2', result('ck2', { error: 'WHATEVER' as never }));
await writeOut(
'ck3',
result('ck3', {
segments: [{ start: 'a', end: 1, text: 'x' }] as never,
}),
);
const results = await readResults(dir, manifest);
expect([...results.values()]).toEqual([null, null, null]);
});
it('clears input and output', async () => {
await writeInputs(dir, [{ claim: CLAIM('ck1'), audio: Buffer.from('a') }]);
await writeOut('ck1', result('ck1'));
await clearWorkDir(dir);
expect(await readdir(path.join(dir, 'in'))).toEqual([]);
expect(await readdir(path.join(dir, 'out'))).toEqual([]);
});
});
@@ -0,0 +1,279 @@
import {
lstat,
mkdir,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import type { VoiceAudioMime } from '../../common/file-signature';
import type { VoiceClaim } from '../claims/voice-claims';
import { MAX_TRANSCRIPT_CHARS, TranscriptSegment } from '../core/transcript';
/**
* The hand-over between the three containers of the voice CronJob: `fetch`
* writes decrypted audio and a manifest to `<dir>/in`, the Whisper container
* writes one result per capture to `<dir>/out`, `ingest` reads the results.
* The directory is a memory-backed emptyDir (32Mi), so decrypted audio never
* touches a disk. File names come from capture ids only, never from content.
*/
export const DEFAULT_WORK_DIR = '/work';
const IN_DIR = 'in';
const OUT_DIR = 'out';
const MANIFEST_FILE = 'manifest.json';
/** Size of the memory volume (deploy/k8s voice component). */
export const WORK_VOLUME_BYTES = 32 * 1024 * 1024;
/** Leaves room for the results of Whisper and the manifest on the volume. */
export const MAX_INPUT_BYTES = WORK_VOLUME_BYTES - 8 * 1024 * 1024;
/** A result is a few kilobytes of text; anything bigger is not from Whisper. */
const MAX_RESULT_BYTES = 1024 * 1024;
const MAX_ITEMS = 50;
const MAX_SEGMENTS = 5000;
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const LANGUAGE_PATTERN = /^[A-Za-z-]{1,16}$/;
const EXTENSIONS: Readonly<Record<VoiceAudioMime, string>> = {
'audio/webm': 'webm',
'audio/ogg': 'ogg',
'audio/mp4': 'mp4',
};
export type WhisperError = 'NO_SPEECH' | 'TOO_LONG' | 'DECODE_FAILED';
const WHISPER_ERRORS: readonly string[] = [
'NO_SPEECH',
'TOO_LONG',
'DECODE_FAILED',
];
export interface WorkManifestItem {
captureId: string;
claimToken: string;
file: string;
mime: VoiceAudioMime;
reportedSec: number | null;
}
export interface WorkManifest {
v: 1;
items: WorkManifestItem[];
}
export interface WhisperResult {
v: 1;
captureId: string;
text: string;
durationSec: number;
language: string;
languageProbability: number;
segments: TranscriptSegment[];
error?: WhisperError;
}
export function workDirFromEnv(
env: Readonly<Record<string, string | undefined>> = process.env,
): string {
return env.VOICE_WORK_DIR || DEFAULT_WORK_DIR;
}
function isAudioMime(value: unknown): value is VoiceAudioMime {
return typeof value === 'string' && Object.hasOwn(EXTENSIONS, value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Writes the audio of the claimed captures and the manifest (last and by
* rename, so Whisper never sees a half-written manifest). Throws, with no
* content in the message, if an id or type is unusable or the audio would
* not fit the volume - checked before anything is written.
*/
export async function writeInputs(
dir: string,
items: readonly { claim: VoiceClaim; audio: Buffer }[],
): Promise<WorkManifest> {
const manifestItems = items.map(({ claim }): WorkManifestItem => {
if (!ID_PATTERN.test(claim.id)) {
throw new Error('voice work dir: unusable capture id');
}
if (!isAudioMime(claim.audioMime)) {
throw new Error('voice work dir: unsupported audio type');
}
if (!TOKEN_PATTERN.test(claim.claimToken)) {
throw new Error('voice work dir: unusable claim token');
}
return {
captureId: claim.id,
claimToken: claim.claimToken,
file: `${claim.id}.${EXTENSIONS[claim.audioMime]}`,
mime: claim.audioMime,
reportedSec: claim.durationSec,
};
});
const total = items.reduce((sum, { audio }) => sum + audio.length, 0);
if (total > MAX_INPUT_BYTES) {
throw new Error('voice work dir: audio too large for the work volume');
}
const inDir = path.join(dir, IN_DIR);
await mkdir(inDir, { recursive: true, mode: 0o700 });
await mkdir(path.join(dir, OUT_DIR), { recursive: true, mode: 0o700 });
for (const [index, { audio }] of items.entries()) {
await writeFile(path.join(inDir, manifestItems[index].file), audio, {
mode: 0o600,
});
}
const manifest: WorkManifest = { v: 1, items: manifestItems };
const temporary = path.join(inDir, `${MANIFEST_FILE}.tmp`);
await writeFile(temporary, JSON.stringify(manifest), { mode: 0o600 });
await rename(temporary, path.join(inDir, MANIFEST_FILE));
return manifest;
}
function toManifestItem(value: unknown): WorkManifestItem | null {
if (!isRecord(value)) {
return null;
}
const { captureId, claimToken, file, mime, reportedSec } = value;
if (
typeof captureId !== 'string' ||
!ID_PATTERN.test(captureId) ||
typeof claimToken !== 'string' ||
!TOKEN_PATTERN.test(claimToken) ||
typeof file !== 'string' ||
!isAudioMime(mime) ||
file !== `${captureId}.${EXTENSIONS[mime]}` ||
!(reportedSec === null || Number.isFinite(reportedSec))
) {
return null;
}
return {
captureId,
claimToken,
file,
mime,
reportedSec: reportedSec as number | null,
};
}
/** The manifest of this run, or null if there is none or it is not valid. */
export async function readManifest(dir: string): Promise<WorkManifest | null> {
let parsed: unknown;
try {
parsed = JSON.parse(
await readFile(path.join(dir, IN_DIR, MANIFEST_FILE), 'utf8'),
);
} catch {
return null;
}
if (!isRecord(parsed) || parsed.v !== 1 || !Array.isArray(parsed.items)) {
return null;
}
if (parsed.items.length > MAX_ITEMS) {
return null;
}
const items = parsed.items.map(toManifestItem);
if (items.some((item) => item === null)) {
return null;
}
return { v: 1, items: items as WorkManifestItem[] };
}
function toSegment(value: unknown): TranscriptSegment | null {
if (!isRecord(value)) {
return null;
}
const { start, end, text } = value;
return typeof start === 'number' &&
Number.isFinite(start) &&
typeof end === 'number' &&
Number.isFinite(end) &&
typeof text === 'string' &&
text.length <= MAX_TRANSCRIPT_CHARS
? { start, end, text }
: null;
}
/** Strict check of one result file; the content is never put into an error. */
function toResult(value: unknown, captureId: string): WhisperResult | null {
if (!isRecord(value) || value.v !== 1 || value.captureId !== captureId) {
return null;
}
const { text, durationSec, language, languageProbability, segments, error } =
value;
if (
typeof text !== 'string' ||
text.length > MAX_TRANSCRIPT_CHARS ||
typeof durationSec !== 'number' ||
!Number.isFinite(durationSec) ||
durationSec < 0 ||
typeof language !== 'string' ||
!LANGUAGE_PATTERN.test(language) ||
typeof languageProbability !== 'number' ||
!(languageProbability >= 0 && languageProbability <= 1) ||
!Array.isArray(segments) ||
segments.length > MAX_SEGMENTS ||
!(error === undefined || WHISPER_ERRORS.includes(error as string))
) {
return null;
}
const checked = segments.map(toSegment);
if (checked.some((segment) => segment === null)) {
return null;
}
const result: WhisperResult = {
v: 1,
captureId,
text,
durationSec,
language,
languageProbability,
segments: checked as TranscriptSegment[],
};
if (error !== undefined) {
result.error = error as WhisperError;
}
return result;
}
async function readResult(
dir: string,
captureId: string,
): Promise<WhisperResult | null> {
const file = path.join(dir, OUT_DIR, `${captureId}.json`);
try {
// lstat: a symlink or device planted by the Whisper container is no result.
const info = await lstat(file);
if (!info.isFile() || info.size > MAX_RESULT_BYTES) {
return null;
}
return toResult(JSON.parse(await readFile(file, 'utf8')), captureId);
} catch {
return null;
}
}
/** One entry per manifest item; null = no file, unreadable or not valid. */
export async function readResults(
dir: string,
manifest: WorkManifest,
): Promise<Map<string, WhisperResult | null>> {
const results = new Map<string, WhisperResult | null>();
for (const item of manifest.items) {
results.set(item.captureId, await readResult(dir, item.captureId));
}
return results;
}
/** Removes everything of a run (audio, manifest, results) and recreates the dirs. */
export async function clearWorkDir(dir: string): Promise<void> {
for (const sub of [IN_DIR, OUT_DIR]) {
const target = path.join(dir, sub);
await rm(target, { recursive: true, force: true });
await mkdir(target, { recursive: true, mode: 0o700 });
}
}
@@ -20,3 +20,9 @@ export const VOICE_ERROR_CODES = [
] as const; ] as const;
export type VoiceErrorCode = (typeof VOICE_ERROR_CODES)[number]; export type VoiceErrorCode = (typeof VOICE_ERROR_CODES)[number];
/**
* Answered by the confirmation when a note belongs to another colony. Not in
* VOICE_ERROR_CODES: it is never stored in lastErrorCode.
*/
export const VOICE_CAPTURE_COLONY_MISMATCH = 'VOICE_CAPTURE_COLONY_MISMATCH';
@@ -0,0 +1,30 @@
import type { VoiceErrorCode } from './voice-error-codes';
/**
* One short German sentence per error code: what the user does next. Fixed
* texts only - a voice note's content never lands in a message.
*/
export const VOICE_ERROR_TEXTS_DE: Readonly<Record<VoiceErrorCode, string>> = {
UNSUPPORTED_AUDIO: 'Das Audioformat wird nicht unterstützt.',
TOO_LARGE: 'Die Aufnahme ist zu groß. Bitte kürzer aufnehmen.',
TOO_MANY_OPEN:
'Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.',
NO_SPEECH: 'Es war nichts zu verstehen. Bitte noch einmal aufnehmen.',
TOO_LONG: 'Die Aufnahme ist zu lang. Bitte kürzer aufnehmen.',
TRANSCRIBE_FAILED:
'Die Aufnahme konnte nicht abgeschrieben werden. Bitte noch einmal aufnehmen.',
AUDIO_EXPIRED:
'Die Aufnahme wurde nach 24 Stunden gelöscht. Bitte noch einmal aufnehmen.',
EXTRACT_DISABLED:
'Die Auswertung ist ausgeschaltet. Bitte die Durchsicht von Hand ausfüllen.',
EXTRACT_RETRY: 'Die Auswertung wird noch einmal versucht.',
EXTRACT_FAILED:
'Die Auswertung hat nicht geklappt. Bitte erneut auswerten oder von Hand ausfüllen.',
DAILY_LIMIT:
'Das Tageslimit für die Auswertung ist erreicht. Bitte morgen erneut auswerten.',
EXTRACT_INVALID:
'Die Auswertung war nicht brauchbar. Bitte erneut auswerten oder von Hand ausfüllen.',
REFUSED:
'Die Auswertung wurde abgelehnt. Bitte die Durchsicht von Hand ausfüllen.',
VOICE_CAPTURE_GONE: 'Die Sprachnotiz ist nicht mehr vorhanden.',
};
@@ -0,0 +1,40 @@
import type { Prisma } from '../../generated/prisma';
import {
recordVoiceCaptureFailed,
recordVoiceCaptureReady,
} from './voice-failure-hook';
describe('voice event writers', () => {
let createMany: jest.Mock;
let tx: Prisma.TransactionClient;
beforeEach(() => {
createMany = jest.fn().mockResolvedValue({ count: 1 });
tx = {
domainEvent: { createMany },
$executeRaw: jest.fn().mockResolvedValue(1),
} as unknown as Prisma.TransactionClient;
});
it('payloads carry only ids and the code', async () => {
await recordVoiceCaptureReady(tx, 'vc-1', 'col-1');
await recordVoiceCaptureFailed(tx, 'vc-2', 'NO_SPEECH');
const rows = createMany.mock.calls.map(
([arg]: [{ data: Record<string, unknown>[] }]) => arg.data[0],
);
expect(rows[0]).toMatchObject({
type: 'VoiceCaptureReady',
aggregateType: 'VoiceCapture',
aggregateId: 'vc-1',
payload: { captureId: 'vc-1', colonyId: 'col-1' },
actorUserId: null,
});
expect(rows[1]).toMatchObject({
type: 'VoiceCaptureFailed',
aggregateType: 'VoiceCapture',
aggregateId: 'vc-2',
payload: { captureId: 'vc-2', code: 'NO_SPEECH' },
});
});
});
@@ -0,0 +1,41 @@
import { Prisma } from '../../generated/prisma';
import { DomainEventsService } from '../domain-events/domain-events.service';
import type { VoiceFailureEventCode } from '../domain-events/domain-event-types';
export type { VoiceFailureEventCode };
// DomainEventsService has no dependencies: jobs, which have no Nest
// container, write events through the same code path as the API.
const events = new DomainEventsService();
/**
* Event writers for the voice jobs (Tasks 11 and 15). Call them in the same
* transaction that sets the voice note READY or FAILED: no commit, no event,
* and no commit without its event. Payloads carry ids and the fixed code
* only - never text, quote, suggestion or audio.
*/
export function recordVoiceCaptureReady(
tx: Prisma.TransactionClient,
captureId: string,
colonyId: string,
): Promise<void> {
return events.record(tx, {
type: 'VoiceCaptureReady',
aggregateType: 'VoiceCapture',
aggregateId: captureId,
payload: { captureId, colonyId },
});
}
export function recordVoiceCaptureFailed(
tx: Prisma.TransactionClient,
captureId: string,
code: VoiceFailureEventCode,
): Promise<void> {
return events.record(tx, {
type: 'VoiceCaptureFailed',
aggregateType: 'VoiceCapture',
aggregateId: captureId,
payload: { captureId, code },
});
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { VoiceEventHandlers } from './voice.handlers';
/**
* The voice event handlers (T7). Only the worker dispatches events, so
* WorkerModule imports it; VoiceModule imports it too, so the API and the
* worker discover the same handlers. Kept apart from VoiceModule, whose
* controllers the worker does not serve.
*/
@Module({
imports: [NotificationsModule],
providers: [VoiceEventHandlers],
})
export class VoiceHandlersModule {}
@@ -0,0 +1,76 @@
import { PrismaService } from '../prisma/prisma.service';
import * as failureHook from './voice-failure-hook';
import { deleteOldVoiceRows, expireVoiceContent } from './voice-retention';
type RawCall = [readonly string[] | { sql: string }, ...unknown[]];
describe('voice retention', () => {
let queryRaw: jest.Mock<Promise<unknown[]>, RawCall>;
let executeRaw: jest.Mock<Promise<number>, RawCall>;
let hook: jest.SpyInstance;
let db: PrismaService;
const sql = (call: RawCall) =>
(Array.isArray(call[0])
? call[0].join('?')
: (call[0] as { sql: string }).sql
).replace(/\s+/g, ' ');
beforeEach(() => {
queryRaw = jest.fn<Promise<unknown[]>, RawCall>(() => Promise.resolve([]));
executeRaw = jest.fn<Promise<number>, RawCall>(() => Promise.resolve(0));
const tx = { $queryRaw: queryRaw, $executeRaw: executeRaw };
db = {
...tx,
$transaction: (fn: (t: typeof tx) => Promise<unknown>) => fn(tx),
} as unknown as PrismaService;
hook = jest
.spyOn(failureHook, 'recordVoiceCaptureFailed')
.mockResolvedValue();
});
afterEach(() => jest.restoreAllMocks());
it('measures the audio age against the database clock, 24 hours', async () => {
await expireVoiceContent(db);
const text = queryRaw.mock.calls.map(sql).join(' | ');
expect(text).toContain(`"createdAt" < now() - interval '24 hours'`);
expect(text).toContain(`'AUDIO_EXPIRED'`);
});
it('calls the failure hook once per capture that ended FAILED/AUDIO_EXPIRED', async () => {
queryRaw.mockResolvedValueOnce([{ id: 'a' }, { id: 'b' }]);
const counts = await expireVoiceContent(db);
expect(counts.audioExpired).toBe(2);
expect(hook).toHaveBeenCalledTimes(2);
expect(hook).toHaveBeenCalledWith(expect.anything(), 'a', 'AUDIO_EXPIRED');
});
it('counts wiped content of expired notes and of deactivated users separately', async () => {
executeRaw.mockResolvedValueOnce(3).mockResolvedValueOnce(4);
const counts = await expireVoiceContent(db);
expect(counts).toEqual({
audioExpired: 0,
contentExpired: 3,
inactiveOwnersWiped: 4,
});
const text = executeRaw.mock.calls.map(sql);
expect(text[0]).toContain(`"contentExpiresAt" < now()`);
expect(text[1]).toContain(`"isActive" = false`);
expect(
text.every((each) => each.includes('"contentDeletedAt" = now()')),
).toBe(true);
});
it('deletes rows after 90 days by the database clock', async () => {
executeRaw.mockResolvedValueOnce(5);
expect(await deleteOldVoiceRows(db)).toBe(5);
expect(sql(executeRaw.mock.calls[0])).toContain(`"createdAt" < now() - (`);
expect(executeRaw.mock.calls[0].slice(1)).toContain(90);
});
});
@@ -0,0 +1,151 @@
import { Prisma } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { recordVoiceCaptureFailed } from './voice-failure-hook';
/** Audio is kept until it is transcribed, 24 hours at most. */
export const VOICE_AUDIO_MAX_AGE_HOURS = 24;
/** The row itself (no content) stays 90 days for the error and cost overview. */
export const VOICE_ROW_RETENTION_DAYS = 90;
export interface VoiceRetentionCounts {
audioExpired: number;
contentExpired: number;
inactiveOwnersWiped: number;
}
export interface VoiceRetentionOptions {
/**
* Extra condition on "VoiceCapture" columns, ANDed to every statement
* (e.g. an owner filter in tests). Trusted SQL only.
*/
filter?: Prisma.Sql;
}
const OPEN_CONTENT = Prisma.sql`"status" NOT IN ('CONFIRMED', 'DISCARDED', 'EXPIRED')`;
/**
* Audio older than 24 hours goes, and a note that still waited for its
* transcript (QUEUED, TRANSCRIBING) ends FAILED/AUDIO_EXPIRED; its claim is
* cleared, so a job that still holds it loses the compare-and-set. A note in
* another state keeps its status (FAILED keeps its own code).
*/
async function expireAudio(
tx: Prisma.TransactionClient,
filter: Prisma.Sql,
): Promise<number> {
const moved = await tx.$queryRaw<{ id: string }[]>(Prisma.sql`
WITH old AS (
SELECT c."id"
FROM "VoiceCaptureAudio" a
JOIN "VoiceCapture" c ON c."id" = a."captureId"
WHERE a."createdAt" < now() - interval '24 hours' ${filter}
ORDER BY c."id"
FOR UPDATE OF c
), gone AS (
DELETE FROM "VoiceCaptureAudio"
WHERE "captureId" IN (SELECT "id" FROM old)
)
UPDATE "VoiceCapture"
SET "status" = 'FAILED',
"lastErrorCode" = 'AUDIO_EXPIRED',
"claimedAt" = NULL,
"claimToken" = NULL,
"updatedAt" = now()
WHERE "id" IN (SELECT "id" FROM old)
AND "status" IN ('QUEUED', 'TRANSCRIBING')
RETURNING "id"`);
for (const { id } of moved) {
await recordVoiceCaptureFailed(tx, id, 'AUDIO_EXPIRED');
}
return moved.length;
}
/**
* Deletes audio, transcript and suggestions of the notes the `due`
* condition selects and marks them EXPIRED. Locks the rows first, so a
* confirm or discard in flight finishes before the wipe looks at the state.
*/
function wipeContent(
tx: Prisma.TransactionClient,
due: Prisma.Sql,
filter: Prisma.Sql,
): Promise<number> {
return tx.$executeRaw(Prisma.sql`
WITH due AS (
SELECT "id" FROM "VoiceCapture"
WHERE "contentDeletedAt" IS NULL AND ${OPEN_CONTENT} AND ${due} ${filter}
ORDER BY "id"
FOR UPDATE
), gone AS (
DELETE FROM "VoiceCaptureAudio"
WHERE "captureId" IN (SELECT "id" FROM due)
)
UPDATE "VoiceCapture"
SET "status" = 'EXPIRED',
"transcriptEnc" = NULL,
"suggestionEnc" = NULL,
"contentDeletedAt" = now(),
"claimedAt" = NULL,
"claimToken" = NULL,
"updatedAt" = now()
WHERE "id" IN (SELECT "id" FROM due)`);
}
/**
* The retention rules that must not wait for the daily cleanup: audio over
* 24 hours, content past its 7 days (contentExpiresAt), content of
* deactivated users. Pure SQL against the database clock, no key needed, so
* every CronJob run (every 2 minutes) can do it - the cleanup job calls the
* same function. Hooks and writes share one transaction.
*/
export function expireVoiceContent(
db: PrismaService,
options: VoiceRetentionOptions = {},
): Promise<VoiceRetentionCounts> {
const filter = options.filter
? Prisma.sql`AND ${options.filter}`
: Prisma.empty;
return db.$transaction(async (tx) => {
const audioExpired = await expireAudio(tx, filter);
const contentExpired = await wipeContent(
tx,
Prisma.sql`"contentExpiresAt" < now()`,
filter,
);
const inactiveOwnersWiped = await wipeContent(
tx,
Prisma.sql`"ownerUserId" IN (SELECT "id" FROM "User" WHERE "isActive" = false)`,
filter,
);
return { audioExpired, contentExpired, inactiveOwnersWiped };
});
}
/**
* Removes successful voice-transcribe runs older than `hours` in which every
* counter is 0 (no work): the CronJob runs every 2 minutes and would add
* 1440 rows a day. Runs with work, and failed runs, stay with the normal
* 90 days. `filter` narrows on "JobRun" columns (tests).
*/
export function deleteIdleVoiceRuns(
db: PrismaService,
hours: number,
filter?: Prisma.Sql,
): Promise<number> {
return db.$executeRaw(Prisma.sql`
DELETE FROM "JobRun"
WHERE "job" = 'voice-transcribe' AND "status" = 'SUCCEEDED'
AND "startedAt" < now() - (${hours} * interval '1 hour')
AND "result" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM jsonb_each_text("result"::jsonb) AS counter
WHERE counter.value <> '0')
${filter ? Prisma.sql`AND ${filter}` : Prisma.empty}`);
}
/** Deletes rows (and, by cascade, any audio) older than 90 days. */
export function deleteOldVoiceRows(db: PrismaService): Promise<number> {
return db.$executeRaw`
DELETE FROM "VoiceCapture"
WHERE "createdAt" < now() - (${VOICE_ROW_RETENTION_DAYS} * interval '1 day')`;
}
@@ -0,0 +1,194 @@
import type { Prisma } from '../../generated/prisma';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import type { NotificationsService } from '../notifications/notifications.service';
import { VOICE_ERROR_CODES } from './voice-error-codes';
import { VOICE_ERROR_TEXTS_DE } from './voice-error-texts';
import { VoiceEventHandlers } from './voice.handlers';
function readyEvent(): AnyDomainEventEnvelope<'VoiceCaptureReady'> {
return {
id: 'ev-1',
type: 'VoiceCaptureReady',
aggregateType: 'VoiceCapture',
aggregateId: 'vc-1',
payload: { captureId: 'vc-1', colonyId: 'col-1' },
actorUserId: null,
correlationId: 'corr-1',
occurredAt: new Date('2026-10-16T08:00:00.000Z'),
attempts: 0,
};
}
function failedEvent(
code: (typeof VOICE_ERROR_CODES)[number],
): AnyDomainEventEnvelope<'VoiceCaptureFailed'> {
return {
...readyEvent(),
type: 'VoiceCaptureFailed',
payload: { captureId: 'vc-1', code },
} as unknown as AnyDomainEventEnvelope<'VoiceCaptureFailed'>;
}
describe('VoiceEventHandlers', () => {
let tx: { voiceCapture: { findUnique: jest.Mock } };
let notifications: {
record: jest.Mock;
recordForPermissionOnce: jest.Mock;
};
let handlers: VoiceEventHandlers;
const asTx = () => tx as unknown as Prisma.TransactionClient;
beforeEach(() => {
tx = {
voiceCapture: {
findUnique: jest.fn().mockResolvedValue({
ownerUserId: 'u-1',
colonyId: 'col-1',
status: 'READY',
colony: { code: 'Volk 7' },
}),
},
};
notifications = {
record: jest.fn().mockResolvedValue({ id: 'n-1' }),
recordForPermissionOnce: jest.fn().mockResolvedValue([]),
};
handlers = new VoiceEventHandlers(
notifications as unknown as NotificationsService,
);
});
it('writes one HINWEIS for the owner only once per capture', async () => {
await handlers.notifyReady(readyEvent(), asTx());
expect(notifications.record).toHaveBeenCalledTimes(1);
expect(notifications.record).toHaveBeenCalledWith(
{
userId: 'u-1',
priority: 'HINWEIS',
category: 'SUGGESTION',
message: 'Sprachnotiz für Volk 7 ist fertig',
relatedType: 'VoiceCapture',
relatedId: 'vc-1',
link: '/inspections/new?colonyId=col-1&voice=vc-1',
requiredPermissions: ['voice-captures:use'],
dedupeKey: 'voice-ready:vc-1',
},
tx,
);
});
it('reads the colony name from the database, not from the payload', async () => {
await handlers.notifyReady(readyEvent(), asTx());
expect(tx.voiceCapture.findUnique).toHaveBeenCalledWith({
where: { id: 'vc-1' },
select: {
ownerUserId: true,
colonyId: true,
status: true,
colony: { select: { code: true } },
},
});
expect(Object.keys(readyEvent().payload).sort()).toEqual([
'captureId',
'colonyId',
]);
});
function withStatus(status: string): void {
tx.voiceCapture.findUnique.mockResolvedValue({
ownerUserId: 'u-1',
colonyId: 'col-1',
status,
colony: { code: 'Volk 7' },
});
}
it.each(['CONFIRMED', 'DISCARDED', 'EXPIRED'])(
'sends no ready notice for a %s note',
async (status) => {
withStatus(status);
await handlers.notifyReady(readyEvent(), asTx());
expect(notifications.record).not.toHaveBeenCalled();
},
);
it.each(['CONFIRMED', 'DISCARDED', 'TRANSCRIBED'])(
'sends no failure notice to the user for a %s note',
async (status) => {
withStatus(status);
await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx());
expect(notifications.record).not.toHaveBeenCalled();
},
);
it('still tells operators when a waiting note reports EXTRACT_DISABLED', async () => {
withStatus('TRANSCRIBED');
await handlers.notifyFailed(failedEvent('EXTRACT_DISABLED'), asTx());
expect(notifications.record).not.toHaveBeenCalled();
expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(1);
});
it('writes nothing when the capture is gone', async () => {
tx.voiceCapture.findUnique.mockResolvedValue(null);
await handlers.notifyReady(readyEvent(), asTx());
await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx());
expect(notifications.record).not.toHaveBeenCalled();
});
it('the failure text names the next step per code', async () => {
withStatus('FAILED');
await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx());
const [input, passedTx] = notifications.record.mock.calls[0] as [
Record<string, unknown>,
unknown,
];
expect(passedTx).toBe(tx);
expect(input).toMatchObject({
userId: 'u-1',
priority: 'WARNUNG',
category: 'SUGGESTION',
message:
'Sprachnotiz für Volk 7 konnte nicht ausgewertet werden. Es war nichts zu verstehen. Bitte noch einmal aufnehmen.',
link: '/voice',
requiredPermissions: ['voice-captures:use'],
dedupeKey: 'voice-failed:vc-1:NO_SPEECH',
});
});
it('has a non-empty text for every error code', () => {
for (const code of VOICE_ERROR_CODES) {
expect(VOICE_ERROR_TEXTS_DE[code].length).toBeGreaterThan(0);
}
});
it('tells operators only when the evaluation is off, without a user in the text', async () => {
withStatus('FAILED');
await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx());
expect(notifications.recordForPermissionOnce).not.toHaveBeenCalled();
await handlers.notifyFailed(failedEvent('EXTRACT_DISABLED'), asTx());
expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(1);
const [permission, input] = notifications.recordForPermissionOnce.mock
.calls[0] as [string, { message: string }];
expect(permission).toBe('system:manage');
expect(input.message).not.toMatch(/Volk|vc-1|u-1/);
});
it('registers both handlers under stable ids', () => {
const ready = Reflect.get(VoiceEventHandlers.prototype, 'notifyReady');
const failed = Reflect.get(VoiceEventHandlers.prototype, 'notifyFailed');
expect(domainEventHandlerMetadata(ready as object)).toEqual([
{ types: ['VoiceCaptureReady'], handlerId: 'voice.notify-ready' },
]);
expect(domainEventHandlerMetadata(failed as object)).toEqual([
{ types: ['VoiceCaptureFailed'], handlerId: 'voice.notify-failed' },
]);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { NotificationsService } from '../notifications/notifications.service';
import { VOICE_ERROR_TEXTS_DE } from './voice-error-texts';
/**
* Tells the owner of a voice note that it can be reviewed or has failed.
* Pure DB writer through the delivery transaction; the colony name and the
* owner come from the database, the payload carries ids only. Quiet when
* the note is gone; an owner without the voice right gets nothing (the
* notification service skips recipients who lack a required permission).
*/
@Injectable()
export class VoiceEventHandlers {
constructor(private readonly notifications: NotificationsService) {}
@OnDomainEvent('VoiceCaptureReady', 'voice.notify-ready')
async notifyReady(
event: AnyDomainEventEnvelope<'VoiceCaptureReady'>,
tx: Prisma.TransactionClient,
): Promise<void> {
const { captureId } = event.payload;
const capture = await this.findCapture(tx, captureId);
// Confirmed or discarded meanwhile: the link would lead nowhere.
if (capture === null || capture.status !== 'READY') {
return;
}
await this.notifications.record(
{
userId: capture.ownerUserId,
priority: 'HINWEIS',
category: 'SUGGESTION',
message: `Sprachnotiz für ${capture.colony.code} ist fertig`,
relatedType: 'VoiceCapture',
relatedId: captureId,
link: `/inspections/new?colonyId=${capture.colonyId}&voice=${captureId}`,
requiredPermissions: ['voice-captures:use'],
dedupeKey: `voice-ready:${captureId}`,
},
tx,
);
}
@OnDomainEvent('VoiceCaptureFailed', 'voice.notify-failed')
async notifyFailed(
event: AnyDomainEventEnvelope<'VoiceCaptureFailed'>,
tx: Prisma.TransactionClient,
): Promise<void> {
const { captureId, code } = event.payload;
const capture = await this.findCapture(tx, captureId);
if (capture === null) {
return;
}
if (code === 'EXTRACT_DISABLED') {
await this.notifyOperators(tx);
}
// Only a note that ended FAILED gets the user notice (a waiting note
// is not a failure; a confirmed or discarded one has no link target).
if (capture.status !== 'FAILED') {
return;
}
await this.notifications.record(
{
userId: capture.ownerUserId,
priority: 'WARNUNG',
category: 'SUGGESTION',
message: `Sprachnotiz für ${capture.colony.code} konnte nicht ausgewertet werden. ${VOICE_ERROR_TEXTS_DE[code]}`,
relatedType: 'VoiceCapture',
relatedId: captureId,
link: '/voice',
requiredPermissions: ['voice-captures:use'],
dedupeKey: `voice-failed:${captureId}:${code}`,
},
tx,
);
}
// No user, colony or capture in the text: operators only learn that the
// evaluation is off. One unread notice at a time.
private async notifyOperators(tx: Prisma.TransactionClient): Promise<void> {
await this.notifications.recordForPermissionOnce(
'system:manage',
{
priority: 'HINWEIS',
category: 'SYSTEM',
message:
'Die Auswertung von Sprachnotizen ist ausgeschaltet. Sprachnotizen bleiben nach dem Abschreiben stehen.',
relatedType: 'VoiceExtraction',
relatedId: 'disabled',
},
tx,
);
}
private findCapture(tx: Prisma.TransactionClient, captureId: string) {
return tx.voiceCapture.findUnique({
where: { id: captureId },
select: {
ownerUserId: true,
colonyId: true,
status: true,
colony: { select: { code: true } },
},
});
}
}
+23
View File
@@ -0,0 +1,23 @@
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';
import { VoiceHandlersModule } from './voice-handlers.module';
/**
* 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({
imports: [VoiceHandlersModule],
controllers: [VoiceCapturesController],
providers: [
VoiceCapturesService,
VoiceCapturesStatsService,
InspectionCreateGuard,
],
exports: [VoiceCapturesService],
})
export class VoiceModule {}
@@ -87,7 +87,7 @@ describe('WebhookEventHandlers', () => {
}); });
}); });
it('none of the five new events is forwarded to webhooks', async () => { it('none of the herd and voice events is forwarded to webhooks', async () => {
const herdEvents = [ const herdEvents = [
['ColonyCreated', { apiaryId: 'a1' }], ['ColonyCreated', { apiaryId: 'a1' }],
['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }], ['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }],
@@ -97,6 +97,8 @@ describe('WebhookEventHandlers', () => {
'HiveChanged', 'HiveChanged',
{ colonyId: 'c1', version: 2, source: 'CARD', inspectionId: null }, { colonyId: 'c1', version: 2, source: 'CARD', inspectionId: null },
], ],
['VoiceCaptureReady', { captureId: 'v1', colonyId: 'c1' }],
['VoiceCaptureFailed', { captureId: 'v1', code: 'NO_SPEECH' }],
] as const; ] as const;
for (const [type, payload] of herdEvents) { for (const [type, payload] of herdEvents) {
expect(Object.keys(DOMAIN_EVENT_WEBHOOKS)).not.toContain(type); expect(Object.keys(DOMAIN_EVENT_WEBHOOKS)).not.toContain(type);
@@ -14,6 +14,7 @@ import { RemindersModule } from '../reminders/reminders.module';
import { SuggestionsModule } from '../suggestions/suggestions.module'; import { SuggestionsModule } from '../suggestions/suggestions.module';
import { TasksModule } from '../tasks/tasks.module'; import { TasksModule } from '../tasks/tasks.module';
import { TelegramModule } from '../telegram/telegram.module'; import { TelegramModule } from '../telegram/telegram.module';
import { VoiceHandlersModule } from '../voice/voice-handlers.module';
import { WebhooksModule } from '../webhooks/webhooks.module'; import { WebhooksModule } from '../webhooks/webhooks.module';
import { WithdrawalModule } from '../withdrawal/withdrawal.module'; import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { WorkerHealthServer } from './worker-health-server.service'; import { WorkerHealthServer } from './worker-health-server.service';
@@ -45,6 +46,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
ComplianceModule, ComplianceModule,
RemindersModule, RemindersModule,
TelegramModule, TelegramModule,
VoiceHandlersModule,
WorkerRuntimeModule, WorkerRuntimeModule,
], ],
providers: [WorkerHealthServer], providers: [WorkerHealthServer],
+131 -11
View File
@@ -5,6 +5,7 @@ import * as bcrypt from 'bcryptjs';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
import { sealTranscript } from '../src/voice/core/transcript';
import { createTestApp } from './utils/create-test-app'; import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api'; import { login } from './utils/api';
@@ -67,6 +68,9 @@ describe('Search and filters (e2e)', () => {
}); });
afterAll(async () => { afterAll(async () => {
await prisma.voiceCapture.deleteMany({
where: { ownerUserId: readonlyId },
});
await prisma.colony.deleteMany({ await prisma.colony.deleteMany({
where: { id: { in: [colonyId, dissolvedColonyId] } }, where: { id: { in: [colonyId, dissolvedColonyId] } },
}); });
@@ -88,6 +92,48 @@ describe('Search and filters (e2e)', () => {
).toBe(true); ).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 () => { it('filters colonies by status', async () => {
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
.get(`/api/v1/colonies?status=DISSOLVED&apiaryId=${apiaryId}`) .get(`/api/v1/colonies?status=DISSOLVED&apiaryId=${apiaryId}`)
@@ -101,29 +147,103 @@ describe('Search and filters (e2e)', () => {
describe('Voice capture content guard', () => { describe('Voice capture content guard', () => {
// VoiceCapture holds personal data that is deleted after at most 7 days. It // VoiceCapture holds personal data that is deleted after at most 7 days. It
// must never be reachable through search or any export/report builder. // must never be reachable through search or any export/report builder.
const sourceRoots = ['search', 'export'].map((dir) => const srcRoot = path.resolve(__dirname, '../src');
path.resolve(__dirname, '../src', dir),
); // 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[] { function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) return sourceFiles(full); 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', () => { function mentionsVoiceCapture(file: string): boolean {
const files = sourceRoots.flatMap(sourceFiles); 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); expect(files.length).toBeGreaterThan(0);
for (const file of files) { for (const file of files) {
expect({ expect({
file: path.basename(file), file: relative(file),
mentions: /voiceCapture|VoiceCapture/.test( mentions: mentionsVoiceCapture(file),
fs.readFileSync(file, 'utf8'), }).toEqual({
), file: relative(file),
}).toEqual({ file: path.basename(file), mentions: false }); 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);
});
}); });
+2 -1
View File
@@ -3,6 +3,7 @@ import * as bcrypt from 'bcryptjs';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { DomainEventDispatcher } from '../src/domain-events/domain-event-dispatcher'; import { DomainEventDispatcher } from '../src/domain-events/domain-event-dispatcher';
import { JOB_NAMES } from '../src/jobs/job-names';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app'; import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api'; import { login } from './utils/api';
@@ -104,7 +105,7 @@ describe('System API (e2e)', () => {
.set(auth(adminToken)) .set(auth(adminToken))
.expect(200); .expect(200);
const body = response.body as OverviewBody; const body = response.body as OverviewBody;
expect(body.jobs).toHaveLength(5); expect(body.jobs).toHaveLength(JOB_NAMES.length);
expect(typeof body.domainEvents.failed).toBe('number'); expect(typeof body.domainEvents.failed).toBe('number');
await request(server()) await request(server())
@@ -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<App>;
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<string, string> = {};
const userIds: Record<string, string> = {};
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<void> {
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<string> {
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();
});
});
});
@@ -0,0 +1,364 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { berlinToday } from '../src/common/dates';
import { AuditLogService } from '../src/audit/audit-log.service';
import { PrismaService } from '../src/prisma/prisma.service';
import {
sealVoiceBytes,
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 SPOKEN =
'Koenigin gesehen, Stifte da, vier Brutwaben, ruhig, Futter niedrig, Honigraum kommt naechste Woche drauf';
const WEBM = Buffer.concat([
Buffer.from([0x1a, 0x45, 0xdf, 0xa3]),
Buffer.from('opus-bytes-of-a-spoken-note'),
]);
interface Body {
id?: string;
code?: string;
}
/**
* Parallel-safe: own users, colonies and apiary found by the suffix; every
* query is scoped to them and afterAll deletes by suffix.
*/
describe('Voice capture confirmation in POST /inspections (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const emailOf = (name: string) => `voice-conf-${name}-${suffix}@example.com`;
const tokens: Record<string, string> = {};
const userIds: Record<string, string> = {};
let apiaryId: string;
let colonyId: string;
let otherColonyId: string;
let keyCounter = 0;
const nextKey = () =>
`voice-conf-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`;
const as = (name: string) => ({ Authorization: `Bearer ${tokens[name]}` });
const colonyIds = () => [colonyId, otherColonyId];
async function createUser(name: string): Promise<void> {
const user = await prisma.user.create({
data: {
email: emailOf(name),
passwordHash: await bcrypt.hash(password, 12),
name: `Voice Conf ${name}`,
role: { connect: { key: 'BEEKEEPER' } },
},
});
userIds[name] = user.id;
tokens[name] = (await login(app, emailOf(name), password)).accessToken;
}
/** A capture with sealed content and an audio row, as the pipeline leaves it. */
async function seedCapture(
owner: string,
status: string,
options: { colony?: string } = {},
): Promise<string> {
const capture = await prisma.voiceCapture.create({
data: {
ownerUserId: userIds[owner],
colonyId: options.colony ?? colonyId,
inspectionDate: new Date(`${berlinToday()}T00:00:00.000Z`),
source: 'AUDIO',
status: status as never,
audioMime: 'audio/webm',
audioBytes: WEBM.length,
durationSec: 30,
contentExpiresAt: new Date(Date.now() + 86_400_000),
claimedAt:
status === 'TRANSCRIBING' || status === 'EXTRACTING'
? new Date()
: null,
claimToken:
status === 'TRANSCRIBING' || status === 'EXTRACTING'
? 'running-token'
: null,
},
});
await prisma.voiceCapture.update({
where: { id: capture.id },
data: {
transcriptEnc: sealTranscript(capture.id, { v: 1, text: SPOKEN }),
suggestionEnc: sealVoiceText(
JSON.stringify({ suggestions: [], unclear: [], dropped: 0 }),
voiceAad(capture.id, 'suggestion'),
),
},
});
await prisma.voiceCaptureAudio.create({
data: {
captureId: capture.id,
dataEnc: new Uint8Array(
sealVoiceBytes(WEBM, voiceAad(capture.id, 'audio')),
),
},
});
return capture.id;
}
function createInspection(
who: string,
body: Record<string, unknown>,
key = nextKey(),
) {
return request(app.getHttpServer())
.post('/api/v1/inspections')
.set(as(who))
.set('Idempotency-Key', key)
.send({ colonyId, date: berlinToday(), ...body });
}
const inspectionsOfColony = () =>
prisma.inspection.findMany({ where: { colonyId } });
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
await createUser('alice');
await createUser('bob');
apiaryId = (
await prisma.apiary.create({
data: { name: `Voice Conf Stand ${suffix}` },
})
).id;
colonyId = (
await prisma.colony.create({
data: {
code: `VCONF-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
otherColonyId = (
await prisma.colony.create({
data: {
code: `VCONF-B-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
});
afterAll(async () => {
const ids = Object.values(userIds);
const inspectionIds = (
await prisma.inspection.findMany({
where: { colonyId: { in: colonyIds() } },
select: { id: true },
})
).map((inspection) => inspection.id);
await prisma.voiceCapture.deleteMany({
where: { ownerUserId: { in: ids } },
});
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'Inspection',
aggregateId: { in: inspectionIds },
},
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds() } },
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds() } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: ids } } });
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.colony.deleteMany({ where: { id: { in: colonyIds() } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await app.close();
});
beforeEach(async () => {
await prisma.voiceCapture.deleteMany({
where: { ownerUserId: { in: Object.values(userIds) } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds() } },
});
});
it('confirms the voice capture and wipes its content in the same transaction', async () => {
const id = await seedCapture('alice', 'READY');
const response = await createInspection('alice', {
voiceCaptureId: id,
colonyStrength: 12,
}).expect(201);
const inspectionId = (response.body as Body).id;
expect(inspectionId).toBeDefined();
expect(response.body).not.toHaveProperty('voiceCaptureId');
const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id } });
expect(row).toMatchObject({
status: 'CONFIRMED',
inspectionId,
transcriptEnc: null,
suggestionEnc: null,
claimToken: null,
claimedAt: null,
});
expect(row.contentDeletedAt).not.toBeNull();
expect(
await prisma.voiceCaptureAudio.count({ where: { captureId: id } }),
).toBe(0);
const audit = await prisma.auditLog.findMany({
where: { entityType: 'VoiceCapture', entityId: id },
});
expect(audit).toHaveLength(1);
expect(JSON.stringify(audit)).not.toContain('Koenigin');
});
it.each(['QUEUED', 'TRANSCRIBED', 'FAILED'])(
'confirms a %s capture too',
async (status) => {
const id = await seedCapture('alice', status);
await createInspection('alice', { voiceCaptureId: id }).expect(201);
const row = await prisma.voiceCapture.findUniqueOrThrow({
where: { id },
});
expect(row.status).toBe('CONFIRMED');
expect(row.transcriptEnc).toBeNull();
},
);
it.each(['TRANSCRIBING', 'EXTRACTING'])(
'takes a %s capture away from a running job: its claim is gone',
async (status) => {
const id = await seedCapture('alice', status);
await createInspection('alice', { voiceCaptureId: id }).expect(201);
const row = await prisma.voiceCapture.findUniqueOrThrow({
where: { id },
});
expect(row).toMatchObject({ status: 'CONFIRMED', claimToken: null });
},
);
it('answers 409 VOICE_CAPTURE_GONE for a discarded, expired or foreign capture and writes no inspection', async () => {
const discarded = await seedCapture('alice', 'DISCARDED');
const expired = await seedCapture('alice', 'EXPIRED');
const foreign = await seedCapture('bob', 'READY');
for (const id of [discarded, expired, foreign, 'does-not-exist']) {
const response = await createInspection('alice', {
voiceCaptureId: id,
}).expect(409);
expect((response.body as Body).code).toBe('VOICE_CAPTURE_GONE');
}
expect(await inspectionsOfColony()).toHaveLength(0);
const untouched = await prisma.voiceCapture.findUniqueOrThrow({
where: { id: foreign },
});
expect(untouched.status).toBe('READY');
expect(untouched.transcriptEnc).not.toBeNull();
});
it('answers 409 when the capture belongs to another colony and keeps its content', async () => {
const id = await seedCapture('alice', 'READY', { colony: otherColonyId });
const response = await createInspection('alice', {
voiceCaptureId: id,
}).expect(409);
expect((response.body as Body).code).toBe('VOICE_CAPTURE_COLONY_MISMATCH');
expect(await inspectionsOfColony()).toHaveLength(0);
const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id } });
expect(row.status).toBe('READY');
expect(row.transcriptEnc).not.toBeNull();
});
it('rolls back inspection and confirmation when the transaction fails after the confirmation ran', async () => {
const id = await seedCapture('alice', 'READY');
const auditLog: AuditLogService = app.get(AuditLogService);
const original = auditLog.record.bind(
auditLog,
) as AuditLogService['record'];
// The confirmation's own audit entry is its last write: failing there
// proves the earlier UPDATE and audio DELETE are rolled back with it.
const spy = jest
.spyOn(auditLog, 'record')
.mockImplementation((input, tx) => {
if (input.entityType === 'VoiceCapture') {
return Promise.reject(new Error('boom after confirm'));
}
return original(input, tx);
});
try {
const response = await createInspection('alice', {
voiceCaptureId: id,
});
expect(response.status).toBe(500);
} finally {
spy.mockRestore();
}
expect(await inspectionsOfColony()).toHaveLength(0);
const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id } });
expect(row).toMatchObject({ status: 'READY', inspectionId: null });
expect(row.transcriptEnc).not.toBeNull();
expect(row.contentDeletedAt).toBeNull();
expect(
await prisma.voiceCaptureAudio.count({ where: { captureId: id } }),
).toBe(1);
});
it('rejects a malformed voiceCaptureId with 400', async () => {
await createInspection('alice', { voiceCaptureId: 'a b/c' }).expect(400);
await createInspection('alice', { voiceCaptureId: 'x'.repeat(41) }).expect(
400,
);
});
it('replays the stored answer for the same Idempotency-Key without failing on the confirmed capture', async () => {
const id = await seedCapture('alice', 'READY');
const key = nextKey();
const first = await createInspection(
'alice',
{ voiceCaptureId: id },
key,
).expect(201);
const second = await createInspection(
'alice',
{ voiceCaptureId: id },
key,
).expect(201);
expect((second.body as Body).id).toBe((first.body as Body).id);
expect(await inspectionsOfColony()).toHaveLength(1);
});
it('answers 409 for a second inspection with a new key on the same capture', async () => {
const id = await seedCapture('alice', 'READY');
await createInspection('alice', { voiceCaptureId: id }).expect(201);
const response = await createInspection('alice', {
voiceCaptureId: id,
}).expect(409);
expect((response.body as Body).code).toBe('VOICE_CAPTURE_GONE');
expect(await inspectionsOfColony()).toHaveLength(1);
});
it('creates an inspection without voiceCaptureId exactly as before and leaves other captures alone', async () => {
const id = await seedCapture('alice', 'READY');
await createInspection('alice', { colonyStrength: 10 }).expect(201);
expect(await inspectionsOfColony()).toHaveLength(1);
const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id } });
expect(row.status).toBe('READY');
});
});
@@ -0,0 +1,530 @@
import { Logger } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Client } from 'pg';
import { Prisma } from '../generated/prisma';
import { WAKE_CHANNELS } from '../src/common/worker-wakeup';
import { VoiceTranscribeJob } from '../src/jobs/voice-transcribe.job';
import { runCli } from '../src/jobs/run-cli';
import { dbSessionConfig } from '../src/prisma/db-session';
import { PrismaService } from '../src/prisma/prisma.service';
import { openTranscript } from '../src/voice/core/transcript';
import { sealVoiceBytes, voiceAad } from '../src/voice/core/voice-crypto';
import {
deleteIdleVoiceRuns,
deleteOldVoiceRows,
} from '../src/voice/voice-retention';
const HOUR_MS = 3_600_000;
const SECRET_TEXT = 'Geheimer Befund Volk sehr unruhig Sperrbrut sichtbar';
/**
* The job halves are called directly with a filter on this file's own
* users and a private work dir, so parallel e2e files sharing the database
* are never touched. Everything is created with a unique suffix and
* removed by it in afterAll.
*/
describe('voice-transcribe job (e2e)', () => {
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const startedAt = new Date(Date.now() - 1000);
let prisma: PrismaService;
let job: VoiceTranscribeJob;
let workDir: string;
let roleId: string;
let userId: string;
let apiaryId: string;
let colonyId: string;
const extraUserIds: string[] = [];
const mine = Prisma.sql`"ownerUserId" IN (SELECT "id" FROM "User" WHERE "email" LIKE ${`%${suffix}%`})`;
const fetch = () => job.runFetchPhase({ filter: mine });
const row = (id: string) =>
prisma.voiceCapture.findUniqueOrThrow({ where: { id } });
const failedEvents = (id: string) =>
prisma.domainEvent.count({
where: {
aggregateType: 'VoiceCapture',
aggregateId: id,
type: 'VoiceCaptureFailed',
},
});
const audioCount = (id: string) =>
prisma.voiceCaptureAudio.count({ where: { captureId: id } });
async function newUser(label: string, isActive = true): Promise<string> {
const user = await prisma.user.create({
data: {
email: `voice-tr-${label}-${suffix}@example.com`,
passwordHash: await bcrypt.hash('SuperSecret123!', 4),
name: `Voice transcribe ${label}`,
roleId,
isActive,
},
});
extraUserIds.push(user.id);
return user.id;
}
async function seed(
data: Partial<Prisma.VoiceCaptureUncheckedCreateInput> = {},
audio: Buffer | null = Buffer.from('OggS-fake-audio-bytes'),
audioCreatedAt?: Date,
): Promise<string> {
const created = await prisma.voiceCapture.create({
data: {
ownerUserId: userId,
colonyId,
inspectionDate: new Date('2026-10-16'),
source: 'AUDIO',
audioMime: 'audio/ogg',
durationSec: 30,
contentExpiresAt: new Date(Date.now() + 7 * 24 * HOUR_MS),
...data,
},
});
if (audio) {
await prisma.voiceCaptureAudio.create({
data: {
captureId: created.id,
dataEnc: Uint8Array.from(
sealVoiceBytes(audio, voiceAad(created.id, 'audio')),
),
...(audioCreatedAt ? { createdAt: audioCreatedAt } : {}),
},
});
}
return created.id;
}
async function whisperOut(id: string, over: Record<string, unknown> = {}) {
await writeFile(
path.join(workDir, 'out', `${id}.json`),
JSON.stringify({
v: 1,
captureId: id,
text: SECRET_TEXT,
durationSec: 21.4,
language: 'de',
languageProbability: 0.97,
segments: [{ start: 0, end: 4, text: SECRET_TEXT }],
...over,
}),
);
}
beforeAll(async () => {
workDir = await mkdtemp(path.join(tmpdir(), 'voice-tr-e2e-'));
process.env.VOICE_WORK_DIR = workDir;
prisma = new PrismaService();
await prisma.$connect();
job = new VoiceTranscribeJob(prisma);
roleId = (
await prisma.role.create({
data: { key: `VOICE_TR_${suffix}`, name: `Voice tr ${suffix}` },
})
).id;
userId = await newUser('main');
apiaryId = (
await prisma.apiary.create({ data: { name: `Voice tr ${suffix}` } })
).id;
colonyId = (
await prisma.colony.create({
data: {
code: `VTR-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
});
beforeEach(async () => {
const stale = await prisma.voiceCapture.findMany({
where: { ownerUserId: { in: [userId, ...extraUserIds] } },
select: { id: true },
});
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'VoiceCapture',
aggregateId: { in: stale.map((c) => c.id) },
},
});
await prisma.voiceCapture.deleteMany({
where: { ownerUserId: { in: [userId, ...extraUserIds] } },
});
});
afterAll(async () => {
const users = await prisma.user.findMany({
where: { email: { contains: suffix } },
select: { id: true },
});
const captures = await prisma.voiceCapture.findMany({
where: { ownerUserId: { in: users.map((u) => u.id) } },
select: { id: true },
});
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'VoiceCapture',
aggregateId: { in: captures.map((c) => c.id) },
},
});
await prisma.voiceCapture.deleteMany({
where: { ownerUserId: { in: users.map((u) => u.id) } },
});
await prisma.user.deleteMany({
where: { id: { in: users.map((u) => u.id) } },
});
await prisma.role.deleteMany({ where: { id: roleId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.jobRun.deleteMany({
where: { job: 'voice-transcribe', startedAt: { gte: startedAt } },
});
delete process.env.VOICE_WORK_DIR;
await rm(workDir, { recursive: true, force: true });
await prisma.$disconnect();
});
it('fetch writes one file per claimed capture and a manifest', async () => {
const a = await seed({}, Buffer.from('OggS-AAA'));
const b = await seed({}, Buffer.from('OggS-BBB'));
const result = await fetch();
expect(result).toMatchObject({ claimed: 2, written: 2 });
expect((await readdir(path.join(workDir, 'in'))).sort()).toEqual(
[`${a}.ogg`, `${b}.ogg`, 'manifest.json'].sort(),
);
expect(await readFile(path.join(workDir, 'in', `${a}.ogg`))).toEqual(
Buffer.from('OggS-AAA'),
);
expect((await row(a)).status).toBe('TRANSCRIBING');
});
it('fetch ends quickly without work and writes no manifest', async () => {
const start = Date.now();
const result = await fetch();
expect(result).toMatchObject({ claimed: 0, written: 0 });
expect(Date.now() - start).toBeLessThan(5000);
expect(await readdir(path.join(workDir, 'in'))).toEqual([]);
});
it('fetch counts a missing audio row as a failed attempt', async () => {
const id = await seed({}, null);
const result = await fetch();
expect(result).toMatchObject({ claimed: 1, written: 0 });
const after = await row(id);
expect(after.status).toBe('QUEUED');
expect(after.attempts).toBe(1);
expect(after.lastErrorCode).toBe('TRANSCRIBE_FAILED');
});
it('ingest stores TRANSCRIBED, deletes the audio and wakes the worker in one transaction', async () => {
const id = await seed();
await fetch();
await whisperOut(id);
const listener = new Client(dbSessionConfig(process.env.DATABASE_URL));
await listener.connect();
const woken = new Promise<string>((resolve) =>
listener.on('notification', (message) => resolve(message.channel)),
);
await listener.query(`LISTEN ${WAKE_CHANNELS.voiceExtraction}`);
try {
const result = await job.ingest();
expect(result).toMatchObject({ ingested: 1, failed: 0, lost: 0 });
expect(await woken).toBe(WAKE_CHANNELS.voiceExtraction);
} finally {
await listener.end();
}
const after = await row(id);
expect(after.status).toBe('TRANSCRIBED');
expect(after.attempts).toBe(0);
expect(after.claimToken).toBeNull();
expect(after.durationSec).toBe(21);
expect(await audioCount(id)).toBe(0);
expect(openTranscript(id, after.transcriptEnc as string).text).toBe(
SECRET_TEXT,
);
expect(after.transcriptEnc).not.toContain('Geheimer');
expect(await readdir(path.join(workDir, 'in'))).toEqual([]);
expect(await readdir(path.join(workDir, 'out'))).toEqual([]);
});
it('ingest does nothing when the claim token no longer matches', async () => {
const id = await seed();
await fetch();
await whisperOut(id);
await prisma.voiceCapture.update({
where: { id },
data: { claimToken: 'another-token' },
});
const result = await job.ingest();
expect(result).toMatchObject({ ingested: 0, lost: 1 });
const after = await row(id);
expect(after.status).toBe('TRANSCRIBING');
expect(after.transcriptEnc).toBeNull();
expect(await audioCount(id)).toBe(1);
});
it('a confirmed capture is not overwritten by a late ingest', async () => {
const id = await seed();
await fetch();
await whisperOut(id);
await prisma.voiceCapture.update({
where: { id },
data: { status: 'DISCARDED', claimToken: null, claimedAt: null },
});
const result = await job.ingest();
expect(result).toMatchObject({ ingested: 0, lost: 1 });
const after = await row(id);
expect(after.status).toBe('DISCARDED');
expect(after.transcriptEnc).toBeNull();
});
it.each(['NO_SPEECH', 'TOO_LONG'] as const)(
'%s ends FAILED with the code and deletes the audio',
async (code) => {
const id = await seed();
await fetch();
await whisperOut(id, { error: code, text: '', segments: [] });
const result = await job.ingest();
expect(result).toMatchObject({ failed: 1 });
const after = await row(id);
expect(after.status).toBe('FAILED');
expect(after.lastErrorCode).toBe(code);
expect(await audioCount(id)).toBe(0);
expect(await failedEvents(id)).toBe(1);
},
);
it('a missing result counts as a failed attempt', async () => {
const id = await seed();
await fetch();
const result = await job.ingest();
expect(result).toMatchObject({ retried: 1 });
const after = await row(id);
expect(after.status).toBe('QUEUED');
expect(after.attempts).toBe(1);
expect(after.lastErrorCode).toBe('TRANSCRIBE_FAILED');
expect(await audioCount(id)).toBe(1);
});
it('the third failed attempt ends FAILED with TRANSCRIBE_FAILED', async () => {
const id = await seed({ attempts: 2 });
await fetch();
await whisperOut(id, { error: 'DECODE_FAILED', text: '', segments: [] });
const result = await job.ingest();
expect(result).toMatchObject({ failed: 1 });
expect((await row(id)).status).toBe('FAILED');
expect((await row(id)).lastErrorCode).toBe('TRANSCRIBE_FAILED');
expect(await failedEvents(id)).toBe(1);
});
it('writes no failure event for a retry or a lost claim', async () => {
const retried = await seed();
const lost = await seed();
await fetch();
await whisperOut(lost);
await prisma.voiceCapture.update({
where: { id: lost },
data: { claimToken: 'another-token' },
});
await job.ingest();
expect((await row(retried)).status).toBe('QUEUED');
expect(await failedEvents(retried)).toBe(0);
expect(await failedEvents(lost)).toBe(0);
});
it('writes one failure event when a stale claim reaches its last attempt', async () => {
const id = await seed({
status: 'TRANSCRIBING',
attempts: 2,
claimToken: 'old',
claimedAt: new Date(Date.now() - 40 * 60_000),
});
await fetch();
await fetch();
const after = await row(id);
expect(after.status).toBe('FAILED');
expect(after.lastErrorCode).toBe('TRANSCRIBE_FAILED');
expect(await failedEvents(id)).toBe(1);
});
it('expires audio after 24 h within one CronJob run', async () => {
const old = new Date(Date.now() - 25 * HOUR_MS);
const id = await seed({ createdAt: old }, undefined, old);
const result = await fetch();
expect(result).toMatchObject({ audioExpired: 1, claimed: 0 });
const after = await row(id);
expect(after.status).toBe('FAILED');
expect(after.lastErrorCode).toBe('AUDIO_EXPIRED');
expect(await audioCount(id)).toBe(0);
expect(await failedEvents(id)).toBe(1);
});
it('wipes content after 7 days and marks EXPIRED', async () => {
const id = await seed(
{
status: 'READY',
transcriptEnc: 'x',
suggestionEnc: 'y',
contentExpiresAt: new Date(Date.now() - HOUR_MS),
},
null,
);
const result = await fetch();
expect(result).toMatchObject({ contentExpired: 1 });
const after = await row(id);
expect(after.status).toBe('EXPIRED');
expect(after.transcriptEnc).toBeNull();
expect(after.suggestionEnc).toBeNull();
expect(after.contentDeletedAt).not.toBeNull();
});
it('wipes content of deactivated users', async () => {
const inactive = await newUser('inactive', false);
const id = await seed(
{ ownerUserId: inactive, status: 'TRANSCRIBED', transcriptEnc: 'x' },
null,
);
const keep = await seed(
{ status: 'TRANSCRIBED', transcriptEnc: 'x' },
null,
);
const result = await fetch();
expect(result).toMatchObject({ inactiveOwnersWiped: 1 });
expect((await row(id)).transcriptEnc).toBeNull();
expect((await row(id)).status).toBe('EXPIRED');
expect((await row(keep)).transcriptEnc).toBe('x');
});
it('releases a stale claim on the next fetch', async () => {
const id = await seed({
status: 'TRANSCRIBING',
claimToken: 'old',
claimedAt: new Date(Date.now() - 40 * 60_000),
});
await fetch();
const after = await row(id);
expect(after.attempts).toBe(1);
expect(after.status).toBe('TRANSCRIBING');
expect(after.claimToken).not.toBe('old');
});
it('deletes rows after 90 days', async () => {
const id = await seed(
{ createdAt: new Date(Date.now() - 91 * 24 * HOUR_MS) },
null,
);
expect(await deleteOldVoiceRows(prisma)).toBeGreaterThanOrEqual(1);
expect(await prisma.voiceCapture.count({ where: { id } })).toBe(0);
});
it('prunes only old successful voice-transcribe runs without any work', async () => {
const old = new Date(Date.now() - 48 * HOUR_MS);
const make = (data: Omit<Prisma.JobRunUncheckedCreateInput, 'job'>) =>
prisma.jobRun
.create({ data: { job: 'voice-transcribe', startedAt: old, ...data } })
.then((r) => r.id);
const idle = await make({
status: 'SUCCEEDED',
result: { claimed: 0, written: 0 },
});
const busy = await make({
status: 'SUCCEEDED',
result: { claimed: 2, written: 0 },
});
const failed = await make({ status: 'FAILED', result: { claimed: 0 } });
const recent = await prisma.jobRun
.create({
data: {
job: 'voice-transcribe',
status: 'SUCCEEDED',
result: { claimed: 0 },
},
})
.then((r) => r.id);
const ids = [idle, busy, failed, recent];
const removed = await deleteIdleVoiceRuns(
prisma,
24,
Prisma.sql`"id" IN (${Prisma.join(ids)})`,
);
expect(removed).toBe(1);
const left = await prisma.jobRun.findMany({ where: { id: { in: ids } } });
await prisma.jobRun.deleteMany({ where: { id: { in: ids } } });
expect(left.map((r) => r.id).sort()).toEqual([busy, failed, recent].sort());
});
it('a measured duration over 240 s ends TOO_LONG instead of being clamped', async () => {
const id = await seed();
await fetch();
await whisperOut(id, { durationSec: 240.6 });
await job.ingest();
expect((await row(id)).status).toBe('FAILED');
expect((await row(id)).lastErrorCode).toBe('TOO_LONG');
expect(await audioCount(id)).toBe(0);
});
it('ingest never logs the transcript', async () => {
const spies = (['log', 'warn', 'error', 'debug', 'verbose'] as const).map(
(level) => jest.spyOn(Logger.prototype, level).mockImplementation(),
);
const id = await seed();
await fetch();
await whisperOut(id);
try {
await job.ingest();
const logged = JSON.stringify(spies.flatMap((s) => s.mock.calls));
expect(logged).not.toContain('Geheimer');
expect(logged).not.toContain('Sperrbrut');
} finally {
spies.forEach((s) => s.mockRestore());
}
});
it('runs through the CLI: ingest without a manifest succeeds and is recorded', async () => {
expect(await runCli(['voice-transcribe', '--phase=ingest'])).toBe(0);
expect(await runCli(['voice-transcribe'])).toBe(1);
const runs = await prisma.jobRun.findMany({
where: { job: 'voice-transcribe', startedAt: { gte: startedAt } },
});
expect(runs.map((r) => r.status).sort()).toEqual(['FAILED', 'SUCCEEDED']);
});
});
+2
View File
@@ -11,6 +11,8 @@ RUN npx next typegen
# Unit-Tests gruen sind. # Unit-Tests gruen sind.
RUN npm run lint && npx tsc --noEmit && npm test RUN npm run lint && npx tsc --noEmit && npm test
RUN npm run build RUN npm run build
# Bundle-Budget (Spec Beute-3D §9.9): schlaegt fehl, wenn der 3D-Chunk > 320 kB gzip ist.
RUN npm run check:3d-budget
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
ENV NODE_ENV=production ENV NODE_ENV=production
+2 -1
View File
@@ -9,7 +9,8 @@
"lint": "eslint", "lint": "eslint",
"typecheck": "next typegen && tsc --noEmit", "typecheck": "next typegen && tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"check:3d-budget": "node scripts/check-3d-budget.mjs"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.7.0",
@@ -0,0 +1,63 @@
// Bundle-Budget fuer den lazy geladenen Beute-3D-Chunk.
// Spec: docs/superpowers/specs/2026-10-15-beute-3d-design.md (§9.9 / L3).
// Laeuft nach `next build`; Exit 1, wenn der three.js-Chunk das Budget
// ueberschreitet oder gar kein three.js-Chunk gefunden wird.
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
// Spec §9.9: hoechstens 320 kB gzip fuer den 3D-Chunk (1 kB = 1000 Byte).
export const BUDGET_BYTES = 320_000;
// Stabile Marker, die three.js auch minifiziert im Chunk behaelt (String-
// Literale): der Renderer-Name und die globale Mehrfach-Import-Markierung.
const THREE_MARKERS = ["WebGLRenderer", "__THREE__"];
export function isThreeChunk(source) {
return THREE_MARKERS.every((marker) => source.includes(marker));
}
/**
* Misst den groessten three.js-Chunk (gzip level 9) unter chunksDir.
* @returns {{ ok: boolean, file: string | null, gzipBytes: number, budget: number, message: string }}
*/
export function checkBudget(chunksDir, budget = BUDGET_BYTES) {
let largest = null;
for (const name of readdirSync(chunksDir)) {
if (!name.endsWith(".js")) continue;
const source = readFileSync(path.join(chunksDir, name));
if (!isThreeChunk(source.toString("latin1"))) continue;
const gzipBytes = gzipSync(source, { level: 9 }).length;
if (!largest || gzipBytes > largest.gzipBytes) largest = { file: name, gzipBytes };
}
if (!largest) {
return {
ok: false,
file: null,
gzipBytes: 0,
budget,
message: `Kein three.js-Chunk in ${chunksDir} gefunden (Marker: ${THREE_MARKERS.join(", ")}). Check kaputt oder Bundle umgebaut?`,
};
}
const ok = largest.gzipBytes <= budget;
return {
ok,
...largest,
budget,
message: `3D-Chunk ${largest.file}: ${largest.gzipBytes} Byte gzip, Budget ${budget} Byte${ok ? "" : " UEBERSCHRITTEN"}`,
};
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const chunksDir = path.resolve(process.cwd(), ".next/static/chunks");
let result;
try {
result = checkBudget(chunksDir);
} catch (error) {
console.error(`check-3d-budget: ${chunksDir} nicht lesbar (zuerst next build?): ${error.message}`);
process.exit(1);
}
(result.ok ? console.log : console.error)(result.message);
process.exit(result.ok ? 0 : 1);
}
@@ -0,0 +1,51 @@
// @vitest-environment node
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BUDGET_BYTES, checkBudget } from "./check-3d-budget.mjs";
const threeChunk = (filler: string) => `/*WebGLRenderer*/window.__THREE__="186";${filler}`;
// Hex of random bytes is barely compressible by gzip (about 50 %).
const noise = (bytes: number) => randomBytes(bytes).toString("hex");
describe("check-3d-budget", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), "budget-"));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it("passes when the three.js chunk is under budget", () => {
writeFileSync(path.join(dir, "a.js"), threeChunk("x".repeat(1000)));
writeFileSync(path.join(dir, "other.js"), "console.log(1)");
const result = checkBudget(dir);
expect(result.ok).toBe(true);
expect(result.file).toBe("a.js");
});
it("fails when the three.js chunk is over budget", () => {
writeFileSync(path.join(dir, "big.js"), threeChunk(noise(1000)));
const result = checkBudget(dir, 100);
expect(result.ok).toBe(false);
expect(result.gzipBytes).toBeGreaterThan(100);
});
it("measures the largest of several three.js chunks", () => {
writeFileSync(path.join(dir, "small.js"), threeChunk("x"));
writeFileSync(path.join(dir, "large.js"), threeChunk(noise(2000)));
expect(checkBudget(dir).file).toBe("large.js");
});
it("fails when no three.js chunk exists", () => {
writeFileSync(path.join(dir, "app.js"), "export const a = 1; // WebGLRenderer only");
const result = checkBudget(dir);
expect(result.ok).toBe(false);
expect(result.file).toBeNull();
});
it("uses the spec budget of 320 kB by default", () => {
expect(BUDGET_BYTES).toBe(320_000);
});
});
@@ -11,6 +11,7 @@ import { berlinCalendarDay } from "@/lib/dates";
import { hiveConflictOf, hiveKeys } from "@/lib/hive/api"; import { hiveConflictOf, hiveKeys } from "@/lib/hive/api";
import { updateHiveSystem } from "@/lib/hive/catalog-api"; import { updateHiveSystem } from "@/lib/hive/catalog-api";
import { describeHiveOps } from "@/lib/hive/describe"; import { describeHiveOps } from "@/lib/hive/describe";
import { findPart } from "@/lib/hive/find-part";
import { hiveLayout } from "@/lib/hive/layout"; import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names"; import { partDisplayName } from "@/lib/hive/names";
import { hiveReasonText } from "@/lib/hive/reason-text"; import { hiveReasonText } from "@/lib/hive/reason-text";
@@ -77,10 +78,6 @@ function historyFocusTarget(used: HTMLElement | null, other: HTMLElement | null,
return enabled(used) ?? enabled(other) ?? heading; return enabled(used) ?? enabled(other) ?? heading;
} }
function hasPart(state: HiveState, id: string): boolean {
return state.stack.some((part) => part.id === id || (part.inserts ?? []).some((insert) => insert.id === id));
}
/** True when the key event belongs to a text control or a dialog, where Ctrl+Z means something else. */ /** True when the key event belongs to a text control or a dialog, where Ctrl+Z means something else. */
function isTypingTarget(target: EventTarget | null): boolean { function isTypingTarget(target: EventTarget | null): boolean {
return target instanceof Element && target.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]') !== null; return target instanceof Element && target.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]') !== null;
@@ -128,7 +125,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres
const hasOps = draft.ops.length > 0; const hasOps = draft.ops.length > 0;
// A selection ends with its part (removed or undone). // A selection ends with its part (removed or undone).
const selectedId = pickedId !== null && hasPart(draft.state, pickedId) ? pickedId : null; const selectedId = pickedId !== null && findPart(draft.state, pickedId) !== undefined ? pickedId : null;
// Forget a vanished selection for good, so a redo does not bring it back. // Forget a vanished selection for good, so a redo does not bring it back.
if (pickedId !== null && selectedId === null) setPickedId(null); if (pickedId !== null && selectedId === null) setPickedId(null);
const layout = useMemo(() => hiveLayout(draft.state, catalog), [draft.state, catalog]); const layout = useMemo(() => hiveLayout(draft.state, catalog), [draft.state, catalog]);
@@ -122,6 +122,23 @@ describe("useHiveDrag", () => {
expect(apply).toHaveBeenCalledWith([{ op: "movePart", id: "c", to: 4 }]); expect(apply).toHaveBeenCalledWith([{ op: "movePart", id: "c", to: 4 }]);
}); });
it("cancels a press or drag when a second finger comes down (a pinch starts, no part drag)", () => {
const { result, apply, announce, begin, move, up } = setup();
begin({ partId: "c" });
act(() => void window.dispatchEvent(pointer("pointerdown", 300, 100, 2)));
move(160);
expect(result.current.drag).toBeNull();
begin({ partId: "c" });
move(160);
expect(result.current.drag).not.toBeNull();
act(() => void window.dispatchEvent(pointer("pointerdown", 300, 100, 2)));
expect(result.current.drag).toBeNull();
expect(announce).toHaveBeenLastCalledWith("Ziehen abgebrochen.");
up(160);
expect(apply).not.toHaveBeenCalled();
});
it("cancels when the window loses focus (Alt+Tab) or the tab is hidden", () => { it("cancels when the window loses focus (Alt+Tab) or the tab is hidden", () => {
const first = setup(); const first = setup();
first.begin({ partTypeId: "box" }); first.begin({ partTypeId: "box" });
@@ -11,7 +11,8 @@ import type { DraftApplyResult } from "./use-hive-draft";
// so mouse, pen and finger behave the same. A press becomes a drag after a // so mouse, pen and finger behave the same. A press becomes a drag after a
// few pixels (a tap stays a tap); the scene reports the slot under the // few pixels (a tap stays a tap); the scene reports the slot under the
// pointer; release drops there, on „Entfernen“ removes; Esc, leaving the // pointer; release drops there, on „Entfernen“ removes; Esc, leaving the
// window (Alt+Tab, hidden tab) or a mouse released outside cancel. // window (Alt+Tab, hidden tab), a mouse released outside or a second finger
// (pinch) cancel.
// Window listeners, no pointer capture: a finger is captured to the element it // Window listeners, no pointer capture: a finger is captured to the element it
// started on anyway, so the scene reads the position from `pointer`. // started on anyway, so the scene reads the position from `pointer`.
@@ -180,6 +181,11 @@ export function useHiveDrag(options: UseHiveDragOptions): HiveDrag {
if (samePointer(event)) end(); if (samePointer(event)) end();
}; };
// A second finger means a pinch zoom: the part under the first finger is not dragged.
const onDown = (event: Event) => {
if (!samePointer(event)) abort();
};
/** Ends without dropping; says so when a drag was running. */ /** Ends without dropping; says so when a drag was running. */
function abort() { function abort() {
const active = dragRef.current !== null; const active = dragRef.current !== null;
@@ -201,6 +207,7 @@ export function useHiveDrag(options: UseHiveDragOptions): HiveDrag {
} }
}; };
window.addEventListener("pointerdown", onDown);
window.addEventListener("pointermove", onMove); window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp); window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onCancel); window.addEventListener("pointercancel", onCancel);
@@ -210,6 +217,7 @@ export function useHiveDrag(options: UseHiveDragOptions): HiveDrag {
return () => { return () => {
window.removeEventListener("blur", onBlur); window.removeEventListener("blur", onBlur);
document.removeEventListener("visibilitychange", onVisibility); document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pointerdown", onDown);
window.removeEventListener("pointermove", onMove); window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp); window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onCancel); window.removeEventListener("pointercancel", onCancel);
@@ -1,7 +1,7 @@
"use client"; "use client";
import { ChoiceGroup } from "@/components/form/choice-group"; import { ChoiceGroup } from "@/components/form/choice-group";
import { coveredSlots, feederRange, frameFeederOps, rangeText } from "@/lib/hive/feeder-slots"; import { coveredSlots, feederRange, rangeText } from "@/lib/hive/feeder-slots";
import type { CatalogPartType, HiveFrame } from "@/lib/hive/types"; import type { CatalogPartType, HiveFrame } from "@/lib/hive/types";
import { StateFieldControl } from "./state-field-control"; import { StateFieldControl } from "./state-field-control";
import type { FieldDraft } from "./state-field-control"; import type { FieldDraft } from "./state-field-control";
@@ -25,9 +25,6 @@ function rangeAt(start: number, type: CatalogPartType) {
return feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type); return feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type);
} }
/** Kept for existing imports; lives in lib/hive/feeder-slots. */
export { frameFeederOps };
interface FrameFeederPickerProps { interface FrameFeederPickerProps {
boxId: string; boxId: string;
type: CatalogPartType; type: CatalogPartType;
@@ -13,11 +13,12 @@ import { hiveErrorCode } from "@/lib/hive/api";
import { hiveReasonText } from "@/lib/hive/reason-text"; import { hiveReasonText } from "@/lib/hive/reason-text";
import { describeHiveOps } from "@/lib/hive/describe"; import { describeHiveOps } from "@/lib/hive/describe";
import { deriveHiveFacts } from "@/lib/hive/facts"; import { deriveHiveFacts } from "@/lib/hive/facts";
import { findPart } from "@/lib/hive/find-part";
import { hiveLayout } from "@/lib/hive/layout"; import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names"; import { partDisplayName } from "@/lib/hive/names";
import { applyHiveOps } from "@/lib/hive/ops"; import { applyHiveOps } from "@/lib/hive/ops";
import { withPendingOps } from "@/lib/hive/pending"; import { withPendingOps } from "@/lib/hive/pending";
import type { HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types"; import type { CatalogPartType, HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { useConnectivity } from "@/lib/offline/connectivity"; import { useConnectivity } from "@/lib/offline/connectivity";
import { FrameSheet } from "./frame-sheet"; import { FrameSheet } from "./frame-sheet";
import { HiveConflictSheet } from "./hive-conflict-sheet"; import { HiveConflictSheet } from "./hive-conflict-sheet";
@@ -196,12 +197,14 @@ interface PastState {
occurredOn: string; occurredOn: string;
} }
function findPartKind(state: HiveState, catalog: HiveCatalog, id: string) { function findPartType(state: HiveState, catalog: HiveCatalog, id: string): CatalogPartType | undefined {
for (const part of state.stack) { const part = findPart(state, id);
const hit = part.id === id ? part : part.inserts?.find((insert) => insert.id === id); return part ? catalog.partTypes.get(part.partTypeId) : undefined;
if (hit) return catalog.partTypes.get(hit.partTypeId); }
}
return undefined; /** Parts whose state the part sheet edits (a box is changed through its frames). */
function hasPartSheet(type: CatalogPartType): boolean {
return type.kind !== "BOX" && (type.stateSchema.length > 0 || type.kind === "FLOOR");
} }
interface HiveCardBodyProps { interface HiveCardBodyProps {
@@ -282,10 +285,9 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
const partName = (id: string) => partDisplayName(shownState, catalog, id); const partName = (id: string) => partDisplayName(shownState, catalog, id);
const focusedId = focusedIdOf(view); const focusedId = focusedIdOf(view);
const leaveFocus = () => view.openBox(null); const leaveFocus = () => view.openBox(null);
/** Parts whose state the part sheet edits (a box is changed through its frames). */
const hasSheet = (id: string) => { const hasSheet = (id: string) => {
const type = findPartKind(shownState, catalog, id); const type = findPartType(shownState, catalog, id);
return type !== undefined && type.kind !== "BOX" && (type.stateSchema.length > 0 || type.kind === "FLOOR"); return type !== undefined && hasPartSheet(type);
}; };
function editFrame(boxId: string, slot: number) { function editFrame(boxId: string, slot: number) {
@@ -297,18 +299,18 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
function editPart(id: string, intent?: "frameFeeder") { function editPart(id: string, intent?: "frameFeeder") {
if (!editable) return; if (!editable) return;
// The outline's add actions report their box; „Futtertasche einsetzen“ says so, the other is the evaporator. // The outline's add actions report their box; „Futtertasche einsetzen“ says so, the other is the evaporator.
const isBox = findPartKind(workingState, catalog, id)?.kind === "BOX"; const isBox = findPartType(workingState, catalog, id)?.kind === "BOX";
setPartTarget({ id, evaporator: isBox && intent !== "frameFeeder", frameFeeder: isBox && intent === "frameFeeder" }); setPartTarget({ id, evaporator: isBox && intent !== "frameFeeder", frameFeeder: isBox && intent === "frameFeeder" });
setPartOpen(true); setPartOpen(true);
} }
function selectPart(id: string) { function selectPart(id: string) {
const type = findPartKind(shownState, catalog, id); const type = findPartType(shownState, catalog, id);
if (type?.kind === "BOX") { if (type?.kind === "BOX") {
view.openBox(id); view.openBox(id);
return; return;
} }
if (type && (type.stateSchema.length > 0 || type.kind === "FLOOR")) editPart(id); if (type && hasPartSheet(type)) editPart(id);
} }
const showRebuild = canEdit && !isInspection; const showRebuild = canEdit && !isInspection;
@@ -2,7 +2,8 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { ArrowLeft, SlidersHorizontal } from "lucide-react"; import { ArrowLeft, SlidersHorizontal } from "lucide-react";
import type { HiveCatalog, HivePart, HiveState } from "@/lib/hive/types"; import { findPart } from "@/lib/hive/find-part";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { GLASS_PILL, GLASS_SURFACE } from "./hive-glass"; import { GLASS_PILL, GLASS_SURFACE } from "./hive-glass";
import { formatAmount } from "./hive-scene/scene-model"; import { formatAmount } from "./hive-scene/scene-model";
@@ -15,15 +16,6 @@ export interface FocusChip {
text: string; text: string;
} }
function findPart(state: HiveState, id: string): HivePart | undefined {
for (const part of state.stack) {
if (part.id === id) return part;
const insert = part.inserts?.find((candidate) => candidate.id === id);
if (insert) return insert;
}
return undefined;
}
/** Chips for the focused part: frame count of a box, every state field with its value. */ /** Chips for the focused part: frame count of a box, every state field with its value. */
export function focusChips(state: HiveState, catalog: HiveCatalog, id: string): FocusChip[] { export function focusChips(state: HiveState, catalog: HiveCatalog, id: string): FocusChip[] {
const part = findPart(state, id); const part = findPart(state, id);
@@ -126,12 +126,12 @@ export function DropTargets({ anchors, hover, pointer, subscribePointer, onHover
}; };
}, [materials]); }, [materials]);
// The canvas rect only changes with the canvas size (the editor does not scroll). // The canvas rect changes with the canvas size and position (r3f measures scroll and resize).
const rect = useRef<DOMRect | null>(null); const rect = useRef<DOMRect | null>(null);
useEffect(() => { useEffect(() => {
const element = gl.domElement as HTMLElement | undefined; const element = gl.domElement as HTMLElement | undefined;
rect.current = element?.getBoundingClientRect ? element.getBoundingClientRect() : null; rect.current = element?.getBoundingClientRect ? element.getBoundingClientRect() : null;
}, [gl, size.width, size.height]); }, [gl, size.width, size.height, size.top, size.left]);
// Render only when the pointer moved; flights and the opening stack request their own frames. // Render only when the pointer moved; flights and the opening stack request their own frames.
useEffect(() => subscribePointer(() => invalidate()), [subscribePointer, invalidate]); useEffect(() => subscribePointer(() => invalidate()), [subscribePointer, invalidate]);
@@ -10,6 +10,7 @@ import { createCausticMaterial, createLiquidMaterial } from "./materials";
import { MAX_FRAME_SECONDS } from "./use-animated-node"; import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { stepToward } from "./use-hive-motion"; import { stepToward } from "./use-hive-motion";
import { noRaycast, useSceneKit } from "./scene-kit"; import { noRaycast, useSceneKit } from "./scene-kit";
import { seededRandom } from "./seeded-random";
import { placeFill, setUniform } from "./scene-mutations"; import { placeFill, setUniform } from "./scene-mutations";
// Levels (spec 9.3): a glowing, half-transparent volume whose surface moves // Levels (spec 9.3): a glowing, half-transparent volume whose surface moves
@@ -104,11 +105,7 @@ function fondantTexture(): CanvasTexture | null {
canvas.height = 128; canvas.height = 128;
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
if (!context) return null; if (!context) return null;
let seed = 97; const next = seededRandom(97);
const next = () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
context.fillStyle = "#d8d8d8"; context.fillStyle = "#d8d8d8";
context.fillRect(0, 0, 128, 128); context.fillRect(0, 0, 128, 128);
for (let dot = 0; dot < 900; dot += 1) { for (let dot = 0; dot < 900; dot += 1) {
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { Color, MeshStandardMaterial } from "three"; import { Color, MeshStandardMaterial } from "three";
import type { Group, ShaderMaterial } from "three"; import type { Group, ShaderMaterial } from "three";
import { subVec3 } from "@/lib/hive/layout";
import type { FrameBox, Vec3 } from "@/lib/hive/layout"; import type { FrameBox, Vec3 } from "@/lib/hive/layout";
import { HoloLabel } from "./hive-labels"; import { HoloLabel } from "./hive-labels";
import { createEdgeMaterial, createGlassMaterial } from "./materials"; import { createEdgeMaterial, createGlassMaterial } from "./materials";
@@ -22,8 +23,6 @@ import type { FrameTarget } from "./use-hive-motion";
// box. In an open box each frame is a clear glass plate; hovered or selected // box. In an open box each frame is a clear glass plate; hovered or selected
// it rises and takes its type look (colour and comb pattern). // it rises and takes its type look (colour and comb pattern).
const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
/** Faint top bars of all frames of a box, one merged mesh using the box's materials. */ /** Faint top bars of all frames of a box, one merged mesh using the box's materials. */
export function FrameHints({ export function FrameHints({
frames, frames,
@@ -39,7 +38,7 @@ export function FrameHints({
const geometries = useMemo(() => { const geometries = useMemo(() => {
const hints = frames const hints = frames
.filter((frame) => frame.type !== "NONE") .filter((frame) => frame.type !== "NONE")
.map((frame) => frameHintSpec(frame.size, sub(frame.center, hostCenter))); .map((frame) => frameHintSpec(frame.size, subVec3(frame.center, hostCenter)));
return { return {
glass: buildGlassGeometry(hints.map((hint) => hint.slab)), glass: buildGlassGeometry(hints.map((hint) => hint.slab)),
edges: buildEdgeGeometry(hints.flatMap((hint) => hint.edges)), edges: buildEdgeGeometry(hints.flatMap((hint) => hint.edges)),
@@ -20,6 +20,17 @@ import { SceneKitProvider } from "./scene-kit";
// The scene content runs on the r3f test renderer (no WebGL). Labels are DOM // The scene content runs on the r3f test renderer (no WebGL). Labels are DOM
// portals (drei Html), so they are replaced by a marker here. // portals (drei Html), so they are replaced by a marker here.
const anchorCalls = vi.hoisted(() => ({ count: 0 })); const anchorCalls = vi.hoisted(() => ({ count: 0 }));
const motionCalls = vi.hoisted(() => ({ count: 0 }));
vi.mock("./use-hive-motion", async (importOriginal) => {
const actual = await importOriginal<typeof import("./use-hive-motion")>();
return {
...actual,
hiveMotionTargets: (...args: Parameters<typeof actual.hiveMotionTargets>) => {
motionCalls.count += 1;
return actual.hiveMotionTargets(...args);
},
};
});
vi.mock("./drop-anchors", async (importOriginal) => { vi.mock("./drop-anchors", async (importOriginal) => {
const actual = await importOriginal<typeof import("./drop-anchors")>(); const actual = await importOriginal<typeof import("./drop-anchors")>();
return { return {
@@ -291,6 +302,22 @@ describe("HiveSceneContent in the editor", () => {
expect(stopPropagation).toHaveBeenCalled(); expect(stopPropagation).toHaveBeenCalled();
}); });
it("takes a press on a frame of the open box there: the host box does not start a drag", async () => {
const editor = editorOf();
const { renderer, store } = await renderScene("full", editor);
await act(async () => store.openBox("b1"));
const stopPropagation = vi.fn();
await renderer.fireEvent(hit(renderer, "b1#4"), "onPointerDown", {
pointerType: "mouse",
pointerId: 3,
clientX: 10,
clientY: 20,
stopPropagation,
});
expect(stopPropagation).toHaveBeenCalled();
expect(editor.onPartPress).not.toHaveBeenCalled();
});
it("shows one glowing marker per legal slot while dragging and opens the stack", async () => { it("shows one glowing marker per legal slot while dragging and opens the stack", async () => {
const rest = await renderScene("off"); const rest = await renderScene("off");
await rest.renderer.advanceFrames(1, 1 / 60); await rest.renderer.advanceFrames(1, 1 / 60);
@@ -309,7 +336,7 @@ describe("HiveSceneContent in the editor", () => {
}); });
describe("HiveSceneContent drop anchors", () => { describe("HiveSceneContent drop anchors", () => {
it("computes the slot anchors once per drag, not again on every hover change", async () => { it("computes the slot anchors and motion targets once per drag, not again on every hover change", async () => {
const state = hiveState(); const state = hiveState();
const layout = hiveLayout(state, catalog); const layout = hiveLayout(state, catalog);
const store = createHiveViewStore(); const store = createHiveViewStore();
@@ -341,11 +368,15 @@ describe("HiveSceneContent drop anchors", () => {
</HiveViewProvider> </HiveViewProvider>
); );
anchorCalls.count = 0; anchorCalls.count = 0;
motionCalls.count = 0;
const renderer = await ReactThreeTestRenderer.create(element(editor(null))); const renderer = await ReactThreeTestRenderer.create(element(editor(null)));
const afterMount = anchorCalls.count; const afterMount = anchorCalls.count;
const motionAfterMount = motionCalls.count;
await renderer.update(element(editor(slots[0]))); await renderer.update(element(editor(slots[0])));
await renderer.update(element(editor(slots[1]))); await renderer.update(element(editor(slots[1])));
expect(anchorCalls.count).toBe(afterMount); expect(anchorCalls.count).toBe(afterMount);
// The motion targets depend on the gap and the moved part, not on the hover.
expect(motionCalls.count).toBe(motionAfterMount);
await renderer.unmount(); await renderer.unmount();
}); });
}); });
@@ -110,15 +110,15 @@ export function HiveSceneContent({
const motionView = useMemo(() => ({ ...view, selectedFrame }), [view, selectedFrame]); const motionView = useMemo(() => ({ ...view, selectedFrame }), [view, selectedFrame]);
const drag = editor?.drag ?? null; const drag = editor?.drag ?? null;
const gap = drag ? DRAG_GAP : REST_GAP; const gap = drag ? DRAG_GAP : REST_GAP;
const movedId = drag && "partId" in drag.source ? drag.source.partId : null;
const targets: HiveMotionTargets = useMemo(() => { const targets: HiveMotionTargets = useMemo(() => {
const computed = hiveMotionTargets(layout, motionView, gap); const computed = hiveMotionTargets(layout, motionView, gap);
const movedId = drag && "partId" in drag.source ? drag.source.partId : null;
const moved = movedId ? computed.nodes.get(movedId) : undefined; const moved = movedId ? computed.nodes.get(movedId) : undefined;
if (!movedId || !moved) return computed; if (!movedId || !moved) return computed;
const nodes = new Map(computed.nodes); const nodes = new Map(computed.nodes);
nodes.set(movedId, { ...moved, opacity: Math.min(moved.opacity, DRAGGED_OPACITY), interactive: false }); nodes.set(movedId, { ...moved, opacity: Math.min(moved.opacity, DRAGGED_OPACITY), interactive: false });
return { ...computed, nodes }; return { ...computed, nodes };
}, [layout, motionView, gap, drag]); }, [layout, motionView, gap, movedId]);
// Once per drag: the hover changes the drag object, not its source or its slots. // Once per drag: the hover changes the drag object, not its source or its slots.
const dragSource = drag?.source ?? null; const dragSource = drag?.source ?? null;
const dragSlots = drag?.slots ?? null; const dragSlots = drag?.slots ?? null;
@@ -199,9 +199,10 @@ export function HiveSceneContent({
onPointerDown(event) { onPointerDown(event) {
pointerType.current = event.pointerType ?? "mouse"; pointerType.current = event.pointerType ?? "mouse";
const parsed = parseKey(key); const parsed = parseKey(key);
if (editor && parsed?.kind === "part") {
// Only the nearest hit takes the press (inserts and frames inside a box, thin parts on top). // Only the nearest hit takes the press (inserts and frames inside a box, thin parts on top).
event.stopPropagation(); // Frames are not dragged: a press on one must not reach the box around it.
if (editor && parsed) event.stopPropagation();
if (editor && parsed?.kind === "part") {
if (signals.pinching) return; if (signals.pinching) return;
// A press on a part may become a part drag: the rig must not turn the model for it. // A press on a part may become a part drag: the rig must not turn the model for it.
markPartPress(signals, true); markPartPress(signals, true);
@@ -16,10 +16,10 @@ import {
SRGBColorSpace, SRGBColorSpace,
ShaderMaterial, ShaderMaterial,
} from "three"; } from "three";
import type { Points } from "three";
import { createQualityMonitor } from "./quality"; import { createQualityMonitor } from "./quality";
import type { QualityVerdict, StageTier } from "./quality"; import type { QualityVerdict, StageTier } from "./quality";
import { useSceneKit } from "./scene-kit"; import { noRaycast, useSceneKit } from "./scene-kit";
import { seededRandom } from "./seeded-random";
import { markTimerFrame, setEffectBokeh, setEffectTarget, setSceneBackground, setUniform } from "./scene-mutations"; import { markTimerFrame, setEffectBokeh, setEffectTarget, setSceneBackground, setUniform } from "./scene-mutations";
import type { HoloTheme } from "./theme"; import type { HoloTheme } from "./theme";
@@ -279,19 +279,9 @@ void main() {
} }
`; `;
/** Deterministic placement, so the stage looks the same on every visit (Park-Miller). */
function seededRandom(start: number): () => number {
let seed = start;
return () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
}
function Particles() { function Particles() {
const { theme } = useSceneKit(); const { theme } = useSceneKit();
const dpr = useThree((state) => state.viewport.dpr); const dpr = useThree((state) => state.viewport.dpr);
const points = useRef<Points>(null);
const { geometry, material } = useMemo(() => { const { geometry, material } = useMemo(() => {
const next = seededRandom(7); const next = seededRandom(7);
const positions: number[] = []; const positions: number[] = [];
@@ -332,7 +322,7 @@ function Particles() {
setUniform(material, "uTime", state.clock.elapsedTime); setUniform(material, "uTime", state.clock.elapsedTime);
setUniform(material, "uPixelRatio", dpr); setUniform(material, "uPixelRatio", dpr);
}); });
return <points ref={points} geometry={geometry} material={material} renderOrder={5} raycast={() => {}} />; return <points geometry={geometry} material={material} renderOrder={5} raycast={noRaycast} />;
} }
function Lights() { function Lights() {
@@ -448,7 +438,7 @@ function Haze() {
material={material} material={material}
renderOrder={-1} renderOrder={-1}
layers={OVERLAY_LAYER} layers={OVERLAY_LAYER}
raycast={() => {}} raycast={noRaycast}
> >
<coneGeometry args={[6.5, HAZE_HEIGHT, 48, 1, true]} /> <coneGeometry args={[6.5, HAZE_HEIGHT, 48, 1, true]} />
</mesh> </mesh>
@@ -362,9 +362,3 @@ export function createLiquidMaterial(options: LiquidOptions): ShaderMaterial {
depthWrite: false, depthWrite: false,
}); });
} }
/** Sets the per-node uniforms shared by every material here. */
export function setOpacity(material: ShaderMaterial, opacity: number): void {
const uniform = material.uniforms.uOpacity;
if (uniform) uniform.value = opacity;
}
@@ -0,0 +1,8 @@
/** Deterministic Park-Miller sequence in (0, 1), so procedural looks are the same on every visit. */
export function seededRandom(start: number): () => number {
let seed = start;
return () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
}
@@ -1,3 +1,4 @@
import { subVec3 } from "@/lib/hive/layout";
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout"; import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { HivePartKind } from "@/lib/hive/types"; import type { HivePartKind } from "@/lib/hive/types";
import type { HiveViewState, HoveredFrame } from "../use-hive-view"; import type { HiveViewState, HoveredFrame } from "../use-hive-view";
@@ -74,7 +75,6 @@ export interface MotionView extends HiveViewState {
export const frameKey = (boxId: string, slot: number): string => `${boxId}#${slot}`; export const frameKey = (boxId: string, slot: number): string => `${boxId}#${slot}`;
const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const lift = (value: Vec3, dy: number): Vec3 => [value[0], value[1] + dy, value[2]]; const lift = (value: Vec3, dy: number): Vec3 => [value[0], value[1] + dy, value[2]];
/** Rest place of a stack part: its layout centre raised by the stack gap. */ /** Rest place of a stack part: its layout centre raised by the stack gap. */
@@ -200,10 +200,10 @@ function insertTarget(
host: PartBox, host: PartBox,
gap: number, gap: number,
): NodeTarget { ): NodeTarget {
const local = sub(insert.center, host.center); const local = subVec3(insert.center, host.center);
if (focus?.isInsert && focus.id === insert.id) { if (focus?.isInsert && focus.id === insert.id) {
return { return {
position: sub(focusPlacement(layout.bounds, insert.size), restCenter(host, gap)), position: subVec3(focusPlacement(layout.bounds, insert.size), restCenter(host, gap)),
scale: FOCUS_SCALE, scale: FOCUS_SCALE,
opacity: 1, opacity: 1,
glow: GLOW.focus, glow: GLOW.focus,
@@ -241,7 +241,7 @@ function insertTarget(
} }
function frameTarget(frame: FrameBox, host: PartBox, view: MotionView, focus: FocusState | null): FrameTarget { function frameTarget(frame: FrameBox, host: PartBox, view: MotionView, focus: FocusState | null): FrameTarget {
const local = sub(frame.center, host.center); const local = subVec3(frame.center, host.center);
if (frame.type === "NONE") return { position: local, opacity: 0, look: "hidden", interactive: false }; if (frame.type === "NONE") return { position: local, opacity: 0, look: "hidden", interactive: false };
const open = focus !== null && !focus.isInsert && focus.id === host.id; const open = focus !== null && !focus.isInsert && focus.id === host.id;
if (!open) { if (!open) {
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { findPart } from "./find-part";
import { part, templateState } from "./test-utils";
describe("findPart", () => {
it("finds stack parts and inserts, and nothing for an unknown id", () => {
const state = templateState();
state.stack[1] = { ...state.stack[1], inserts: [part("e", "evaporator")] };
expect(findPart(state, "b")).toBe(state.stack[1]);
expect(findPart(state, "e")).toBe(state.stack[1].inserts![0]);
expect(findPart(state, "nope")).toBeUndefined();
});
});
@@ -0,0 +1,11 @@
import type { HivePart, HiveState } from "./types";
/** A stack part or an insert by id; undefined for an unknown id. */
export function findPart(state: HiveState, id: string): HivePart | undefined {
for (const part of state.stack) {
if (part.id === id) return part;
const insert = part.inserts?.find((candidate) => candidate.id === id);
if (insert) return insert;
}
return undefined;
}
+1 -9
View File
@@ -1,4 +1,5 @@
import type { FeedType } from "@/lib/feedings/types"; import type { FeedType } from "@/lib/feedings/types";
import { findPart } from "./find-part";
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HiveState, LevelField } from "./types"; import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HiveState, LevelField } from "./types";
// Spec section 8: suggestions only. Nothing here books a feeding or treatment. // Spec section 8: suggestions only. Nothing here books a feeding or treatment.
@@ -15,15 +16,6 @@ const ACID_KEY = "acidMl";
const isFeedKind = (kind: string | undefined): boolean => kind === "FEEDER" || kind === "FRAME_FEEDER"; const isFeedKind = (kind: string | undefined): boolean => kind === "FEEDER" || kind === "FRAME_FEEDER";
const ROUNDING = 1000; const ROUNDING = 1000;
function findPart(state: HiveState, id: string): HivePart | undefined {
for (const part of state.stack) {
if (part.id === id) return part;
const insert = part.inserts?.find((candidate) => candidate.id === id);
if (insert) return insert;
}
return undefined;
}
function typeOf(part: HivePart | undefined, catalog: HiveCatalog): CatalogPartType | undefined { function typeOf(part: HivePart | undefined, catalog: HiveCatalog): CatalogPartType | undefined {
return part ? catalog.partTypes.get(part.partTypeId) : undefined; return part ? catalog.partTypes.get(part.partTypeId) : undefined;
} }
+5
View File
@@ -6,6 +6,11 @@ import type { CatalogPartType, FrameType, HiveCatalog, HivePart, HivePartKind, H
export type Vec3 = [number, number, number]; export type Vec3 = [number, number, number];
/** a − b, component by component. */
export function subVec3(a: Vec3, b: Vec3): Vec3 {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
export interface PartBox { export interface PartBox {
id: string; id: string;
partTypeId: string; partTypeId: string;
+6 -3
View File
@@ -340,17 +340,20 @@ Spec: `docs/superpowers/specs/2026-10-15-beute-3d-design.md`. Teil 1 (Daten) und
- [x] L2 Karte ohne 3D (SVG, Aufbau-Liste, Sheets, Volk-Seite, Historie, Vorschläge Fütterung/Behandlung) - [x] L2 Karte ohne 3D (SVG, Aufbau-Liste, Sheets, Volk-Seite, Historie, Vorschläge Fütterung/Behandlung)
- [x] L3 3D-Szene (Glas-Holo, Wanderboden, Fokusflug mit Geist-Ansicht, Pegel, Futtertasche, Bewegungsstufen, adaptive Qualität) - [x] L3 3D-Szene (Glas-Holo, Wanderboden, Fokusflug mit Geist-Ansicht, Pegel, Futtertasche, Bewegungsstufen, adaptive Qualität)
- [x] L4 Umbauen ohne 3D-Ziehen (Vollbild-Seite `/colonies/[id]/hive`, Liste und Palette, Rückgängig/Wiederholen, Konflikt, Vorlage speichern) - [x] L4 Umbauen ohne 3D-Ziehen (Vollbild-Seite `/colonies/[id]/hive`, Liste und Palette, Rückgängig/Wiederholen, Konflikt, Vorlage speichern)
- [ ] L4 Rest: Drag & Drop in der 3D-Szene und Zoom 0,6–2,5 (Task 18) - [x] L4 Rest: Drag & Drop in der 3D-Szene und Zoom 0,6–2,5 (Task 18, 45274f43 + 19550d13)
- [x] L5 Durchsicht-Abschnitt „Beute“ und Offline (Paket, Warteschlange, Hinweis bei abgelehnter Änderung) - [x] L5 Durchsicht-Abschnitt „Beute“ und Offline (Paket, Warteschlange, Hinweis bei abgelehnter Änderung)
- [x] Browserprüfung L3–L5 (1280/390 px, Tastatur, offline, reduced motion): 0 kritisch, 2 wichtig, behoben (2026-10-16) - [x] Browserprüfung L3–L5 (1280/390 px, Tastatur, offline, reduced motion): 0 kritisch, 2 wichtig, behoben (2026-10-16)
- [ ] Folgepunkte Szene (Review 2026-10-16): Allokationen in `useFrame`, Qualitätsstufe per Uniform, DOF nach Fokus abbauen, Reflector freigeben, Geometrie-Cache räumen, Messung auf echtem Mittelklasse-Handy - [ ] Folgepunkte Szene (Review 2026-10-16): Allokationen in `useFrame`, Qualitätsstufe per Uniform, DOF nach Fokus abbauen, Reflector freigeben, Geometrie-Cache räumen, Messung auf echtem Mittelklasse-Handy
- [ ] Task 22 Doku (ARCHITECTURE-Abschnitt Beute, Annahmen) - [x] Task 22 Doku (ARCHITECTURE §13/13.1, §2 offline, Annahmen 101–105) und Vereinfachung des Frontend-Codes
- [x] Größenbudget 3D-Chunk (320 kB gzip) im Frontend-Image-Build geprüft (299 kB am 2026-10-16)
- [ ] Inhaber: Maße prüfen (Zarge außen, Styropor-Einlage, Verdunster) und Fotos der Beuten schicken (Spec §14) - [ ] Inhaber: Maße prüfen (Zarge außen, Styropor-Einlage, Verdunster) und Fotos der Beuten schicken (Spec §14)
- [ ] T5-Spec um eine Bewegungs-Sprache ergänzen, bevor T5 umgesetzt wird (Spec §12.4) - [ ] T5-Spec um eine Bewegungs-Sprache ergänzen, bevor T5 umgesetzt wird (Spec §12.4)
- [ ] Teil 4 Sprach-Durchsicht: Anforderungen in `docs/superpowers/specs/2026-10-16-beute-teil4-sprach-durchsicht-anforderungen.md`; Brainstorming + Spec nach L5, baut auf T7 auf - [ ] Teil 4 Sprach-Durchsicht: Anforderungen in `docs/superpowers/specs/2026-10-16-beute-teil4-sprach-durchsicht-anforderungen.md`; Brainstorming + Spec nach L5, baut auf T7 auf
- [x] T7 L1 Diktat (Spracherkennung im Browser, Einwilligung, Diktat-Knopf, Seite „Sprache & Datenschutz“); Plan `docs/superpowers/plans/2026-10-16-t7-sprache.md` - [x] T7 L1 Diktat (Spracherkennung im Browser, Einwilligung, Diktat-Knopf, Seite „Sprache & Datenschutz“); Plan `docs/superpowers/plans/2026-10-16-t7-sprache.md`
- [ ] T7 L1 mit echtem Mikrofon in Chrome prüfen (Inhaber, headless nicht möglich) - [ ] T7 L1 mit echtem Mikrofon in Chrome prüfen (Inhaber, headless nicht möglich)
- [ ] T7 L2–L6 (Sprachnotizen, Abschreiben im Cluster, Auswertung mit Claude, Aufnahme, e2e); Spec-Fragen 1–3 an den Inhaber offen - [ ] T7 L2 Backend: Tasks 4, 5, 6, 7, 10, 12 fertig; 8 (Übernahme in die Durchsicht), 9 (Ereignisse), 11 (Abschreib-Job) in Arbeit
- [ ] T7 L3–L6 (Whisper-Image, Auswertung mit Claude, Aufnahme im Frontend, e2e); Auswertung mit Claude (Tasks 14–16, 21) wartet auf Spec-Frage 3 (Drittland)
- [ ] Vor dem nächsten Backend-Rollout: `VOICE_ENCRYPTION_KEY` per `ensure-secrets.sh` anlegen und sichern (#28)
- [ ] Next.js auf aktuelle Patch-Version heben (npm audit: kritische Lücke nur für Windows-Server, Produktion läuft unter Linux) - [ ] Next.js auf aktuelle Patch-Version heben (npm audit: kritische Lücke nur für Windows-Server, Produktion läuft unter Linux)
## Später (KANN) – Abschnitt 26, Liste 3 ## Später (KANN) – Abschnitt 26, Liste 3