teqo-backup/src/collections/Consent.ts
Francisco Solla 019cd456cc 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>
2026-08-25 00:13:47 -03:00

127 lines
3.7 KiB
TypeScript

import { payloadAdminOnly } from '@/utilities/campaignAccess'
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',
labels: {
singular: 'Consentimento',
plural: 'Consentimentos',
},
admin: {
group: 'Contatos',
useAsTitle: 'text',
},
// Versioned legal texts referenced by signatures/subscriptions/supporters.
// Server flows resolve them via the Local API without a user; only admins
// may create or alter them.
access: {
create: payloadAdminOnly,
read: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
hooks: {
beforeDelete: [blockConsentDeletionWithReferences],
},
fields: [
{
name: 'key',
type: 'text',
label: 'Chave estável',
required: false,
unique: true,
index: true,
admin: {
description: 'Identificador estável para referências no código.',
},
},
{
name: 'text',
type: 'richText',
label: 'Texto',
required: true,
},
],
}