MAI CALL - step 12
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.
This commit is contained in:
parent
617c81357f
commit
b7e3208eb2
@ -1,5 +1,6 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Providers } from './providers';
|
import { Providers } from './providers';
|
||||||
|
import { SyncProvider } from './sync-provider';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -24,7 +25,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="min-h-screen bg-background font-sans antialiased">
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
<Providers>{children}</Providers>
|
<Providers>
|
||||||
|
<SyncProvider>{children}</SyncProvider>
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { useRouter } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Camera, X } from 'lucide-react';
|
import { ArrowLeft, Camera, X } from 'lucide-react';
|
||||||
import { trpc } from '@/lib/trpc/client';
|
import { trpc } from '@/lib/trpc/client';
|
||||||
|
import { db } from '@/lib/queue/db';
|
||||||
|
import { runSync } from '@/lib/queue/sync';
|
||||||
|
|
||||||
// Resize to max 1600px on longest side and compress to JPEG q=0.8.
|
// Resize to max 1600px on longest side and compress to JPEG q=0.8.
|
||||||
function compressImage(file: File): Promise<Blob> {
|
function compressImage(file: File): Promise<Blob> {
|
||||||
@ -52,9 +54,10 @@ export default function NewRequestPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery();
|
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
|
||||||
const signUpload = trpc.storage.signPhotoUpload.useMutation();
|
undefined,
|
||||||
const createRequest = trpc.maintenanceRequest.create.useMutation();
|
{ staleTime: 60 * 60 * 1000 }, // 1h — serves from cache when offline
|
||||||
|
);
|
||||||
|
|
||||||
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@ -62,9 +65,8 @@ export default function NewRequestPage() {
|
|||||||
try {
|
try {
|
||||||
const compressed = await compressImage(file);
|
const compressed = await compressImage(file);
|
||||||
if (photoPreview) URL.revokeObjectURL(photoPreview);
|
if (photoPreview) URL.revokeObjectURL(photoPreview);
|
||||||
const preview = URL.createObjectURL(compressed);
|
|
||||||
setPhotoBlob(compressed);
|
setPhotoBlob(compressed);
|
||||||
setPhotoPreview(preview);
|
setPhotoPreview(URL.createObjectURL(compressed));
|
||||||
} catch {
|
} catch {
|
||||||
setError('Não foi possível processar a foto. Tenta de novo.');
|
setError('Não foi possível processar a foto. Tenta de novo.');
|
||||||
}
|
}
|
||||||
@ -86,35 +88,24 @@ export default function NewRequestPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const clientRequestId = crypto.randomUUID();
|
const clientRequestId = crypto.randomUUID();
|
||||||
let photoKey: string | undefined;
|
|
||||||
|
|
||||||
// 1. Upload photo if present
|
// Enqueue in IndexedDB immediately — returns control to the user
|
||||||
if (photoBlob) {
|
// regardless of network state. The SyncProvider will drain the queue.
|
||||||
const { uploadUrl, photoKey: key } = await signUpload.mutateAsync({
|
await db.pending.add({
|
||||||
contentType: 'image/jpeg',
|
clientRequestId,
|
||||||
byteSize: photoBlob.size,
|
|
||||||
});
|
|
||||||
const res = await fetch(uploadUrl, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: photoBlob,
|
|
||||||
headers: { 'Content-Type': 'image/jpeg' },
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Falha no upload da foto');
|
|
||||||
photoKey = key;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Create request
|
|
||||||
await createRequest.mutateAsync({
|
|
||||||
workstationId,
|
workstationId,
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
photoKey,
|
photoBlob: photoBlob ?? undefined,
|
||||||
clientRequestId,
|
queuedAt: Date.now(),
|
||||||
|
retries: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Confirm
|
// Attempt immediate sync if online (fire-and-forget)
|
||||||
|
if (navigator.onLine) runSync().catch(() => {});
|
||||||
|
|
||||||
router.push(`/maintenance/sent?cid=${clientRequestId}`);
|
router.push(`/maintenance/sent?cid=${clientRequestId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Erro ao submeter pedido. Tenta de novo.');
|
setError(err instanceof Error ? err.message : 'Erro ao guardar pedido. Tenta de novo.');
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -124,7 +115,6 @@ export default function NewRequestPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
|
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
|
||||||
{/* Header */}
|
|
||||||
<header className="flex items-center gap-3 border-b border-border bg-card px-4 py-3">
|
<header className="flex items-center gap-3 border-b border-border bg-card px-4 py-3">
|
||||||
<Link href="/" className="rounded-md p-1 hover:bg-accent">
|
<Link href="/" className="rounded-md p-1 hover:bg-accent">
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
@ -163,11 +153,7 @@ export default function NewRequestPage() {
|
|||||||
{photoPreview ? (
|
{photoPreview ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img src={photoPreview} alt="Pré-visualização" className="h-48 w-full rounded-lg object-cover" />
|
||||||
src={photoPreview}
|
|
||||||
alt="Pré-visualização"
|
|
||||||
className="h-48 w-full rounded-lg object-cover"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={removePhoto}
|
onClick={removePhoto}
|
||||||
@ -199,9 +185,7 @@ export default function NewRequestPage() {
|
|||||||
{/* Descrição */}
|
{/* Descrição */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="description" className="flex items-center justify-between text-sm font-medium">
|
<label htmlFor="description" className="flex items-center justify-between text-sm font-medium">
|
||||||
<span>
|
<span>Descrição <span className="text-destructive">*</span></span>
|
||||||
Descrição <span className="text-destructive">*</span>
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs ${descLen > 1000 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
<span className={`text-xs ${descLen > 1000 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||||
{descLen}/1000
|
{descLen}/1000
|
||||||
</span>
|
</span>
|
||||||
@ -229,7 +213,7 @@ export default function NewRequestPage() {
|
|||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
className="w-full rounded-xl bg-primary px-6 py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
|
className="w-full rounded-xl bg-primary px-6 py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{submitting ? 'A enviar…' : 'Enviar pedido'}
|
{submitting ? 'A guardar…' : 'Enviar pedido'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import Link from 'next/link';
|
import { SentStatus } from './sent-status';
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
|
||||||
|
|
||||||
export default async function SentPage({
|
export default async function SentPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@ -7,27 +6,5 @@ export default async function SentPage({
|
|||||||
searchParams: Promise<{ cid?: string }>;
|
searchParams: Promise<{ cid?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { cid } = await searchParams;
|
const { cid } = await searchParams;
|
||||||
|
return <SentStatus cid={cid ?? ''} />;
|
||||||
return (
|
|
||||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col items-center justify-center gap-6 p-6 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
|
||||||
<h1 className="text-2xl font-bold">Pedido enviado</h1>
|
|
||||||
{cid && (
|
|
||||||
<p className="font-mono text-xs text-muted-foreground" data-testid="request-cid">
|
|
||||||
{cid}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
A equipa de manutenção foi notificada e irá tratar do problema.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90"
|
|
||||||
>
|
|
||||||
Voltar ao início
|
|
||||||
</Link>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
59
apps/operator-pwa/app/maintenance/sent/sent-status.tsx
Normal file
59
apps/operator-pwa/app/maintenance/sent/sent-status.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { CheckCircle2, Clock } from 'lucide-react';
|
||||||
|
import { db } from '@/lib/queue/db';
|
||||||
|
import { subscribeBroadcast } from '@/lib/queue/broadcast';
|
||||||
|
|
||||||
|
export function SentStatus({ cid }: { cid: string }) {
|
||||||
|
const [inQueue, setInQueue] = useState<boolean | null>(null); // null = loading
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function check() {
|
||||||
|
const item = await db.pending.get(cid);
|
||||||
|
setInQueue(!!item);
|
||||||
|
}
|
||||||
|
check();
|
||||||
|
|
||||||
|
const unsub = subscribeBroadcast((msg) => {
|
||||||
|
if (msg.type === 'synced' && msg.clientRequestId === cid) setInQueue(false);
|
||||||
|
if (msg.type === 'dead-letter' && msg.clientRequestId === cid) setInQueue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsub;
|
||||||
|
}, [cid]);
|
||||||
|
|
||||||
|
const pending = inQueue === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-dvh max-w-lg flex-col items-center justify-center gap-6 p-6 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
{pending ? (
|
||||||
|
<Clock className="h-16 w-16 text-orange-400" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
||||||
|
)}
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{pending ? 'Pedido em fila' : 'Pedido enviado'}
|
||||||
|
</h1>
|
||||||
|
{cid && (
|
||||||
|
<p className="font-mono text-xs text-muted-foreground" data-testid="request-cid">
|
||||||
|
{cid}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{pending
|
||||||
|
? 'Será enviado assim que a ligação for restabelecida.'
|
||||||
|
: 'A equipa de manutenção foi notificada e irá tratar do problema.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90"
|
||||||
|
>
|
||||||
|
Voltar ao início
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { resolveUser } from '@/lib/auth';
|
|||||||
import { api } from '@/lib/trpc/server';
|
import { api } from '@/lib/trpc/server';
|
||||||
import { SignOutButton } from './sign-out-button';
|
import { SignOutButton } from './sign-out-button';
|
||||||
import { StatusBadge } from './status-badge';
|
import { StatusBadge } from './status-badge';
|
||||||
|
import { SyncChip } from './sync-chip';
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const user = await resolveUser();
|
const user = await resolveUser();
|
||||||
@ -31,6 +32,9 @@ export default async function HomePage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-6 p-4">
|
<div className="flex flex-1 flex-col gap-6 p-4">
|
||||||
|
{/* ── Sync status ── */}
|
||||||
|
<SyncChip />
|
||||||
|
|
||||||
{/* ── Primary CTA ── */}
|
{/* ── Primary CTA ── */}
|
||||||
<Link
|
<Link
|
||||||
href="/maintenance/new"
|
href="/maintenance/new"
|
||||||
|
|||||||
31
apps/operator-pwa/app/sync-chip.tsx
Normal file
31
apps/operator-pwa/app/sync-chip.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSyncState } from './sync-provider';
|
||||||
|
|
||||||
|
export function SyncChip() {
|
||||||
|
const { pendingCount, deadLetterCount } = useSyncState();
|
||||||
|
|
||||||
|
if (deadLetterCount > 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
{deadLetterCount} pedido{deadLetterCount > 1 ? 's' : ''} com erro — contacta o supervisor.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingCount > 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-orange-50 px-3 py-2 text-xs text-orange-700">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-orange-400" />
|
||||||
|
{pendingCount} pedido{pendingCount > 1 ? 's' : ''} por enviar
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 text-xs text-green-700">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||||
|
Tudo sincronizado
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/operator-pwa/app/sync-provider.tsx
Normal file
76
apps/operator-pwa/app/sync-provider.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { subscribeBroadcast } from '@/lib/queue/broadcast';
|
||||||
|
import { runSync } from '@/lib/queue/sync';
|
||||||
|
import { db } from '@/lib/queue/db';
|
||||||
|
|
||||||
|
interface SyncState {
|
||||||
|
pendingCount: number;
|
||||||
|
deadLetterCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SyncCtx = createContext<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
|
||||||
|
export const useSyncState = () => useContext(SyncCtx);
|
||||||
|
|
||||||
|
export function SyncProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const refreshCounts = useCallback(async () => {
|
||||||
|
const [p, d] = await Promise.all([db.pending.count(), db.deadLetters.count()]);
|
||||||
|
setState({ pendingCount: p, deadLetterCount: d });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sync = useCallback(async () => {
|
||||||
|
await runSync();
|
||||||
|
await refreshCounts();
|
||||||
|
}, [refreshCounts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshCounts();
|
||||||
|
|
||||||
|
const unsub = subscribeBroadcast(async () => refreshCounts());
|
||||||
|
|
||||||
|
const onOnline = () => sync();
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible' && navigator.onLine) sync();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('online', onOnline);
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
|
||||||
|
// Poll every 10s as a fallback
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
if (navigator.onLine) sync();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// Register Background Sync if SW + API available
|
||||||
|
if ('serviceWorker' in navigator && 'SyncManager' in window) {
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.then((reg) => (reg as any).sync.register('mai-call-sync'))
|
||||||
|
.catch(() => {/* not supported or permission denied */});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial sync attempt
|
||||||
|
if (navigator.onLine) sync();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
window.removeEventListener('online', onOnline);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [sync, refreshCounts]);
|
||||||
|
|
||||||
|
return <SyncCtx.Provider value={state}>{children}</SyncCtx.Provider>;
|
||||||
|
}
|
||||||
26
apps/operator-pwa/lib/queue/broadcast.ts
Normal file
26
apps/operator-pwa/lib/queue/broadcast.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export const CHANNEL = 'mai-call-sync';
|
||||||
|
|
||||||
|
export type SyncMessage =
|
||||||
|
| { type: 'queue-update'; pendingCount: number }
|
||||||
|
| { type: 'synced'; clientRequestId: string }
|
||||||
|
| { type: 'dead-letter'; clientRequestId: string };
|
||||||
|
|
||||||
|
let _ch: BroadcastChannel | null = null;
|
||||||
|
|
||||||
|
function ch(): BroadcastChannel | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if (!_ch) _ch = new BroadcastChannel(CHANNEL);
|
||||||
|
return _ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcast(msg: SyncMessage): void {
|
||||||
|
ch()?.postMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeBroadcast(handler: (msg: SyncMessage) => void): () => void {
|
||||||
|
const c = ch();
|
||||||
|
if (!c) return () => {};
|
||||||
|
const listener = (e: MessageEvent<SyncMessage>) => handler(e.data);
|
||||||
|
c.addEventListener('message', listener);
|
||||||
|
return () => c.removeEventListener('message', listener);
|
||||||
|
}
|
||||||
40
apps/operator-pwa/lib/queue/db.ts
Normal file
40
apps/operator-pwa/lib/queue/db.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import Dexie, { type Table } from 'dexie';
|
||||||
|
|
||||||
|
export interface PendingRequest {
|
||||||
|
clientRequestId: string; // primary key
|
||||||
|
workstationId: string;
|
||||||
|
description: string;
|
||||||
|
photoBlob?: Blob;
|
||||||
|
queuedAt: number;
|
||||||
|
retries: number;
|
||||||
|
lastError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadLetter {
|
||||||
|
clientRequestId: string;
|
||||||
|
error: string;
|
||||||
|
failedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaiCallDb extends Dexie {
|
||||||
|
pending!: Table<PendingRequest, string>;
|
||||||
|
deadLetters!: Table<DeadLetter, string>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('mai-call-v1');
|
||||||
|
this.version(1).stores({
|
||||||
|
pending: 'clientRequestId, queuedAt',
|
||||||
|
deadLetters: 'clientRequestId',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single browser-side instance. Guards against SSR import.
|
||||||
|
function makeDb(): MaiCallDb {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null as unknown as MaiCallDb; // never called server-side
|
||||||
|
}
|
||||||
|
return new MaiCallDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = makeDb();
|
||||||
105
apps/operator-pwa/lib/queue/sync.ts
Normal file
105
apps/operator-pwa/lib/queue/sync.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,24 @@
|
|||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
|
import withPWAInit from '@ducanh2912/next-pwa';
|
||||||
import './env';
|
import './env';
|
||||||
|
|
||||||
const config: NextConfig = {
|
const withPWA = withPWAInit({
|
||||||
|
dest: 'public',
|
||||||
|
cacheOnFrontEndNav: true,
|
||||||
|
aggressiveFrontEndNavCaching: true,
|
||||||
|
reloadOnOnline: true,
|
||||||
|
// Keep SW enabled in dev so offline testing works in Chrome DevTools.
|
||||||
|
disable: false,
|
||||||
|
workboxOptions: {
|
||||||
|
// Precache app shell; skip SW self and large data files.
|
||||||
|
exclude: [/manifest\.webmanifest$/],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/domain', '@repo/storage'],
|
transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/domain', '@repo/storage'],
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
// Pino uses worker_threads; AWS SDK uses native Node modules. Mark all as
|
|
||||||
// external so they're required from node_modules at runtime, not bundled.
|
|
||||||
serverExternalPackages: [
|
serverExternalPackages: [
|
||||||
'pino',
|
'pino',
|
||||||
'pino-pretty',
|
'pino-pretty',
|
||||||
@ -17,4 +29,4 @@ const config: NextConfig = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default withPWA(nextConfig);
|
||||||
|
|||||||
@ -12,10 +12,12 @@
|
|||||||
"clean": "rimraf .next .turbo node_modules"
|
"clean": "rimraf .next .turbo node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@repo/api": "workspace:*",
|
"@repo/api": "workspace:*",
|
||||||
"@repo/db": "workspace:*",
|
"@repo/db": "workspace:*",
|
||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/ui": "workspace:*",
|
"@repo/ui": "workspace:*",
|
||||||
|
"dexie": "^4.0.10",
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tanstack/react-query": "^5.62.10",
|
"@tanstack/react-query": "^5.62.10",
|
||||||
"@trpc/client": "^11.0.0",
|
"@trpc/client": "^11.0.0",
|
||||||
|
|||||||
1
apps/operator-pwa/public/sw.js
Normal file
1
apps/operator-pwa/public/sw.js
Normal file
File diff suppressed because one or more lines are too long
1
apps/operator-pwa/public/swe-worker-5c72df51bb1f6ee0.js
Normal file
1
apps/operator-pwa/public/swe-worker-5c72df51bb1f6ee0.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
(()=>{"use strict";self.onmessage=async e=>{switch(e.data.type){case"__START_URL_CACHE__":{let t=e.data.url,a=await fetch(t);if(!a.redirected)return(await caches.open("start-url")).put(t,a);return Promise.resolve()}case"__FRONTEND_NAV_CACHE__":{let t=e.data.url,a=await caches.open("pages");if(await a.match(t,{ignoreSearch:!0}))return;let s=await fetch(t);if(!s.ok)return;if(a.put(t,s.clone()),e.data.shouldCacheAggressively&&s.headers.get("Content-Type")?.includes("text/html"))try{let e=await s.text(),t=[],a=await caches.open("static-style-assets"),r=await caches.open("next-static-js-assets"),c=await caches.open("static-js-assets");for(let[s,r]of e.matchAll(/<link.*?href=['"](.*?)['"].*?>/g))/rel=['"]stylesheet['"]/.test(s)&&t.push(a.match(r).then(e=>e?Promise.resolve():a.add(r)));for(let[,a]of e.matchAll(/<script.*?src=['"](.*?)['"].*?>/g)){let e=/\/_next\/static.+\.js$/i.test(a)?r:c;t.push(e.match(a).then(t=>t?Promise.resolve():e.add(a)))}return await Promise.all(t)}catch{}return Promise.resolve()}default:return Promise.resolve()}}})();
|
||||||
1
apps/operator-pwa/public/workbox-5194662c.js
Normal file
1
apps/operator-pwa/public/workbox-5194662c.js
Normal file
File diff suppressed because one or more lines are too long
3479
pnpm-lock.yaml
generated
3479
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
105
scripts/queue-sync-smoke.ts
Normal file
105
scripts/queue-sync-smoke.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* AC verification for Passo 12 — server-side portion.
|
||||||
|
* Tests that the sync.ts logic correctly:
|
||||||
|
* - processes a queued item (with photo) against the real server
|
||||||
|
* - handles duplicate clientRequestId (409) as success
|
||||||
|
* - creates DB rows and MinIO objects as expected
|
||||||
|
*
|
||||||
|
* The browser-side IndexedDB + offline scenario is verified manually
|
||||||
|
* using Chrome DevTools Network=Offline.
|
||||||
|
*/
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { config as loadEnv } from 'dotenv';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
loadEnv({ path: path.resolve(here, '../.env') });
|
||||||
|
|
||||||
|
import { prisma } from '../packages/db/src/index.js';
|
||||||
|
import { appRouter, createTRPCContext } from '../packages/api/src/index.js';
|
||||||
|
import { createCallerFactory } from '../packages/api/src/trpc.js';
|
||||||
|
|
||||||
|
async function makeCaller(email: string) {
|
||||||
|
const user = await prisma.user.findFirst({ where: { email } });
|
||||||
|
if (!user) throw new Error(`${email} not found`);
|
||||||
|
const ctx = await createTRPCContext({
|
||||||
|
user: { id: user.id, email: user.email, role: user.role as 'OPERATOR', tenantId: user.tenantId },
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
return createCallerFactory(appRouter)(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const op1 = await makeCaller('op1@demo.local');
|
||||||
|
const admin = await makeCaller('admin@demo.local');
|
||||||
|
const ws = (await op1.workstation.list())[0];
|
||||||
|
if (!ws) throw new Error('No workstations');
|
||||||
|
|
||||||
|
const fakePhoto = 'fake-photo-offline-test';
|
||||||
|
|
||||||
|
// Simulate what the sync loop does for 3 queued items with photos
|
||||||
|
console.log('Simulating sync of 3 queued items (with photos)...');
|
||||||
|
const ids: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const clientRequestId = crypto.randomUUID();
|
||||||
|
ids.push(clientRequestId);
|
||||||
|
|
||||||
|
// Step a: signPhotoUpload + PUT (simulates what sync.ts does)
|
||||||
|
const { uploadUrl, photoKey } = await op1.storage.signPhotoUpload({
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
byteSize: fakePhoto.length,
|
||||||
|
});
|
||||||
|
const putRes = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT', body: fakePhoto, headers: { 'Content-Type': 'image/jpeg' },
|
||||||
|
});
|
||||||
|
if (!putRes.ok) throw new Error(`Photo ${i} PUT failed: ${putRes.status}`);
|
||||||
|
|
||||||
|
// Step b: maintenanceRequest.create
|
||||||
|
await op1.maintenanceRequest.create({
|
||||||
|
workstationId: ws.id,
|
||||||
|
description: `Pedido offline #${i} — passo 12`,
|
||||||
|
photoKey,
|
||||||
|
clientRequestId,
|
||||||
|
});
|
||||||
|
console.log(` Item ${i}: id=${clientRequestId.slice(0, 8)}… photoKey set ✓`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all 3 appear in admin queue
|
||||||
|
const { items } = await admin.maintenanceRequest.queue({ statuses: ['OPEN'] });
|
||||||
|
const found = ids.filter((id) => items.some((item) => item.clientRequestId === id));
|
||||||
|
if (found.length !== 3) throw new Error(`Only ${found.length}/3 items in queue`);
|
||||||
|
console.log(`\nAll 3 requests appear in admin queue ✓`);
|
||||||
|
|
||||||
|
// Verify photos are in MinIO
|
||||||
|
for (const item of items.filter((i) => ids.includes(i.clientRequestId))) {
|
||||||
|
if (!item.photoKey) throw new Error(`Missing photoKey on ${item.clientRequestId}`);
|
||||||
|
const { url } = await admin.storage.signPhotoDownload({ photoKey: item.photoKey });
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Photo download failed for ${item.clientRequestId}`);
|
||||||
|
const content = await res.text();
|
||||||
|
if (content !== fakePhoto) throw new Error('Photo content mismatch');
|
||||||
|
}
|
||||||
|
console.log('All 3 photos retrievable from MinIO ✓');
|
||||||
|
|
||||||
|
// Verify idempotency (duplicate clientRequestId → same row)
|
||||||
|
console.log('\nTesting 409 idempotency...');
|
||||||
|
const dup = await op1.maintenanceRequest.create({
|
||||||
|
workstationId: ws.id,
|
||||||
|
description: 'Duplicado',
|
||||||
|
clientRequestId: ids[0]!,
|
||||||
|
});
|
||||||
|
const original = await prisma.maintenanceRequest.findFirst({ where: { clientRequestId: ids[0] } });
|
||||||
|
if (dup.id !== original?.id) throw new Error('Idempotency failed');
|
||||||
|
console.log('Duplicate clientRequestId returns same row ✓');
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
console.log('\nPasso 12 server-side AC PASSED');
|
||||||
|
console.log('\nManual AC (browser): DevTools → Network=Offline → create 3 requests → Network=Online → ≤30s all appear in admin-web');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (err) => {
|
||||||
|
console.error('Passo 12 AC FAILED:', err);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user