feat: Sprachnotizen-API (T7 L2 Task 7)
Upload (Audio nur per Magic Bytes, versiegelt, 2 MiB), Text-Weg, Liste, Abruf, Wiederholen, Verwerfen, alle verwerfen, Konfiguration und Zähler unter /voice-captures. Rechte voice-captures:use plus inspections:create (eigener Guard, API-Schlüssel ausgeschlossen), Zähler nur mit system:manage; jede Abfrage auf den Besitzer begrenzt. Grenzen je Benutzer unter Advisory-Lock (10 offen, 30 je Tag, 10 je 60 s), Wiederholen ebenfalls begrenzt und gedrosselt, Audio für QUEUED nur unter 24 h. Verwerfen löscht Inhalt und Claim sofort. Keine Inhalte in Log oder Audit. Wächter gegen Sprachinhalte in Suche, Export und Berichten erweitert. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
19550d1394
commit
0b0bc3c6dc
21 files changed
+2843
-12
No files matched your search
@@ -64,6 +64,7 @@ import { HiveModule } from './hive/hive.module';
|
|||||||
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
|
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
|
||||||
import { ComplianceModule } from './compliance/compliance.module';
|
import { ComplianceModule } from './compliance/compliance.module';
|
||||||
import { ReadinessModule } from './compliance/readiness/readiness.module';
|
import { ReadinessModule } from './compliance/readiness/readiness.module';
|
||||||
|
import { VoiceModule } from './voice/voice.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||||
@@ -140,6 +141,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
|||||||
HiveModule,
|
HiveModule,
|
||||||
ComplianceModule,
|
ComplianceModule,
|
||||||
ReadinessModule,
|
ReadinessModule,
|
||||||
|
VoiceModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { matchesFileSignature } from './file-signature';
|
import { detectAudioType, matchesFileSignature } from './file-signature';
|
||||||
|
|
||||||
describe('matchesFileSignature', () => {
|
describe('matchesFileSignature', () => {
|
||||||
it('accepts a buffer starting with the PNG signature', () => {
|
it('accepts a buffer starting with the PNG signature', () => {
|
||||||
@@ -47,3 +47,63 @@ describe('matchesFileSignature', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('detectAudioType', () => {
|
||||||
|
it('knows EBML, OggS and ftyp and nothing else', () => {
|
||||||
|
const webm = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42]);
|
||||||
|
const ogg = Buffer.concat([Buffer.from('OggS', 'ascii'), Buffer.alloc(8)]);
|
||||||
|
const mp4 = Buffer.concat([
|
||||||
|
Buffer.from([0x00, 0x00, 0x00, 0x20]),
|
||||||
|
Buffer.from('ftypM4A ', 'ascii'),
|
||||||
|
]);
|
||||||
|
expect(detectAudioType(webm)).toBe('audio/webm');
|
||||||
|
expect(detectAudioType(ogg)).toBe('audio/ogg');
|
||||||
|
expect(detectAudioType(mp4)).toBe('audio/mp4');
|
||||||
|
expect(
|
||||||
|
detectAudioType(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a])),
|
||||||
|
).toBeNull();
|
||||||
|
expect(detectAudioType(Buffer.from('RIFF....WAVE', 'ascii'))).toBeNull();
|
||||||
|
expect(detectAudioType(Buffer.from('%PDF-1.7', 'ascii'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['heic', 'avif', 'mif1', 'qt ', '3gp4'])(
|
||||||
|
'rejects an ftyp file with the brand %s',
|
||||||
|
(brand) => {
|
||||||
|
const file = Buffer.concat([
|
||||||
|
Buffer.from([0, 0, 0, 0x20]),
|
||||||
|
Buffer.from(`ftyp${brand}`, 'latin1'),
|
||||||
|
]);
|
||||||
|
expect(detectAudioType(file)).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['M4A ', 'isom', 'iso2', 'iso6', 'mp41', 'mp42', 'dash'])(
|
||||||
|
'accepts the audio brand %s',
|
||||||
|
(brand) => {
|
||||||
|
const file = Buffer.concat([
|
||||||
|
Buffer.from([0, 0, 0, 0x20]),
|
||||||
|
Buffer.from(`ftyp${brand}`, 'latin1'),
|
||||||
|
]);
|
||||||
|
expect(detectAudioType(file)).toBe('audio/mp4');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects buffers that are too short for any signature', () => {
|
||||||
|
expect(detectAudioType(Buffer.alloc(0))).toBeNull();
|
||||||
|
expect(detectAudioType(Buffer.from([0x1a, 0x45, 0xdf]))).toBeNull();
|
||||||
|
expect(
|
||||||
|
detectAudioType(Buffer.from('OggS', 'ascii').subarray(0, 3)),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
detectAudioType(Buffer.from([0, 0, 0, 0x20, 0x66, 0x74])),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not take a signature from the middle of the buffer', () => {
|
||||||
|
const late = Buffer.concat([
|
||||||
|
Buffer.alloc(16),
|
||||||
|
Buffer.from('OggS', 'ascii'),
|
||||||
|
]);
|
||||||
|
expect(detectAudioType(late)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,3 +33,47 @@ export function matchesFileSignature(
|
|||||||
const check = SIGNATURE_CHECKS[declaredMimeType];
|
const check = SIGNATURE_CHECKS[declaredMimeType];
|
||||||
return check ? check(buffer) : true;
|
return check ? check(buffer) : true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VoiceAudioMime = 'audio/webm' | 'audio/ogg' | 'audio/mp4';
|
||||||
|
|
||||||
|
// Major brands of MP4/M4A audio; HEIC, AVIF and other ISO-BMFF files are not.
|
||||||
|
const MP4_AUDIO_BRANDS: ReadonlySet<string> = new Set([
|
||||||
|
'M4A ',
|
||||||
|
'isom',
|
||||||
|
'iso2',
|
||||||
|
'iso3',
|
||||||
|
'iso4',
|
||||||
|
'iso5',
|
||||||
|
'iso6',
|
||||||
|
'mp41',
|
||||||
|
'mp42',
|
||||||
|
'dash',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EBML_MAGIC = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names the audio container by its first bytes - strict, unlike
|
||||||
|
* matchesFileSignature: anything unknown is null, and the declared type of
|
||||||
|
* the upload plays no part. EBML (WebM/Matroska), "OggS" and "ftyp" at byte 4
|
||||||
|
* (MP4/M4A) are the containers MediaRecorder produces.
|
||||||
|
*/
|
||||||
|
export function detectAudioType(buffer: Buffer): VoiceAudioMime | null {
|
||||||
|
if (buffer.length >= 4 && buffer.subarray(0, 4).equals(EBML_MAGIC)) {
|
||||||
|
return 'audio/webm';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buffer.length >= 4 &&
|
||||||
|
buffer.subarray(0, 4).toString('latin1') === 'OggS'
|
||||||
|
) {
|
||||||
|
return 'audio/ogg';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buffer.length >= 12 &&
|
||||||
|
buffer.subarray(4, 8).toString('latin1') === 'ftyp' &&
|
||||||
|
MP4_AUDIO_BRANDS.has(buffer.subarray(8, 12).toString('latin1'))
|
||||||
|
) {
|
||||||
|
return 'audio/mp4';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export const WAKE_CHANNELS = {
|
|||||||
calendarSync: 'calendar_sync',
|
calendarSync: 'calendar_sync',
|
||||||
webhookDeliveries: 'webhook_delivery',
|
webhookDeliveries: 'webhook_delivery',
|
||||||
notificationDeliveries: 'notification_delivery',
|
notificationDeliveries: 'notification_delivery',
|
||||||
|
voiceExtraction: 'voice_extraction',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
|
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
|
||||||
|
|||||||
@@ -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,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { InspectionCreateGuard } from './captures/inspection-create.guard';
|
||||||
|
import { VoiceCapturesController } from './captures/voice-captures.controller';
|
||||||
|
import { VoiceCapturesStatsService } from './captures/voice-captures-stats.service';
|
||||||
|
import { VoiceCapturesService } from './captures/voice-captures.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The voice note API (T7). Audit, the permission resolver and the Prisma
|
||||||
|
* client come from global modules. Later tasks add the confirmation service
|
||||||
|
* (task 8) and the handlers (task 9).
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [VoiceCapturesController],
|
||||||
|
providers: [
|
||||||
|
VoiceCapturesService,
|
||||||
|
VoiceCapturesStatsService,
|
||||||
|
InspectionCreateGuard,
|
||||||
|
],
|
||||||
|
exports: [VoiceCapturesService],
|
||||||
|
})
|
||||||
|
export class VoiceModule {}
|
||||||
@@ -5,6 +5,7 @@ import * as bcrypt from 'bcryptjs';
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { sealTranscript } from '../src/voice/core/transcript';
|
||||||
import { createTestApp } from './utils/create-test-app';
|
import { createTestApp } from './utils/create-test-app';
|
||||||
import { login } from './utils/api';
|
import { login } from './utils/api';
|
||||||
|
|
||||||
@@ -67,6 +68,9 @@ describe('Search and filters (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await prisma.voiceCapture.deleteMany({
|
||||||
|
where: { ownerUserId: readonlyId },
|
||||||
|
});
|
||||||
await prisma.colony.deleteMany({
|
await prisma.colony.deleteMany({
|
||||||
where: { id: { in: [colonyId, dissolvedColonyId] } },
|
where: { id: { in: [colonyId, dissolvedColonyId] } },
|
||||||
});
|
});
|
||||||
@@ -88,6 +92,48 @@ describe('Search and filters (e2e)', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns nothing of a voice capture, neither by its text nor by its id', async () => {
|
||||||
|
const spoken = `Stifte${Date.now()}x`;
|
||||||
|
const capture = await prisma.voiceCapture.create({
|
||||||
|
data: {
|
||||||
|
ownerUserId: readonlyId,
|
||||||
|
colonyId,
|
||||||
|
inspectionDate: new Date(),
|
||||||
|
source: 'TEXT',
|
||||||
|
status: 'TRANSCRIBED',
|
||||||
|
contentExpiresAt: new Date(Date.now() + 86_400_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.voiceCapture.update({
|
||||||
|
where: { id: capture.id },
|
||||||
|
data: {
|
||||||
|
transcriptEnc: sealTranscript(capture.id, {
|
||||||
|
v: 1,
|
||||||
|
text: `${spoken} Koenigin gesehen`,
|
||||||
|
}),
|
||||||
|
lastErrorCode: 'EXTRACT_DISABLED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The colony code is found (so the colony is in play), the capture never.
|
||||||
|
for (const term of [spoken, capture.id, 'EXTRACT_DISABLED', uniqueCode]) {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/search?q=${term}`)
|
||||||
|
.set('Authorization', `Bearer ${readonlyToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const results = response.body as SearchResultBody[];
|
||||||
|
const text = JSON.stringify(results);
|
||||||
|
expect(text).not.toContain(capture.id);
|
||||||
|
expect(text).not.toContain(spoken);
|
||||||
|
for (const entry of results) {
|
||||||
|
expect(Object.keys(entry).sort()).toEqual(['id', 'label', 'type']);
|
||||||
|
expect(['Apiary', 'Colony', 'Queen', 'Task', 'HoneyBatch']).toContain(
|
||||||
|
entry.type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('filters colonies by status', async () => {
|
it('filters colonies by status', async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/api/v1/colonies?status=DISSOLVED&apiaryId=${apiaryId}`)
|
.get(`/api/v1/colonies?status=DISSOLVED&apiaryId=${apiaryId}`)
|
||||||
@@ -101,29 +147,103 @@ describe('Search and filters (e2e)', () => {
|
|||||||
describe('Voice capture content guard', () => {
|
describe('Voice capture content guard', () => {
|
||||||
// VoiceCapture holds personal data that is deleted after at most 7 days. It
|
// VoiceCapture holds personal data that is deleted after at most 7 days. It
|
||||||
// must never be reachable through search or any export/report builder.
|
// must never be reachable through search or any export/report builder.
|
||||||
const sourceRoots = ['search', 'export'].map((dir) =>
|
const srcRoot = path.resolve(__dirname, '../src');
|
||||||
path.resolve(__dirname, '../src', dir),
|
|
||||||
);
|
// Every place that builds a document or a list for the user or another
|
||||||
|
// system, by directory: nothing here may touch the voice tables.
|
||||||
|
const builderDirs = [
|
||||||
|
'search',
|
||||||
|
'export',
|
||||||
|
'erp-export',
|
||||||
|
'compliance',
|
||||||
|
'label-templates',
|
||||||
|
'statistics',
|
||||||
|
'dashboard',
|
||||||
|
'public',
|
||||||
|
'qr',
|
||||||
|
'calendar',
|
||||||
|
'calendar-sync',
|
||||||
|
'webhooks',
|
||||||
|
'shop-sync',
|
||||||
|
'documents',
|
||||||
|
'import',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Files that build a PDF, CSV or download wherever they live. Found by
|
||||||
|
// content, so a new report builder in any folder is covered without an
|
||||||
|
// edit here. src/voice itself and src/backup (which lists the voice models
|
||||||
|
// as excluded) are the only places allowed to name the model.
|
||||||
|
const builderMarkers =
|
||||||
|
/PDFDocument|pdf-lib|pdfkit|application\/pdf|text\/csv|spreadsheetml|Content-Disposition|toCsv|csv-stringify/;
|
||||||
|
|
||||||
function sourceFiles(dir: string): string[] {
|
function sourceFiles(dir: string): string[] {
|
||||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||||
const full = path.join(dir, entry.name);
|
const full = path.join(dir, entry.name);
|
||||||
if (entry.isDirectory()) return sourceFiles(full);
|
if (entry.isDirectory()) return sourceFiles(full);
|
||||||
return entry.name.endsWith('.ts') ? [full] : [];
|
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
|
||||||
|
? [full]
|
||||||
|
: [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it('VoiceCapture appears in no search provider and no export', () => {
|
function mentionsVoiceCapture(file: string): boolean {
|
||||||
const files = sourceRoots.flatMap(sourceFiles);
|
return /voiceCapture|VoiceCapture/.test(fs.readFileSync(file, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(file: string): string {
|
||||||
|
return path.relative(srcRoot, file).split(path.sep).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('VoiceCapture appears in no search provider, export or report directory', () => {
|
||||||
|
const files = builderDirs.flatMap((dir) =>
|
||||||
|
sourceFiles(path.join(srcRoot, dir)),
|
||||||
|
);
|
||||||
|
|
||||||
expect(files.length).toBeGreaterThan(0);
|
expect(files.length).toBeGreaterThan(0);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
expect({
|
expect({
|
||||||
file: path.basename(file),
|
file: relative(file),
|
||||||
mentions: /voiceCapture|VoiceCapture/.test(
|
mentions: mentionsVoiceCapture(file),
|
||||||
fs.readFileSync(file, 'utf8'),
|
}).toEqual({
|
||||||
),
|
file: relative(file),
|
||||||
}).toEqual({ file: path.basename(file), mentions: false });
|
mentions: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('VoiceCapture appears in no file that builds a PDF, CSV or download, wherever it lives', () => {
|
||||||
|
const builders = sourceFiles(srcRoot).filter((file) => {
|
||||||
|
const name = relative(file);
|
||||||
|
return (
|
||||||
|
!name.startsWith('voice/') &&
|
||||||
|
!name.startsWith('backup/') &&
|
||||||
|
builderMarkers.test(fs.readFileSync(file, 'utf8'))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The scan must find the known builders, or it checks nothing.
|
||||||
|
expect(builders.map(relative)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringMatching(/^export\//),
|
||||||
|
expect.stringMatching(/^compliance\/pdf\//),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
for (const file of builders) {
|
||||||
|
expect({
|
||||||
|
file: relative(file),
|
||||||
|
mentions: mentionsVoiceCapture(file),
|
||||||
|
}).toEqual({
|
||||||
|
file: relative(file),
|
||||||
|
mentions: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SearchService names no voice type, field or table', () => {
|
||||||
|
const service = fs.readFileSync(
|
||||||
|
path.join(srcRoot, 'search/search.service.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
expect(service).not.toMatch(/voice|transcript|suggestionEnc/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user