teqo-backup/src/collections/CampaignUser.ts
Francisco Solla aca3ea6188 feat(C153): catálogo de falas do Solla — schema, access, import idempotente e busca
Collections speech/speechSegment no grupo Comunicação, papel communicator
(leitura restrita) e índice trigram normalizado para a busca por segmento.
Import idempotente da Câmara (discurso→evento→trecho→VOD→ASR) com facetas
gazetteer+LLM, proveniência e preservação de curadoria manual; parser de
quem presidia; reusa a lógica pura do piloto C152 via scripts/lib.

Related #955
2026-09-13 02:08:55 -03:00

586 lines
18 KiB
TypeScript

import {
ADVISOR_EDITING_OPTIONS,
ADVISOR_VISIBILITY_OPTIONS,
isCoherentAdvisorProfile,
} from '@/lib/campaignAdvisorProfile'
import { CAMPAIGN_SESSION_TTL_LONG } from '@/lib/campaignSessionTtl'
import { normalizeBrazilianPhone } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import {
ADVISOR_PROFILE_COHERENCE_MESSAGE,
isPlanilhaPlaceholderEmail,
} from '@/lib/schemas/advisor'
import type { CampaignUser as CampaignUserDocument } from '@/payload-types'
import {
canCreateCampaignUserPhone,
canManageCampaignUserRole,
canManageCampaignUsers,
canReadCampaignUserIdentity,
canReadCampaignUserPhone,
canReadCampaignUsers,
canSetCampaignSystemField,
canUpdateCampaignUser,
canUpdateCampaignUserAvatar,
canUpdateCampaignUserPhone,
} from '@/utilities/campaignAccess'
import { buildCampaignPasswordResetUrl } from '@/utilities/campaignPasswordReset'
import { findOrCreateContactByPhone } from '@/utilities/contactIdentity'
import { acquireContactFichaLock } from '@/utilities/contactPhoneLocks'
import {
APIError,
type CollectionAfterReadHook,
type CollectionBeforeChangeHook,
type CollectionBeforeDeleteHook,
type CollectionBeforeValidateHook,
type CollectionConfig,
type Payload,
type PayloadRequest,
} from 'payload'
const removePrivateAuthFields: CollectionAfterReadHook<CampaignUserDocument> = async ({
doc,
req,
}) => {
const user = req.user
if (!user || user.collection === 'users' || String(user.id) === String(doc.id)) {
return doc
}
const privateDoc = doc as unknown as Record<string, unknown>
delete privateDoc.email
delete privateDoc.username
delete privateDoc.sessions
delete privateDoc.salt
delete privateDoc.hash
delete privateDoc.resetPasswordToken
delete privateDoc.resetPasswordExpiration
return doc
}
const preventAssignedAdvisorDowngrade: CollectionBeforeChangeHook<CampaignUserDocument> = async ({
data,
operation,
originalDoc,
req,
}) => {
if (
operation !== 'update' ||
!originalDoc ||
data.role !== 'leader' ||
originalDoc.role === 'leader'
) {
return data
}
// Intentional admin bypass: the downgrade guard must see every relation the
// account is assigned to, regardless of the actor's read scope.
const assignments = await Promise.all([
req.payload.find({
collection: 'municipality',
where: { advisors: { contains: originalDoc.id } },
depth: 0,
limit: 1,
select: { slug: true },
overrideAccess: true,
req,
}),
req.payload.find({
collection: 'stateDeputy',
where: { advisors: { contains: originalDoc.id } },
depth: 0,
limit: 1,
select: { slug: true },
// Same justified bypass as the municipality probe above.
overrideAccess: true,
req,
}),
req.payload.find({
collection: 'leadership',
where: { advisors: { contains: originalDoc.id } },
depth: 0,
limit: 1,
select: { contact: true },
// Same justified bypass as the probes above.
overrideAccess: true,
req,
}),
])
if (assignments.some((result) => result.totalDocs > 0)) {
throw new APIError(
'Remova ou substitua este usuário da assessoria de municípios, dobradinhas ou lideranças antes de alterar o papel para liderança.',
409,
)
}
return data
}
const preventSelfServicePrivilegedFields: CollectionBeforeChangeHook<
CampaignUserDocument
> = async ({ data, operation, originalDoc, req }) => {
if (operation !== 'update' || !originalDoc || req.user?.collection !== 'campaignUser') {
return data
}
if (String(req.user.id) !== String(originalDoc.id)) return data
for (const field of [
'role',
'name',
'email',
'username',
'phone',
'contact',
'visibility',
'editing',
] as const) {
if (field in data) delete data[field]
}
return data
}
/**
* C141 — the advisor permission profile must stay coherent: Edição "Tudo"
* (edits everything visible) only makes sense with Visão "Tudo". Fail-closed on
* the API itself (REST/admin included), not just in the campaign form actions.
* Non-advisor roles never consult the profile, so the rule only guards advisors.
*/
const enforceCoherentAdvisorProfile: CollectionBeforeValidateHook<CampaignUserDocument> = async ({
data,
originalDoc,
}) => {
if (!data) return data
const role = data.role ?? originalDoc?.role
if (role !== 'advisor') return data
const visibility = data.visibility ?? originalDoc?.visibility ?? 'carteira'
const editing = data.editing ?? originalDoc?.editing ?? 'carteira'
if (!isCoherentAdvisorProfile(visibility, editing)) {
throw new APIError(ADVISOR_PROFILE_COHERENCE_MESSAGE, 400)
}
return data
}
/**
* Resolves the `Contact` ficha for an account that has none yet: by phone
* (find-or-create, same dedupe policy as the supporter flows) or a fresh
* name-only ficha when there is no phone. Multiple accounts may share one
* ficha — a person with two roles (liderança + assessora) keeps two accounts
* but ONE ficha, which is exactly the C100 dedupe.
*/
const resolveContactForAccount = async ({
payload,
req,
name,
email,
phone,
}: {
payload: Payload
req: PayloadRequest
name: string
email?: string | null
phone?: string | null
}): Promise<number> => {
const { contactID } = await findOrCreateContactByPhone({
payload,
req,
phones: typeof phone === 'string' && phone.length > 0 ? [phone] : [],
name,
email: email && !isPlanilhaPlaceholderEmail(email) ? email : null,
})
return contactID
}
/**
* C99 — every staff account points at ONE normalized `Contact` ficha, without
* asking for the person's data twice (admin UI and the inline advisor flows
* only collect account fields). An explicit `data.contact` (different from the
* linked ficha) always wins — the hook only fills the gap. On updates of a
* linked account, identity changes (name/e-mail/phone) are synced one-way
* account → ficha so the C100 list reads fresh data; a phone already used by
* another ficha is shared, not rejected (C111 — the phone is a contact
* channel; the account's `username` keeps the login key unique).
*
* Runs AFTER `preventSelfServicePrivilegedFields` on purpose: the strip
* deletes the merged identity fields (including `contact`) for self-service,
* so by the time this hook sees them they look "explicitly different" — which
* is what keeps self-service edits from ever writing the ficha.
*/
const ensureCampaignUserContactIdentity: CollectionBeforeChangeHook<CampaignUserDocument> = async ({
data,
operation,
originalDoc,
req,
}) => {
if (!data) return data
// Unauthenticated updates are the password-reset token flow
// (`payload.forgotPassword` runs its `update` with `overrideAccess` and no
// `req.user`): it must never create or rewrite a LGPD-relevant `Contact`
// row, and a ficha phone conflict must not be able to block password
// recovery. Every real identity write is authenticated (admin REST token or
// a server action passing `user`); the invite redemption passes `contact`
// explicitly, so skipping the hook there changes nothing.
if (!req.user && operation === 'update') return data
// Payload's field `beforeValidate` merges the original document into the
// incoming data, so "explicitly provided" is "different from the linked
// ficha", never "key present".
if (operation === 'create') {
if (data.contact !== undefined) return data
data.contact = await resolveContactForAccount({
payload: req.payload,
req,
name: data.name ?? '',
email: data.email ?? null,
phone: data.phone ?? null,
})
return data
}
if (data.contact !== originalDoc?.contact) return data
const linkedContactID = relationshipId(originalDoc?.contact)
if (linkedContactID !== null) {
const contactData: {
name?: string
email?: string | null
phones?: { value: string }[]
} = {}
if (data.name !== undefined && data.name !== originalDoc?.name) {
contactData.name = data.name
}
if (data.email !== undefined && data.email !== originalDoc?.email) {
if (data.email === null || !isPlanilhaPlaceholderEmail(data.email)) {
contactData.email = data.email
}
}
// Clearing the account's phone never clears the ficha's: the ficha's
// phones are the person's dedupe keys across every join (supporter,
// leadership, dobradinha), and the account edit is not a ficha edit.
// A changed account phone is APPENDED when the ficha lacks it (never
// reordering the mesa's priority, never removing the previous number);
// creation still seeds it as the primary via find-or-create.
if (
typeof data.phone === 'string' &&
data.phone.length > 0 &&
data.phone !== originalDoc?.phone
) {
// C120 — same ficha lock as the find-or-create reuse append: this
// read-modify-write must not read a stale phones array while another
// flow appends to the same ficha. Lock first, then the read below is the
// RMW's read already serialized by it.
await acquireContactFichaLock(req.payload, req, linkedContactID)
const contact = await req.payload.findByID({
collection: 'contact',
id: linkedContactID,
depth: 0,
select: { phones: { value: true } },
// Intentional admin bypass: same account-scoped policy as the ficha
// write below — the read only derives the append target.
overrideAccess: true,
req,
})
const existingPhones = (contact.phones ?? [])
.map((entry) => entry.value)
.filter((value): value is string => Boolean(value))
if (!existingPhones.includes(data.phone)) {
contactData.phones = [...existingPhones, data.phone].map((value) => ({ value }))
}
}
if (Object.keys(contactData).length === 0) return data
await req.payload.update({
collection: 'contact',
id: linkedContactID,
data: contactData,
depth: 0,
// Intentional admin bypass: the account operation is already authorized;
// the ficha write is scoped by the account link and may share the phone
// with other fichas (C111).
overrideAccess: true,
req,
})
return data
}
data.contact = await resolveContactForAccount({
payload: req.payload,
req,
name: data.name ?? originalDoc?.name ?? '',
email: data.email ?? originalDoc?.email ?? null,
phone: data.phone ?? originalDoc?.phone ?? null,
})
return data
}
/**
* Revokes the account's passkeys (roadmap B40) before it is deleted. This is a
* hook rather than an `ON DELETE cascade` because Payload derives FK actions
* from the field config, so a hand-written cascade in the migration is drift the
* next `migrate:create` reverts. `campaign_web_authn_credential.user_id` is NOT
* NULL with `ON DELETE set null`, so without this the delete fails and the
* account becomes undeletable the moment it enrolled a passkey. Note that the
* test fixtures rely on it too: `campaignWebAuthnCredential` is the one
* collection with no `owned` entry, because `deleteOwned('campaignUser')`
* reaches it through here.
*/
const deleteCampaignUserPasskeys: CollectionBeforeDeleteHook = async ({ id, req }) => {
await req.payload.delete({
collection: 'campaignWebAuthnCredential',
where: { user: { equals: id } },
overrideAccess: true,
req,
})
}
const deleteCampaignUserNotifications: CollectionBeforeDeleteHook = async ({ id, req }) => {
await req.payload.delete({
collection: 'notification',
where: { recipient: { equals: id } },
overrideAccess: true,
req,
})
await req.payload.delete({
collection: 'pushSubscription',
where: { user: { equals: id } },
overrideAccess: true,
req,
})
}
/**
* Staged CSV-import batches authored by the deleted account must go BEFORE the
* account. D13 aligned the schema (`actor_id` is now nullable, matching the
* `ON DELETE set null` FK — issue #643), so the FK no longer violates NOT NULL;
* the hook still deletes the transient staging rows explicitly rather than
* leaving orphaned NULL-actor batches until the expiry sweep. Same pattern as
* the passkey/notification cascade above; `deletePersonRecord` spells the same
* cleanup by hand.
*/
const deleteCampaignUserImportBatches: CollectionBeforeDeleteHook = async ({ id, req }) => {
await req.payload.delete({
collection: 'supporterImportBatch',
where: { actor: { equals: id } },
depth: 0,
// Intentional bypass: the account deletion itself is the authorization;
// the batches are transient staging rows owned by the deleted account
// (same cascade contract as the passkey/notification deletes above).
overrideAccess: true,
req,
})
}
export const CampaignUser: CollectionConfig = {
slug: 'campaignUser',
labels: {
singular: 'Usuário',
plural: 'Usuários',
},
admin: {
group: 'Campanha',
useAsTitle: 'name',
},
auth: {
tokenExpiration: CAMPAIGN_SESSION_TTL_LONG,
loginWithUsername: {
allowEmailLogin: true,
requireEmail: false,
requireUsername: false,
},
forgotPassword: {
generateEmailSubject: () => 'Redefinir sua senha — Campanha Jorge Solla',
generateEmailHTML: (args) => {
const token = args?.token
const user = args?.user
if (!token) {
throw new Error('Password reset token is required to generate email HTML.')
}
const resetPasswordURL = buildCampaignPasswordResetUrl(token)
const greeting = user?.email ? `Olá, ${user.email}` : 'Olá'
return `
<!doctype html>
<html lang="pt-BR">
<body style="font-family: sans-serif; line-height: 1.5; color: #111827;">
<p>${greeting}</p>
<p>Recebemos um pedido para redefinir a senha da sua conta na ferramenta de campanha.</p>
<p>
<a href="${resetPasswordURL}">Redefinir senha</a>
</p>
<p>Se você não solicitou esta alteração, ignore este e-mail.</p>
<p style="font-size: 12px; color: #6b7280;">${resetPasswordURL}</p>
</body>
</html>
`
},
},
},
access: {
create: canManageCampaignUsers,
read: canReadCampaignUsers,
update: canUpdateCampaignUser,
delete: canManageCampaignUsers,
},
hooks: {
beforeValidate: [enforceCoherentAdvisorProfile],
beforeChange: [
preventAssignedAdvisorDowngrade,
preventSelfServicePrivilegedFields,
ensureCampaignUserContactIdentity,
],
beforeDelete: [
deleteCampaignUserPasskeys,
deleteCampaignUserNotifications,
deleteCampaignUserImportBatches,
],
afterRead: [removePrivateAuthFields],
},
fields: [
{
name: 'contact',
type: 'relationship',
relationTo: 'contact',
label: 'Ficha de contato',
index: true,
access: {
read: canReadCampaignUserIdentity,
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'name',
type: 'text',
label: 'Nome',
required: true,
},
{
name: 'avatar',
type: 'upload',
relationTo: 'media',
label: 'Foto de perfil',
access: {
read: () => true,
update: canUpdateCampaignUserAvatar,
},
},
{
name: 'role',
type: 'select',
label: 'Papel',
required: true,
defaultValue: 'leader',
saveToJWT: true,
options: [
{ label: 'Coordenador Geral', value: 'coordinator' },
{ label: 'Assessor', value: 'advisor' },
{ label: 'Candidato', value: 'candidate' },
{ label: 'Assessor de Comunicação', value: 'communicator' },
{ label: 'Liderança', value: 'leader' },
],
access: {
update: canManageCampaignUserRole,
},
},
{
name: 'visibility',
type: 'select',
label: 'Visão',
required: true,
defaultValue: 'carteira',
admin: {
description: 'O que o assessor enxerga no /campanha.',
condition: (data) => data.role === 'advisor',
},
options: ADVISOR_VISIBILITY_OPTIONS.map(({ value, label }) => ({ value, label })),
access: {
update: canManageCampaignUserRole,
},
},
{
name: 'editing',
type: 'select',
label: 'Edição',
required: true,
defaultValue: 'carteira',
admin: {
description: 'O que o assessor pode editar no /campanha.',
condition: (data) => data.role === 'advisor',
},
options: ADVISOR_EDITING_OPTIONS.map(({ value, label }) => ({ value, label })),
access: {
update: canManageCampaignUserRole,
},
},
{
name: 'email',
type: 'email',
label: 'E-mail',
unique: true,
index: true,
access: {
read: canReadCampaignUserIdentity,
update: canManageCampaignUserRole,
},
},
{
name: 'phone',
type: 'text',
label: 'Celular de contato',
index: true,
access: {
create: canCreateCampaignUserPhone,
read: canReadCampaignUserPhone,
update: canUpdateCampaignUserPhone,
},
hooks: {
beforeValidate: [
({ value }) => {
if (typeof value !== 'string' || value.length === 0) return value
return normalizeBrazilianPhone(value) ?? value
},
],
},
validate: (value: unknown) => {
if (value === undefined || value === null || value === '') return true
return normalizeBrazilianPhone(String(value)) ? true : 'Informe um celular válido com DDD.'
},
},
{
name: 'username',
type: 'text',
label: 'Celular de acesso',
unique: true,
index: true,
access: {
read: canReadCampaignUserIdentity,
update: canManageCampaignUserRole,
},
hooks: {
beforeValidate: [
({ value }) => {
if (typeof value !== 'string' || value.length === 0) return value
return normalizeBrazilianPhone(value) ?? value
},
],
},
validate: (value: unknown) => {
if (value === undefined || value === null || value === '') return true
return normalizeBrazilianPhone(String(value)) ? true : 'Informe um celular válido com DDD.'
},
},
],
}