teqo/scripts/check-push-chain.mjs

174 lines
6.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
}