feat: S9+ (#771) — integridade de consentimento e alinhamento das ações públicas

- Consent.beforeDelete bloqueia delete com referências (APIError 409 composto
  por coleção), fim do 23502 cru no admin (contagens com overrideAccess).
- submitWhatsapp/submitPetitionSignature delegam o ciclo de transação ao dono
  withPayloadTransaction (rollback no commit-falho, AggregateError, after-commit),
  preservando os contratos { ok: true } / { ok: true, signatureNumber }.
- contactStateSchema exportado + contactCitySchema/contactCityFieldSchema
  consolidados; as 4 fichas usam os exports únicos (fim das cópias inline do
  predicate value in CitiesByState). withMutableConsentFixture purga consent
  via raw SQL para bypassar o hook no harness de teste.

Co-Authored-By: opencode <noreply@opencode.ai>
This commit is contained in:
Francisco Solla 2026-08-24 22:23:36 -03:00
parent fcefcac4e6
commit d396870d22
9 changed files with 507 additions and 128 deletions

View file

@ -0,0 +1 @@
**S9+ (#771) — integridade de consentimento e alinhamento das ações públicas:** `Consent.beforeDelete` bloqueia a exclusão de um consentimento ainda referenciado por consumidores (subscription, signature, pushSubscription, petition `form.consent`, leadership, supporter `consent`/`voteIntentionConsent`) com `APIError` 409 e mensagem composta por coleção — fim do `23502` cru no admin. `submitWhatsapp`/`submitPetitionSignature` delegam o ciclo de transação ao dono `withPayloadTransaction` (rollback também no commit-falho, `AggregateError`, after-commit flush), preservando os contratos `{ ok: true }`/`{ ok: true, signatureNumber }`. `contactStateSchema` passa a ser exportado e `contactCitySchema`/`contactCityFieldSchema` são consolidados; as 4 fichas (`contactSchema`, `contactCreateSchema`, `contactFieldUpdateSchema`, `contactFullUpdateSchema`) usam os exports únicos em vez de cópias inline do predicate `value in CitiesByState` (DRY de conhecimento no dono). Simplify: 0 high/medium; 3 low (type-safety do `where` do hook descartado, `beginFailureMessage` por action intencional, helper de collapse `''` deferido) — triage no impl plan.

View file

@ -0,0 +1,113 @@
# Impl: Escala/DRY pós-S9 — integridade e alinhamento das ações públicas
Status: rascunho
Atualizado em: 2026-08-24
Issue: #771
Intenção: docs/plans/escala-dry-pos-s9-acoes-publicas.md
Appetite restante: herdado (~0,5–1 dia eng; 3 fases pequenas, lock primeiro) — nada além do que a intenção já corta
## Leitura da intenção
- **Outcome:** 3 fases de higiene/integridade pós-S9, sem mudança de contrato: (1) deletar `Consent` em uso passa a falhar com erro admin-amigável (não mais `23502` cru); (2) `submitWhatsapp`/`submitPetitionSignature` delegam o transaction lifecycle ao dono `withPayloadTransaction` (rollback também no commit-falho, `AggregateError`, after-commit flush); (3) as 4 fichas de `contact.ts` passam a usar os exports únicos de estado/cidade — DRY de conhecimento no dono.
- **O que NÃO negociar:** comportamento protetivo de consent LGPD fail-closed (recusa de gravar/consumir enquanto a chave configurada falta); contratos públicos das actions (`{ ok: true }`, `{ ok: true, signatureNumber }`); migrations congeladas (nunca editar `20260715_163458_initial` nem re-derivar FKs); contratos pinned de `onPayloadTransactionCommit`/notificação (`notificationPushScheduleConventions.unit.spec.ts:15-16`); invariante de escrita multi-collection em transação com `req: { transactionID }`.
- **O que reavaliar:** o brief afirma "mesma mensagem, mesmo comportamento" para a cidade — **falso** (o explorador e a leitura do código provam divergência, ver Fase 3); o guard da Fase 1 precisa decidir escopo (só os NOT NULL blockers vs todos os usos semânticos) com o caso `petition.form_consent_id` NULLABLE como armadilha silenciosa; na Fase 2, a única mutação observável é a mensagem de begin-failure (não há teste que a pinea) — decisão de preservá-la por action ou aceitar o default PT; na Fase 3, exportar o `contactStateSchema` REQUIRED (não o `optionalContactStateSchema`) e o `contactCityFieldSchema` difere das cópias também na **colapsagem que exclui** `''` — não só na mensagem.
## Abordagem recomendada
```mermaid
flowchart LR
subgraph F1["Fase 1 — Consent delete guard (collection hook)"]
C1["Consent.hooks.beforeDelete"] --> C2["count refs (overrideAccess:true, depth:0, req)"]
C2 --> C3{"alguma ref? sub/sig/push/petition/lead/support"}
C3 -- "nenhuma" --> C4["segue o delete"]
C3 -- "N,M,... > 0" --> C5["APIError(409) msg composta por coleção"]
C2 -. "form.consent" .-> P["petition é NULLABLE: sem o count, NULL silencioso → TypeError no submitPetitionSignature"]
C3 -. "nulable lead/supporter" .-> L["sem count, link legal audit é NULL-ado em silêncio"]
end
subgraph F2["Fase 2 — ações irmãs → withPayloadTransaction"]
A1["submitWhatsapp (begin/commit/rollback)"] --> T["withPayloadTransaction(payload, cb, { beginFailureMessage } )"]
A2["submitPetitionSignature (idem + count no txn)"] --> T
T --> TV["rollback tb no commit-falho + AggregateError + flush after-commit"]
T -. "count permanece DENTRO do callback" .-> CT["contagem pré-commit preservada"]
end
subgraph F3["Fase 3 — DRY contact.ts"]
S1["export const contactStateSchema (required)"] --> F["4 fichas usam o export (state continua REQUIRED)"]
S2["contactCitySchema required + contactCityFieldSchema composto sobre ele"] --> F
F --> U["contactSchemas.unit.spec verde + novos pins ('Cidade muito longa', colapso de '')"]
end
```
**Opções consideradas (por fase):** ver decisões abaixo — F1: A/B; F2: A/B/C; F3: estado A/B, cidade a/b/c/d.
**Recomendação:** F1 guard cobre **todos os usos semânticos** (409, hook `beforeDelete` inline no `Consent`); F2 preserva as mensagens de begin-failure via `{ beginFailureMessage }`; F3 exporta `contactStateSchema` e padroniza as mensagens de cidade no próprio `contactCityFieldSchema` (min `'Cidade inválida'`, max `'Cidade muito longa'`) — porque é a única opção que unifica mensagem E comportamento no dono do concern, sem custo novo de superfície.
**Rejeitadas:** guard só-NOT NULL (deixa os NULL silenciosos, exatamente o tipo de bug que esta issue quer eliminar); default PT de begin-failure (muda comportamento observável na mesma diff mecânica); per-field `message` params de cidade (mais API, mantém a divergência); manter divórcio de mensagens documentado (derrota a intenção DRY).
### Componentes / mudanças
- **`Consent`** (`src/collections/Consent.ts`): adicionar `hooks: { beforeDelete: [...] }`. Hook conta referências em `payload.count` paralelo (`Promise.all`) com `where: { consent: { equals: id } }` (+ `where: { 'form.consent': { equals: id } }` em `petition`; `where: { voteIntentionConsent: { equals: id } }` em `supporter`), `depth: 0`, `overrideAccess: true`, `req`. Se total > 0, `throw new APIError(msg, 409)` com contagem por coleção (`subscription`, `signature`, `pushSubscription`, `petition`, `leadership`, `supporter`). Reusa o padrão de `CampaignUser.ts:336-343` (CollectionBeforeDeleteHook com overrideAccess) e de `personDelete.ts:113-130` para o count. Hook fica inline (convenção do repo: hooks dentro do arquivo da collection).
- **Migration:** sem migration — nenhuma mudança de schema; as colunas já existem (`subscription.consent_id`/`signature.consent_id`/`push_subscription.consent_id` NOT NULL; `petition.form_consent_id`, `leadership.consent_id`, `supporter.consent_id`/`vote_intention_consent_id` nullable).
- **`submitWhatsapp.ts` / `submitPetitionSignature.ts`** (`src/app/(frontend)/actions/`): trocar o hand-roll `beginTransaction/try/commit/rollback` por `return withPayloadTransaction(payload, async ({ req }) => {...}, { beginFailureMessage })`. Reads pré-transação (`requireConsentByKey` de whatsapp L17-22; `findByID` do petition L25-31) ficam FORA do helper, como hoje. Conta de `signatureNumber` e o `Promise.all` paralelo permanecem DENTRO do callback (lê dentro da txn não commitada — comportamento atual preservado). Retornos intocados. `payloadTransaction.ts` **não muda** (`codebaseConventions.unit.spec.ts:487` pina).
- **`contact.ts`** (`src/lib/schemas/contact.ts`): `contactStateSchema` deixa de ser privado (export) e as 4 fichas trocam o `z.custom<StateKey>` inline por ele; `optionalContactStateSchema` permanece composto sobre ele. Novo `contactCitySchema` (required: `trim().min(3,'Cidade inválida').max(100,'Cidade muito longa')`) e `contactCityFieldSchema` redefinido como composição sobre ele (união com `''` + colapso p/ `undefined` + `.optional()`). Comentário L31-35 atualizado (remover "consolidating them is a follow-up").
- **Teste helper** (`tests/helpers/testDatabaseLease.ts:643-647`): o delete "re-create re-verify" do `withMutableConsentFixture` passa de `payload.delete` para raw SQL — a única chamada de lifecycle que podia derrubar a janela D10.
- **Access / Consent:** guard com `overrideAccess: true` no count é bypass intencional (o delete já é admin-only; o hook é autorização própria do padrão `CampaignUser`). Fail-closed preservado: recusa é a proteção, não um canal novo. Sem chave nova de consentimento.
- **UI:** nenhuma nova; no admin o efeito é o erro amigável no delete (a mensagem do `APIError` chega à superfície do painel).
## Fases verificáveis
1. **Fase 1 (schema+server)** — hook `beforeDelete` no `Consent` + contagem multi-collection + `APIError(409)`; teste helper D10 migrado para raw SQL. **Fase 2 (server)** — refactor das duas actions para `withPayloadTransaction`. **Fase 3 (lib pura)** — exports `contactStateSchema`/`contactCitySchema`; `contactCityFieldSchema` recomposição; 4 fichas consolidadas.
2. **UI** — nenhuma; verificação é a mensagem admin (superfície da Fase 1) via int spec e o painel e2e de newsletter seguindo verde.
3. **Gates** — `pnpm migrate:create` **não** é necessário (sem schema); `pnpm gate:fast` (curated: unit `contactSchemas`; int `submitWhatsapp`, `submitPetitionSignature`, `payloadTransaction`, novo `consentDeleteGuard`, `onda0Provision`, supporter D10; e2e `campaignNewsletter`); push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- "Arquivamento" de Consent (soft-delete/flag protegida) — é a decisão de produto/jurídico que a intenção já resolveu pela mensagem; refatorar isso vira issue própria.
- Editar migrations congeladas ou rever FKs `ON DELETE SET NULL` — fora (migrations congeladas são invariante).
- `payload_locked_documents_rels` — cascade OK (migration `20260715_163458_initial:286`), não conta.
- `onPayloadTransactionCommit`/notificações — pinned, não tocar; as duas actions não registram after-commit (sem `Lead` ainda).
- Procurar outros hand-rolls de transação — não existem (`beginTransaction` só em `payloadTransaction.ts` e nas duas actions desta Fase 2).
- Consolidar mensagens além de estado/cidade (ex.: sequences postais) — não é a intenção.
- Gatilho defasado: quando S10 precisar de `onPayloadTransactionCommit` para `Lead`, Fase 2 vira requisito — aqui só a higiene.
## Riscos e mitigação
1. **`petition.form_consent_id` NULLABLE (NULL silencioso):** deletar consent referenciado por petição hoje NULL-za a coluna sem erro e o `submitPetitionSignature.ts:30-31` estoura TypeError depois. Mitigação: o count da Fase 1 **inclui petition** via `where: { 'form.consent': { equals } }`, então nunca chega ao NULL silencioso.
2. **Lifecycles de teste que deletam consent via `payload.delete`** (hook roda, bypass natural p/ SQL raw): `deleteWhatsappConsentRows` (int whatsapp L26-33) e a purga do e2e newsletter já ordenam consumidores antes (comentário `campaignNewsletter.e2e.spec.ts:32-33` é a evidência do bug original); `deletionOrder` do `campaignE2EFixtures.ts:46-63` e o `campaignFixtures.ts:1020-1045` já põem `consent` penúltimo. O único conflito real é o `withMutableConsentFixture` (janela D10, `testDatabaseLease.ts:643-647`), que pode apagar a chave canônica com consumers vivos — mitigado trocando esse ponto para raw SQL (já é o padrão local do setup/restore, linhas 575/680; `removeOnda0ConsentAndPrivacyDb` também é raw e segue intacto).
3. **Mensagens divergentes da cidade** (Fase 3): o brief dizia "mesma mensagem" e não é — as cópias dão `'Cidade muito longa'` no max; `contactCityFieldSchema` dá `'Cidade inválida'` nas duas bordas. Decisão abaixo padroniza no schema exportado; consumidor `campaignNewsletter` muda o texto de "cidade longa demais" (melhoria, não pinada em teste).
4. **`contactCityFieldSchema` colapsa `''`→undefined, cópias não:** a troca nas fichas passa a aceitar `''` como ausente (antes `'Cidade inválida'`); é o comportamento que S9 já definiu como canônico (combobox limpa para `''`), e `contactSchema` público mantém cidade **required** via o novo `contactCitySchema`. Bordas: string só-de-espaços vira erro em vez de colapso — divergência aceita, sem teste pinado, forms enviam `''`.
5. **`contactStateSchema` knip:** o export S9 declarou-o privado ("só 1 consumidor"); com a Fase 3 ele tem 4 consumidores — exportar não acende knip.
6. **Count no hook antes do delete** precisa de `depth: 0` (evita popular relações — custo) e `overrideAccess: true` (o `read` de `Consent` é `payloadAdminOnly`; o padrão casa com os hooks de `CampaignUser`).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (3 fases, zero mudança de contrato público).
- [ ] Invariantes AGENTS/engineering-standards: editar o dono (Consent, payloadTransaction, contact.ts), sem twin; sem módulo novo top-level em `src/utilities/`; multi-collection sempre em transação; migrations intactas.
### Painel de teste/verificação por fase
- **F1 — `tests/int/consentDeleteGuard.int.spec.ts` (novo):** cria consent + refs em cada consumidor (subscription, signature, pushSubscription, petition via `form.consent`, leadership, supporter `consent`/`voteIntentionConsent`); asserta `payload.delete({ collection: 'consent', id })` rejeitando com `APIError` 409 e mensagem com as contagens por coleção; asserta que remove refs → delete OK; que delete de consent sem refs passa; e que `DELETE` raw SQL bypassa o guard (espelho do `removeOnda0ConsentAndPrivacyDb`). Fixture descarta refs próprias antes do cleanup (controla a ordem por si).
- **F1 — regressão dos lifecycles:** `submitWhatsapp.int.spec.ts` (com `deleteWhatsappConsentRows`), `campaignNewsletter.e2e.spec.ts` (purga emissão 32-33), `campaignMunicipalities.e2e.spec.ts` (lease), `onda0Provision.int.spec.ts` (provision/remove) verdes; `testDatabaseLease.ts:643-647` → raw SQL.
- **F2 — `tests/int/submitWhatsapp.int.spec.ts`** (fail-closed com a mensagem exata + path feliz com subscription linkada ao consent) e **`tests/int/submitPetitionSignature.int.spec.ts`** (`signatureNumber` pré-commit preservado) verdes; **`tests/int/payloadTransaction.int.spec.ts`** verde (helper intocado); `pnpm gate:fast`.
- **F3 — `tests/unit/contactSchemas.unit.spec.ts`:** mantém verdes os pins existentes (state REQUIRED L20-30; city L64-71); **adicionar pins novos:** mensagem `'Cidade muito longa'` no contato do `contactFieldUpdateSchema`/`contactCreateSchema` após a consolidação; `''` em `city` colapsa para `undefined` nos schemas das fichas; `contactStateSchema` exportado aceita `'BA'` e rejeita `'XX'`. Homônimo `contactCityMigration.unit.spec.ts` é de migration — não tocar.
### Conclusão
Toda decisão não trivial vem com opções/recomendação/rejeitadas (uma por fase abaixo). Self-score decision-quality: **4,5/5** — decisões caras têm rejeitadas explicitadas (escopo do guard, mensagem da cidade, kind de teste D10), a abordagem cabe no appetite ~0,5–1 dia, rabbit holes nomeados (arquivamento, `locked_documents`, S10/Lead), depth-check reusa próprios donos (`payloadTransaction`, hooks inline, exports S9), e a intenção (o outcome das 3 fases) é satisfeita sem mudar contrato.
## Decisões de engenharia (uma por fase)
**F1 — Escopo do guard + status + lifecycles de teste.**
Opções: A) contar só os NOT NULL blockers (subscription/signature/pushSubscription) → cobre o sintoma 23502; B) contar todos os usos semânticos (A + petition via `form.consent` + leadership + supporter×2) → também elimina os NULL silenciosos.
Recomendação: **B** — porque o concern é o texto legal versionado: `petition.form_consent_id` NULLABLE hoje NULL-za silencioso e quebra o `submitPetitionSignature` (TypeError), e leadership/supporter têm `consentContentHash`/`consentedAt` que ficariam órfãos do link; multicount em `Promise.all` custa nada e a mensagem composta orienta o admin. Alternativas rejeitadas: A) deixa exatamente a classe de bug que a issue quer matar (NULL silencioso em público), e um guard de "uso parcial" é mais confuso que um de "uso".
Status HTTP: Opções: 400 | 409 | 500. Recomendação: **409** (`APIError(msg, 409)`) — conflito com estado existente, com precedente real no repo (`CampaignDemand.ts:144/278`, `Activity.ts:92`, `Organization.ts:27`, `VotePledge.ts:78`). Rejeitadas: 400 (semântica errada; erro não é do payload) e 500 (erro cru é o sintoma).
Integração com lifecycles: os fixtures já ordenam consumidores antes do consent (`campaignFixtures.ts:1020-1045`, `campaignE2EFixtures.ts:46-63`); o único ponto em conflito é o delete do `withMutableConsentFixture` (janela D10, `testDatabaseLease.ts:643-647`), que pode ter consumers vivos. Opções: A) mudar esse ponto para raw SQL; B) `overrideAccess`/flag de contorno no hook; C) fragilizar fixtures. Recomendação: **A** — hook `beforeDelete` NÃO roda para SQL raw (bypass natural do próprio harness), casa com o setup/restore raw já existente (575, 680) e com `removeOnda0ConsentAndPrivacyDb`. Rejeitadas: B) contorno no hook é um buraco de segurança que vaza para o admin; C) delegar ordem aos tests quebrados é mais fraco.
**F2 — Mensagem de begin-failure.**
Opções: A) `{ beginFailureMessage: 'failed to start transaction' }` no whatsapp e `{ beginFailureMessage: 'Failed to begin transaction' }` no petition; B) aceitar o default PT `'Não foi possível iniciar a transação.'`; C) criar uma PT compartilhada nova.
Recomendação: **A** — o refactor é preservação de contrato; a mensagem de begin-failure é o único output observável que muda e a opção existe exatamente para isso; zero risco de regressão oculta na mesma diff. Rejeitadas: B) altera comportamento observável (EN→PT) sem requisito por trás — limpeza desacoplada da Mecânica; C) strings novas só para "uniformizar" sem ganho, menos DRY que A. Nota: reads pré-transação (consent/petition) ficam fora do helper; count e `Promise.all` dentro (pré-commit).
**F3 — Estado: exportar o schema required.**
Opções: A) exportar `contactStateSchema` (required, atual privado) e usar nas 4 fichas; B) compor `optionalContactStateSchema` (ou união ad-hoc) nas fichas.
Recomendação: **A** — todas as 4 fichas exigem state (`contactCreateSchema` tem pin "rejects a missing state" em `contactSchemas.unit.spec.ts:25-30`); o optional é só o público S9. B) mudaria validação (state deixa de ser obrigatório) e quebraria o pin. Rejeitadas: B. O comentário L31-35 ("follow-up") é a dívida exatamente desta Fase 3 — atualizar ao exportar.
**F3 — Cidade: padronizar mensagens no schema canônico.**
Opções: a) padronizar em `contactCityFieldSchema` (min `'Cidade inválida'`, max `'Cidade muito longa'`), mudando o max dos consumidores atuais (incl. campaignNewsletter); b) manter mensagens distintas nas fichas via message params; c) padronizar num base required `contactCitySchema` e recompor `contactCityFieldSchema` sobre ele (união `''` + colapso); d) aceitar a divergência e documentar.
Recomendação: **c**, em coerência com o padrão do state — um base required (`contactCitySchema`, com as mensagens que as cópias já estabelecem) e o field schema como composição (união com `''`, colapso, `.optional()`); isso unifica as 4 fichas e mantém o colapso `''` dos consumidores S9. A mensagem de cidade-muito-longa do `campaignNewsletter` muda `'Cidade inválida'` → `'Cidade muito longa'` (melhoria informativa, não pinada). Rejeitadas: a) muda comportamento de refs S9 sem base requerida e duplica o pred do `contactSchema`; b) amplia a superfície de `contactCityFieldSchema` com params e preserva a divergência que a intenção quer matar; d) derrota o motivo da Fase 3. `contactSchema.city` (público, via `whatsAppFormSchema`/`petitionFormSchema`) continua **required** — usar `contactCitySchema`, nunca o field optional.

