Passo 12 completo. Build limpo, AC server-side totalmente verificado. O que foi implementado: lib/queue/ — camada de persistência offline: db.ts — Dexie 4 com tabelas pending e deadLetters broadcast.ts — BroadcastChannel helper (mai-call-sync) para comunicar entre tabs sync.ts — loop de sync com retry/backoff: signPhotoUpload → PUT MinIO → create; 409 = sucesso; 4xx = dead-letter; erros de rede = paragem + retry na próxima volta SyncProvider — React Context que: Arranca sync ao reconectar (online event + visibilitychange) Polling de 10s como fallback Regista Background Sync API quando disponível Expõe pendingCount / deadLetterCount via useSyncState() Formulário (/maintenance/new) — refatorado: ao submeter, escreve em IndexedDB e navega imediatamente para /sent sem esperar pelo servidor. O SyncProvider processa a fila em background. Feedback visual: SyncChip na home: "Tudo sincronizado" / "N pedidos por enviar" / erro dead-letter /maintenance/sent: mostra "Em fila" (Clock) ou "Enviado" (CheckCircle2) reactivamente via BroadcastChannel Workbox (@ducanh2912/next-pwa) — app shell precaching ativo, para que o app carregue mesmo sem rede depois da primeira visita.
106 lines
3.3 KiB
TypeScript
106 lines
3.3 KiB
TypeScript
import { createTRPCClient, httpBatchLink } from '@trpc/client';
|
|
import superjson from 'superjson';
|
|
import type { AppRouter } from '@repo/api';
|
|
import { db } from './db';
|
|
import { broadcast } from './broadcast';
|
|
|
|
let _client: ReturnType<typeof createTRPCClient<AppRouter>> | null = null;
|
|
|
|
function getClient() {
|
|
if (!_client) {
|
|
_client = createTRPCClient<AppRouter>({
|
|
links: [httpBatchLink({ url: '/api/trpc', transformer: superjson })],
|
|
});
|
|
}
|
|
return _client;
|
|
}
|
|
|
|
function httpStatus(err: unknown): number | null {
|
|
if (typeof err === 'object' && err !== null && 'data' in err) {
|
|
const d = (err as { data?: { httpStatus?: number } }).data;
|
|
return d?.httpStatus ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isNetworkErr(err: unknown): boolean {
|
|
return err instanceof TypeError;
|
|
}
|
|
|
|
let _running = false;
|
|
|
|
export async function runSync(): Promise<void> {
|
|
if (_running || !navigator.onLine) return;
|
|
_running = true;
|
|
try {
|
|
const items = await db.pending.orderBy('queuedAt').toArray();
|
|
if (!items.length) return;
|
|
|
|
const client = getClient();
|
|
|
|
for (const item of items) {
|
|
try {
|
|
let photoKey: string | undefined;
|
|
|
|
if (item.photoBlob) {
|
|
const { uploadUrl, photoKey: key } = await client.storage.signPhotoUpload.mutate({
|
|
contentType: 'image/jpeg',
|
|
byteSize: item.photoBlob.size,
|
|
});
|
|
const res = await fetch(uploadUrl, {
|
|
method: 'PUT',
|
|
body: item.photoBlob,
|
|
headers: { 'Content-Type': 'image/jpeg' },
|
|
});
|
|
if (!res.ok) throw new Error(`Photo PUT ${res.status}`);
|
|
photoKey = key;
|
|
}
|
|
|
|
await client.maintenanceRequest.create.mutate({
|
|
workstationId: item.workstationId,
|
|
description: item.description,
|
|
photoKey,
|
|
clientRequestId: item.clientRequestId,
|
|
});
|
|
|
|
await db.pending.delete(item.clientRequestId);
|
|
broadcast({ type: 'synced', clientRequestId: item.clientRequestId });
|
|
} catch (err) {
|
|
if (isNetworkErr(err)) break; // no connectivity — stop this cycle
|
|
|
|
const status = httpStatus(err);
|
|
|
|
if (status === 409) {
|
|
// Already exists (duplicate clientRequestId) — idempotent success
|
|
await db.pending.delete(item.clientRequestId);
|
|
broadcast({ type: 'synced', clientRequestId: item.clientRequestId });
|
|
continue;
|
|
}
|
|
|
|
if (status !== null && status >= 400 && status < 500) {
|
|
// 4xx other than 409 — permanent failure, move to dead-letter
|
|
await db.deadLetters.put({
|
|
clientRequestId: item.clientRequestId,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
failedAt: Date.now(),
|
|
});
|
|
await db.pending.delete(item.clientRequestId);
|
|
broadcast({ type: 'dead-letter', clientRequestId: item.clientRequestId });
|
|
continue;
|
|
}
|
|
|
|
// 5xx / unknown — increment retries, leave in queue
|
|
await db.pending.update(item.clientRequestId, {
|
|
retries: (item.retries ?? 0) + 1,
|
|
lastError: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
const remaining = await db.pending.count();
|
|
broadcast({ type: 'queue-update', pendingCount: remaining });
|
|
} finally {
|
|
_running = false;
|
|
}
|
|
}
|