mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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
586 lines
18 KiB
TypeScript
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.'
|
|
},
|
|
},
|
|
],
|
|
}
|