feat: Sprachnotizen übernehmen, melden und abschreiben (T7 L2 Tasks 8, 9, 11)
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:
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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user