feat: Sprachnotizen in Synchronisierung, Warteschlange und Abmelden (T7 L5 Task 18)
Backend Image / build-and-push (push) Successful in 7m6s
Frontend Image / build-and-push (push) Successful in 15m27s
Manifests / validate (push) Successful in 16s

Wartende Sprachnotizen werden nach Einträgen und Fotos hochgeladen, je
Notiz ein fester Idempotency-Key; endgültige 4xx markieren die Notiz
mit festem deutschem Text, 401/408/429/5xx/Netz warten auf den nächsten
Lauf, „wird bearbeitet“ wird übersprungen. Warteschlangen-Sheet mit
Abschnitt „Sprachnotizen“ (Verwerfen je Notiz nur für den Besitzer,
alle verwerfen mit Rückfrage, Hinweis bei zu vielen offenen Notizen),
Offline-Band zählt nur wartende Notizen, Abmelden fragt bei wartenden
Notizen nach. Zählen liest keine Audiodaten.

Gates: Frontend lint, typecheck, 2886 Tests.

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:10:14 +02:00
1 parent 0c7147f626
commit e614ded967
18 files changed
+1201 -30

No files matched your search

@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch, apiFetchFormData } from "@/lib/auth/api-client";
import {
discardAllVoiceCaptures,
discardVoiceCapture,
fetchVoiceCaptures,
retryVoiceCapture,
submitVoiceText,
uploadVoiceCapture,
voiceKeys,
} from "./api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(), apiFetchFormData: vi.fn() }));
describe("voice api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset().mockResolvedValue(undefined);
vi.mocked(apiFetchFormData).mockReset().mockResolvedValue({ id: "c1", status: "QUEUED" });
});
it("uploads the audio as multipart with the note's Idempotency-Key", async () => {
const blob = new Blob(["a"], { type: "audio/webm;codecs=opus" });
await uploadVoiceCapture({
id: "n1",
ownerUserId: "u1",
blob,
mime: blob.type,
durationSec: 42.4,
colonyId: "col-1",
inspectionDate: "2026-10-16",
createdAt: "2026-10-16T08:00:00.000Z",
uploadIdempotencyKey: "key-1",
state: "recorded",
});
const [path, form, options] = vi.mocked(apiFetchFormData).mock.calls[0];
expect(path).toBe("/voice-captures");
const data = form as FormData;
expect(data.get("colonyId")).toBe("col-1");
expect(data.get("inspectionDate")).toBe("2026-10-16");
expect(data.get("durationSec")).toBe("42");
expect(data.get("audio")).toBeInstanceOf(Blob);
expect(options?.headers).toEqual({ "Idempotency-Key": "key-1" });
});
it("sends text with its key", async () => {
await submitVoiceText({ colonyId: "c", inspectionDate: "2026-10-16", text: "x" }, "k");
const [path, init] = vi.mocked(apiFetch).mock.calls[0];
expect(path).toBe("/voice-captures/text");
expect(init).toMatchObject({ method: "POST", headers: { "Idempotency-Key": "k" } });
});
it("lists by status and acts on one capture", async () => {
await fetchVoiceCaptures("READY");
expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe("/voice-captures?status=READY");
await retryVoiceCapture("a b");
expect(vi.mocked(apiFetch).mock.calls[1][0]).toBe("/voice-captures/a%20b/retry");
await discardVoiceCapture("x");
expect(vi.mocked(apiFetch).mock.calls[2][0]).toBe("/voice-captures/x/discard");
await discardAllVoiceCaptures();
expect(vi.mocked(apiFetch).mock.calls[3][0]).toBe("/voice-captures/discard-all");
});
it("has stable query keys", () => {
expect(voiceKeys.detail("1")).toEqual(["voice-captures", "1"]);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { apiFetch, apiFetchFormData } from "@/lib/auth/api-client";
import { idempotencyHeaders } from "@/lib/offline/idempotency";
import type { StoredVoiceNote } from "@/lib/offline/db";
/*
* Client for /voice-captures. Shapes mirror ApiCore-Backend voice-capture-view.ts.
* Nothing here logs: audio, transcripts and suggestions are content.
*/
export type VoiceCaptureStatus =
| "QUEUED"
| "TRANSCRIBING"
| "TRANSCRIBED"
| "EXTRACTING"
| "READY"
| "FAILED"
| "CONFIRMED"
| "DISCARDED"
| "EXPIRED";
export interface VoiceCaptureListItem {
id: string;
colonyId: string;
colonyName: string;
status: VoiceCaptureStatus;
source: "AUDIO" | "TEXT";
createdAt: string;
lastErrorCode: string | null;
contentExpiresAt: string;
}
export interface VoiceSuggestion {
field: string;
value: number | boolean | string;
quote: string;
corrected: boolean;
truncated: boolean;
previous?: { value: number | boolean | string; quote: string };
}
export interface VoiceCaptureDetail extends VoiceCaptureListItem {
inspectionDate: string;
transcript: string | null;
suggestions: VoiceSuggestion[] | null;
unclear: string[];
dropped: number;
}
export interface VoiceClientConfig {
recordingEnabled: boolean;
extractionEnabled: boolean;
model: string;
retention: { audioHours: number; contentDays: number; rowDays: number };
}
export const voiceKeys = {
all: ["voice-captures"] as const,
detail: (id: string) => ["voice-captures", id] as const,
config: ["voice-captures", "config"] as const,
};
const BASE = "/voice-captures";
const idPath = (id: string, action?: string): string =>
`${BASE}/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
/**
* Uploads one stored recording. The note's own key makes a repeat after a lost
* answer return the first capture instead of creating a second one.
*/
export function uploadVoiceCapture(
note: StoredVoiceNote,
): Promise<{ id: string; status: "QUEUED" }> {
const formData = new FormData();
formData.append("colonyId", note.colonyId);
formData.append("inspectionDate", note.inspectionDate);
formData.append("durationSec", String(Math.max(1, Math.round(note.durationSec))));
formData.append("audio", note.blob, "aufnahme");
return apiFetchFormData<{ id: string; status: "QUEUED" }>(BASE, formData, {
headers: idempotencyHeaders({ idempotencyKey: note.uploadIdempotencyKey }),
});
}
export function submitVoiceText(
input: { colonyId: string; inspectionDate: string; text: string },
idempotencyKey: string,
): Promise<{ id: string }> {
return apiFetch<{ id: string }>(`${BASE}/text`, {
method: "POST",
headers: idempotencyHeaders({ idempotencyKey }),
body: JSON.stringify(input),
});
}
export function fetchVoiceCaptures(status?: string): Promise<VoiceCaptureListItem[]> {
const query = status ? `?${new URLSearchParams({ status }).toString()}` : "";
return apiFetch<VoiceCaptureListItem[]>(`${BASE}${query}`);
}
export function fetchVoiceCapture(id: string): Promise<VoiceCaptureDetail> {
return apiFetch<VoiceCaptureDetail>(idPath(id));
}
export function fetchVoiceConfig(): Promise<VoiceClientConfig> {
return apiFetch<VoiceClientConfig>(`${BASE}/config`);
}
export function retryVoiceCapture(id: string): Promise<void> {
return apiFetch<void>(idPath(id, "retry"), { method: "POST" });
}
export function discardVoiceCapture(id: string): Promise<void> {
return apiFetch<void>(idPath(id, "discard"), { method: "POST" });
}
export function discardAllVoiceCaptures(): Promise<{ discarded: number }> {
return apiFetch<{ discarded: number }>(`${BASE}/discard-all`, { method: "POST" });
}
@@ -1,4 +1,4 @@
import { getDb, type StoredVoiceNote } from "@/lib/offline/db";
import { getDb, type QueueError, type StoredVoiceNote } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
export type { StoredVoiceNote } from "@/lib/offline/db";
@@ -13,7 +13,7 @@ export type { StoredVoiceNote } from "@/lib/offline/db";
/** The device had no room left for another recording. */
export class VoiceStorageFullError extends Error {
constructor() {
super("Auf dem Gerät ist kein Platz mehr für die Aufnahme.");
super("Auf dem Gerät ist kein Platz mehr für die Sprachnotiz.");
this.name = "VoiceStorageFullError";
}
}
@@ -98,3 +98,33 @@ export async function discardAllPendingVoice(ownerUserId: string): Promise<numbe
await tx.done;
return ids.length;
}
/** One note by id, or undefined when it is gone (discarded meanwhile). */
export async function getPendingVoice(id: string): Promise<StoredVoiceNote | undefined> {
const db = await getDb();
return db.get("pending-voice", id);
}
/** Books the server's refusal on a note that still exists; the audio stays for the user to see. */
export async function markVoiceFailed(id: string, error: QueueError): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-voice", "readwrite");
const note = await tx.store.get(id);
if (note) {
const rejected: StoredVoiceNote = { ...note, lastError: error, state: "recorded" };
delete rejected.blockedBy;
await tx.store.put(rejected);
}
await tx.done;
}
/** Notes that the server could take later (too many open): the sheet says so. */
export async function markVoiceBlocked(id: string, blockedBy: "TOO_MANY_OPEN"): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-voice", "readwrite");
const note = await tx.store.get(id);
if (note) {
await tx.store.put({ ...note, blockedBy });
}
await tx.done;
}
@@ -0,0 +1,192 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
vi.mock("./api", () => ({ uploadVoiceCapture: vi.fn() }));
type Upload = MockedFunction<typeof import("./api").uploadVoiceCapture>;
let pending: typeof import("./pending-voice");
let upload: typeof import("./upload-pending-voice");
let uploadVoiceCapture: Upload;
let ApiError: typeof import("@/lib/auth/api-client").ApiError;
const input = (ownerUserId = "user-1", colonyId = "colony-1") => ({
ownerUserId,
blob: new Blob(["audio"], { type: "audio/webm;codecs=opus" }),
mime: "audio/webm;codecs=opus",
durationSec: 42,
colonyId,
inspectionDate: "2026-10-16",
});
const never = () => false;
describe("uploadPendingVoice", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
vi.stubGlobal("navigator", { storage: { persist: vi.fn().mockResolvedValue(true) } });
vi.resetModules();
pending = await import("./pending-voice");
upload = await import("./upload-pending-voice");
uploadVoiceCapture = vi.mocked((await import("./api")).uploadVoiceCapture);
ApiError = (await import("@/lib/auth/api-client")).ApiError;
uploadVoiceCapture.mockReset();
uploadVoiceCapture.mockResolvedValue({ id: "cap-1", status: "QUEUED" });
});
it("sends a recorded note with its stable key", async () => {
const note = await pending.queueVoiceNote(input());
const result = await upload.uploadPendingVoice("user-1", never);
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(uploadVoiceCapture).toHaveBeenCalledTimes(1);
expect(uploadVoiceCapture.mock.calls[0][0].uploadIdempotencyKey).toBe(
note.uploadIdempotencyKey,
);
});
it("deletes the local audio after a 2xx", async () => {
await pending.queueVoiceNote(input());
await upload.uploadPendingVoice("user-1", never);
expect(await pending.listPendingVoice("user-1")).toEqual([]);
});
it("sends the same key again after a lost answer", async () => {
const note = await pending.queueVoiceNote(input());
uploadVoiceCapture.mockRejectedValueOnce(new TypeError("Failed to fetch"));
await upload.uploadPendingVoice("user-1", never);
await upload.uploadPendingVoice("user-1", never);
const keys = uploadVoiceCapture.mock.calls.map(([n]) => n.uploadIdempotencyKey);
expect(keys).toEqual([note.uploadIdempotencyKey, note.uploadIdempotencyKey]);
expect(await pending.listPendingVoice("user-1")).toEqual([]);
});
it("keeps 4xx rejections as lastError and does not send them again", async () => {
await pending.queueVoiceNote(input());
uploadVoiceCapture.mockRejectedValueOnce(
new ApiError(415, "Dieses Audioformat wird nicht unterstützt.", {
code: "UNSUPPORTED_AUDIO",
}),
);
const first = await upload.uploadPendingVoice("user-1", never);
const second = await upload.uploadPendingVoice("user-1", never);
const [note] = await pending.listPendingVoice("user-1");
expect(note.lastError).toMatchObject({ status: 415, details: { code: "UNSUPPORTED_AUDIO" } });
expect(first.failed).toBe(1);
expect(second).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
expect(uploadVoiceCapture).toHaveBeenCalledTimes(1);
});
it.each([429, 503, 408])("treats %i as try later and keeps the rest", async (status) => {
await pending.queueVoiceNote(input());
await pending.queueVoiceNote(input("user-1", "colony-2"));
uploadVoiceCapture.mockRejectedValueOnce(new ApiError(status, "später"));
const result = await upload.uploadPendingVoice("user-1", never);
expect(result.synced).toBe(0);
const notes = await pending.listPendingVoice("user-1");
expect(notes).toHaveLength(2);
expect(notes.every((n) => n.lastError === undefined)).toBe(true);
});
it("a 409 'still being processed' skips that note and goes on", async () => {
await pending.queueVoiceNote(input());
await pending.queueVoiceNote(input("user-1", "colony-2"));
uploadVoiceCapture.mockRejectedValueOnce(
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
);
const result = await upload.uploadPendingVoice("user-1", never);
expect(uploadVoiceCapture).toHaveBeenCalledTimes(2);
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
const [left] = await pending.listPendingVoice("user-1");
expect(left.lastError).toBeUndefined();
});
it("any other 409 is a real refusal and is kept on the note", async () => {
await pending.queueVoiceNote(input());
uploadVoiceCapture.mockRejectedValueOnce(new ApiError(409, "Schlüssel schon benutzt"));
await upload.uploadPendingVoice("user-1", never);
const [note] = await pending.listPendingVoice("user-1");
expect(note.lastError).toMatchObject({ status: 409 });
});
it("remembers that a run stopped on TOO_MANY_OPEN", async () => {
await pending.queueVoiceNote(input());
uploadVoiceCapture.mockRejectedValueOnce(
new ApiError(429, "x", { code: "TOO_MANY_OPEN" }),
);
await upload.uploadPendingVoice("user-1", never);
const [note] = await pending.listPendingVoice("user-1");
expect(note.blockedBy).toBe("TOO_MANY_OPEN");
expect(note.lastError).toBeUndefined();
});
it("stops on a network error and keeps the rest", async () => {
await pending.queueVoiceNote(input());
await pending.queueVoiceNote(input("user-1", "colony-2"));
uploadVoiceCapture.mockRejectedValue(new TypeError("Failed to fetch"));
const result = await upload.uploadPendingVoice("user-1", never);
expect(uploadVoiceCapture).toHaveBeenCalledTimes(1);
expect(result.failed).toBe(1);
expect(await pending.listPendingVoice("user-1")).toHaveLength(2);
});
it("stops on 401 without marking the note", async () => {
await pending.queueVoiceNote(input());
uploadVoiceCapture.mockRejectedValue(new ApiError(401, "Nicht angemeldet"));
await upload.uploadPendingVoice("user-1", never);
const [note] = await pending.listPendingVoice("user-1");
expect(note.lastError).toBeUndefined();
});
it("never sends another user's recordings", async () => {
await pending.queueVoiceNote(input("user-2"));
const result = await upload.uploadPendingVoice("user-1", never);
expect(uploadVoiceCapture).not.toHaveBeenCalled();
expect(result.skippedForeign).toBe(1);
expect(await pending.listPendingVoice("user-2")).toHaveLength(1);
});
it("a cancelled run sends no voice note", async () => {
await pending.queueVoiceNote(input());
await upload.uploadPendingVoice("user-1", () => true);
expect(uploadVoiceCapture).not.toHaveBeenCalled();
expect(await pending.listPendingVoice("user-1")).toHaveLength(1);
});
it("skips a note discarded while the run was going", async () => {
const first = await pending.queueVoiceNote(input());
await pending.queueVoiceNote(input("user-1", "colony-2"));
uploadVoiceCapture.mockImplementationOnce(async () => {
const [, second] = await pending.listPendingVoice("user-1");
await pending.discardPendingVoice(second.id, "user-1");
return { id: first.id, status: "QUEUED" as const };
});
const result = await upload.uploadPendingVoice("user-1", never);
expect(uploadVoiceCapture).toHaveBeenCalledTimes(1);
expect(result.synced).toBe(1);
});
});
@@ -0,0 +1,81 @@
import { ApiError } from "@/lib/auth/api-client";
import { isRetryLaterError } from "@/lib/bulk/api";
import { toQueueError } from "@/lib/offline/db";
import type { SyncResult } from "@/lib/offline/sync";
import { uploadVoiceCapture } from "./api";
import {
countForeignVoice,
discardPendingVoice,
getPendingVoice,
listPendingVoice,
markVoiceBlocked,
markVoiceFailed,
} from "./pending-voice";
const UNAUTHORIZED = 401;
/** 4xx answers that say „not now“, not „this note is wrong“. */
const LATER_STATUSES: readonly number[] = [408, 429];
const TOO_MANY_OPEN = "TOO_MANY_OPEN";
const SERVER_ERROR_FROM = 500;
function hasCode(error: unknown, code: string): boolean {
return (
error instanceof ApiError && (error.body as { code?: unknown } | null | undefined)?.code === code
);
}
/**
* Sends the run user's recorded voice notes one after the other, oldest first.
* 2xx deletes the local audio at once. A definitive refusal (4xx) is stored on
* the note, which then waits for the user and is not sent again. A network
* error, 5xx, 408 and 429 stop the run: the rest waits, the same key
* goes out next time. A 409 „still being processed“ skips that note only. 401 stops the run and leaves the note alone.
* Another account's notes are never sent, only counted.
*/
export async function uploadPendingVoice(
userId: string,
isCancelled: () => boolean,
): Promise<SyncResult> {
const own = (await listPendingVoice(userId)).filter((note) => !note.lastError);
let synced = 0;
let failed = 0;
const skippedForeign = await countForeignVoice(userId);
for (const { id } of own) {
const note = await getPendingVoice(id);
if (isCancelled()) {
break;
}
if (!note || note.lastError || note.ownerUserId !== userId) {
continue;
}
try {
await uploadVoiceCapture(note);
} catch (error) {
if (isRetryLaterError(error)) {
continue;
}
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
break;
}
failed += 1;
const isLater =
!(error instanceof ApiError) ||
error.status >= SERVER_ERROR_FROM ||
LATER_STATUSES.includes(error.status);
if (isLater) {
if (hasCode(error, TOO_MANY_OPEN)) {
await markVoiceBlocked(id, TOO_MANY_OPEN);
}
break;
}
await markVoiceFailed(id, toQueueError(error));
continue;
}
// Deleted by id; the user may have discarded it meanwhile - then nothing is left to do.
await discardPendingVoice(id, userId);
synced += 1;
}
return { synced, failed, skippedForeign };
}