fix: Sprachnotizen werden bei ausgeschalteter Aufnahme abgelehnt

POST /voice-captures und /voice-captures/text antworten mit 403
VOICE_RECORDING_DISABLED („Sprachnotizen sind gerade ausgeschaltet.“),
solange VOICE_RECORDING_ENABLED nicht true ist; Liste, Abruf,
Wiederholen und Verwerfen bleiben möglich (Ruling 14).

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 11:36:47 +02:00
1 parent 484d799a36
commit 462ac46d1e
4 files changed
+84 -1

No files matched your search

@@ -67,6 +67,7 @@ describe('VoiceCapturesService', () => {
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);
process.env.VOICE_RECORDING_ENABLED = 'true';
counts = { open: 0, today: 0, minute: 0 };
const findFirst = jest.fn();
tx = {
@@ -254,6 +255,30 @@ describe('VoiceCapturesService', () => {
});
});
describe('recording switch', () => {
it.each(['upload', 'text'])(
'refuses %s with 403 VOICE_RECORDING_DISABLED before any work',
async (kind) => {
process.env.VOICE_RECORDING_ENABLED = 'false';
const call =
kind === 'upload'
? service.upload('user-1', file(WEBM), uploadDto)
: service.submitText('user-1', {
colonyId: 'colony-1',
inspectionDate: '2026-10-16',
text: SECRET_TEXT,
});
const error = await failureOf(call);
expect(error.getStatus()).toBe(403);
expect(codeOf(error)).toBe('VOICE_RECORDING_DISABLED');
expect((error.getResponse() as { message: string }).message).toBe(
'Sprachnotizen sind gerade ausgeschaltet.',
);
expect(prisma.$transaction).not.toHaveBeenCalled();
},
);
});
describe('submitText', () => {
const textDto = {
colonyId: 'colony-1',
@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
@@ -21,7 +22,7 @@ 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 { VOICE_RECORDING_DISABLED, VoiceErrorCode } from '../voice-error-codes';
import { SubmitVoiceTextDto } from './dto/submit-voice-text.dto';
import { UploadVoiceCaptureDto } from './dto/upload-voice-capture.dto';
import {
@@ -107,6 +108,17 @@ function gone(): ConflictException {
});
}
/** New notes are refused while the switch is off; reading and cleaning up stay open. */
function assertRecordingEnabled(): void {
if (!voiceConfig().recordingEnabled) {
throw new ForbiddenException({
statusCode: HttpStatus.FORBIDDEN,
code: VOICE_RECORDING_DISABLED,
message: 'Sprachnotizen sind gerade ausgeschaltet.',
});
}
}
function tooManyOpen(): HttpException {
const code: VoiceErrorCode = 'TOO_MANY_OPEN';
return new HttpException(
@@ -153,6 +165,7 @@ export class VoiceCapturesService {
file: Express.Multer.File,
dto: UploadVoiceCaptureDto,
): Promise<{ id: string; status: 'QUEUED' }> {
assertRecordingEnabled();
if (file.size > MAX_AUDIO_BYTES || file.buffer.length > MAX_AUDIO_BYTES) {
throw new PayloadTooLargeException({
statusCode: HttpStatus.PAYLOAD_TOO_LARGE,
@@ -200,6 +213,7 @@ export class VoiceCapturesService {
userId: string,
dto: SubmitVoiceTextDto,
): Promise<{ id: string; status: 'TRANSCRIBED' }> {
assertRecordingEnabled();
const text = normalizeInputText(dto.text);
const length = Array.from(text).length;
if (length < MIN_TEXT_CHARS || length > MAX_TEXT_CHARS) {
@@ -26,3 +26,9 @@ export type VoiceErrorCode = (typeof VOICE_ERROR_CODES)[number];
* VOICE_ERROR_CODES: it is never stored in lastErrorCode.
*/
export const VOICE_CAPTURE_COLONY_MISMATCH = 'VOICE_CAPTURE_COLONY_MISMATCH';
/**
* Answered by upload and text when VOICE_RECORDING_ENABLED is off. Not in
* VOICE_ERROR_CODES: it is never stored in lastErrorCode.
*/
export const VOICE_RECORDING_DISABLED = 'VOICE_RECORDING_DISABLED';