feat: Sprachnotizen übernehmen, melden und abschreiben (T7 L2 Tasks 8, 9, 11)
Backend Image / build-and-push (push) Successful in 6m51s
Frontend Image / build-and-push (push) Successful in 18m9s

Task 8: POST /inspections nimmt voiceCaptureId; die Notiz wird zuletzt
in derselben Transaktion bestätigt (jeder offene Status, Ruling 1),
Inhalt, Audio und Claim gelöscht; fremde oder geschlossene Notiz 409
VOICE_CAPTURE_GONE, anderes Volk 409 VOICE_CAPTURE_COLONY_MISMATCH;
Wiederholung mit gleichem Schlüssel idempotent.

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

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

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

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 10:38:14 +02:00
1 parent e9c6604e3b
commit 1dc39f7658
35 files changed
+2886 -27

No files matched your search

@@ -0,0 +1,194 @@
import type { Prisma } from '../../generated/prisma';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import type { NotificationsService } from '../notifications/notifications.service';
import { VOICE_ERROR_CODES } from './voice-error-codes';
import { VOICE_ERROR_TEXTS_DE } from './voice-error-texts';
import { VoiceEventHandlers } from './voice.handlers';
function readyEvent(): AnyDomainEventEnvelope<'VoiceCaptureReady'> {
return {
id: 'ev-1',
type: 'VoiceCaptureReady',
aggregateType: 'VoiceCapture',
aggregateId: 'vc-1',
payload: { captureId: 'vc-1', colonyId: 'col-1' },
actorUserId: null,
correlationId: 'corr-1',
occurredAt: new Date('2026-10-16T08:00:00.000Z'),
attempts: 0,
};
}
function failedEvent(
code: (typeof VOICE_ERROR_CODES)[number],
): AnyDomainEventEnvelope<'VoiceCaptureFailed'> {
return {
...readyEvent(),
type: 'VoiceCaptureFailed',
payload: { captureId: 'vc-1', code },
} as unknown as AnyDomainEventEnvelope<'VoiceCaptureFailed'>;
}
describe('VoiceEventHandlers', () => {
let tx: { voiceCapture: { findUnique: jest.Mock } };
let notifications: {
record: jest.Mock;
recordForPermissionOnce: jest.Mock;
};
let handlers: VoiceEventHandlers;
const asTx = () => tx as unknown as Prisma.TransactionClient;
beforeEach(() => {
tx = {
voiceCapture: {
findUnique: jest.fn().mockResolvedValue({
ownerUserId: 'u-1',
colonyId: 'col-1',
status: 'READY',
colony: { code: 'Volk 7' },
}),
},
};
notifications = {
record: jest.fn().mockResolvedValue({ id: 'n-1' }),
recordForPermissionOnce: jest.fn().mockResolvedValue([]),
};
handlers = new VoiceEventHandlers(
notifications as unknown as NotificationsService,
);
});
it('writes one HINWEIS for the owner only once per capture', async () => {
await handlers.notifyReady(readyEvent(), asTx());
expect(notifications.record).toHaveBeenCalledTimes(1);
expect(notifications.record).toHaveBeenCalledWith(
{
userId: 'u-1',
priority: 'HINWEIS',
category: 'SUGGESTION',
message: 'Sprachnotiz für Volk 7 ist fertig',
relatedType: 'VoiceCapture',
relatedId: 'vc-1',
link: '/inspections/new?colonyId=col-1&voice=vc-1',
requiredPermissions: ['voice-captures:use'],
dedupeKey: 'voice-ready:vc-1',
},
tx,
);
});
it('reads the colony name from the database, not from the payload', async () => {
await handlers.notifyReady(readyEvent(), asTx());
expect(tx.voiceCapture.findUnique).toHaveBeenCalledWith({
where: { id: 'vc-1' },
select: {
ownerUserId: true,
colonyId: true,
status: true,
colony: { select: { code: true } },
},
});
expect(Object.keys(readyEvent().payload).sort()).toEqual([
'captureId',
'colonyId',
]);
});
function withStatus(status: string): void {
tx.voiceCapture.findUnique.mockResolvedValue({
ownerUserId: 'u-1',
colonyId: 'col-1',
status,
colony: { code: 'Volk 7' },
});
}
it.each(['CONFIRMED', 'DISCARDED', 'EXPIRED'])(
'sends no ready notice for a %s note',
async (status) => {
withStatus(status);
await handlers.notifyReady(readyEvent(), asTx());
expect(notifications.record).not.toHaveBeenCalled();
},
);
it.each(['CONFIRMED', 'DISCARDED', 'TRANSCRIBED'])(
'sends no failure notice to the user for a %s note',
async (status) => {
withStatus(status);
await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx());
expect(notifications.record).not.toHaveBeenCalled();
},
);
it('still tells operators when a waiting note reports EXTRACT_DISABLED', async () => {
withStatus('TRANSCRIBED');
await handlers.notifyFailed(failedEvent('EXTRACT_DISABLED'), asTx());
expect(notifications.record).not.toHaveBeenCalled();
expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(1);
});
it('writes nothing when the capture is gone', async () => {
tx.voiceCapture.findUnique.mockResolvedValue(null);
await handlers.notifyReady(readyEvent(), asTx());
await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx());
expect(notifications.record).not.toHaveBeenCalled();
});
it('the failure text names the next step per code', async () => {
withStatus('FAILED');
await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx());
const [input, passedTx] = notifications.record.mock.calls[0] as [
Record<string, unknown>,
unknown,
];
expect(passedTx).toBe(tx);
expect(input).toMatchObject({
userId: 'u-1',
priority: 'WARNUNG',
category: 'SUGGESTION',
message:
'Sprachnotiz für Volk 7 konnte nicht ausgewertet werden. Es war nichts zu verstehen. Bitte noch einmal aufnehmen.',
link: '/voice',
requiredPermissions: ['voice-captures:use'],
dedupeKey: 'voice-failed:vc-1:NO_SPEECH',
});
});
it('has a non-empty text for every error code', () => {
for (const code of VOICE_ERROR_CODES) {
expect(VOICE_ERROR_TEXTS_DE[code].length).toBeGreaterThan(0);
}
});
it('tells operators only when the evaluation is off, without a user in the text', async () => {
withStatus('FAILED');
await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx());
expect(notifications.recordForPermissionOnce).not.toHaveBeenCalled();
await handlers.notifyFailed(failedEvent('EXTRACT_DISABLED'), asTx());
expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(1);
const [permission, input] = notifications.recordForPermissionOnce.mock
.calls[0] as [string, { message: string }];
expect(permission).toBe('system:manage');
expect(input.message).not.toMatch(/Volk|vc-1|u-1/);
});
it('registers both handlers under stable ids', () => {
const ready = Reflect.get(VoiceEventHandlers.prototype, 'notifyReady');
const failed = Reflect.get(VoiceEventHandlers.prototype, 'notifyFailed');
expect(domainEventHandlerMetadata(ready as object)).toEqual([
{ types: ['VoiceCaptureReady'], handlerId: 'voice.notify-ready' },
]);
expect(domainEventHandlerMetadata(failed as object)).toEqual([
{ types: ['VoiceCaptureFailed'], handlerId: 'voice.notify-failed' },
]);
});
});