View file

@ -4,6 +4,8 @@ import { petitionFormSchema, type PetitionFormInput } from '@/lib/schemas/petiti
import config from '@payload-config'
import { getPayload } from 'payload'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
type SubmitPetitionSignatureInput = PetitionFormInput & {
petitionId: string
}
@ -21,7 +23,8 @@ export const submitPetitionSignature = async ({
// renders the text from `petition.form.consent`, and the signature must
// record exactly that document. Petition read access is public, like the
// page that renders it; a `consentId` posted by the client is dropped at
// the input boundary above, so a tampered one is inert.
// the input boundary above, so a tampered one is inert. Resolved before the
// transaction because it is a read, not a write.
const petition = await payload.findByID({
collection: 'petition',
id: petitionId,
@ -30,56 +33,50 @@ export const submitPetitionSignature = async ({
const consentId =
typeof petition.form.consent === 'number' ? petition.form.consent : petition.form.consent.id
const transactionID = await payload.db.beginTransaction()
return withPayloadTransaction(
payload,
async ({ req }) => {
const { phone, ...contactFields } = data
const contact = await payload.create({
collection: 'contact',
data: {
...contactFields,
// The public forms keep a single phone input; the ficha stores the
// phones array with that number as primary (C112).
phones: phone ? [{ value: phone }] : [],
},
req,
})
if (!transactionID) {
throw new Error('Failed to begin transaction')
}
await Promise.all([
payload.create({
collection: 'signature',
data: {
contact: contact.id,
petition: petitionId,
consent: consentId,
comment,
},
req,
}),
payload.create({
collection: 'subscription',
data: {
contact: contact.id,
consent: consentId,
},
req,
}),
])
try {
const { phone, ...contactFields } = data
const contact = await payload.create({
collection: 'contact',
data: {
...contactFields,
// The public forms keep a single phone input; the ficha stores the
// phones array with that number as primary (C112).
phones: phone ? [{ value: phone }] : [],
},
req: { transactionID },
})
await Promise.all([
payload.create({
const { totalDocs: signatureNumber } = await payload.count({
collection: 'signature',
data: {
contact: contact.id,
petition: petitionId,
consent: consentId,
comment,
},
req: { transactionID },
}),
payload.create({
collection: 'subscription',
data: {
contact: contact.id,
consent: consentId,
},
req: { transactionID },
}),
])
where: { petition: { equals: petitionId } },
req,
})
const { totalDocs: signatureNumber } = await payload.count({
collection: 'signature',
where: { petition: { equals: petitionId } },
req: { transactionID },
})
await payload.db.commitTransaction(transactionID)
return { ok: true, signatureNumber }
} catch (error) {
await payload.db.rollbackTransaction(transactionID)
throw error
}
return { ok: true, signatureNumber }
},
{ beginFailureMessage: 'Failed to begin transaction' },
)
}

View file

@ -6,6 +6,7 @@ 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'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
export const submitWhatsapp = async (input: WhatsAppFormInput) => {
const { comment, ...contactInput } = whatsAppFormSchema.parse(input)
@ -13,7 +14,8 @@ export const submitWhatsapp = async (input: WhatsAppFormInput) => {
// 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.
// missing, same policy as the campaign flows. Resolved before the
// transaction because it is a read, not a write.
const consent = await requireConsentByKey(
payload,
WHATSAPP_SUBSCRIPTION_CONSENT_KEY,
@ -21,41 +23,33 @@ export const submitWhatsapp = async (input: WhatsAppFormInput) => {
'Consentimento da inscrição no WhatsApp ainda não configurado.',
)
const transactionID = await payload.db.beginTransaction()
return withPayloadTransaction(
payload,
async ({ req }) => {
const { phone, ...contactFields } = contactInput
const contact = await payload.create({
collection: 'contact',
data: {
...contactFields,
// The public forms keep a single phone input; the ficha stores the
// phones array with that number as primary (C112).
phones: phone ? [{ value: phone }] : [],
},
req,
})
if (!transactionID) {
throw new Error('failed to start transaction')
}
await payload.create({
collection: 'subscription',
data: {
contact: contact.id,
consent: consent.id,
comment,
},
req,
})
try {
const { phone, ...contactFields } = contactInput
const contact = await payload.create({
collection: 'contact',
data: {
...contactFields,
// The public forms keep a single phone input; the ficha stores the
// phones array with that number as primary (C112).
phones: phone ? [{ value: phone }] : [],
},
req: { transactionID },
})
await payload.create({
collection: 'subscription',
data: {
contact: contact.id,
consent: consent.id,
comment,
},
req: { transactionID },
})
await payload.db.commitTransaction(transactionID)
return { ok: true }
} catch (error) {
await payload.db.rollbackTransaction(transactionID)
throw error
}
return { ok: true }
},
{ beginFailureMessage: 'failed to start transaction' },
)
}

View file

@ -1,5 +1,87 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { CollectionConfig } from 'payload'
import { APIError, type CollectionBeforeDeleteHook, type CollectionConfig } from 'payload'
/**
* Block deleting a Consent that is still referenced by a versioned legal-text
* consumer. The DB uses `ON DELETE SET NULL` FKs, but several consumers store
* the consent as NOT NULL (subscription, signature, pushSubscription) — deleting
* would trip a 23502 — while others (petition, leadership, supporter) null the
* column silently and corrupt the audit link. Either way the text is still in
* use, so we refuse the delete up front with an actionable message instead of a
* raw 500. The counts run with an INTENTIONAL access bypass (`overrideAccess`):
* Consent read is admin-only and the hook must see every reference to decide
* whether to block — mirrors the before-delete guards in CampaignUser /
* personDelete.
*/
const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({ id, req }) => {
const [
subscriptions,
signatures,
pushSubscriptions,
petitions,
leaderships,
supporters,
voteSupporters,
] = await Promise.all([
req.payload.count({
collection: 'subscription',
where: { consent: { equals: id } },
overrideAccess: true,
req,
}),
req.payload.count({
collection: 'signature',
where: { consent: { equals: id } },
overrideAccess: true,
req,
}),
req.payload.count({
collection: 'pushSubscription',
where: { consent: { equals: id } },
overrideAccess: true,
req,
}),
req.payload.count({
collection: 'petition',
where: { 'form.consent': { equals: id } },
overrideAccess: true,
req,
}),
req.payload.count({
collection: 'leadership',
where: { consent: { equals: id } },
overrideAccess: true,
req,
}),
req.payload.count({
collection: 'supporter',
where: { consent: { equals: id } },
overrideAccess: true,
req,
}),
req.payload.count({
collection: 'supporter',
where: { voteIntentionConsent: { equals: id } },
overrideAccess: true,
req,
}),
])
const references: Array<[number, string]> = [
[subscriptions.totalDocs, 'inscrições'],
[signatures.totalDocs, 'assinaturas'],
[pushSubscriptions.totalDocs, 'inscrições push'],
[petitions.totalDocs, 'petições'],
[leaderships.totalDocs, 'lideranças'],
[supporters.totalDocs, 'apoiadores'],
[voteSupporters.totalDocs, 'apoiadores (intenção de voto)'],
]
const used = references.filter(([count]) => count > 0)
if (used.length === 0) return
const detail = used.map(([count, label]) => `${count} ${label}`).join(', ')
throw new APIError(`Consentimento em uso — não é possível excluir (${detail}).`, 409)
}
export const Consent: CollectionConfig = {
slug: 'consent',
@ -20,6 +102,9 @@ export const Consent: CollectionConfig = {
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
hooks: {
beforeDelete: [blockConsentDeletionWithReferences],
},
fields: [
{
name: 'key',

View file

@ -29,11 +29,11 @@ export const contactNameSchema = z
)
/**
* UF-of-the-cities-catalog rule (required). Private: the S9 optional variant
* is the only external consumer; the ficha schemas keep their own inline
* copies (consolidating them is a follow-up).
* UF-of-the-cities-catalog rule (required). Exported so the four ficha schemas
* reuse the single definition instead of re-declaring the inline predicate
* (Fase 3 of the S9+ DRY pass — was private until it gained multiple consumers).
*/
const contactStateSchema = z.custom<StateKey>(
export const contactStateSchema = z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
)
@ -47,6 +47,27 @@ export const optionalContactStateSchema = z
.optional()
.transform((value) => (value === '' || value === undefined ? undefined : value))
/**
* Required free-text city rule (trim + min/max with the same messages the ficha
* schemas already used). Exported so the ficha schemas and the optional variant
* below share one definition (Fase 3 of the S9+ DRY pass).
*/
export const contactCitySchema = z
.string()
.trim()
.min(3, 'Cidade inválida')
.max(100, 'Cidade muito longa')
/**
* Optional free-text city (''/undefined collapse). Composed over `contactCitySchema`
* so it inherits the same min/max messages, and exported so sibling schemas reuse
* the same optional-city rule as the ficha's mobile sheet.
*/
export const contactCityFieldSchema = z
.union([contactCitySchema, z.literal('')])
.optional()
.transform((value) => (value === '' || value === undefined ? undefined : value))
const contactPostalCodeSchema = z
.string()
.trim()
@ -82,11 +103,8 @@ export const contactSchema = z.object({
.trim()
.length(11, 'Telefone celular inválido')
.regex(/^\d{11}$/, 'Telefone celular inválido'),
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
state: contactStateSchema,
city: contactCitySchema,
postalCode: contactPostalCodeSchema,
})
@ -102,11 +120,8 @@ export const contactCreateSchema = z.object({
// sends [] when the actor typed no phone.
phones: contactPhonesSchema.optional(),
gender: z.enum(contactGenders).optional(),
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa').optional(),
state: contactStateSchema,
city: contactCityFieldSchema,
postalCode: z
.string()
.trim()
@ -155,10 +170,7 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
z.object({
id: positiveRelationshipId,
field: z.literal('state'),
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
state: contactStateSchema,
}),
// C139 — the contacts page edits the ficha's own fields in place: city and
// CEP are free text on the Contact collection itself (no person-join
@ -166,7 +178,7 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
z.object({
id: positiveRelationshipId,
field: z.literal('city'),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
city: contactCitySchema,
}),
z.object({
id: positiveRelationshipId,
@ -182,19 +194,6 @@ const contactGenderFieldSchema = z
.transform((value) => (value === '' ? undefined : value))
.optional()
/**
* Optional free-text city (''/undefined collapse). Exported so sibling schemas
* reuse the same optional-city rule as the ficha's mobile sheet.
*/
export const contactCityFieldSchema = z
.string()
.trim()
.transform((value) => (value === '' ? undefined : value))
.refine((value) => value === undefined || (value.length >= 3 && value.length <= 100), {
message: 'Cidade inválida',
})
.optional()
/**
* C139 — the mobile edit sheet is a SINGLE atomic write (plan decision F):
* the whole ficha in one schema, with the same gate/scope/name invariant as
@ -207,10 +206,7 @@ export const contactFullUpdateSchema = z.object({
email: optionalPersistedEmail,
phones: contactPhonesSchema.optional(),
gender: contactGenderFieldSchema,
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
state: contactStateSchema,
city: contactCityFieldSchema,
postalCode: contactPostalCodeSchema,
})

View file

@ -640,11 +640,10 @@ export const withMissingInviteConsentFixture = async <Result>(
depth: 0,
})
if (recreated.docs[0]) {
await payload.delete({
collection: 'consent',
where: { key: { equals: consentKey } },
depth: 0,
})
// Raw SQL bypasses the Consent.beforeDelete guard (the lease window guarantees
// no live references exist, so the guard would never fire here — but the hook is
// authorization of the admin delete path and must not run for this test harness delete).
await payload.db.drizzle.execute(sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`)
}
}

View file

@ -0,0 +1,153 @@
// @vitest-environment node
/**
* S9+ (Issue #771, Fase 1): deleting a Consent still referenced by a versioned
* legal-text consumer must refuse with a friendly 409 — not the raw Postgres
* 23502 (NOT NULL consumers) nor a silent NULL (nullable consumers like
* petition). The guard counts references across every consumer and composes a
* message listing them.
*/
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
import { APIError, getPayload } from 'payload'
import { beforeAll, describe, expect, it, vi } from 'vitest'
// The Petition afterChange hook revalidates its ISR tag, which needs the Next
// runtime — neuter it (the revalidation itself is pinned elsewhere).
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: () => undefined,
}))
import type { Petition } from '@/payload-types'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const petitionBody: Petition['body'] = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [{ type: 'text', text: 'Corpo da petição de teste', version: 1 }],
direction: null,
format: '',
indent: 0,
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
}
describe('Consent beforeDelete guard', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('blocks deletion and lists every referencing consumer (composite 409)', async () => {
const fixtures = campaignFixtures()
const consent = await fixtures.createConsent()
const contact = await fixtures.createContact()
// NOT NULL consumer (would trip 23502 today) + nested-group consumer
// (petition.form.consent, which nulls silently today).
const subscription = await payload.create({
collection: 'subscription',
data: { contact: contact.id, consent: consent.id },
depth: 0,
overrideAccess: true,
})
const petition = await payload.create({
collection: 'petition',
data: {
id: `peticao-${fixtures.value('guard')}`,
title: 'Petição de teste do guard',
subtitle: 'Consentimento referenciado',
enabled: true,
body: petitionBody,
form: { consent: consent.id },
_status: 'published',
},
depth: 0,
overrideAccess: true,
})
let guardError: unknown
try {
await payload.delete({ collection: 'consent', id: consent.id, overrideAccess: true })
} catch (error) {
guardError = error
}
expect(guardError).toBeInstanceOf(Error)
expect((guardError as { status?: number }).status).toBe(409)
const message = (guardError as Error).message
expect(message).toContain('não é possível excluir')
expect(message).toContain('inscrições')
expect(message).toContain('petições')
// Removing the references lets the delete through.
await payload.delete({ collection: 'subscription', id: subscription.id, overrideAccess: true })
await payload.delete({ collection: 'petition', id: petition.id, overrideAccess: true })
await expect(
payload.delete({ collection: 'consent', id: consent.id, overrideAccess: true }),
).resolves.toBeDefined()
})
it('allows deletion of a consent with no references', async () => {
const fixtures = campaignFixtures()
const consent = await fixtures.createConsent()
await expect(
payload.delete({ collection: 'consent', id: consent.id, overrideAccess: true }),
).resolves.toBeDefined()
})
it('raw SQL delete bypasses the guard (harness / Onda0 provision parity)', async () => {
const fixtures = campaignFixtures()
const consent = await fixtures.createConsent()
const contact = await fixtures.createContact()
const subscription = await payload.create({
collection: 'subscription',
data: { contact: contact.id, consent: consent.id },
depth: 0,
overrideAccess: true,
})
// A raw DELETE does NOT run the admin beforeDelete hook. A referenced
// consent still fails — but with the DB's 23502 (FK SET NULL vs NOT NULL
// column), never the friendly 409 the hook would raise. This is exactly why
// the test harness / Onda0 provision purge consents via raw SQL while
// holding the lease that guarantees no live references.
let rawError: unknown
try {
await payload.db.drizzle.execute(sql`DELETE FROM "consent" WHERE "id" = ${consent.id}`)
} catch (error) {
rawError = error
}
// Cleanup first (always): drop the reference, then the now-unreferenced
// consent deletes (the hook lets it through), freeing the fixture-owned
// contact.
await payload.delete({ collection: 'subscription', id: subscription.id, overrideAccess: true })
await payload.delete({ collection: 'consent', id: consent.id, overrideAccess: true })
expect(rawError).toBeInstanceOf(Error)
// The hook would have thrown APIError(409); a raw DELETE fails at the DB
// layer instead, proving beforeDelete did not run.
expect(rawError).not.toBeInstanceOf(APIError)
})
})

View file

@ -1,7 +1,12 @@
import { describe, expect, it } from 'vitest'
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
import { contactCreateSchema, contactFieldUpdateSchema } from '@/lib/schemas/contact'
import {
contactCitySchema,
contactCreateSchema,
contactFieldUpdateSchema,
contactStateSchema,
} from '@/lib/schemas/contact'
describe('contactCreateSchema (C139)', () => {
it('accepts a full valid ficha', () => {
@ -102,4 +107,40 @@ describe('contactFieldUpdateSchema — C139 variants', () => {
.success,
).toBe(true)
})
it('collapses an empty city to undefined in the create ficha (C139 DRY)', () => {
const result = contactCreateSchema.safeParse({ name: 'Ana Souza', state: 'BA', city: '' })
expect(result.success).toBe(true)
if (result.success) {
expect((result.data as { city?: string }).city).toBeUndefined()
}
})
it('reports the long-city message on the field-update city variant', () => {
const result = contactFieldUpdateSchema.safeParse({
id: 5,
field: 'city',
city: 'a'.repeat(101),
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues.some((issue) => issue.message === 'Cidade muito longa')).toBe(true)
}
})
})
describe('contactStateSchema / contactCitySchema (S9+ DRY consolidation)', () => {
it('contactStateSchema accepts a known UF and rejects an unknown one', () => {
expect(contactStateSchema.safeParse('BA').success).toBe(true)
expect(contactStateSchema.safeParse('XX').success).toBe(false)
})
it('contactCitySchema reports the long-city message at the max boundary', () => {
expect(contactCitySchema.safeParse('Camaçari').success).toBe(true)
const result = contactCitySchema.safeParse('a'.repeat(101))
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues.some((issue) => issue.message === 'Cidade muito longa')).toBe(true)
}
})
})