diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index e3573836..33bb8b4a 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-09):** **D6** (Issue #521) — push no device fechado de ponta a ponta. **Diagnóstico com evidência por elo** (leitura read-only de prod): (1) **envs VAPID ausentes no Vercel production** (verificado com `vercel env ls production`) — elo raiz: sem elas o servidor nunca chama o web-push e o card de perfil desabilita "Ativar"; (2) consentimento `campanha-notificacoes-push` já publicado (Onda 0, 2026-07-19); (3) 0 inscrições `pushSubscription` (bloqueado pelo elo 1); (4) 74 `Notification` geradas no sino (eventos fluem) mas **zero envios**; (5) banner no OS = passo humano (Android). **Dois defeitos de código corrigidos com evidência:** (a) o SW `/campanha/sw.js` servia `CACHE_NAME "campanha-dev"` em prod — deploys são CLI `vercel build --prebuilt` (git builds ignorados), e a rota `force-static` congelava o buildId no build onde `VERCEL_GIT_COMMIT_SHA`/`VERCEL_DEPLOYMENT_ID` não existem; a rota virou **`force-dynamic`** (lê `VERCEL_DEPLOYMENT_ID`, garantido em runtime) → cache versionado por deploy (unit test pina `dynamic`); (b) **subscribe não idempotente** — segundo toque em "Ativar neste dispositivo" no mesmo device devolvia a mesma subscription do browser e estourava o UNIQUE do `endpoint` (erro falso com o device já ativo); `subscribeCampaignPush` agora faz find-before-create e responde sucesso. **Ferramenta nova:** `pnpm db:check:push-chain` (`scripts/check-push-chain.mjs`, read-only — `PROD_DATABASE_URL` obrigatória, conexões com `default_transaction_read_only`) fecha um veredito por elo (SW servido/versionado, envs, consentimento, inscrições por usuário, log de eventos) com exit code. **Ops:** par VAPID gerado e adicionado em **Production** (`VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY`/`VAPID_SUBJECT`/`NEXT_PUBLIC_VAPID_PUBLIC_KEY`); preview ficou de fora — o projeto não tem Git repo conectado (deploys CLI) e `vercel env add ... preview` exige branch (`branch_not_found`); `NEXT_PUBLIC_*` precisa do build do próximo deploy para inliner. **Pendência humana:** teste em device real (Android Chrome) após o deploy — ativar em `/campanha/perfil`, disparar evento, conferir banner + toque; `db:check:push-chain` antes/depois. Sem migration, access ou Consent (fail-closed intacto). Intenção [`push-notificacoes-dispositivo-producao.md`](plans/push-notificacoes-dispositivo-producao.md); impl [`push-notificacoes-dispositivo-producao-impl.md`](plans/push-notificacoes-dispositivo-producao-impl.md). + **Recently resolved (2026-08-09):** **B183** (Issue #501) — o auto-zoom do Safari iOS em inputs do PWA `/campanha` (font < 16px ao focar, escala que não volta no standalone) foi eliminado pela **causa**: uma regra CSS única em `styles.css` — `@media (pointer: coarse) and (hover: none)` + `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem }` — cobrindo omnibox B127, Command search, native-select e textareas em qualquer orientação do iPhone (landscape chega a 932px de largura, o que descartou escopo por `max-width`). Unlayered vence as utilities `text-sm` sem `!important`; desktop (pointer fine) fica intocado; `Input`/`Textarea` do ui já eram `text-base md:text-sm`. Sem migration, access, Consent ou mudança de componente. E2E novo `tests/e2e/campaignIosInputZoom.e2e.spec.ts` pina o contrato: emulação `devices['iPhone 13']` → omnibox de `/campanha/municipios` computa **16px**; control desktop → permanece **14px**. Intenção [`pwa-ios-autozoom-input-foco.md`](plans/pwa-ios-autozoom-input-foco.md); impl [`pwa-ios-autozoom-input-foco-impl.md`](plans/pwa-ios-autozoom-input-foco-impl.md). **Recently resolved (2026-08-09):** **C102** (Issue #498) — no mobile (<768px), nenhuma tela `/campanha` de staff mostra mais hamburger nem sheet de sidebar: a navegação staff mora na barra inferior + drawer "Mais". `CampaignMobileTopBar` esconde o `SidebarTrigger` via `useCampaignPageChromeRole` + `isStaffCampaignRole` (leader mantém — lockdown sem barra inferior); `CampaignSidebar` retorna `null` quando `isMobile && staff`, desmontando o Sheet; **B18 (filtros salvos de Municípios) migrou para o drawer "Mais"** — `MunicipalityNavSavedFilters` ganhou `variant: 'overflow'` (mesma store `useMunicipalitySavedFilters`, mesma lógica apagar/desfazer/sucessão de foco; no drawer o fallback de foco vira o link Perfil do footer) e `CampaignBottomNav` passou a renderizá-la entre destinos e footer (container com `overflow-y-auto`). **Bug pré-existente corrigido no caminho (D4):** o `` vivia dentro do wrapper `[contain:layout_paint]` do painel da AI — `contain: paint` vira containing block para `position: fixed` e cria stacking context próprio, prendendo o toast (z 999999999 do sonner) ABAIXO dos portais z-50 de drawer/sheet: qualquer toast com ação (ex. "Desfazer") era inalcançável com modal aberto; o Toaster foi movido para o root layout `(campaign)/layout.tsx` (irmão do `{children}`, direto no ``). Testes: 2 unit novos na top bar (staff sem trigger / leader com trigger), 3 e2e novos (staff mobile sem trigger e sem dialog Sidebar; leader abre o sheet com "Meus contatos"; fluxo completo salvar→navegar→apagar→desfazer no drawer). Sem migration, access ou Consent. Intenção [`c102-mobile-sem-botao-sidebar.md`](plans/c102-mobile-sem-botao-sidebar.md); impl [`c102-mobile-sem-botao-sidebar-impl.md`](plans/c102-mobile-sem-botao-sidebar-impl.md). diff --git a/docs/plans/push-notificacoes-dispositivo-producao-impl.md b/docs/plans/push-notificacoes-dispositivo-producao-impl.md new file mode 100644 index 00000000..cecd56b5 --- /dev/null +++ b/docs/plans/push-notificacoes-dispositivo-producao-impl.md @@ -0,0 +1,101 @@ +# Impl: D6 — Push no dispositivo funcionando de ponta a ponta (notificações nativas em produção) + +Status: aprovado +Atualizado em: 2026-08-09 +Issue: #521 +Intenção: docs/plans/push-notificacoes-dispositivo-producao.md +Appetite restante: herdado (~1 dia eng + passos humanos) + +## Leitura da intenção + +- **Outcome:** device real optado recebe banner nativo para cada evento que hoje gera notificação in-app; toque abre a tela certa. Diagnóstico da cadeia fecha com evidência por elo. iOS sem PWA instalado documentado (não é defeito). +- **O que NÃO negociar:** Consent fail-closed (`campanha-notificacoes-push`); push exclusivo da vertical `/campanha`; não reescrever a mecânica D2; WhatsApp fora. +- **O que reavaliar:** a hipótese da intenção era "verificação/config". O diagnóstico encontrou **dois defeitos de código com evidência** (SW buildId `dev` em prod; subscribe não idempotente) além do elo de config quebrado (envs VAPID ausentes). + +## Diagnóstico da cadeia (evidência colhida em prod, 2026-08-09) + +| Elo | Estado | Evidência | +| ---------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Envs VAPID (Vercel prod) | **QUEBRADO** | `vercel env ls production`: `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `NEXT_PUBLIC_VAPID_PUBLIC_KEY` **ausentes** (também `REVALIDATE_SECRET` ausente — débito ops, fora do escopo) | +| 2. Consentimento publicado | OK | DB prod (read-only): `consent` com `key='campanha-notificacoes-push'`, `updated_at` 2026-07-19 (migration Onda 0) | +| 3. Inscrição push por device | 0 rows | DB prod: `push_subscription` count = 0 — bloqueado pelo elo 1 no cliente (card desabilita "Ativar" quando `vapidPublicKey` é null) | +| 4. Entrega pela plataforma | nunca tentada | Elo 1 ausente → `ensureVapidConfigured()` retorna false → `sendCampaignPushForNotification` retorna cedo (web-push nunca chamado) | +| 5. Banner no OS | pendente | passo humano (Android Chrome) após elos 1–4 fechados | +| —. Sino in-app | OK | DB prod: `notification` = 74 rows (`municipality_update` 69, `activity_attention` 5) — geração de eventos funciona | + +**Defeitos de código com evidência:** + +- **A — SW serve `build dev` em prod.** `curl https://pt.jorgesolla.com.br/campanha/sw.js` → `CACHE_NAME = "campanha-dev"`. Causa: deploys são CLI (`vercel build` + `--prebuilt`; git builds ignorados via `vercel-ignore-build.sh`) e a rota é `force-static` → o buildId é congelado no build, onde `VERCEL_GIT_COMMIT_SHA`/`VERCEL_DEPLOYMENT_ID` não existem. `VERCEL_DEPLOYMENT_ID` é disponível em build **e runtime** (docs Vercel); `force-dynamic` lê runtime → id único por deploy. Impacto: cache do SW não versiona por deploy — caches de navegação antigos (`campanha-dev`) nunca purgam no `activate` e o update check nunca muda de cache name. +- **B — Subscribe não idempotente.** `PushSubscription.endpoint` é UNIQUE e `subscribeCampaignPush` faz `payload.create` direto. Segundo toque em "Ativar neste dispositivo" (device já inscrito → `pushManager.subscribe` devolve a mesma subscription → mesmo endpoint) estoura unique violation → erro falso "Não foi possível ativar", com o device já inscrito. Fluxo real de opt-in quebra no segundo toque. + +## Abordagem recomendada + +```mermaid +flowchart LR + D["Diagnóstico (feito — evidências acima)"] --> A["Fix SW: rota force-dynamic
buildId por deploy"] + D --> B["Fix subscribe: find-before-create
idempotente"] + D --> S["scripts/check-push-chain.mjs
read-only, evidência por elo"] + A --> G["gate:fast + testes"] + B --> G + S --> G + G --> O1["OPS humano: envs VAPID ×4 no Vercel
(prod + preview)"] + O1 --> O2["Redeploy (CI) — NEXT_PUBLIC inlined no build"] + O2 --> O3["Device test (humano, Android)
perfil → ativar → evento → banner → toque"] + O3 --> O4["check-push-chain após cada passo"] +``` + +**Opções consideradas (fix buildId):** A) rota `force-dynamic` lendo runtime | B) build-time `execSync('git rev-parse HEAD')` | C) `vercel.json` buildCommand exportando env | D) aceitar `dev` +**Recomendação:** A — mínimo, env garantido em runtime (`VERCEL_DEPLOYMENT_ID`, docs Vercel: build+runtime), mantém local `dev`. +**Rejeitadas:** B porque depende do estado do repo no CI (frágil fora de Vercel builds); C porque adiciona cerimônia de build sem ganho; D porque deixa o defeito de cache versioning em prod. + +**Opções (script diagnóstico):** A) CLI read-only em `scripts/` (padrão `db:pull`) | B) página admin `/campanha` | C) endpoint `/api` +**Recomendação:** A — sem superfície de UI/auth nova; serve o device test (antes/depois) e incidentes futuros. O card de perfil já mostra consentimento/VAPID ao usuário. +**Rejeitadas:** B/C porque criam superfície e acesso novo para um ato de operação (rabbit hole: "verificação é entrega de verificação", não produto). + +**Opções (subscribe):** A) find-before-create inline na action | B) upsert atualizando consent fields | C) remover UNIQUE do endpoint +**Recomendação:** A — endpoint por usuário é 1:1 na prática do navegador; subscribe existente = device já ativo, retorna sucesso. +**Rejeitadas:** B porque não há o que atualizar (subscribe ativo já é o estado desejado); C porque enfraquece a invariante de unicidade. + +### Componentes / mudanças + +- **`sw.js/route.ts`** (`src/app/(campaign)/campanha/sw.js/route.ts`): `force-static` → `force-dynamic` + comentário do porquê. Nada muda no body nem nos headers (`no-cache`, `Service-Worker-Allowed`). +- **`subscribeCampaignPush`** (`src/app/(campaign)/campanha/actions/notifications.ts`): antes do `payload.create`, `find` por `(user, endpoint)`; se existir → `{ message: 'Avisos push ativados neste dispositivo.' }` sem criar (idempotente). Zero novas abstrações. +- **`scripts/check-push-chain.mjs`** (novo, `pg` já é dependência direta): verifica os 5 elos read-only: + 1. SW servido: `fetch(site + '/campanha/sw.js')` → `CACHE_NAME` (≠ `campanha-dev` em prod pós-fix); + 2. Envs VAPID presentes no `process.env` (operador fornece via `vercel env pull`, padrão `db:pull`); + 3. Consent `campanha-notificacoes-push` no DB; + 4. Count de `push_subscription` (+ por usuário); + 5. Count + `created_at` mais recente de `notification`. + Gate: `PROD_DATABASE_URL` obrigatória (mesma leitura read-only do `db:pull`); consultas `SELECT` via `pg` com statement timeout. Verdict por elo + exit code. +- **Migration:** nenhuma (sem mudança de schema). +- **Access / Consent:** nenhuma mudança — fail-closed mantido. +- **UI:** Impeccable A — N/A (sem superfície nova). +- **Ops (gate humano):** gerar par VAPID (`npx web-push generate-vapid-keys`), `vercel env add` ×4 (`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` — já com default no código mas explícito —, `NEXT_PUBLIC_VAPID_PUBLIC_KEY`) em production **e** preview; redeploy via CI; device test Android (validar também `check-push-chain` pós-env e pós-opt-in). + +## Fases verificáveis + +1. **Diagnóstico fechado** — evidências acima registradas (feito). +2. **Fixes + ferramenta** — rota SW, subscribe idempotente, `check-push-chain.mjs`, testes unit (rota dinâmica; script coberto por rodada manual em local). +3. **Gates** — `pnpm gate:fast`; entrega com `pnpm push`. +4. **Ops humano** — envs VAPID (Vercel, prod+preview), redeploy, device test, CHANGELOG-AGENTS entry, débitos (REVALIDATE_SECRET ausente em prod). + +## Rabbit holes / Não escopo (engenharia) + +- Reescrever a cadeia D2 (está correta; só o elo config quebrava). +- Nudge no sino, agrupamento/dedupe, expurgo, rotação de VAPID — itens próprios. +- Estado "ativo neste device" no card de push (polish; gatilho: reclamação de UX no opt-in). +- "Consertar" iOS sem PWA instalado — limitação de plataforma, já comunicada no card. +- Estado "assinante" no admin (PushSubscription já é collection do grupo Campanha). + +## Riscos e mitigação + +- `force-dynamic` na rota SW: function call por update check de navegação — custo trivial, sem DB/auth; mantém `Cache-Control: no-cache`. +- Envs adicionadas erradas (pares VAPID divergentes): `check-push-chain` elo 2 valida presença; device test valida par completo (falha de assinatura → erro de subscribe visível no card). +- Device test indisponível: iOS com PWA instalado também valida a cadeia (limitação já documentada); senão, o teste fica como passo humano pendente comentado na Issue. +- Deploy pós-merge necessário para `NEXT_PUBLIC_VAPID_PUBLIC_KEY` inline: documentado no passo ops. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (device real recebe banner; evidência por elo) +- [ ] Invariantes AGENTS/engineering-standards (sem schema novo; sem access novo; read-only em prod) +- [ ] Testes: unit da rota SW dinâmica; `check-push-chain` validado contra local e prod (read-only) diff --git a/package.json b/package.json index 162f2b12..b6314074 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs", "db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs", "db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs", + "db:check:push-chain": "node scripts/check-push-chain.mjs", "agent:claim": "node scripts/agent-claim.mjs", "agent:register": "node scripts/agent-register.mjs", "agent:ready": "node scripts/agent-ready.mjs", diff --git a/scripts/check-push-chain.mjs b/scripts/check-push-chain.mjs new file mode 100644 index 00000000..0a1f0263 --- /dev/null +++ b/scripts/check-push-chain.mjs @@ -0,0 +1,166 @@ +/** + * D6 — read-only diagnostic of the /campanha push delivery chain, closing one + * verdict per link: VAPID envs → published consent → per-device subscriptions → + * event log → SW served (build id per deploy). + * + * Safety model: + * - PROD_DATABASE_URL is only READ: every connection runs with + * `default_transaction_read_only = on` and a statement timeout. No writes + * are possible from this script. + * - The site URL is only fetched with GET. + * + * Usage: + * PROD_DATABASE_URL=postgres://... pnpm db:check:push-chain [--site https://pt.jorgesolla.com.br] + * + * PROD_DATABASE_URL should be the UNPOOLED Neon connection string (the + * DATABASE_URL_UNPOOLED value from `vercel env pull --environment=production`). + * The VAPID env check reads process.env — source them the same way, or run the + * check inside an environment that already has them. + */ +import { Client } from 'pg' + +import { dieWithLabel, loadCliEnv } from './lib/cli.mjs' + +const die = dieWithLabel('check:push-chain') + +loadCliEnv() + +const PROD_DATABASE_URL = process.env.PROD_DATABASE_URL +if (!PROD_DATABASE_URL) { + die( + 'PROD_DATABASE_URL is not set.\n' + + 'Run it with your UNPOOLED Neon URL (DATABASE_URL_UNPOOLED from `vercel env pull`):\n' + + ' PROD_DATABASE_URL="postgres://...neon.tech/neondb?sslmode=require" pnpm db:check:push-chain', + ) +} + +const siteArg = process.argv.find((arg) => arg.startsWith('--site=')) +const site = siteArg?.slice('--site='.length) || 'https://pt.jorgesolla.com.br' +const siteOrigin = new URL(site).origin + +const results = [] +const report = (name, ok, detail) => { + results.push({ name, ok, detail }) + console.log(`${ok ? ' PASS' : ' FAIL'} ${name}: ${detail}`) +} + +const fetchWithTimeout = async (url) => { + const response = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: 'follow' }) + return { response, body: await response.text() } +} + +console.log(`[check:push-chain] Site: ${siteOrigin}`) + +// --- Elo 1: service worker served with a deploy-scoped build id --------------- +try { + const { response, body } = await fetchWithTimeout(`${siteOrigin}/campanha/sw.js`) + const cacheName = body.match(/CACHE_NAME\s*=\s*"([^"]+)"/)?.[1] ?? null + const frozenBuild = cacheName === 'campanha-dev' || cacheName === 'campanha-dev"' + report( + 'SW servido', + response.ok && cacheName !== null && !frozenBuild, + response.ok + ? `HTTP ${response.status}, cache name "${cacheName}" (${frozenBuild ? 'build id congelado no build — defeito D6' : 'versionado por deploy'})` + : `HTTP ${response.status}`, + ) +} catch (error) { + report('SW servido', false, `falha ao buscar ${siteOrigin}/campanha/sw.js: ${error.message}`) +} + +// --- Elo 2: VAPID envs (server + public) -------------------------------------- +const vapidServer = process.env.VAPID_PUBLIC_KEY?.trim() +const vapidPrivate = process.env.VAPID_PRIVATE_KEY?.trim() +const vapidPublic = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY?.trim() +const missingEnvs = [ + !vapidServer && 'VAPID_PUBLIC_KEY', + !vapidPrivate && 'VAPID_PRIVATE_KEY', + !vapidPublic && 'NEXT_PUBLIC_VAPID_PUBLIC_KEY', +].filter(Boolean) +report( + 'Envs VAPID', + missingEnvs.length === 0, + missingEnvs.length === 0 + ? 'VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / NEXT_PUBLIC_VAPID_PUBLIC_KEY presentes' + : `ausentes no process.env: ${missingEnvs.join(', ')} (fonte: vercel env pull)`, +) + +// --- Elos 3–5: DB (read-only) ------------------------------------------------- +// Neon URLs carry `sslmode=require`; hand TLS explicitly so pg-connection-string +// does not emit its v8 deprecation warning for that mode. +const hadSslMode = /sslmode=require/.test(PROD_DATABASE_URL) +const connectionString = hadSslMode + ? PROD_DATABASE_URL.replace(/sslmode=require/, '').replace(/[?&]$/, '') + : PROD_DATABASE_URL +const client = new Client({ + connectionString, + ...(hadSslMode ? { ssl: { rejectUnauthorized: false } } : {}), +}) + +try { + await client.connect() + await client.query('SET default_transaction_read_only = on') + await client.query('SET statement_timeout = 15000') + + // Elo 3: consent published + const consent = await client.query( + `SELECT key, updated_at FROM consent WHERE key = 'campanha-notificacoes-push' LIMIT 1`, + ) + report( + 'Consentimento', + consent.rowCount === 1, + consent.rowCount === 1 + ? `campanha-notificacoes-push publicado (atualizado ${consent.rows[0].updated_at})` + : 'chave campanha-notificacoes-push ausente na collection consent', + ) + + // Elo 4: per-device subscriptions + const subscriptionCount = await client.query('SELECT count(*)::int AS n FROM push_subscription') + const subscriptionsByUser = await client.query( + `SELECT COALESCE(cu.name, '') AS user_name, count(ps.id)::int AS n + FROM push_subscription ps + LEFT JOIN campaign_user cu ON cu.id = ps.user_id + GROUP BY user_name + ORDER BY n DESC`, + ) + const userBreakdown = + subscriptionsByUser.rows.length === 0 + ? 'nenhuma' + : subscriptionsByUser.rows.map((row) => `${row.user_name} (${row.n})`).join(', ') + report( + 'Inscrições push', + subscriptionCount.rows[0].n > 0, + `${subscriptionCount.rows[0].n} inscrição(ões) — ${userBreakdown}`, + ) + + // Elo 5: event log feeding the chain + const events = await client.query( + `SELECT count(*)::int AS n, max(created_at) AS last FROM notification`, + ) + const eventsByType = await client.query( + `SELECT type, count(*)::int AS n FROM notification GROUP BY type ORDER BY n DESC`, + ) + report( + 'Eventos (log Notification)', + events.rows[0].n > 0, + events.rows[0].n > 0 + ? `${events.rows[0].n} evento(s), último ${events.rows[0].last} — ${eventsByType.rows + .map((row) => `${row.type} (${row.n})`) + .join(', ')}` + : 'nenhum evento registrado ainda', + ) +} catch (error) { + report('DB (leituras)', false, `falha ao consultar: ${error.message}`) +} finally { + await client.end().catch(() => {}) +} + +// --- Verdict ------------------------------------------------------------------ +console.log('') +const failed = results.filter((result) => !result.ok) +if (failed.length === 0) { + console.log(`[check:push-chain] OK — ${results.length}/${results.length} elos fechados.`) +} else { + console.log(`[check:push-chain] ${failed.length} elo(s) quebrado(s):`) + for (const result of failed) console.log(` - ${result.name}`) + process.exit(1) +} diff --git a/src/app/(campaign)/campanha/actions/notifications.ts b/src/app/(campaign)/campanha/actions/notifications.ts index ecefb661..7deac685 100644 --- a/src/app/(campaign)/campanha/actions/notifications.ts +++ b/src/app/(campaign)/campanha/actions/notifications.ts @@ -125,6 +125,24 @@ export const subscribeCampaignPush = async (input: PushSubscribeInput) => throw new Error(CAMPAIGN_PUSH_SUBSCRIPTION_INVALID_MESSAGE) } + // Idempotent subscribe (D6): the browser returns the SAME subscription when + // the device is already registered, and `endpoint` is unique — a second + // create would throw and show a false failure with the device already active. + const existing = await payload.find({ + collection: 'pushSubscription', + where: { + and: [{ user: { equals: user.id } }, { endpoint: { equals: endpoint } }], + }, + depth: 0, + limit: 1, + pagination: false, + overrideAccess: true, + }) + + if (existing.docs.length > 0) { + return { message: 'Avisos push já estavam ativados neste dispositivo.' } + } + await payload.create({ collection: 'pushSubscription', data: { diff --git a/src/app/(campaign)/campanha/sw.js/route.ts b/src/app/(campaign)/campanha/sw.js/route.ts index ac8523a2..11bb059f 100644 --- a/src/app/(campaign)/campanha/sw.js/route.ts +++ b/src/app/(campaign)/campanha/sw.js/route.ts @@ -6,8 +6,12 @@ import { resolveCampaignPwaBuildId, } from '@/utilities/campaignPwa' -// Built once per deploy; build id in the script body still invalidates SW updates. -export const dynamic = 'force-static' +// Dynamic on purpose (D6): deploys are CLI `vercel build` + `--prebuilt`, so the +// Vercel build-time envs (`VERCEL_GIT_COMMIT_SHA`/`VERCEL_DEPLOYMENT_ID`) are not +// present when a `force-static` route is prerendered — prod was serving `dev` as +// the cache name, so SW caches never versioned per deploy. `VERCEL_DEPLOYMENT_ID` +// IS set at runtime, which makes the build id unique per deployment. +export const dynamic = 'force-dynamic' export function GET(): NextResponse { const script = buildCampaignServiceWorkerScript(resolveCampaignPwaBuildId()) diff --git a/tests/unit/campaignPwa.unit.spec.ts b/tests/unit/campaignPwa.unit.spec.ts index 3a0fe523..dc0214ec 100644 --- a/tests/unit/campaignPwa.unit.spec.ts +++ b/tests/unit/campaignPwa.unit.spec.ts @@ -68,4 +68,13 @@ describe('campaign PWA route handlers', () => { expect(body).toContain(`SCOPE + '/convite'`) expect(body).toContain('isRscRequest') }) + + it('serves the service worker dynamically so the deploy-scoped build id resolves at runtime', async () => { + const route = await import('@/app/(campaign)/campanha/sw.js/route') + + // D6: CLI `--prebuilt` deploys lack the build-time Vercel envs that a + // force-static prerender would read; runtime VERCEL_DEPLOYMENT_ID is + // guaranteed on Vercel, so the cache name must not be frozen at build. + expect(route.dynamic).toBe('force-dynamic') + }) })