mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
O escopo A/C do gate (PR #1370) é superado por decisão do dono (2026-10-01, aval jurídico declarado): o índice vira anônimo (archivePhotoFace, foto + descritor 128-d, sem nome/vínculo), o enrollment por pessoa morre (faceSubject/faces:enroll removidos) e qualquer visitante encontra as próprias fotos aprovadas. - faces:index persiste os descritores por foto (transação substitui as linhas + marker = modelo); desaprovar/remover/deletar foto apaga os descritores (hooks + FK cascade) - POST /api/fotos/selfie casa o vetor da consulta contra o índice em memória (TTL 60 s) e devolve só a interseção com o read aprovado do C233, sem score/nome de terceiro; leave-index apaga os descritores que casarem - Consentimentos seguem fail-closed: busca-selfie-fotos (consulta) e busca-selfie-indice (aviso público do índice), textos versionados em scripts/lib/faceConsentTexts.mjs e provisionados por pnpm seed:face-consents - pnpm archive:publish aprova o acervo em lote (draft -> approved; removed nunca) com guardas e recibo - C243: seção condicional SelfieSearchHomeSection (port do design hi-fi, cenas 390/1280) entre a Central e a newsletter; sem engine/captura na home e sem CTA morto quando fechada - docs: planos C242/C243, AGENTS-public, runbook §C242, changelog Provas: gate:fast 457 arquivos/5.055 testes; int full 109/1.075; e2e frontendFotosSelfie 9/9; knip verde.
127 lines
3.7 KiB
TypeScript
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,
|
|
},
|
|
],
|
|
}
|