From e614ded9674dc5ad90d7c9b0847f6140934c2dd5 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 11:10:14 +0200 Subject: [PATCH] feat: Sprachnotizen in Synchronisierung, Warteschlange und Abmelden (T7 L5 Task 18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../auth/logout-confirm-dialog.spec.tsx | 26 +++ .../components/auth/logout-confirm-dialog.tsx | 32 +-- .../components/offline/offline-band.spec.tsx | 44 ++++ .../src/components/offline/offline-band.tsx | 14 +- .../offline/pending-queue-sheet.spec.tsx | 152 +++++++++++++- .../offline/pending-queue-sheet.tsx | 152 +++++++++++++- ApiCore-Frontend/src/lib/offline/db.spec.ts | 65 ++++++ ApiCore-Frontend/src/lib/offline/db.ts | 91 ++++++++- .../src/lib/offline/null-roundtrip.spec.ts | 5 + .../src/lib/offline/queue-texts.spec.ts | 39 ++++ .../src/lib/offline/queue-texts.ts | 51 +++++ ApiCore-Frontend/src/lib/offline/sync.spec.ts | 61 ++++++ ApiCore-Frontend/src/lib/offline/sync.ts | 6 +- ApiCore-Frontend/src/lib/voice/api.spec.ts | 68 +++++++ ApiCore-Frontend/src/lib/voice/api.ts | 118 +++++++++++ .../src/lib/voice/pending-voice.ts | 34 +++- .../lib/voice/upload-pending-voice.spec.ts | 192 ++++++++++++++++++ .../src/lib/voice/upload-pending-voice.ts | 81 ++++++++ 18 files changed, 1201 insertions(+), 30 deletions(-) create mode 100644 ApiCore-Frontend/src/lib/voice/api.spec.ts create mode 100644 ApiCore-Frontend/src/lib/voice/api.ts create mode 100644 ApiCore-Frontend/src/lib/voice/upload-pending-voice.spec.ts create mode 100644 ApiCore-Frontend/src/lib/voice/upload-pending-voice.ts diff --git a/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx index 65b0e03..6fd5e71 100644 --- a/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx +++ b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx @@ -66,6 +66,32 @@ describe("LogoutConfirmDialog", () => { expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible(); }); + it("asks to discard waiting voice notes and says they cannot be restored", async () => { + vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2 }); + + renderDialog(); + + const dialog = await screen.findByRole("dialog", { name: "Abmelden?" }); + expect(dialog).toHaveTextContent("3 Einträge sind noch nicht gesendet."); + expect(dialog).toHaveTextContent("Darunter 2 Sprachnotizen"); + expect(dialog).toHaveTextContent("nicht wiederherstellen"); + expect(logoutMock).not.toHaveBeenCalled(); + await userEvent.click( + screen.getByRole("button", { name: "Abmelden und Einträge löschen" }), + ); + await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true })); + }); + + it("does not mention recordings when there are none", async () => { + vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 }); + + renderDialog(); + + expect(await screen.findByRole("dialog", { name: "Abmelden?" })).not.toHaveTextContent( + "Sprachnotiz", + ); + }); + it("deletes the entries only after the destructive button was pressed", async () => { vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 }); renderDialog(); diff --git a/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx index 3f877fb..43eaecb 100644 --- a/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx +++ b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx @@ -13,7 +13,7 @@ import { import { useAuth } from "@/lib/auth/auth-context"; import { useConnectivity } from "@/lib/offline/connectivity"; import { summarizeQueue } from "@/lib/offline/db"; -import { entriesNotSent } from "@/lib/offline/queue-texts"; +import { entriesNotSent, voiceNotesLostOnLogout } from "@/lib/offline/queue-texts"; import { syncPendingEntries } from "@/lib/offline/sync"; interface LogoutConfirmDialogProps { @@ -24,14 +24,20 @@ interface LogoutConfirmDialogProps { onLoggedOut: () => void; } +interface Unsent { + entries: number; + /** Of the entries: the voice recordings, which cannot be recreated. */ + voice: number; +} + /** Waiting and rejected entries of the person; owner-less legacy ones count as theirs. */ -async function countUnsent(userId: string): Promise { +async function countUnsent(userId: string): Promise { try { - const { own, failed } = await summarizeQueue(userId); - return own + failed; + const { own, failed, voice = 0 } = await summarizeQueue(userId); + return { entries: own + failed, voice }; } catch { // The queue cannot be read, so it cannot be lost either. - return 0; + return { entries: 0, voice: 0 }; } } @@ -49,7 +55,7 @@ export function LogoutConfirmDialog({ }: LogoutConfirmDialogProps) { const { logout, sessionMode } = useAuth(); const connectivity = useConnectivity(); - const [unsent, setUnsent] = useState(null); + const [unsent, setUnsent] = useState(null); const [isBusy, setIsBusy] = useState(false); const [error, setError] = useState(null); const [wasOpen, setWasOpen] = useState(open); @@ -81,14 +87,14 @@ export function LogoutConfirmDialog({ return; } let isCurrent = true; - void countUnsent(userId).then((count) => { + void countUnsent(userId).then((counted) => { if (!isCurrent) { return; } - if (count === 0) { + if (counted.entries === 0) { void signOut(false); } else { - setUnsent(count); + setUnsent(counted); } }); return () => { @@ -107,7 +113,7 @@ export function LogoutConfirmDialog({ // Whatever could not be sent is counted below. } const remaining = await countUnsent(userId); - if (remaining === 0) { + if (remaining.entries === 0) { await signOut(false); return; } @@ -117,7 +123,7 @@ export function LogoutConfirmDialog({ // An offline session has no token: nothing can be sent, whatever the network says. const isOnline = connectivity === "online" && sessionMode !== "offline"; - const hasEntries = unsent !== null && unsent > 0; + const hasEntries = unsent !== null && unsent.entries > 0; return ( @@ -126,7 +132,9 @@ export function LogoutConfirmDialog({ Abmelden? {hasEntries - ? `${entriesNotSent(unsent)} ${ + ? `${entriesNotSent(unsent.entries)} ${ + unsent.voice > 0 ? `${voiceNotesLostOnLogout(unsent.voice)} ` : "" + }${ isOnline ? "Sende sie zuerst, oder melde dich ab und lösche sie." : "Ohne Netz kannst du sie nicht senden. Beim Abmelden werden sie gelöscht." diff --git a/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx b/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx index 17ffb08..602c7fd 100644 --- a/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx +++ b/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx @@ -99,6 +99,50 @@ describe("OfflineBand", () => { expect(syncPendingEntries).not.toHaveBeenCalled(); }); + it("counts voice notes in the waiting entries", async () => { + setOnLine(false); + vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2, voiceWaiting: 2 }); + + renderBand(); + + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + "Offline – 3 Einträge warten (davon 2 Sprachnotizen)", + ), + ); + }); + + it("does not count a rejected voice note as waiting", async () => { + setOnLine(false); + vi.mocked(summarizeQueue).mockResolvedValue({ + own: 1, + failed: 1, + foreign: 0, + voice: 2, + voiceWaiting: 1, + }); + + renderBand(); + + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + "Offline – 1 Eintrag wartet (davon 1 Sprachnotiz)", + ), + ); + }); + + it("names the voice notes next to „Jetzt senden\" when online", async () => { + setOnLine(true); + vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0, voice: 1, voiceWaiting: 1 }); + + renderBand(); + + expect( + await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" }), + ).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent("davon 1 Sprachnotiz"); + }); + it("says „1 Eintrag wartet\" for a single entry", async () => { setOnLine(false); vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 }); diff --git a/ApiCore-Frontend/src/components/offline/offline-band.tsx b/ApiCore-Frontend/src/components/offline/offline-band.tsx index b3d9259..234fdce 100644 --- a/ApiCore-Frontend/src/components/offline/offline-band.tsx +++ b/ApiCore-Frontend/src/components/offline/offline-band.tsx @@ -9,7 +9,12 @@ import { SheetTrigger } from "@/components/ui/sheet"; import { cn } from "@/lib/utils"; import { useConnectivity } from "@/lib/offline/connectivity"; import { QueueSummary, summarizeQueue } from "@/lib/offline/db"; -import { entriesRejected, entriesSynced, entriesWaiting } from "@/lib/offline/queue-texts"; +import { + entriesRejected, + entriesSynced, + entriesWaiting, + voiceNotesAmong, +} from "@/lib/offline/queue-texts"; import { resumeSync, syncPendingEntries } from "@/lib/offline/sync"; import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation"; import { PendingQueueSheet } from "./pending-queue-sheet"; @@ -111,6 +116,8 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl // An offline session cannot send, whatever the network says. const isOffline = connectivity === "offline" || !syncEnabled; + // „(davon 2 Sprachnotizen)“: voice notes are part of the counts above. + const voiceSuffix = queue.voiceWaiting ? ` (${voiceNotesAmong(queue.voiceWaiting)})` : ""; const hasContent = isOffline || queue.own > 0 || hasRejected; // Back to the trigger while it exists; otherwise to the band's message, or @@ -140,7 +147,7 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl

{queue.own > 0 - ? `Offline – ${entriesWaiting(queue.own)}` + ? `Offline – ${entriesWaiting(queue.own)}${voiceSuffix}` : "Offline – Erfassungen werden gespeichert und später gesendet. Vorschläge und Aufgaben brauchen eine Verbindung."}

) : null} @@ -157,6 +164,9 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl {sync.isPending ? "Wird gesendet…" : `${entriesWaiting(queue.own)} – Jetzt senden`} ) : null} + {!isOffline && queue.own > 0 && queue.voiceWaiting ? ( + {voiceSuffix.trim()} + ) : null} ({ retryBulkWithout: vi.fn(), retryInspectionWithoutHiveChange: vi.fn(), })); +vi.mock("@/lib/voice/pending-voice", () => ({ + discardPendingVoice: vi.fn(), + discardAllPendingVoice: vi.fn(), +})); vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() })); vi.mock("@/lib/photos/store", async (importOriginal) => ({ ...(await importOriginal()), @@ -97,6 +102,8 @@ describe("PendingQueueSheet", () => { vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined); vi.mocked(retryInspectionWithoutHiveChange).mockReset().mockResolvedValue(undefined); vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]); + vi.mocked(discardPendingVoice).mockReset().mockResolvedValue(undefined); + vi.mocked(discardAllPendingVoice).mockReset().mockResolvedValue(2); vi.mocked(discardReadyPhoto).mockReset().mockResolvedValue(undefined); vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined); vi.mocked(syncPendingEntries) @@ -337,4 +344,147 @@ describe("PendingQueueSheet", () => { expect(await within(sheet).findByText(text)).toBeInTheDocument(); expect(within(sheet).queryByRole("button", { name: /Verwerfen/ })).not.toBeInTheDocument(); }); + + describe("voice notes", () => { + const VOICE_WAITING: QueueItem = { + store: "pending-voice", + id: "voice-1", + label: "Sprachnotiz", + queuedAt: "2026-10-16T08:00:00.000Z", + voice: { colonyId: "colony-1", inspectionDate: "2026-10-16", durationSec: 75 }, + }; + const VOICE_REJECTED: QueueItem = { + ...VOICE_WAITING, + id: "voice-2", + queuedAt: "2026-10-16T08:05:00.000Z", + lastError: { + status: 415, + message: "Unsupported", + details: { code: "UNSUPPORTED_AUDIO" }, + at: "2026-10-16T08:06:00.000Z", + }, + }; + + it("shows a voice note with Erneut aufnehmen and Verwerfen", async () => { + vi.mocked(listQueue).mockResolvedValue([VOICE_REJECTED]); + renderSheet(); + const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" }); + + expect(await within(sheet).findByText("Sprachnotiz")).toBeVisible(); + expect(within(sheet).getByText(/Dauer 1:15/)).toBeVisible(); + expect( + within(sheet).getByText(/Dieses Audioformat wird nicht unterstützt/), + ).toBeVisible(); + const again = within(sheet).getByRole("link", { name: /^Erneut aufnehmen/ }); + expect(again).toHaveAttribute("href", "/voice/new?colonyId=colony-1"); + expect(within(sheet).getByRole("button", { name: /^Verwerfen: Sprachnotiz vom/ })).toBeVisible(); + }); + + it("offers Erneut aufnehmen only for a rejected note", async () => { + vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING]); + renderSheet(); + const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" }); + + expect(await within(sheet).findByText("Wartet auf Senden")).toBeVisible(); + expect(within(sheet).queryByRole("link", { name: /Erneut aufnehmen/ })).toBeNull(); + }); + + it("discards one note owner-scoped after confirmation", async () => { + vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING]); + renderSheet(); + const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" }); + + await userEvent.click( + await within(sheet).findByRole("button", { name: /^Verwerfen: Sprachnotiz vom/ }), + ); + await userEvent.click( + within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }), + ); + + await waitFor(() => expect(discardPendingVoice).toHaveBeenCalledWith("voice-1", "user-1")); + expect(discardQueueItem).not.toHaveBeenCalled(); + }); + + it("discards all recordings of the user at once", async () => { + vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING, VOICE_REJECTED]); + renderSheet(); + const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" }); + + await userEvent.click( + await within(sheet).findByRole("button", { name: "Alle Sprachnotizen verwerfen" }), + ); + await userEvent.click( + within(await screen.findByRole("alertdialog")).getByRole("button", { + name: "Alle verwerfen", + }), + ); + + await waitFor(() => expect(discardAllPendingVoice).toHaveBeenCalledWith("user-1")); + }); + + it("does not delete the refused note on a modified click", async () => { + vi.mocked(listQueue).mockResolvedValue([VOICE_REJECTED]); + renderSheet(); + const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" }); + const link = await within(sheet).findByRole("link", { name: /^Erneut aufnehmen/ }); + document.addEventListener("click", (event) => event.preventDefault(), { once: true, capture: false }); + + fireEvent.click(link, { ctrlKey: true }); + fireEvent.click(link, { metaKey: true }); + fireEvent.click(link, { shiftKey: true }); + fireEvent.click(link, { button: 1 }); + + expect(discardPendingVoice).not.toHaveBeenCalled(); + }); + + it("deletes the refused note on a plain click", async () => { + vi.mocked(listQueue).mockResolvedValue([VOICE_REJECTED]); + renderSheet(); + const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" }); + const link = await within(sheet).findByRole("link", { name: /^Erneut aufnehmen/ }); + document.addEventListener("click", (event) => event.preventDefault(), { once: true, capture: false }); + + await userEvent.click(link); + + await waitFor(() => expect(discardPendingVoice).toHaveBeenCalledWith("voice-2", "user-1")); + }); + + it("moves focus to the Sprachnotizen heading after a single discard", async () => { + vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING, VOICE_REJECTED]); + renderSheet(); + const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" }); + + await userEvent.click( + (await within(sheet).findAllByRole("button", { name: /^Verwerfen: Sprachnotiz vom/ }))[0], + ); + await userEvent.click( + within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }), + ); + + await waitFor(() => + expect(within(sheet).getByRole("heading", { name: "Sprachnotizen" })).toHaveFocus(), + ); + }); + + it("tells without a toast that too many open notes will be sent later", async () => { + vi.mocked(listQueue).mockResolvedValue([ + { ...VOICE_WAITING, voice: { ...VOICE_WAITING.voice!, blockedBy: "TOO_MANY_OPEN" } }, + ]); + renderSheet(); + const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" }); + + expect( + await within(sheet).findByText("Zu viele offene Sprachnotizen – sie werden später gesendet."), + ).toBeVisible(); + }); + + it("shows only the count of another account's notes", async () => { + vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING]); + renderSheet(2); + const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" }); + + expect(await within(sheet).findByText("2 Einträge eines anderen Kontos warten")).toBeVisible(); + expect(within(sheet).getAllByText("Sprachnotiz")).toHaveLength(1); + }); + }); }); diff --git a/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx b/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx index 437451e..f43da7d 100644 --- a/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx +++ b/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx @@ -1,13 +1,14 @@ "use client"; -import type { ComponentProps, ReactNode } from "react"; -import { AlertTriangle, Camera, Clock, RotateCw } from "lucide-react"; +import { useRef, type ComponentProps, type MouseEvent, type ReactNode } from "react"; +import Link from "next/link"; +import { AlertTriangle, Camera, Clock, Mic, RotateCw } from "lucide-react"; import { toast } from "sonner"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { NavSheet } from "@/components/nav/nav-sheet"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { PhotoThumbnail } from "@/components/photos/photo-capture-field"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { discardQueueItem, listQueue, @@ -21,9 +22,12 @@ import { foreignEntriesWaiting, photosAttached, queueRejectionText, + VOICE_TOO_MANY_OPEN_HINT, + voiceRejectionText, } from "@/lib/offline/queue-texts"; import { syncPendingEntries } from "@/lib/offline/sync"; import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation"; +import { discardAllPendingVoice, discardPendingVoice } from "@/lib/voice/pending-voice"; import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store"; interface PendingQueueSheetProps { @@ -44,6 +48,30 @@ interface Named { } const PHOTO_LABEL = "Foto"; +const VOICE_STORE = "pending-voice"; + +/** 75 seconds as "1:15". */ +function durationLabel(totalSec: number): string { + const seconds = Math.round(totalSec); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +} + +/** True for a plain left click; ctrl, meta, shift, alt and middle click open a link elsewhere. */ +function isPlainLeftClick(event: MouseEvent): boolean { + return ( + event.button === 0 && + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ); +} + +/** The recording page, for the colony of a rejected note. */ +function recordAgainHref(item: QueueItem): string { + return `/voice/new?${new URLSearchParams({ colonyId: item.voice?.colonyId ?? "" }).toString()}`; +} function queuedAtLabel(queuedAt: string): string { return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" }); @@ -81,14 +109,19 @@ function discardDescription(item: QueueItem): string { function QueueStatus({ lastError, changesHive = false, + isVoice = false, }: { lastError?: QueueError; changesHive?: boolean; + isVoice?: boolean; }) { return lastError ? (

- Abgelehnt: {queueRejectionText(lastError, changesHive)} + + Abgelehnt:{" "} + {isVoice ? voiceRejectionText(lastError) : queueRejectionText(lastError, changesHive)} +

) : (

@@ -164,6 +197,29 @@ export function PendingQueueSheet({ onError: () => toast.error("Eintrag konnte nicht verworfen werden."), }); + const voiceHeadingRef = useRef(null); + const discardVoice = useMutation({ + mutationFn: (noteId: string) => discardPendingVoice(noteId, userId), + networkMode: "always", + onSuccess: () => { + // The discard button is gone: keep the focus inside the list. + voiceHeadingRef.current?.focus(); + void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); + toast.success("Sprachnotiz verworfen"); + }, + onError: () => toast.error("Sprachnotiz konnte nicht verworfen werden."), + }); + + const discardAllVoice = useMutation({ + mutationFn: () => discardAllPendingVoice(userId), + networkMode: "always", + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); + toast.success("Sprachnotizen verworfen"); + }, + onError: () => toast.error("Sprachnotizen konnten nicht verworfen werden."), + }); + const discardPhoto = useMutation({ mutationFn: (photoId: string) => discardReadyPhoto(photoId), networkMode: "always", @@ -233,7 +289,10 @@ export function PendingQueueSheet({ onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."), }); - const names = entryNames(items); + const records = items.filter((item) => item.store !== VOICE_STORE); + const voiceNotes = items.filter((item) => item.store === VOICE_STORE); + const names = entryNames(records); + const voiceNames = entryNames(voiceNotes); const photoNames = entryNames( photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })), ); @@ -251,7 +310,7 @@ export function PendingQueueSheet({

Von dir wartet nichts mehr.

) : null}
    - {items.map((item, index) => ( + {records.map((item, index) => (
  • ))}
+ {voiceNotes.length > 0 ? ( +
+
+

+ Sprachnotizen +

+ {voiceNotes.length > 1 ? ( + discardAllVoice.mutate()} + isPending={discardAllVoice.isPending} + /> + ) : null} +
+ {voiceNotes.some((item) => item.voice?.blockedBy === "TOO_MANY_OPEN") ? ( +

+ {VOICE_TOO_MANY_OPEN_HINT} +

+ ) : null} +
    + {voiceNotes.map((item, index) => ( +
  • +
    + +
    +

    {item.label}

    +

    + Aufgenommen am {queuedAtLabel(item.queuedAt)} + {item.voice ? ` · Dauer ${durationLabel(item.voice.durationSec)}` : ""} +

    +
    +
    + +
    + {item.lastError ? ( + { + // Only a plain click stays in this tab; the refused audio is of no + // use any more and the new recording replaces it. + if (!isPlainLeftClick(event)) return; + discardVoice.mutate(String(item.id)); + onOpenChange(false); + }} + > + + Erneut aufnehmen + + ) : null} + discardVoice.mutate(String(item.id))} + isPending={discardVoice.isPending} + /> +
    +
  • + ))} +
+
+ ) : null} {foreignCount > 0 ? (

{foreignEntriesWaiting(foreignCount)}

) : null} diff --git a/ApiCore-Frontend/src/lib/offline/db.spec.ts b/ApiCore-Frontend/src/lib/offline/db.spec.ts index 57f251a..a916e8a 100644 --- a/ApiCore-Frontend/src/lib/offline/db.spec.ts +++ b/ApiCore-Frontend/src/lib/offline/db.spec.ts @@ -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 }); + }); +}); diff --git a/ApiCore-Frontend/src/lib/offline/db.ts b/ApiCore-Frontend/src/lib/offline/db.ts index 547e52f..9aef902 100644 --- a/ApiCore-Frontend/src/lib/offline/db.ts +++ b/ApiCore-Frontend/src/lib/offline/db.ts @@ -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 { 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 { + 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 { + 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( - (summary, entry) => { + const summary = waiting.reduce( + (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, + ownerUserId: string, +): Promise { + 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 { const db = await getDb(); @@ -607,7 +685,8 @@ export async function listQueue(currentUserId: string): Promise { 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 { diff --git a/ApiCore-Frontend/src/lib/offline/null-roundtrip.spec.ts b/ApiCore-Frontend/src/lib/offline/null-roundtrip.spec.ts index ce86a38..6710e3e 100644 --- a/ApiCore-Frontend/src/lib/offline/null-roundtrip.spec.ts +++ b/ApiCore-Frontend/src/lib/offline/null-roundtrip.spec.ts @@ -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()), apiFetch: vi.fn(async () => ({ id: "saved" })), diff --git a/ApiCore-Frontend/src/lib/offline/queue-texts.spec.ts b/ApiCore-Frontend/src/lib/offline/queue-texts.spec.ts index 8abca2a..535ed21 100644 --- a/ApiCore-Frontend/src/lib/offline/queue-texts.spec.ts +++ b/ApiCore-Frontend/src/lib/offline/queue-texts.spec.ts @@ -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"); + }); + }); }); diff --git a/ApiCore-Frontend/src/lib/offline/queue-texts.ts b/ApiCore-Frontend/src/lib/offline/queue-texts.ts index ec52111..f0f7a32 100644 --- a/ApiCore-Frontend/src/lib/offline/queue-texts.ts +++ b/ApiCore-Frontend/src/lib/offline/queue-texts.ts @@ -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 & Partial; + +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 = { + 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.`; +} diff --git a/ApiCore-Frontend/src/lib/offline/sync.spec.ts b/ApiCore-Frontend/src/lib/offline/sync.spec.ts index c2f55dc..ce3c3f4 100644 --- a/ApiCore-Frontend/src/lib/offline/sync.spec.ts +++ b/ApiCore-Frontend/src/lib/offline/sync.spec.ts @@ -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", diff --git a/ApiCore-Frontend/src/lib/offline/sync.ts b/ApiCore-Frontend/src/lib/offline/sync.ts index a12d5a9..5b11f14 100644 --- a/ApiCore-Frontend/src/lib/offline/sync.ts +++ b/ApiCore-Frontend/src/lib/offline/sync.ts @@ -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 { 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 { ? 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); } diff --git a/ApiCore-Frontend/src/lib/voice/api.spec.ts b/ApiCore-Frontend/src/lib/voice/api.spec.ts new file mode 100644 index 0000000..0457676 --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/api.spec.ts @@ -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"]); + }); +}); diff --git a/ApiCore-Frontend/src/lib/voice/api.ts b/ApiCore-Frontend/src/lib/voice/api.ts new file mode 100644 index 0000000..62f3c50 --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/api.ts @@ -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 { + const query = status ? `?${new URLSearchParams({ status }).toString()}` : ""; + return apiFetch(`${BASE}${query}`); +} + +export function fetchVoiceCapture(id: string): Promise { + return apiFetch(idPath(id)); +} + +export function fetchVoiceConfig(): Promise { + return apiFetch(`${BASE}/config`); +} + +export function retryVoiceCapture(id: string): Promise { + return apiFetch(idPath(id, "retry"), { method: "POST" }); +} + +export function discardVoiceCapture(id: string): Promise { + return apiFetch(idPath(id, "discard"), { method: "POST" }); +} + +export function discardAllVoiceCaptures(): Promise<{ discarded: number }> { + return apiFetch<{ discarded: number }>(`${BASE}/discard-all`, { method: "POST" }); +} diff --git a/ApiCore-Frontend/src/lib/voice/pending-voice.ts b/ApiCore-Frontend/src/lib/voice/pending-voice.ts index 97f69ac..87d1d36 100644 --- a/ApiCore-Frontend/src/lib/voice/pending-voice.ts +++ b/ApiCore-Frontend/src/lib/voice/pending-voice.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/ApiCore-Frontend/src/lib/voice/upload-pending-voice.spec.ts b/ApiCore-Frontend/src/lib/voice/upload-pending-voice.spec.ts new file mode 100644 index 0000000..74688fc --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/upload-pending-voice.spec.ts @@ -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; +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); + }); +}); diff --git a/ApiCore-Frontend/src/lib/voice/upload-pending-voice.ts b/ApiCore-Frontend/src/lib/voice/upload-pending-voice.ts new file mode 100644 index 0000000..a6331dc --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/upload-pending-voice.ts @@ -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 { + 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 }; +}