Auditoria de segurança + robustez da área do representante antes de produção. API (autorização e injeção): - IDOR em clients: endpoints de detalhe (findOne, notas, ctr, top-produtos, histórico, contatos) validam carteira do rep via assertClienteDaCarteira; cliente alheio retorna 404. orders.create idem antes de aceitar idCliente. - Role guard em dashboards supervisor/manager e ger/chamados (rep -> 403). - Datas em $queryRawUnsafe agora exigem YYYY-MM-DD (DateOnlySchema + revalidação); dtAniversario de contato validado e escapado. - id_empresa de CTR/NF usa matrizEmpresa() em vez de 1/9001 hardcoded. - Alçada de desconto: transmit valida desconto efetivo por item (preço vs tabela pauta>promo>base + promoção ativa), não só o desconto global. - Idempotency-Key escopado a empresa+vendedor; unsubscribe com DTO e dono. Web (erros nunca silenciosos): - Remove dupla serialização (apiFetch já faz JSON.stringify) em funil, sac e politicas — corrige criar oportunidade, abrir chamado e editar políticas. - Handler global de erro no QueryClient (query e mutation viram toast). - Error boundary por rota (payload divergente não derruba o app em branco). - Alert usa message= em vez de title= (AntD 6); sw.js usa globalThis. Qualidade: - Corrige ping.contract.spec (idEmpresa) e zera erros de lint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
124 lines
3.7 KiB
JavaScript
124 lines
3.7 KiB
JavaScript
// Service Worker SAR
|
|
// C4/NFR-2: cache de API para uso offline (network-first, fallback to cache)
|
|
// C6: Web Push
|
|
// App shell: stale-while-revalidate para assets estáticos
|
|
|
|
const API_CACHE = 'sar-api-v2';
|
|
const SHELL_CACHE = 'sar-shell-v2';
|
|
|
|
// Paths de API que valem ser cacheados para offline
|
|
// Auth e mutations (POST/PATCH) nunca são interceptados
|
|
const CACHEABLE_API = [
|
|
'/api/v1/clients',
|
|
'/api/v1/catalog',
|
|
'/api/v1/orders',
|
|
'/api/v1/dashboard',
|
|
'/api/v1/auth/me',
|
|
];
|
|
|
|
// ── Fetch ──────────────────────────────────────────────────────────────────────
|
|
|
|
globalThis.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
if (request.method !== 'GET') return;
|
|
|
|
const url = new URL(request.url);
|
|
|
|
if (url.pathname.startsWith('/api/v1/')) {
|
|
const cacheable = CACHEABLE_API.some((p) => url.pathname.startsWith(p));
|
|
if (cacheable) {
|
|
event.respondWith(networkFirst(request, API_CACHE));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (request.mode === 'navigate') {
|
|
// App shell HTML — network first, cache fallback
|
|
event.respondWith(shellNavigate(request));
|
|
return;
|
|
}
|
|
|
|
// Assets estáticos (JS/CSS/fontes/imagens) — stale-while-revalidate
|
|
if (/\.(js|css|woff2?|png|svg|ico)$/.test(url.pathname)) {
|
|
event.respondWith(staleWhileRevalidate(request, SHELL_CACHE));
|
|
}
|
|
});
|
|
|
|
// Network-first: tenta rede, cai no cache se offline
|
|
async function networkFirst(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
const cached = await cache.match(request);
|
|
if (cached) return cached;
|
|
return offlineResponse();
|
|
}
|
|
}
|
|
|
|
// Navigate: network first; fallback para a raiz cacheada
|
|
async function shellNavigate(request) {
|
|
const cache = await caches.open(SHELL_CACHE);
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
cache.put(request, response.clone());
|
|
// Sempre armazena a raiz como fallback universal
|
|
cache.put(new Request('/'), response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
const cached = (await cache.match(request)) ?? (await cache.match('/'));
|
|
if (cached) return cached;
|
|
return offlineResponse();
|
|
}
|
|
}
|
|
|
|
// Stale-while-revalidate: responde do cache, atualiza em background
|
|
async function staleWhileRevalidate(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
const cached = await cache.match(request);
|
|
const networkFetch = fetch(request).then((response) => {
|
|
if (response.ok) cache.put(request, response.clone());
|
|
return response;
|
|
});
|
|
return cached ?? networkFetch;
|
|
}
|
|
|
|
function offlineResponse() {
|
|
return new Response(
|
|
JSON.stringify({
|
|
type: 'sar:offline',
|
|
title: 'Sem conexão',
|
|
status: 503,
|
|
}),
|
|
{ status: 503, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
|
|
// ── Push (C6) ─────────────────────────────────────────────────────────────────
|
|
|
|
globalThis.addEventListener('push', (event) => {
|
|
const data = event.data?.json() ?? {};
|
|
event.waitUntil(
|
|
globalThis.registration.showNotification(data.title ?? 'SAR', {
|
|
body: data.body ?? '',
|
|
icon: '/sar-icon.png',
|
|
badge: '/sar-icon.png',
|
|
data: data.url ? { url: data.url } : undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
globalThis.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
const url = event.notification.data?.url;
|
|
if (url) {
|
|
event.waitUntil(clients.openWindow(url));
|
|
}
|
|
});
|