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]>
131 lines
4.5 KiB
TypeScript
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;
|
|
}
|