feat: Sprachnotizen in Synchronisierung, Warteschlange und Abmelden (T7 L5 Task 18)
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:
1 parent
0c7147f626
commit
e614ded967
18 files changed
+1201
-30
No files matched your search
@@ -628,3 +628,68 @@ describe("bulk entries in the offline queue", () => {
|
||||
expect(await onlyBulkEntry()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice notes in the offline queue", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
function note(id: string, ownerUserId: string, lastError?: typeof REJECTION) {
|
||||
return {
|
||||
id,
|
||||
ownerUserId,
|
||||
blob: new Blob(["audio"]),
|
||||
mime: "audio/webm;codecs=opus",
|
||||
durationSec: 30,
|
||||
colonyId: "colony-1",
|
||||
inspectionDate: "2026-10-16",
|
||||
createdAt: "2026-10-16T08:00:00.000Z",
|
||||
uploadIdempotencyKey: `upload-${id}`,
|
||||
state: "recorded" as const,
|
||||
...(lastError ? { lastError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function storeNotes() {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||
const raw = await openDB(DB_NAME);
|
||||
await raw.put("pending-voice", note("v1", "user-1"));
|
||||
await raw.put("pending-voice", note("v2", "user-1", REJECTION));
|
||||
await raw.put("pending-voice", note("v3", "user-2"));
|
||||
raw.close();
|
||||
return db;
|
||||
}
|
||||
|
||||
it("summarizeQueue counts waiting, rejected and foreign voice notes", async () => {
|
||||
const db = await storeNotes();
|
||||
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({
|
||||
own: 2,
|
||||
failed: 1,
|
||||
foreign: 1,
|
||||
voice: 2,
|
||||
voiceWaiting: 1,
|
||||
});
|
||||
expect(await db.summarizeQueue("user-2")).toEqual({
|
||||
own: 1,
|
||||
failed: 0,
|
||||
foreign: 3,
|
||||
voice: 1,
|
||||
voiceWaiting: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("listQueue lists the user's voice notes and never another account's", async () => {
|
||||
const db = await storeNotes();
|
||||
|
||||
const items = (await db.listQueue("user-1")).filter((item) => item.store === "pending-voice");
|
||||
|
||||
expect(items.map((item) => item.id)).toEqual(["v1", "v2"]);
|
||||
expect(items[0]).toMatchObject({
|
||||
label: "Sprachnotiz",
|
||||
voice: { colonyId: "colony-1", inspectionDate: "2026-10-16", durationSec: 30 },
|
||||
});
|
||||
expect(items[1].lastError).toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,8 @@ export interface StoredVoiceNote {
|
||||
uploadIdempotencyKey: string;
|
||||
state: "recorded" | "uploading";
|
||||
lastError?: QueueError;
|
||||
/** The last try was told "later": too many open notes on the server. */
|
||||
blockedBy?: "TOO_MANY_OPEN";
|
||||
}
|
||||
|
||||
export interface StoredPack {
|
||||
@@ -457,6 +459,10 @@ export interface QueueSummary {
|
||||
failed: number;
|
||||
/** Entries of another account on this device - never sent from here. */
|
||||
foreign: number;
|
||||
/** Of `own` and `failed`: the user's voice notes. Left out when there are none. */
|
||||
voice?: number;
|
||||
/** Of `own` only: voice notes still waiting to be sent (not rejected). Left out when none. */
|
||||
voiceWaiting?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,23 +476,55 @@ async function readPhotosWaitingOnTheirOwn(): Promise<StoredPhoto[]> {
|
||||
return photos.filter((photo) => photo.state === "ready");
|
||||
}
|
||||
|
||||
/**
|
||||
* What counting needs of a voice note. Read through a cursor that keeps only
|
||||
* these fields: no audio Blob is held (browsers hand out a Blob as a handle to
|
||||
* its file, the bytes are not read), and no schema change is needed.
|
||||
*/
|
||||
interface VoiceMeta {
|
||||
ownerUserId: string;
|
||||
lastError?: QueueError;
|
||||
}
|
||||
|
||||
async function readVoiceMeta(): Promise<VoiceMeta[]> {
|
||||
const db = await getDb();
|
||||
const metas: VoiceMeta[] = [];
|
||||
let cursor = await db.transaction("pending-voice").store.openCursor();
|
||||
while (cursor) {
|
||||
const { ownerUserId, lastError } = cursor.value;
|
||||
metas.push(lastError ? { ownerUserId, lastError } : { ownerUserId });
|
||||
cursor = await cursor.continue();
|
||||
}
|
||||
return metas;
|
||||
}
|
||||
|
||||
export async function summarizeQueue(currentUserId: string): Promise<QueueSummary> {
|
||||
const voiceNotes = await readVoiceMeta();
|
||||
const waiting: { ownerUserId?: string; lastError?: QueueError }[] = [
|
||||
...(await readQueue()).map(({ entry }) => entry),
|
||||
...(await readPhotosWaitingOnTheirOwn()),
|
||||
// Recordings wait on their own and count like entries (foreign ones as a number only).
|
||||
...voiceNotes,
|
||||
];
|
||||
return waiting.reduce<QueueSummary>(
|
||||
(summary, entry) => {
|
||||
const summary = waiting.reduce<QueueSummary>(
|
||||
(total, entry) => {
|
||||
if (isForeignEntry(entry, currentUserId)) {
|
||||
return { ...summary, foreign: summary.foreign + 1 };
|
||||
return { ...total, foreign: total.foreign + 1 };
|
||||
}
|
||||
if (entry.lastError) {
|
||||
return { ...summary, failed: summary.failed + 1 };
|
||||
return { ...total, failed: total.failed + 1 };
|
||||
}
|
||||
return { ...summary, own: summary.own + 1 };
|
||||
return { ...total, own: total.own + 1 };
|
||||
},
|
||||
{ own: 0, failed: 0, foreign: 0 },
|
||||
);
|
||||
const ownVoice = voiceNotes.filter((note) => !isForeignEntry(note, currentUserId));
|
||||
const voiceWaiting = ownVoice.filter((note) => !note.lastError).length;
|
||||
return {
|
||||
...summary,
|
||||
...(ownVoice.length > 0 ? { voice: ownVoice.length } : {}),
|
||||
...(voiceWaiting > 0 ? { voiceWaiting } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The server refused the hive part: 403, or a conflict with the hive's current state. */
|
||||
@@ -527,6 +565,14 @@ export interface QueueItem {
|
||||
queuedAt: string;
|
||||
lastError?: QueueError;
|
||||
photoCount?: number;
|
||||
/** A recorded voice note (store `pending-voice`, string id): its colony, inspection day and length. */
|
||||
voice?: {
|
||||
colonyId: string;
|
||||
inspectionDate: string;
|
||||
durationSec: number;
|
||||
/** The server said "later" (too many open notes). */
|
||||
blockedBy?: "TOO_MANY_OPEN";
|
||||
};
|
||||
/** The entry changes a colony's hive (a hive entry, or an inspection with a hive change). */
|
||||
hasHiveChange?: boolean;
|
||||
/** A rejected inspection whose hive change can be left out and the rest sent. */
|
||||
@@ -566,6 +612,38 @@ async function resendWithoutOf(
|
||||
};
|
||||
}
|
||||
|
||||
const VOICE_LABEL = "Sprachnotiz";
|
||||
|
||||
/** The user's voice notes as queue items, read by cursor without keeping the audio. */
|
||||
async function readVoiceItems(
|
||||
db: IDBPDatabase<OfflineDB>,
|
||||
ownerUserId: string,
|
||||
): Promise<QueueItem[]> {
|
||||
const items: QueueItem[] = [];
|
||||
let cursor = await db
|
||||
.transaction("pending-voice")
|
||||
.store.index("ownerUserId")
|
||||
.openCursor(IDBKeyRange.only(ownerUserId));
|
||||
while (cursor) {
|
||||
const note = cursor.value;
|
||||
items.push({
|
||||
store: "pending-voice",
|
||||
id: note.id,
|
||||
label: VOICE_LABEL,
|
||||
queuedAt: note.createdAt,
|
||||
voice: {
|
||||
colonyId: note.colonyId,
|
||||
inspectionDate: note.inspectionDate,
|
||||
durationSec: note.durationSec,
|
||||
...(note.blockedBy ? { blockedBy: note.blockedBy } : {}),
|
||||
},
|
||||
...(note.lastError ? { lastError: note.lastError } : {}),
|
||||
});
|
||||
cursor = await cursor.continue();
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** The signed-in user's waiting and rejected entries, oldest first. */
|
||||
export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
||||
const db = await getDb();
|
||||
@@ -607,7 +685,8 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
||||
return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive };
|
||||
}),
|
||||
);
|
||||
return items.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
|
||||
const voiceItems = await readVoiceItems(db, currentUserId);
|
||||
return [...items, ...voiceItems].sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
|
||||
}
|
||||
|
||||
function asQueueStore(store: string): QueueStoreName {
|
||||
|
||||
@@ -49,6 +49,11 @@ vi.mock("@/lib/photos/store", () => ({
|
||||
uploadReadyPhotos: vi.fn(async () => ({ synced: 0, failed: 0, skippedForeign: 0 })),
|
||||
}));
|
||||
|
||||
// Recordings follow in lib/voice/upload-pending-voice.spec.ts.
|
||||
vi.mock("@/lib/voice/upload-pending-voice", () => ({
|
||||
uploadPendingVoice: vi.fn(async () => ({ synced: 0, failed: 0, skippedForeign: 0 })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(async () => ({ id: "saved" })),
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
queueRejectionText,
|
||||
rejectionText,
|
||||
sessionExpiredWithEntries,
|
||||
voiceNotesAmong,
|
||||
voiceNotesLostOnLogout,
|
||||
voiceRejectionText,
|
||||
} from "./queue-texts";
|
||||
|
||||
describe("queue texts", () => {
|
||||
@@ -85,4 +88,40 @@ describe("rejectionText", () => {
|
||||
).toBe("Idempotency-Key reused");
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice notes", () => {
|
||||
const at = "2026-10-16T08:00:00.000Z";
|
||||
|
||||
it.each([
|
||||
[413, "TOO_LARGE", "Die Sprachnotiz ist zu groß. Bitte kürzer aufnehmen."],
|
||||
[415, "UNSUPPORTED_AUDIO", "Dieses Audioformat wird nicht unterstützt. Bitte neu aufnehmen."],
|
||||
[
|
||||
429,
|
||||
"TOO_MANY_OPEN",
|
||||
"Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.",
|
||||
],
|
||||
])("words %i %s in German", (status, code, text) => {
|
||||
expect(voiceRejectionText({ status, message: "x", details: { code }, at })).toBe(text);
|
||||
});
|
||||
|
||||
it("falls back on the status and never shows the raw server message", () => {
|
||||
expect(voiceRejectionText({ status: 403, message: "Forbidden", at })).toBe(
|
||||
"Keine Berechtigung für Sprachnotizen.",
|
||||
);
|
||||
const neutral = "Die Sprachnotiz passt nicht mehr zur Durchsicht. Bitte neu aufnehmen.";
|
||||
expect(voiceRejectionText({ status: 404, message: "Not Found", at })).toBe(neutral);
|
||||
expect(voiceRejectionText({ status: 400, message: "bad", at })).toBe(neutral);
|
||||
});
|
||||
|
||||
it("names recordings in the sign-out warning", () => {
|
||||
expect(voiceNotesLostOnLogout(1)).toBe(
|
||||
"Darunter 1 Sprachnotiz. Gelöschte Sprachnotizen lassen sich nicht wiederherstellen.",
|
||||
);
|
||||
});
|
||||
|
||||
it("counts recordings among the waiting entries", () => {
|
||||
expect(voiceNotesAmong(1)).toBe("davon 1 Sprachnotiz");
|
||||
expect(voiceNotesAmong(3)).toBe("davon 3 Sprachnotizen");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { hiveReasonText } from "@/lib/hive/reason-text";
|
||||
import type { QueueError } from "./db";
|
||||
|
||||
// German counts for the offline queue: "1 Eintrag wartet", "2 Einträge warten".
|
||||
|
||||
@@ -90,3 +91,53 @@ export function sessionExpiredWithEntries(n: number): string {
|
||||
const rest = n === 1 ? "1 Eintrag wartet und wird danach gesendet." : `${n} Einträge warten und werden danach gesendet.`;
|
||||
return `${expired} ${rest}`;
|
||||
}
|
||||
|
||||
/** What the queue sheet needs of a rejected voice upload. */
|
||||
type VoiceRejection = Pick<QueueError, "status"> & Partial<QueueError>;
|
||||
|
||||
const VOICE_NO_LONGER_FITS = "Die Sprachnotiz passt nicht mehr zur Durchsicht. Bitte neu aufnehmen.";
|
||||
|
||||
/** A run stopped because the server holds too many open notes; they go later. */
|
||||
export const VOICE_TOO_MANY_OPEN_HINT =
|
||||
"Zu viele offene Sprachnotizen – sie werden später gesendet.";
|
||||
|
||||
const VOICE_REJECTION_BY_CODE: Record<string, string> = {
|
||||
TOO_LARGE: "Die Sprachnotiz ist zu groß. Bitte kürzer aufnehmen.",
|
||||
UNSUPPORTED_AUDIO: "Dieses Audioformat wird nicht unterstützt. Bitte neu aufnehmen.",
|
||||
TOO_MANY_OPEN: "Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.",
|
||||
VOICE_CAPTURE_GONE: "Die Sprachnotiz gibt es nicht mehr.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Why the server refused a recording, in words the beekeeper can act on.
|
||||
* Fixed texts by the server's error code, then by status; never the server's
|
||||
* own message, so nothing unexpected reaches the screen.
|
||||
*/
|
||||
export function voiceRejectionText(error: VoiceRejection): string {
|
||||
const code = (error.details as { code?: unknown } | null | undefined)?.code;
|
||||
if (typeof code === "string" && code in VOICE_REJECTION_BY_CODE) {
|
||||
return VOICE_REJECTION_BY_CODE[code];
|
||||
}
|
||||
switch (error.status) {
|
||||
case 403:
|
||||
return "Keine Berechtigung für Sprachnotizen.";
|
||||
case 413:
|
||||
return VOICE_REJECTION_BY_CODE.TOO_LARGE;
|
||||
case 415:
|
||||
return VOICE_REJECTION_BY_CODE.UNSUPPORTED_AUDIO;
|
||||
default:
|
||||
// 400 without a code (colony gone, date too old) and anything unforeseen.
|
||||
return VOICE_NO_LONGER_FITS;
|
||||
}
|
||||
}
|
||||
|
||||
/** „davon 2 Sprachnotizen“ - the part of the waiting entries that are voice notes. */
|
||||
export function voiceNotesAmong(n: number): string {
|
||||
return `davon ${count(n, "Sprachnotiz", "Sprachnotizen")}`;
|
||||
}
|
||||
|
||||
/** Sign-out dialog: recordings are deleted for good, so they are named. */
|
||||
export function voiceNotesLostOnLogout(n: number): string {
|
||||
const recordings = count(n, "Sprachnotiz", "Sprachnotizen");
|
||||
return `Darunter ${recordings}. Gelöschte Sprachnotizen lassen sich nicht wiederherstellen.`;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
|
||||
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
|
||||
vi.mock("@/lib/phenology/api", () => ({ createPhenologyReport: vi.fn() }));
|
||||
vi.mock("@/lib/hive/api", () => ({ postHiveChange: vi.fn() }));
|
||||
vi.mock("@/lib/voice/api", () => ({ uploadVoiceCapture: vi.fn() }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
|
||||
|
||||
// What the inspection drain hands over to the photos, seen at that moment.
|
||||
@@ -131,6 +132,66 @@ describe("syncPendingEntries", () => {
|
||||
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
|
||||
});
|
||||
|
||||
it("uploads recorded voice notes after the records, inside the run", async () => {
|
||||
const order: string[] = [];
|
||||
const { uploadVoiceCapture } = await import("@/lib/voice/api");
|
||||
vi.mocked(uploadVoiceCapture).mockReset().mockImplementation(async () => {
|
||||
order.push("voice");
|
||||
return { id: "cap-1", status: "QUEUED" as const };
|
||||
});
|
||||
createInspection.mockImplementation(async () => {
|
||||
order.push("inspection");
|
||||
return { id: "x" } as never;
|
||||
});
|
||||
await db.queueInspection(inspection(1), "key-1", "user-1");
|
||||
const raw = await openDB("apicore-offline");
|
||||
await raw.put("pending-voice", {
|
||||
id: "v1",
|
||||
ownerUserId: "user-1",
|
||||
blob: new Blob(["audio"]),
|
||||
mime: "audio/webm",
|
||||
durationSec: 20,
|
||||
colonyId: "colony-1",
|
||||
inspectionDate: "2026-10-16",
|
||||
createdAt: "2026-10-16T08:00:00.000Z",
|
||||
uploadIdempotencyKey: "upload-v1",
|
||||
state: "recorded",
|
||||
});
|
||||
raw.close();
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(order).toEqual(["inspection", "voice"]);
|
||||
expect(result).toEqual({ synced: 2, failed: 0, skippedForeign: 0 });
|
||||
});
|
||||
|
||||
it("a cancelled run sends no voice note", async () => {
|
||||
const { uploadVoiceCapture } = await import("@/lib/voice/api");
|
||||
vi.mocked(uploadVoiceCapture).mockReset();
|
||||
await db.getPendingEntries("pending-inspections");
|
||||
const raw = await openDB("apicore-offline");
|
||||
await raw.put("pending-voice", {
|
||||
id: "v1",
|
||||
ownerUserId: "user-1",
|
||||
blob: new Blob(["audio"]),
|
||||
mime: "audio/webm",
|
||||
durationSec: 20,
|
||||
colonyId: "colony-1",
|
||||
inspectionDate: "2026-10-16",
|
||||
createdAt: "2026-10-16T08:00:00.000Z",
|
||||
uploadIdempotencyKey: "upload-v1",
|
||||
state: "recorded",
|
||||
});
|
||||
raw.close();
|
||||
|
||||
const run = sync.syncPendingEntries("user-1");
|
||||
await sync.cancelSync();
|
||||
await run;
|
||||
sync.resumeSync();
|
||||
|
||||
expect(uploadVoiceCapture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offline the plant report is queued and synced later with its key", async () => {
|
||||
const report = {
|
||||
phase: "SALWEIDE",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/lib/bulk/api";
|
||||
import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
|
||||
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
|
||||
import { uploadPendingVoice } from "@/lib/voice/upload-pending-voice";
|
||||
import { finishHandOvers, resolveQueuedInspection, uploadReadyPhotos } from "@/lib/photos/store";
|
||||
import {
|
||||
BulkKind,
|
||||
@@ -493,6 +494,9 @@ async function runSync(run: SyncRun): Promise<SyncResult> {
|
||||
await finishHandOvers(run.userId);
|
||||
}
|
||||
const photos = await uploadReadyPhotos(run.userId, uploadDocument, run.isCancelled);
|
||||
// Recordings after records and photos: they are the largest, and a weak
|
||||
// connection should deliver the small things first.
|
||||
const voice = await uploadPendingVoice(run.userId, run.isCancelled);
|
||||
// Packs last, quietly and only with the run user's token: what the server
|
||||
// has just accepted or rejected (a colony ended since the pack was loaded)
|
||||
// shows up offline too - the packs of rejected colonies at once, not only
|
||||
@@ -503,5 +507,5 @@ async function runSync(run: SyncRun): Promise<SyncResult> {
|
||||
? refreshPacksAfterSync(run.userId, Date.now(), run.isCancelled, { force: true, colonyIds })
|
||||
: refreshPacksAfterSync(run.userId, Date.now(), run.isCancelled));
|
||||
}
|
||||
return [...records, photos].reduce(addResults, NOTHING_SYNCED);
|
||||
return [...records, photos, voice].reduce(addResults, NOTHING_SYNCED);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user