fix(D6): push no device ponta a ponta — SW dinâmico, subscribe idempotente, check-push-chain

- /campanha/sw.js force-dynamic: buildId VERCEL_DEPLOYMENT_ID em runtime
  (deploys CLI --prebuilt não têm envs de build; prod servia campanha-dev)
- subscribeCampaignPush find-before-create (endpoint UNIQUE — 2º toque
  estourava com erro falso)
- scripts/check-push-chain.mjs + pnpm db:check:push-chain (read-only,
  veredito por elo: SW/envs/consent/inscrições/eventos)
- VAPID envs adicionadas no Vercel production (ops)
- impl plan + CHANGELOG-AGENTS
This commit is contained in:
Francisco Solla 2026-08-09 23:41:00 -03:00
parent 8ab4c5a326
commit f4873b8b3f
7 changed files with 303 additions and 2 deletions

View file

@ -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 `<Toaster>` 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 `<body>`). 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).

View file

@ -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<br/>buildId por deploy"]
D --> B["Fix subscribe: find-before-create<br/>idempotente"]
D --> S["scripts/check-push-chain.mjs<br/>read-only, evidência por elo"]
A --> G["gate:fast + testes"]
B --> G
S --> G
G --> O1["OPS humano: envs VAPID ×4 no Vercel<br/>(prod + preview)"]
O1 --> O2["Redeploy (CI) — NEXT_PUBLIC inlined no build"]
O2 --> O3["Device test (humano, Android)<br/>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)

View file

@ -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",

View file

@ -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, '<campaign_user excluído>') 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)
}

View file

@ -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: {

View file

@ -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())

View file

@ -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')
})
})