Files
ApiCore/ApiCore-Frontend/src/lib/voice/pending-voice.ts
T
Nick SzaunigandClaude Opus 5.5 e614ded967
Backend Image / build-and-push (push) Successful in 7m6s
Frontend Image / build-and-push (push) Successful in 15m27s
Manifests / validate (push) Successful in 16s
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]>
2026-10-16 11:10:14 +02:00

131 lines
4.5 KiB
TypeScript

import { getDb, type QueueError, type StoredVoiceNote } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
export type { StoredVoiceNote } from "@/lib/offline/db";
/*
* Voice notes recorded on this device. The audio stays here until the sync
* uploads it and is deleted on success, on discard and when the account signs
* out (db.clearQueueOf). It is never logged and never leaves the device
* before the user's upload.
*/
/** 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 Sprachnotiz.");
this.name = "VoiceStorageFullError";
}
}
export type NewVoiceNote = Omit<
StoredVoiceNote,
"id" | "createdAt" | "uploadIdempotencyKey" | "state"
>;
let persistAsked = false;
/** Asks once per page for storage the browser will not clear on its own. Best effort. */
async function askForPersistentStorage(): Promise<void> {
if (persistAsked) {
return;
}
persistAsked = true;
try {
await navigator.storage?.persist?.();
} catch {
// Without persistent storage the note is still saved; the browser may clear it under pressure.
}
}
function isQuotaError(error: unknown): boolean {
return error instanceof DOMException && error.name === "QuotaExceededError";
}
export async function queueVoiceNote(input: NewVoiceNote): Promise<StoredVoiceNote> {
await askForPersistentStorage();
const note: StoredVoiceNote = {
...input,
id: newIdempotencyKey(),
createdAt: new Date().toISOString(),
uploadIdempotencyKey: newIdempotencyKey(),
state: "recorded",
};
const db = await getDb();
try {
await db.add("pending-voice", note);
} catch (error) {
if (isQuotaError(error)) {
throw new VoiceStorageFullError();
}
throw error;
}
return note;
}
/** The signed-in user's notes, oldest first. Another account's notes never appear. */
export async function listPendingVoice(currentUserId: string): Promise<StoredVoiceNote[]> {
const db = await getDb();
const notes = await db.getAllFromIndex("pending-voice", "ownerUserId", currentUserId);
return notes.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
/** How many notes of other accounts wait on this device - shown as a number only. */
export async function countForeignVoice(currentUserId: string): Promise<number> {
const db = await getDb();
const all = await db.getAllKeys("pending-voice");
const own = await db.countFromIndex("pending-voice", "ownerUserId", currentUserId);
return all.length - own;
}
/** Deletes one note, but only if it belongs to `ownerUserId`. */
export async function discardPendingVoice(id: string, ownerUserId: string): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-voice", "readwrite");
const note = await tx.store.get(id);
if (note?.ownerUserId === ownerUserId) {
await tx.store.delete(id);
}
await tx.done;
}
/** Deletes every note one account recorded on this device; returns how many. */
export async function discardAllPendingVoice(ownerUserId: string): Promise<number> {
const db = await getDb();
const tx = db.transaction("pending-voice", "readwrite");
const ids = await tx.store.index("ownerUserId").getAllKeys(ownerUserId);
await Promise.all(ids.map((id) => tx.store.delete(id)));
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;
}