Merge pull request #535 from fsolla/D6-push-no-dispositivo-funcionando-de-ponta-a-ponta-notifica
fix(D6): push no device ponta a ponta — SW dinâmico, subscribe idempotente, check-push-chain
This commit is contained in:
commit
a4d7b88375
7 changed files with 305 additions and 3 deletions
|
|
@ -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).
|
||||
|
|
|
|||
101
docs/plans/push-notificacoes-dispositivo-producao-impl.md
Normal file
101
docs/plans/push-notificacoes-dispositivo-producao-impl.md
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
174
scripts/check-push-chain.mjs
Normal file
174
scripts/check-push-chain.mjs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* 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, LOCAL_HOSTS } 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',
|
||||
)
|
||||
}
|
||||
|
||||
// Foot-gun guard: this script reports PRODUCTION facts; a local URL would
|
||||
// produce verdicts about the wrong data that look like prod.
|
||||
if (LOCAL_HOSTS.has(new URL(PROD_DATABASE_URL).hostname)) {
|
||||
die(`PROD_DATABASE_URL points at a local host ("${new URL(PROD_DATABASE_URL).hostname}").`)
|
||||
}
|
||||
|
||||
let site
|
||||
try {
|
||||
const siteArg = process.argv.find((arg) => arg.startsWith('--site='))
|
||||
site = new URL(siteArg?.slice('--site='.length) || 'https://pt.jorgesolla.com.br').origin
|
||||
} catch {
|
||||
die('--site=<url> deve ser uma URL válida (ex.: https://pt.jorgesolla.com.br).')
|
||||
}
|
||||
|
||||
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: ${site}`)
|
||||
|
||||
// --- Elo 1: service worker served with a deploy-scoped build id ---------------
|
||||
// `campanha-dev` = CAMPAIGN_CACHE_PREFIX ('campanha-') + fallback 'dev' of
|
||||
// resolveCampaignPwaBuildId frozen at build time (the D6 defect).
|
||||
try {
|
||||
const { response, body } = await fetchWithTimeout(`${site}/campanha/sw.js`)
|
||||
const cacheName = body.match(/CACHE_NAME\s*=\s*"([^"]+)"/)?.[1] ?? null
|
||||
const frozenBuild = 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 ${site}/campanha/sw.js: ${error.message}`)
|
||||
}
|
||||
|
||||
// --- Elo 2: VAPID envs (server + public) --------------------------------------
|
||||
// Proves the DIAGNOSTIC environment, not the deployment — source them from
|
||||
// `vercel env pull --environment=production` to mirror the deployed server.
|
||||
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 (ambiente de diagnóstico)',
|
||||
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`; upgrade it to verify-full so pg keeps
|
||||
// certificate verification without its v8 warning for `require` aliases.
|
||||
const connectionString = PROD_DATABASE_URL.replace('sslmode=require', 'sslmode=verify-full')
|
||||
const client = new Client({ connectionString })
|
||||
|
||||
try {
|
||||
await client.connect()
|
||||
await client.query('SET default_transaction_read_only = on')
|
||||
await client.query('SET statement_timeout = 15000')
|
||||
|
||||
// Elo 3: consent published — stable key lives in src/lib/campaignConsentKeys.ts
|
||||
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)
|
||||
}
|
||||
|
|
@ -125,6 +125,22 @@ export const subscribeCampaignPush = async (input: PushSubscribeInput) =>
|
|||
throw new Error(CAMPAIGN_PUSH_SUBSCRIPTION_INVALID_MESSAGE)
|
||||
}
|
||||
|
||||
// Idempotent (D6): `endpoint` is unique and the browser returns the same
|
||||
// subscription on re-subscribe — a second create would show a false failure.
|
||||
const existing = await payload.find({
|
||||
collection: 'pushSubscription',
|
||||
where: { endpoint: { equals: endpoint } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
if (existing.docs.length > 0) {
|
||||
return { message: 'Avisos push já estavam ativados neste dispositivo.' }
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: 'pushSubscription',
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import {
|
|||
resolveCampaignPwaBuildId,
|
||||
} from '@/utilities/campaignPwa'
|
||||
|
||||
// Built once per deploy; build id in the script body still invalidates SW updates.
|
||||
export const dynamic = 'force-static'
|
||||
// Runtime env only: CLI `--prebuilt` builds have no VERCEL_* build-time envs
|
||||
// (prod served `campanha-dev`); `VERCEL_DEPLOYMENT_ID` exists at runtime.
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export function GET(): NextResponse {
|
||||
const script = buildCampaignServiceWorkerScript(resolveCampaignPwaBuildId())
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { GET as getManifest } from '@/app/(campaign)/campanha/manifest.webmanifest/route'
|
||||
import { GET as getServiceWorker } from '@/app/(campaign)/campanha/sw.js/route'
|
||||
import {
|
||||
GET as getServiceWorker,
|
||||
dynamic as serviceWorkerDynamic,
|
||||
} from '@/app/(campaign)/campanha/sw.js/route'
|
||||
import {
|
||||
buildCampaignServiceWorkerScript,
|
||||
CAMPAIGN_CACHE_PREFIX,
|
||||
|
|
@ -68,4 +71,8 @@ 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', () => {
|
||||
expect(serviceWorkerDynamic).toBe('force-dynamic')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue