Pass 2 D3: consent do WhatsApp resolvido por chave estavel (fim do consent: 2)

- submitWhatsapp resolve Consent.key = 'whatsapp-inscricao' via requireConsentByKey e FALHA FECHADO com mensagem clara enquanto o documento nao existir
- migration 20260725_170000 (data-only, idempotente, guardada): tagueia o documento vivo id 2 (key vazia/'') com a chave estavel; no-op se ja houver documento com a chave ou se id 2 tiver outra chave; NAO inventa texto juridico (politica Onda 0)
- chave em lib/campaignConsentKeys + lease key nas fixtures; int spec pina fail-closed + caminho feliz (contact + subscription -> consent.id)
- ATENCAO DEPLOY: revisar o SQL antes do push (aplica em producao no build da Vercel)
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 16:02:57 -03:00
parent 851f4bee3c
commit 304eb34b64
7 changed files with 174 additions and 2 deletions

View file

@ -12,7 +12,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | open | AGENTS Known Gaps #1 |
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | open | AGENTS Known Gaps #2 |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
| E2E campaign journeys latency-flake on loaded local machines (5s expect / 30s test budgets vs cold dev compiles; suite green with CI retries) | `tests/e2e/campaignMunicipalities.e2e.spec.ts` | test-infra | low | S | P3 | open — consider per-step budgets or a warmed dist dir | |

View file

@ -2,12 +2,25 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { WHATSAPP_SUBSCRIPTION_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import { WhatsAppFormInput, whatsAppFormSchema } from '@/lib/schemas/whatsapp-form'
import { requireConsentByKey } from '@/utilities/campaignConsent'
export const submitWhatsapp = async (input: WhatsAppFormInput) => {
const { comment, ...contactInput } = whatsAppFormSchema.parse(input)
const payload = await getPayload({ config })
// Fail-closed consent resolution by stable key (Pass 2 D3) — the flow
// refuses to record a subscription while the keyed Consent document is
// missing, same policy as the campaign flows.
const consent = await requireConsentByKey(
payload,
WHATSAPP_SUBSCRIPTION_CONSENT_KEY,
undefined,
'Consentimento da inscrição no WhatsApp ainda não configurado.',
)
const transactionID = await payload.db.beginTransaction()
if (!transactionID) {
@ -25,7 +38,7 @@ export const submitWhatsapp = async (input: WhatsAppFormInput) => {
collection: 'subscription',
data: {
contact: contact.id,
consent: 2,
consent: consent.id,
comment,
},
req: { transactionID },

View file

@ -8,3 +8,10 @@ export const CAMPAIGN_INVITE_CONSENT_KEY = 'lideranca-autopreenchimento'
export const SUPPORTER_REGISTRATION_CONSENT_KEY = 'apoiador-cadastro'
export const SUPPORTER_VOTE_INTENTION_CONSENT_KEY = 'apoiador-intencao-voto'
/**
* Public-site WhatsApp subscription consent. Historically the flow hardcoded
* `consent: 2`; migration `20260725_170000_whatsapp_subscription_consent_key`
* tags that live document with this stable key (Pass 2 D3).
*/
export const WHATSAPP_SUBSCRIPTION_CONSENT_KEY = 'whatsapp-inscricao'

View file

@ -0,0 +1,38 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Pass 2 D3 — retire the last hardcoded consent document id.
*
* The public WhatsApp subscription flow (`submitWhatsapp.ts`) used to write
* `consent: 2`. This tags that SAME live document with the stable key
* `whatsapp-inscricao`, so the code can resolve it fail-closed by key like
* every campaign consent. Idempotent and guarded:
*
* - no-op when a document already carries the key (target state);
* - no-op when id 2 does not exist or already carries a different key —
* the flow then fails closed until an admin creates the keyed document
* (deliberately NOT inventing consent text here: the wording is
* counsel-owned, same policy as the Onda 0 keys).
*
* The live document's `key` is the EMPTY STRING (Payload text default), so
* the guard treats '' and NULL alike.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
UPDATE "consent"
SET "key" = 'whatsapp-inscricao', "updated_at" = now()
WHERE "id" = 2
AND ("key" IS NULL OR "key" = '')
AND NOT EXISTS (
SELECT 1 FROM "consent" WHERE "key" = 'whatsapp-inscricao'
);
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
UPDATE "consent"
SET "key" = NULL, "updated_at" = now()
WHERE "id" = 2 AND "key" = 'whatsapp-inscricao';
`)
}

View file

@ -28,6 +28,7 @@ import * as migration_20260724_175500_contact_phone_optional from './20260724_17
import * as migration_20260724_180000_add_campaign_foundation_records from './20260724_180000_add_campaign_foundation_records'
import * as migration_20260724_180000_add_campaign_goals_global from './20260724_180000_add_campaign_goals_global'
import * as migration_20260725_022155_add_municipality_budget_notes from './20260725_022155_add_municipality_budget_notes'
import * as migration_20260725_170000_whatsapp_subscription_consent_key from './20260725_170000_whatsapp_subscription_consent_key'
export const migrations = [
{
@ -180,4 +181,9 @@ export const migrations = [
down: migration_20260725_022155_add_municipality_budget_notes.down,
name: '20260725_022155_add_municipality_budget_notes',
},
{
up: migration_20260725_170000_whatsapp_subscription_consent_key.up,
down: migration_20260725_170000_whatsapp_subscription_consent_key.down,
name: '20260725_170000_whatsapp_subscription_consent_key',
},
]

View file

@ -130,6 +130,7 @@ const leasedConsentKeys = new Set([
'lideranca-autopreenchimento',
'apoiador-cadastro',
'apoiador-intencao-voto',
'whatsapp-inscricao',
])
const defaultConsentText = (text: string): Consent['text'] => ({

View file

@ -0,0 +1,107 @@
// @vitest-environment node
/**
* Pass 2 D3: the public WhatsApp subscription flow resolves its Consent by the
* stable key `whatsapp-inscricao` and FAILS CLOSED while the keyed document is
* missing — the last hardcoded consent id (`consent: 2`) is gone.
*/
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { submitWhatsapp } from '@/app/(frontend)/actions/submitWhatsapp'
import { WHATSAPP_SUBSCRIPTION_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const deleteWhatsappConsentRows = async () => {
await payload.delete({
collection: 'consent',
where: { key: { equals: WHATSAPP_SUBSCRIPTION_CONSENT_KEY } },
depth: 0,
overrideAccess: true,
})
}
describe('submitWhatsapp (consent by stable key)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
// Only this spec file touches the whatsapp key, so removing it here cannot
// rob a parallel spec (unlike the shared campaign keys, which are leased).
it('fails closed while the keyed Consent document is missing', async () => {
await deleteWhatsappConsentRows()
await expect(
submitWhatsapp({
name: 'Contato Whatsapp',
email: `${campaignFixtures().value('whats')}@example.com`,
phone: campaignFixtures().phone(),
state: 'BA',
city: 'Salvador',
}),
).rejects.toThrow('Consentimento da inscrição no WhatsApp ainda não configurado.')
})
it('records contact + subscription linked to the keyed consent', async () => {
const fixtures = campaignFixtures()
const consent = await fixtures.createConsent({ key: WHATSAPP_SUBSCRIPTION_CONSENT_KEY })
const phone = fixtures.phone()
const result = await submitWhatsapp({
name: 'Contato Whatsapp',
email: `${fixtures.value('whats')}@example.com`,
phone,
state: 'BA',
city: 'Salvador',
comment: 'Quero receber as novidades.',
})
expect(result).toEqual({ ok: true })
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const contact = contacts.docs[0]
expect(contact).toBeDefined()
const subscriptions = await payload.find({
collection: 'subscription',
where: { contact: { equals: contact!.id } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const subscription = subscriptions.docs[0]
expect(subscription).toBeDefined()
expect(
typeof subscription!.consent === 'object' && subscription!.consent !== null
? subscription!.consent.id
: subscription!.consent,
).toBe(consent.id)
// The action's rows are not fixture-owned — clean them here so the
// fixture's contact cleanup does not trip on the subscription FK.
await payload.delete({
collection: 'subscription',
id: subscription!.id,
depth: 0,
overrideAccess: true,
})
})
})