Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dc39f7658 | ||
|
|
e9c6604e3b | ||
|
|
2207bd2e67 | ||
|
|
92c00b4453 | ||
|
|
9f6ab4ea3f | ||
|
|
0b0bc3c6dc | ||
|
|
19550d1394 |
No files matched your search
@@ -64,6 +64,7 @@ import { HiveModule } from './hive/hive.module';
|
||||
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
|
||||
import { ComplianceModule } from './compliance/compliance.module';
|
||||
import { ReadinessModule } from './compliance/readiness/readiness.module';
|
||||
import { VoiceModule } from './voice/voice.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||
@@ -140,6 +141,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
||||
HiveModule,
|
||||
ComplianceModule,
|
||||
ReadinessModule,
|
||||
VoiceModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { matchesFileSignature } from './file-signature';
|
||||
import { detectAudioType, matchesFileSignature } from './file-signature';
|
||||
|
||||
describe('matchesFileSignature', () => {
|
||||
it('accepts a buffer starting with the PNG signature', () => {
|
||||
@@ -47,3 +47,63 @@ describe('matchesFileSignature', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAudioType', () => {
|
||||
it('knows EBML, OggS and ftyp and nothing else', () => {
|
||||
const webm = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42]);
|
||||
const ogg = Buffer.concat([Buffer.from('OggS', 'ascii'), Buffer.alloc(8)]);
|
||||
const mp4 = Buffer.concat([
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x20]),
|
||||
Buffer.from('ftypM4A ', 'ascii'),
|
||||
]);
|
||||
expect(detectAudioType(webm)).toBe('audio/webm');
|
||||
expect(detectAudioType(ogg)).toBe('audio/ogg');
|
||||
expect(detectAudioType(mp4)).toBe('audio/mp4');
|
||||
expect(
|
||||
detectAudioType(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a])),
|
||||
).toBeNull();
|
||||
expect(detectAudioType(Buffer.from('RIFF....WAVE', 'ascii'))).toBeNull();
|
||||
expect(detectAudioType(Buffer.from('%PDF-1.7', 'ascii'))).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['heic', 'avif', 'mif1', 'qt ', '3gp4'])(
|
||||
'rejects an ftyp file with the brand %s',
|
||||
(brand) => {
|
||||
const file = Buffer.concat([
|
||||
Buffer.from([0, 0, 0, 0x20]),
|
||||
Buffer.from(`ftyp${brand}`, 'latin1'),
|
||||
]);
|
||||
expect(detectAudioType(file)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['M4A ', 'isom', 'iso2', 'iso6', 'mp41', 'mp42', 'dash'])(
|
||||
'accepts the audio brand %s',
|
||||
(brand) => {
|
||||
const file = Buffer.concat([
|
||||
Buffer.from([0, 0, 0, 0x20]),
|
||||
Buffer.from(`ftyp${brand}`, 'latin1'),
|
||||
]);
|
||||
expect(detectAudioType(file)).toBe('audio/mp4');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects buffers that are too short for any signature', () => {
|
||||
expect(detectAudioType(Buffer.alloc(0))).toBeNull();
|
||||
expect(detectAudioType(Buffer.from([0x1a, 0x45, 0xdf]))).toBeNull();
|
||||
expect(
|
||||
detectAudioType(Buffer.from('OggS', 'ascii').subarray(0, 3)),
|
||||
).toBeNull();
|
||||
expect(
|
||||
detectAudioType(Buffer.from([0, 0, 0, 0x20, 0x66, 0x74])),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not take a signature from the middle of the buffer', () => {
|
||||
const late = Buffer.concat([
|
||||
Buffer.alloc(16),
|
||||
Buffer.from('OggS', 'ascii'),
|
||||
]);
|
||||
expect(detectAudioType(late)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -33,3 +33,47 @@ export function matchesFileSignature(
|
||||
const check = SIGNATURE_CHECKS[declaredMimeType];
|
||||
return check ? check(buffer) : true;
|
||||
}
|
||||
|
||||
export type VoiceAudioMime = 'audio/webm' | 'audio/ogg' | 'audio/mp4';
|
||||
|
||||
// Major brands of MP4/M4A audio; HEIC, AVIF and other ISO-BMFF files are not.
|
||||
const MP4_AUDIO_BRANDS: ReadonlySet<string> = new Set([
|
||||
'M4A ',
|
||||
'isom',
|
||||
'iso2',
|
||||
'iso3',
|
||||
'iso4',
|
||||
'iso5',
|
||||
'iso6',
|
||||
'mp41',
|
||||
'mp42',
|
||||
'dash',
|
||||
]);
|
||||
|
||||
const EBML_MAGIC = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]);
|
||||
|
||||
/**
|
||||
* Names the audio container by its first bytes - strict, unlike
|
||||
* matchesFileSignature: anything unknown is null, and the declared type of
|
||||
* the upload plays no part. EBML (WebM/Matroska), "OggS" and "ftyp" at byte 4
|
||||
* (MP4/M4A) are the containers MediaRecorder produces.
|
||||
*/
|
||||
export function detectAudioType(buffer: Buffer): VoiceAudioMime | null {
|
||||
if (buffer.length >= 4 && buffer.subarray(0, 4).equals(EBML_MAGIC)) {
|
||||
return 'audio/webm';
|
||||
}
|
||||
if (
|
||||
buffer.length >= 4 &&
|
||||
buffer.subarray(0, 4).toString('latin1') === 'OggS'
|
||||
) {
|
||||
return 'audio/ogg';
|
||||
}
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(4, 8).toString('latin1') === 'ftyp' &&
|
||||
MP4_AUDIO_BRANDS.has(buffer.subarray(8, 12).toString('latin1'))
|
||||
) {
|
||||
return 'audio/mp4';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -23,7 +23,9 @@ interface SourceFile {
|
||||
|
||||
const WEATHER_HOSTS = /open-meteo\.com/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([
|
||||
'http',
|
||||
'https',
|
||||
@@ -180,6 +182,11 @@ describe('network calls stay inside their loop directories', () => {
|
||||
describe('with planted violations', () => {
|
||||
it.each([
|
||||
['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', 'await globalThis.fetch(url);'],
|
||||
['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', "const scheme = 'https';"],
|
||||
['colonies/colonies.service.ts', 'const o = { fetch: 1 };'],
|
||||
['jobs/job-names.ts', "const a = ['--phase=fetch', '--phase=ingest'];"],
|
||||
])('lets %s pass: %s', (path, source) => {
|
||||
expect(violations([{ path, source }])).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export const WAKE_CHANNELS = {
|
||||
calendarSync: 'calendar_sync',
|
||||
webhookDeliveries: 'webhook_delivery',
|
||||
notificationDeliveries: 'notification_delivery',
|
||||
voiceExtraction: 'voice_extraction',
|
||||
} as const;
|
||||
|
||||
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
|
||||
|
||||
@@ -12,6 +12,21 @@ import {
|
||||
TaskStatus,
|
||||
} 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 {
|
||||
status: ColonyStatus;
|
||||
healthStatus: ColonyCondition | null;
|
||||
@@ -154,6 +169,12 @@ export interface DomainEventPayloads {
|
||||
source: HiveChangeSource;
|
||||
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;
|
||||
@@ -199,6 +220,8 @@ const CATALOG: Record<DomainEventType, true> = {
|
||||
ApiaryChanged: true,
|
||||
TskReferenceDayReached: true,
|
||||
HiveChanged: true,
|
||||
VoiceCaptureReady: true,
|
||||
VoiceCaptureFailed: true,
|
||||
};
|
||||
|
||||
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
|
||||
@@ -223,7 +246,8 @@ export type AggregateType =
|
||||
| 'PhenologyReport'
|
||||
| 'WinterCheck'
|
||||
| 'Apiary'
|
||||
| 'Compliance';
|
||||
| 'Compliance'
|
||||
| 'VoiceCapture';
|
||||
|
||||
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
|
||||
type: T;
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
|
||||
|
||||
describe('domain event catalog', () => {
|
||||
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(DOMAIN_EVENT_TYPES).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -207,6 +207,8 @@ describe('domain event catalog', () => {
|
||||
'ApiaryChanged',
|
||||
'TskReferenceDayReached',
|
||||
'HiveChanged',
|
||||
'VoiceCaptureReady',
|
||||
'VoiceCaptureFailed',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
@@ -37,6 +38,15 @@ export class InspectionHiveChangeDto {
|
||||
}
|
||||
|
||||
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()
|
||||
@IsString()
|
||||
colonyId!: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { ColoniesModule } from '../colonies/colonies.module';
|
||||
import { HiveModule } from '../hive/hive.module';
|
||||
import { VoiceConfirmationModule } from '../voice/captures/voice-confirmation.module';
|
||||
import { InspectionsController } from './inspections.controller';
|
||||
import { ColonyInspectionsController } from './colony-inspections.controller';
|
||||
import { InspectionsService } from './inspections.service';
|
||||
@@ -12,7 +13,7 @@ import { InspectionsService } from './inspections.service';
|
||||
* PermissionResolverService comes from the global resolver module.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TasksModule, ColoniesModule, HiveModule],
|
||||
imports: [TasksModule, ColoniesModule, HiveModule, VoiceConfirmationModule],
|
||||
controllers: [InspectionsController, ColonyInspectionsController],
|
||||
providers: [InspectionsService],
|
||||
exports: [InspectionsService],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ColoniesService } from '../colonies/colonies.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { HiveService } from '../hive/hive.service';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { VoiceConfirmationService } from '../voice/captures/voice-confirmation.service';
|
||||
|
||||
describe('InspectionsService', () => {
|
||||
let service: InspectionsService;
|
||||
@@ -37,6 +38,7 @@ describe('InspectionsService', () => {
|
||||
let colonies: { updateIn: jest.Mock };
|
||||
let resolver: { getPermissions: jest.Mock };
|
||||
let hive: { applyChange: jest.Mock };
|
||||
let voice: { confirmIn: jest.Mock };
|
||||
|
||||
const inspection = {
|
||||
id: 'inspection-1',
|
||||
@@ -77,6 +79,7 @@ describe('InspectionsService', () => {
|
||||
getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])),
|
||||
};
|
||||
hive = { applyChange: jest.fn().mockResolvedValue({ version: 2 }) };
|
||||
voice = { confirmIn: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new InspectionsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
@@ -85,6 +88,7 @@ describe('InspectionsService', () => {
|
||||
colonies as unknown as ColoniesService,
|
||||
resolver as unknown as PermissionResolverService,
|
||||
hive as unknown as HiveService,
|
||||
voice as unknown as VoiceConfirmationService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -99,6 +103,7 @@ describe('InspectionsService', () => {
|
||||
ColoniesService,
|
||||
PermissionResolverService,
|
||||
HiveService,
|
||||
VoiceConfirmationService,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -152,6 +157,74 @@ describe('InspectionsService', () => {
|
||||
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', () => {
|
||||
const saved = {
|
||||
...inspection,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { optionalDate } from '../common/patch-values';
|
||||
import { berlinDayOf } from '../common/dates';
|
||||
import type { HiveOp } from '../hive/hive-types';
|
||||
import { HiveService, HiveSetupView } from '../hive/hive.service';
|
||||
import { VoiceConfirmationService } from '../voice/captures/voice-confirmation.service';
|
||||
|
||||
export type InspectionWithTakeover = Inspection & {
|
||||
colonyTakeover: ColonyTakeover;
|
||||
@@ -51,6 +52,7 @@ export class InspectionsService {
|
||||
private readonly colonies: ColoniesService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly hiveService: HiveService,
|
||||
private readonly voiceConfirmation: VoiceConfirmationService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Inspection[]> {
|
||||
@@ -72,7 +74,9 @@ export class InspectionsService {
|
||||
dto: CreateInspectionDto,
|
||||
actingUserId: string,
|
||||
): 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
|
||||
// both features: a takeover only applies with colonies:update, a hive
|
||||
// change is refused without it.
|
||||
@@ -123,22 +127,36 @@ export class InspectionsService {
|
||||
canUpdateColony,
|
||||
actingUserId,
|
||||
);
|
||||
if (!hiveChange) {
|
||||
return { ...inspection, colonyTakeover };
|
||||
const hive = hiveChange
|
||||
? await this.hiveService.applyChange(
|
||||
tx,
|
||||
dto.colonyId,
|
||||
{
|
||||
occurredOn: berlinDayOf(new Date(dto.date)),
|
||||
source: 'INSPECTION',
|
||||
// Plain objects only: the DTO instances carry class metadata.
|
||||
ops: JSON.parse(JSON.stringify(hiveChange.ops)) as HiveOp[],
|
||||
inspectionId: inspection.id,
|
||||
},
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
const hive = await this.hiveService.applyChange(
|
||||
tx,
|
||||
dto.colonyId,
|
||||
{
|
||||
occurredOn: berlinDayOf(new Date(dto.date)),
|
||||
source: 'INSPECTION',
|
||||
// Plain objects only: the DTO instances carry class metadata.
|
||||
ops: JSON.parse(JSON.stringify(hiveChange.ops)) as HiveOp[],
|
||||
inspectionId: inspection.id,
|
||||
},
|
||||
actingUserId,
|
||||
);
|
||||
return { ...inspection, colonyTakeover, hive };
|
||||
return hive
|
||||
? { ...inspection, colonyTakeover, hive }
|
||||
: { ...inspection, colonyTakeover };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as voiceRetention from '../voice/voice-retention';
|
||||
import { JobRunService } from '../system/job-run.service';
|
||||
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
FAILED_RETENTION_DAYS,
|
||||
JOB_RUN_RETENTION_DAYS,
|
||||
JOB_RUN_STALE_HOURS,
|
||||
VOICE_IDLE_RUN_RETENTION_HOURS,
|
||||
PENDING_DELIVERY_MAX_AGE_DAYS,
|
||||
WEBHOOK_DELIVERY_RETENTION_DAYS,
|
||||
} from './cleanup.job';
|
||||
@@ -37,6 +39,13 @@ describe('CleanupJob', () => {
|
||||
};
|
||||
|
||||
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;
|
||||
executeRaw = jest.fn<Promise<number>, RawCall>(() =>
|
||||
Promise.resolve(count++),
|
||||
@@ -62,6 +71,17 @@ describe('CleanupJob', () => {
|
||||
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 () => {
|
||||
await job.run();
|
||||
|
||||
@@ -94,6 +114,11 @@ describe('CleanupJob', () => {
|
||||
'jobRuns.aborted': 1,
|
||||
'jobRuns.deleted': 19,
|
||||
weatherDays: 20,
|
||||
'voice.audioExpired': 1,
|
||||
'voice.contentExpired': 2,
|
||||
'voice.inactiveOwnersWiped': 3,
|
||||
'voice.rowsDeleted': 4,
|
||||
'jobRuns.voiceIdle': 21,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.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 { Job, JobResult } from './job';
|
||||
import { JobName } from './job-names';
|
||||
@@ -18,6 +23,8 @@ export const JOB_RUN_RETENTION_DAYS = 90;
|
||||
export const PENDING_DELIVERY_MAX_AGE_DAYS = 7;
|
||||
/** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */
|
||||
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(),
|
||||
@@ -100,6 +107,17 @@ export class CleanupJob implements Job {
|
||||
DELETE FROM "ApiaryWeatherDay"
|
||||
WHERE "day" < make_date(
|
||||
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 {
|
||||
idempotencyRecords,
|
||||
consumedTokens,
|
||||
@@ -115,6 +133,11 @@ export class CleanupJob implements Job {
|
||||
'jobRuns.aborted': jobRunsAborted,
|
||||
'jobRuns.deleted': jobRunsDeleted,
|
||||
weatherDays,
|
||||
'voice.audioExpired': voiceRetention.audioExpired,
|
||||
'voice.contentExpired': voiceRetention.contentExpired,
|
||||
'voice.inactiveOwnersWiped': voiceRetention.inactiveOwnersWiped,
|
||||
'voice.rowsDeleted': voiceRowsDeleted,
|
||||
'jobRuns.voiceIdle': voiceIdleRuns,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,14 @@ describe('job names', () => {
|
||||
).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]);
|
||||
for (const name of JOB_NAMES) {
|
||||
expect(JOB_ALLOWED_ARGS[name]).toEqual(
|
||||
name === 'rekey' ? ['--dry-run'] : [],
|
||||
);
|
||||
const expected: Record<string, string[]> = {
|
||||
rekey: ['--dry-run'],
|
||||
'voice-transcribe': ['--phase=fetch', '--phase=ingest'],
|
||||
};
|
||||
expect(JOB_ALLOWED_ARGS[name]).toEqual(expected[name] ?? []);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ export const JOB_NAMES = [
|
||||
'cleanup',
|
||||
'app-backup',
|
||||
'rekey',
|
||||
'voice-transcribe',
|
||||
] as const;
|
||||
|
||||
export type JobName = (typeof JOB_NAMES)[number];
|
||||
@@ -18,6 +19,7 @@ export const JOB_LABELS_DE: Record<JobName, string> = {
|
||||
cleanup: 'Aufräumen',
|
||||
'app-backup': 'App-Backup',
|
||||
rekey: 'Schlüssel umstellen',
|
||||
'voice-transcribe': 'Sprachnotizen abschreiben',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -32,6 +34,7 @@ export const JOB_FAILURE_PERMISSION: Record<JobName, PermissionKey> = {
|
||||
cleanup: 'system:manage',
|
||||
'app-backup': 'backup:configure',
|
||||
rekey: 'system:manage',
|
||||
'voice-transcribe': 'system:manage',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -45,6 +48,7 @@ export const JOB_ALLOWED_ARGS: Record<JobName, readonly string[]> = {
|
||||
cleanup: [],
|
||||
'app-backup': [],
|
||||
rekey: ['--dry-run'],
|
||||
'voice-transcribe': ['--phase=fetch', '--phase=ingest'],
|
||||
};
|
||||
|
||||
export function isJobName(value: string): value is JobName {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { JobRegistry, JOBS } from './job-registry';
|
||||
import { RekeyJob } from './rekey.job';
|
||||
import { TimeEventsStep } from './steps/time-events.step';
|
||||
import { TaskTemplatesJob } from './task-templates.job';
|
||||
import { VoiceTranscribeJob } from './voice-transcribe.job';
|
||||
|
||||
const JOB_CLASSES = [
|
||||
DailyEvaluationJob,
|
||||
@@ -28,6 +29,7 @@ const JOB_CLASSES = [
|
||||
CleanupJob,
|
||||
AppBackupJob,
|
||||
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 { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JOB_NAMES } from '../jobs/job-names';
|
||||
import { JobRunService } from './job-run.service';
|
||||
import { SystemQueueService } from './system-queue.service';
|
||||
|
||||
@@ -67,7 +68,7 @@ describe('SystemQueueService', () => {
|
||||
oldestPendingAgeSeconds: 42.5,
|
||||
});
|
||||
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]);
|
||||
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;
|
||||
|
||||
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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
['ColonyCreated', { apiaryId: 'a1' }],
|
||||
['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }],
|
||||
@@ -97,6 +97,8 @@ describe('WebhookEventHandlers', () => {
|
||||
'HiveChanged',
|
||||
{ colonyId: 'c1', version: 2, source: 'CARD', inspectionId: null },
|
||||
],
|
||||
['VoiceCaptureReady', { captureId: 'v1', colonyId: 'c1' }],
|
||||
['VoiceCaptureFailed', { captureId: 'v1', code: 'NO_SPEECH' }],
|
||||
] as const;
|
||||
for (const [type, payload] of herdEvents) {
|
||||
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 { TasksModule } from '../tasks/tasks.module';
|
||||
import { TelegramModule } from '../telegram/telegram.module';
|
||||
import { VoiceHandlersModule } from '../voice/voice-handlers.module';
|
||||
import { WebhooksModule } from '../webhooks/webhooks.module';
|
||||
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
|
||||
import { WorkerHealthServer } from './worker-health-server.service';
|
||||
@@ -45,6 +46,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
|
||||
ComplianceModule,
|
||||
RemindersModule,
|
||||
TelegramModule,
|
||||
VoiceHandlersModule,
|
||||
WorkerRuntimeModule,
|
||||
],
|
||||
providers: [WorkerHealthServer],
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { sealTranscript } from '../src/voice/core/transcript';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
|
||||
@@ -67,6 +68,9 @@ describe('Search and filters (e2e)', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.voiceCapture.deleteMany({
|
||||
where: { ownerUserId: readonlyId },
|
||||
});
|
||||
await prisma.colony.deleteMany({
|
||||
where: { id: { in: [colonyId, dissolvedColonyId] } },
|
||||
});
|
||||
@@ -88,6 +92,48 @@ describe('Search and filters (e2e)', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns nothing of a voice capture, neither by its text nor by its id', async () => {
|
||||
const spoken = `Stifte${Date.now()}x`;
|
||||
const capture = await prisma.voiceCapture.create({
|
||||
data: {
|
||||
ownerUserId: readonlyId,
|
||||
colonyId,
|
||||
inspectionDate: new Date(),
|
||||
source: 'TEXT',
|
||||
status: 'TRANSCRIBED',
|
||||
contentExpiresAt: new Date(Date.now() + 86_400_000),
|
||||
},
|
||||
});
|
||||
await prisma.voiceCapture.update({
|
||||
where: { id: capture.id },
|
||||
data: {
|
||||
transcriptEnc: sealTranscript(capture.id, {
|
||||
v: 1,
|
||||
text: `${spoken} Koenigin gesehen`,
|
||||
}),
|
||||
lastErrorCode: 'EXTRACT_DISABLED',
|
||||
},
|
||||
});
|
||||
|
||||
// The colony code is found (so the colony is in play), the capture never.
|
||||
for (const term of [spoken, capture.id, 'EXTRACT_DISABLED', uniqueCode]) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/search?q=${term}`)
|
||||
.set('Authorization', `Bearer ${readonlyToken}`)
|
||||
.expect(200);
|
||||
const results = response.body as SearchResultBody[];
|
||||
const text = JSON.stringify(results);
|
||||
expect(text).not.toContain(capture.id);
|
||||
expect(text).not.toContain(spoken);
|
||||
for (const entry of results) {
|
||||
expect(Object.keys(entry).sort()).toEqual(['id', 'label', 'type']);
|
||||
expect(['Apiary', 'Colony', 'Queen', 'Task', 'HoneyBatch']).toContain(
|
||||
entry.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('filters colonies by status', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/colonies?status=DISSOLVED&apiaryId=${apiaryId}`)
|
||||
@@ -101,29 +147,103 @@ describe('Search and filters (e2e)', () => {
|
||||
describe('Voice capture content guard', () => {
|
||||
// VoiceCapture holds personal data that is deleted after at most 7 days. It
|
||||
// must never be reachable through search or any export/report builder.
|
||||
const sourceRoots = ['search', 'export'].map((dir) =>
|
||||
path.resolve(__dirname, '../src', dir),
|
||||
);
|
||||
const srcRoot = path.resolve(__dirname, '../src');
|
||||
|
||||
// Every place that builds a document or a list for the user or another
|
||||
// system, by directory: nothing here may touch the voice tables.
|
||||
const builderDirs = [
|
||||
'search',
|
||||
'export',
|
||||
'erp-export',
|
||||
'compliance',
|
||||
'label-templates',
|
||||
'statistics',
|
||||
'dashboard',
|
||||
'public',
|
||||
'qr',
|
||||
'calendar',
|
||||
'calendar-sync',
|
||||
'webhooks',
|
||||
'shop-sync',
|
||||
'documents',
|
||||
'import',
|
||||
];
|
||||
|
||||
// Files that build a PDF, CSV or download wherever they live. Found by
|
||||
// content, so a new report builder in any folder is covered without an
|
||||
// edit here. src/voice itself and src/backup (which lists the voice models
|
||||
// as excluded) are the only places allowed to name the model.
|
||||
const builderMarkers =
|
||||
/PDFDocument|pdf-lib|pdfkit|application\/pdf|text\/csv|spreadsheetml|Content-Disposition|toCsv|csv-stringify/;
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return sourceFiles(full);
|
||||
return entry.name.endsWith('.ts') ? [full] : [];
|
||||
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
|
||||
? [full]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
it('VoiceCapture appears in no search provider and no export', () => {
|
||||
const files = sourceRoots.flatMap(sourceFiles);
|
||||
function mentionsVoiceCapture(file: string): boolean {
|
||||
return /voiceCapture|VoiceCapture/.test(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
function relative(file: string): string {
|
||||
return path.relative(srcRoot, file).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
it('VoiceCapture appears in no search provider, export or report directory', () => {
|
||||
const files = builderDirs.flatMap((dir) =>
|
||||
sourceFiles(path.join(srcRoot, dir)),
|
||||
);
|
||||
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
for (const file of files) {
|
||||
expect({
|
||||
file: path.basename(file),
|
||||
mentions: /voiceCapture|VoiceCapture/.test(
|
||||
fs.readFileSync(file, 'utf8'),
|
||||
),
|
||||
}).toEqual({ file: path.basename(file), mentions: false });
|
||||
file: relative(file),
|
||||
mentions: mentionsVoiceCapture(file),
|
||||
}).toEqual({
|
||||
file: relative(file),
|
||||
mentions: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('VoiceCapture appears in no file that builds a PDF, CSV or download, wherever it lives', () => {
|
||||
const builders = sourceFiles(srcRoot).filter((file) => {
|
||||
const name = relative(file);
|
||||
return (
|
||||
!name.startsWith('voice/') &&
|
||||
!name.startsWith('backup/') &&
|
||||
builderMarkers.test(fs.readFileSync(file, 'utf8'))
|
||||
);
|
||||
});
|
||||
|
||||
// The scan must find the known builders, or it checks nothing.
|
||||
expect(builders.map(relative)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/^export\//),
|
||||
expect.stringMatching(/^compliance\/pdf\//),
|
||||
]),
|
||||
);
|
||||
for (const file of builders) {
|
||||
expect({
|
||||
file: relative(file),
|
||||
mentions: mentionsVoiceCapture(file),
|
||||
}).toEqual({
|
||||
file: relative(file),
|
||||
mentions: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('SearchService names no voice type, field or table', () => {
|
||||
const service = fs.readFileSync(
|
||||
path.join(srcRoot, 'search/search.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
expect(service).not.toMatch(/voice|transcript|suggestionEnc/i);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
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 { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
@@ -104,7 +105,7 @@ describe('System API (e2e)', () => {
|
||||
.set(auth(adminToken))
|
||||
.expect(200);
|
||||
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');
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ RUN npx next typegen
|
||||
# Unit-Tests gruen sind.
|
||||
RUN npm run lint && npx tsc --noEmit && npm test
|
||||
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
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"lint": "eslint",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"check:3d-budget": "node scripts/check-3d-budget.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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 { updateHiveSystem } from "@/lib/hive/catalog-api";
|
||||
import { describeHiveOps } from "@/lib/hive/describe";
|
||||
import { findPart } from "@/lib/hive/find-part";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
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;
|
||||
}
|
||||
|
||||
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. */
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
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;
|
||||
// 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.
|
||||
if (pickedId !== null && selectedId === null) setPickedId(null);
|
||||
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 }]);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const first = setup();
|
||||
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
|
||||
// few pixels (a tap stays a tap); the scene reports the slot under 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
|
||||
// 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();
|
||||
};
|
||||
|
||||
// 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. */
|
||||
function abort() {
|
||||
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("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onCancel);
|
||||
@@ -210,6 +217,7 @@ export function useHiveDrag(options: UseHiveDragOptions): HiveDrag {
|
||||
return () => {
|
||||
window.removeEventListener("blur", onBlur);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
window.removeEventListener("pointerdown", onDown);
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onCancel);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { StateFieldControl } 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);
|
||||
}
|
||||
|
||||
/** Kept for existing imports; lives in lib/hive/feeder-slots. */
|
||||
export { frameFeederOps };
|
||||
|
||||
interface FrameFeederPickerProps {
|
||||
boxId: string;
|
||||
type: CatalogPartType;
|
||||
|
||||
@@ -13,11 +13,12 @@ import { hiveErrorCode } from "@/lib/hive/api";
|
||||
import { hiveReasonText } from "@/lib/hive/reason-text";
|
||||
import { describeHiveOps } from "@/lib/hive/describe";
|
||||
import { deriveHiveFacts } from "@/lib/hive/facts";
|
||||
import { findPart } from "@/lib/hive/find-part";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { applyHiveOps } from "@/lib/hive/ops";
|
||||
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 { FrameSheet } from "./frame-sheet";
|
||||
import { HiveConflictSheet } from "./hive-conflict-sheet";
|
||||
@@ -112,20 +113,20 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initial
|
||||
if (hive.isError || systems.isError) {
|
||||
return (
|
||||
<PlainCardBody>
|
||||
<div role="alert" className="flex flex-1 flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-sm">{LOAD_FAILED_TEXT}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
onClick={() => {
|
||||
if (hive.isError) void hive.refetch();
|
||||
if (systems.isError) void systems.refetch();
|
||||
}}
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
<div role="alert" className="flex flex-1 flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-sm">{LOAD_FAILED_TEXT}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
onClick={() => {
|
||||
if (hive.isError) void hive.refetch();
|
||||
if (systems.isError) void systems.refetch();
|
||||
}}
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
</PlainCardBody>
|
||||
);
|
||||
}
|
||||
@@ -196,12 +197,14 @@ interface PastState {
|
||||
occurredOn: string;
|
||||
}
|
||||
|
||||
function findPartKind(state: HiveState, catalog: HiveCatalog, id: string) {
|
||||
for (const part of state.stack) {
|
||||
const hit = part.id === id ? part : part.inserts?.find((insert) => insert.id === id);
|
||||
if (hit) return catalog.partTypes.get(hit.partTypeId);
|
||||
}
|
||||
return undefined;
|
||||
function findPartType(state: HiveState, catalog: HiveCatalog, id: string): CatalogPartType | undefined {
|
||||
const part = findPart(state, id);
|
||||
return part ? catalog.partTypes.get(part.partTypeId) : 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 {
|
||||
@@ -282,10 +285,9 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
|
||||
const partName = (id: string) => partDisplayName(shownState, catalog, id);
|
||||
const focusedId = focusedIdOf(view);
|
||||
const leaveFocus = () => view.openBox(null);
|
||||
/** Parts whose state the part sheet edits (a box is changed through its frames). */
|
||||
const hasSheet = (id: string) => {
|
||||
const type = findPartKind(shownState, catalog, id);
|
||||
return type !== undefined && type.kind !== "BOX" && (type.stateSchema.length > 0 || type.kind === "FLOOR");
|
||||
const type = findPartType(shownState, catalog, id);
|
||||
return type !== undefined && hasPartSheet(type);
|
||||
};
|
||||
|
||||
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") {
|
||||
if (!editable) return;
|
||||
// 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" });
|
||||
setPartOpen(true);
|
||||
}
|
||||
|
||||
function selectPart(id: string) {
|
||||
const type = findPartKind(shownState, catalog, id);
|
||||
const type = findPartType(shownState, catalog, id);
|
||||
if (type?.kind === "BOX") {
|
||||
view.openBox(id);
|
||||
return;
|
||||
}
|
||||
if (type && (type.stateSchema.length > 0 || type.kind === "FLOOR")) editPart(id);
|
||||
if (type && hasPartSheet(type)) editPart(id);
|
||||
}
|
||||
|
||||
const showRebuild = canEdit && !isInspection;
|
||||
@@ -364,7 +366,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
|
||||
{focusedId ? (
|
||||
// In focus mode the top belongs to the focus bar; the facts stay readable here.
|
||||
// Phones keep the room for the part; the focus chips carry its state there.
|
||||
<div className={`hidden max-w-full rounded-xl px-3 py-1.5 text-center sm:block ${GLASS_SURFACE}`}>
|
||||
<div className={`hidden max-w-full rounded-xl px-3 py-1.5 text-center sm:block ${GLASS_SURFACE}`}>
|
||||
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
|
||||
<HivePendingNote colonyId={colonyId} className={`justify-center ${PENDING_NOTE_CLASS}`} />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useEffect } from "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 { formatAmount } from "./hive-scene/scene-model";
|
||||
|
||||
@@ -15,15 +16,6 @@ export interface FocusChip {
|
||||
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. */
|
||||
export function focusChips(state: HiveState, catalog: HiveCatalog, id: string): FocusChip[] {
|
||||
const part = findPart(state, id);
|
||||
|
||||
@@ -126,12 +126,12 @@ export function DropTargets({ anchors, hover, pointer, subscribePointer, onHover
|
||||
};
|
||||
}, [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);
|
||||
useEffect(() => {
|
||||
const element = gl.domElement as HTMLElement | undefined;
|
||||
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.
|
||||
useEffect(() => subscribePointer(() => invalidate()), [subscribePointer, invalidate]);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createCausticMaterial, createLiquidMaterial } from "./materials";
|
||||
import { MAX_FRAME_SECONDS } from "./use-animated-node";
|
||||
import { stepToward } from "./use-hive-motion";
|
||||
import { noRaycast, useSceneKit } from "./scene-kit";
|
||||
import { seededRandom } from "./seeded-random";
|
||||
import { placeFill, setUniform } from "./scene-mutations";
|
||||
|
||||
// Levels (spec 9.3): a glowing, half-transparent volume whose surface moves
|
||||
@@ -104,11 +105,7 @@ function fondantTexture(): CanvasTexture | null {
|
||||
canvas.height = 128;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return null;
|
||||
let seed = 97;
|
||||
const next = () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
return seed / 2147483647;
|
||||
};
|
||||
const next = seededRandom(97);
|
||||
context.fillStyle = "#d8d8d8";
|
||||
context.fillRect(0, 0, 128, 128);
|
||||
for (let dot = 0; dot < 900; dot += 1) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Color, MeshStandardMaterial } from "three";
|
||||
import type { Group, ShaderMaterial } from "three";
|
||||
import { subVec3 } from "@/lib/hive/layout";
|
||||
import type { FrameBox, Vec3 } from "@/lib/hive/layout";
|
||||
import { HoloLabel } from "./hive-labels";
|
||||
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
|
||||
// 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. */
|
||||
export function FrameHints({
|
||||
frames,
|
||||
@@ -39,7 +38,7 @@ export function FrameHints({
|
||||
const geometries = useMemo(() => {
|
||||
const hints = frames
|
||||
.filter((frame) => frame.type !== "NONE")
|
||||
.map((frame) => frameHintSpec(frame.size, sub(frame.center, hostCenter)));
|
||||
.map((frame) => frameHintSpec(frame.size, subVec3(frame.center, hostCenter)));
|
||||
return {
|
||||
glass: buildGlassGeometry(hints.map((hint) => hint.slab)),
|
||||
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
|
||||
// portals (drei Html), so they are replaced by a marker here.
|
||||
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) => {
|
||||
const actual = await importOriginal<typeof import("./drop-anchors")>();
|
||||
return {
|
||||
@@ -291,6 +302,22 @@ describe("HiveSceneContent in the editor", () => {
|
||||
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 () => {
|
||||
const rest = await renderScene("off");
|
||||
await rest.renderer.advanceFrames(1, 1 / 60);
|
||||
@@ -309,7 +336,7 @@ describe("HiveSceneContent in the editor", () => {
|
||||
});
|
||||
|
||||
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 layout = hiveLayout(state, catalog);
|
||||
const store = createHiveViewStore();
|
||||
@@ -341,11 +368,15 @@ describe("HiveSceneContent drop anchors", () => {
|
||||
</HiveViewProvider>
|
||||
);
|
||||
anchorCalls.count = 0;
|
||||
motionCalls.count = 0;
|
||||
const renderer = await ReactThreeTestRenderer.create(element(editor(null)));
|
||||
const afterMount = anchorCalls.count;
|
||||
const motionAfterMount = motionCalls.count;
|
||||
await renderer.update(element(editor(slots[0])));
|
||||
await renderer.update(element(editor(slots[1])));
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,15 +110,15 @@ export function HiveSceneContent({
|
||||
const motionView = useMemo(() => ({ ...view, selectedFrame }), [view, selectedFrame]);
|
||||
const drag = editor?.drag ?? null;
|
||||
const gap = drag ? DRAG_GAP : REST_GAP;
|
||||
const movedId = drag && "partId" in drag.source ? drag.source.partId : null;
|
||||
const targets: HiveMotionTargets = useMemo(() => {
|
||||
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;
|
||||
if (!movedId || !moved) return computed;
|
||||
const nodes = new Map(computed.nodes);
|
||||
nodes.set(movedId, { ...moved, opacity: Math.min(moved.opacity, DRAGGED_OPACITY), interactive: false });
|
||||
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.
|
||||
const dragSource = drag?.source ?? null;
|
||||
const dragSlots = drag?.slots ?? null;
|
||||
@@ -199,9 +199,10 @@ export function HiveSceneContent({
|
||||
onPointerDown(event) {
|
||||
pointerType.current = event.pointerType ?? "mouse";
|
||||
const parsed = parseKey(key);
|
||||
// Only the nearest hit takes the press (inserts and frames inside a box, thin parts on top).
|
||||
// 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") {
|
||||
// Only the nearest hit takes the press (inserts and frames inside a box, thin parts on top).
|
||||
event.stopPropagation();
|
||||
if (signals.pinching) return;
|
||||
// A press on a part may become a part drag: the rig must not turn the model for it.
|
||||
markPartPress(signals, true);
|
||||
|
||||
@@ -16,10 +16,10 @@ import {
|
||||
SRGBColorSpace,
|
||||
ShaderMaterial,
|
||||
} from "three";
|
||||
import type { Points } from "three";
|
||||
import { createQualityMonitor } 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 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() {
|
||||
const { theme } = useSceneKit();
|
||||
const dpr = useThree((state) => state.viewport.dpr);
|
||||
const points = useRef<Points>(null);
|
||||
const { geometry, material } = useMemo(() => {
|
||||
const next = seededRandom(7);
|
||||
const positions: number[] = [];
|
||||
@@ -332,7 +322,7 @@ function Particles() {
|
||||
setUniform(material, "uTime", state.clock.elapsedTime);
|
||||
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() {
|
||||
@@ -448,7 +438,7 @@ function Haze() {
|
||||
material={material}
|
||||
renderOrder={-1}
|
||||
layers={OVERLAY_LAYER}
|
||||
raycast={() => {}}
|
||||
raycast={noRaycast}
|
||||
>
|
||||
<coneGeometry args={[6.5, HAZE_HEIGHT, 48, 1, true]} />
|
||||
</mesh>
|
||||
|
||||
@@ -362,9 +362,3 @@ export function createLiquidMaterial(options: LiquidOptions): ShaderMaterial {
|
||||
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 { HivePartKind } from "@/lib/hive/types";
|
||||
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}`;
|
||||
|
||||
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]];
|
||||
|
||||
/** Rest place of a stack part: its layout centre raised by the stack gap. */
|
||||
@@ -200,10 +200,10 @@ function insertTarget(
|
||||
host: PartBox,
|
||||
gap: number,
|
||||
): NodeTarget {
|
||||
const local = sub(insert.center, host.center);
|
||||
const local = subVec3(insert.center, host.center);
|
||||
if (focus?.isInsert && focus.id === insert.id) {
|
||||
return {
|
||||
position: sub(focusPlacement(layout.bounds, insert.size), restCenter(host, gap)),
|
||||
position: subVec3(focusPlacement(layout.bounds, insert.size), restCenter(host, gap)),
|
||||
scale: FOCUS_SCALE,
|
||||
opacity: 1,
|
||||
glow: GLOW.focus,
|
||||
@@ -241,7 +241,7 @@ function insertTarget(
|
||||
}
|
||||
|
||||
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 };
|
||||
const open = focus !== null && !focus.isInsert && focus.id === host.id;
|
||||
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,4 +1,5 @@
|
||||
import type { FeedType } from "@/lib/feedings/types";
|
||||
import { findPart } from "./find-part";
|
||||
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HiveState, LevelField } from "./types";
|
||||
|
||||
// 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 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 {
|
||||
return part ? catalog.partTypes.get(part.partTypeId) : undefined;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import type { CatalogPartType, FrameType, HiveCatalog, HivePart, HivePartKind, H
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
partTypeId: string;
|
||||
|
||||
@@ -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] 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)
|
||||
- [ ] 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] 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
|
||||
- [ ] 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)
|
||||
- [ ] 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
|
||||
- [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 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)
|
||||
|
||||
## Später (KANN) – Abschnitt 26, Liste 3
|
||||
|
||||
Reference in new issue
Block a user