Merge pull request #511 from fsolla/C99-identidade-toda-pessoa-da-campanha-tem-uma-ficha-contact

C99 — Identidade: toda pessoa da campanha tem uma ficha Contact
This commit is contained in:
Francisco Solla 2026-08-09 19:28:45 -03:00 committed by GitHub
commit aa7da74c22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 14775 additions and 292 deletions

View file

@ -0,0 +1,102 @@
# Impl: Identidade — toda pessoa da campanha tem uma ficha Contact
Status: aprovado
Atualizado em: 2026-08-09
Issue: #494
Intenção: docs/plans/pessoas-identidade-contact.md
Appetite restante: ~1–1,5 dia eng (herdado); uma migration aditiva
## Leitura da intenção
- **Outcome:** `campaignUser` ganha vínculo opcional com `Contact` (uma conta → uma ficha), garantido no fluxo de criação/edição de staff (cria ou vincula — nunca pede dados da pessoa duas vezes); `leadership` ganha `advisors` (assessores responsáveis, mesmo padrão de `stateDeputy.advisors`). C100 (lista unificada) passa a conseguir juntar staff, lideranças e dobradinhas pela mesma ficha.
- **O que NÃO negociar:** auth intacta (login e-mail/celular, token, sessão, WebAuthn, convites, notificações); leader lockdown; consentimentos intactos; `leadership.user` permanece; sem backfill por heurística (vínculo só por decisão explícita de criação/edição); estado de produto "sem superfície nova".
- **O que reavaliar:** a hipótese "mudar cada fluxo de criação de usuário" — um hook de collection cobre admin + criação inline de assessor (action + popover do município) + convite, com um dono só; e o vínculo NÃO precisa ser único no sentido inverso (duas contas da mesma pessoa física podem apontar para a mesma ficha — é exatamente o dedupe que a C100 quer).
## Abordagem recomendada
```mermaid
flowchart LR
A[beforeChange em campaignUser] --> B{data.contact set?}
B -- sim --> C[respeita o explícito; sync de identidade se mudou]
B -- não --> D{tem telefone?}
D -- sim --> E[lock contact-phone → find por telefone]
E -- 1 ficha --> F[vincula: data.contact = ficha]
E -- 0 fichas --> G[cria ficha BA com nome/telefone/e-mail não-stub]
E -- 2+ fichas --> H[erro AMBIGUOUS fail-closed]
D -- não --> G
C --> I[leadership.advisors: campo + validação + action + célula]
F --> I
G --> I
```
**Opções consideradas:** A) hook `beforeChange` em `campaignUser` (cria/vincula por telefone + sync de identidade) | B) chamadas explícitas em cada server action + campo manual no admin | C) hook só no create, sem sync em update
**Recomendação:** A — um dono só para a invariante "conta tem ficha"; cobre o admin (Payload não permite hook de UI) e os três fluxos de criação (action de assessor, popover inline do município, resgate de convite) sem N call sites espalhados; o sync de identidade em update mantém a ficha fresca quando a coordenação edita o staff em `/campanha/assessores`.
**Rejeitadas:** B porque deixa o admin sem o comportamento "sem pedir duas vezes" (a coordenação criaria a ficha à parte) e duplica a invariante em N ações; C porque o aceite fala em "criação/edição" e a C100 leria telefone/nome obsoletos da ficha após edições rotineiras do staff.
### Componentes / mudanças
- **`src/collections/CampaignUser.ts`**:
- Campo novo `contact` (relationship → `contact`, optional, `index: true`, label "Ficha de contato").
- Field access: read = `canReadCampaignUserIdentity` (própria conta ou admin — mesmo tratamento de email/username); create/update = `canSetCampaignSystemField` (só admin escreve explícito; o hook preenche no fluxo staff).
- `preventSelfServicePrivilegedFields`: adicionar `contact` à lista de campos removidos em self-service (usuário não realinha a própria identidade).
- Hook novo `beforeChange` `ensureCampaignUserContactIdentity` (último da ordem, após `preventSelfServicePrivilegedFields`):
- Create: `data.contact` explícito → respeita; senão → cria/vincula por telefone (lock `contact-phone:<phone>` adq. na própria transação; >1 ficha → `CONTACT_PHONE_AMBIGUOUS_MESSAGE`; 1 → vincula; 0 → cria ficha `{ name, email: só e-mail não-placeholder, phone, state: 'BA', city: null }` com `overrideAccess` justificado — precedente `createStateDeputyWithContact`).
- Update: `contact` presente no data → respeita (inclusive null explícito); ausente e `originalDoc.contact` set → sync 1 via (conta → ficha) de name/email (não-placeholder)/phone quando a identidade mudou (a ficha tem o próprio hook `enforceUniqueContactPhone` → conflito = falha da operação com mensagem padrão, fail-closed, mesma semântica do fluxo de contato de dobradinha); ausente e sem vínculo → mesmo cria/vincula do create.
- Sem `unique: true` no campo (ver Decisões).
- **`src/collections/Leadership.ts`**: campo `advisors` (relationship → `campaignUser`, hasMany, `index: true`, label "Assessores responsáveis", `filterOptions: eligibleCampaignStaffWhere`, access create = `canAssignCampaignStaffAdvisors`, update = `canManageCampaignStaffAdvisors`) + validação `beforeValidate` via hook compartilhado extraído.
- **`src/utilities/contactIdentity.ts`** (novo, extraído): `findOrCreateContactByPhone` — lock + find (limit 2) + ambíguo/reusa/cria com `state: 'BA'` (aceita `phone: null` → ficha name-only; `gender`/`city` opcionais). `supporter.ts` migra `upsertContactByPhone` para ele (lock passou a ser interno ao utility — reentrante na mesma transação; o `skipContactPhoneInvariant` do create é mantido, adquirido junto com o lock na mesma transação, fail-closed do hook da ficha). `campaignUser` hook e o create de liderança usam o mesmo utility (3 call sites, política "cidade padrão BA" com dono único).
- **`src/utilities/campaignStaffAdvisors.ts`** (novo): hook de validação `validateEligibleCampaignStaffAdvisors` (3ª cópia idêntica → extrai de `Municipality.ts` e `StateDeputy.ts`) + acesso `canAssignCampaignStaffAdvisors` / `canManageCampaignStaffAdvisors` (rename+move de `access/stateDeputies.ts`, que tinha nomes específicos de dobradinha). Municípios mantém `canAssignMunicipalityAdvisors` próprio (semântica de carteira, pode divergir — comentário E14 existente).
- **`src/lib/leadershipAdvisorMembership.ts`** (novo): `nextLeadershipAdvisorIdsAfterMembership` — mesmo bounded-delta de `stateDeputyAdvisorMembership.ts` com cap `MAX_ADVISORS_PER_LEADERSHIP = 10`.
- **`src/app/(campaign)/campanha/actions/leadership.ts`**: `setLeadershipAdvisorMembership` — espelho de `setStateDeputyAdvisorMembershipRecord` (lock `leadership-advisors:<id>`, `reloadUnrestrictedActor`, update com `overrideAccess`).
- **`src/components/campaign/leadership/LeadershipAdvisorRelationCell.tsx`** (novo): 2º thin wrapper sobre `RelationOptionCell` (padrão B156; chips linkam `/campanha/assessores/[id]`, `readOnly` para staff sem atribuição).
- **`src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx` + `formActions.ts`**: seção "Assessores responsáveis" na ficha existente (mesmo layout da dobradinha: `canEditAdvisors = isCampaignUnrestricted(user)`, `loadEligibleAdvisorOptions` quando editável) + `setLeadershipAdvisorMembershipFormAction` (revalidate `/campanha/liderancas/[id]`).
- **`src/utilities/leadership/leadershipData.ts`**: `LeadershipDetailViewModel` ganha `advisors: Array<{ id, name }>` resolvidos via `loadCampaignUserNamesByIds` (bypass justificado já documentado no helper).
- **`src/utilities/campaignInviteRedemption.ts`**: create do account de líder ganha `contact: leadership.contact` (a ficha já existe — vínculo do dedupe liderança/staff).
- **Migration:** `migrate:create` — `campaign_user.contact_id` (FK → contacts, `ON DELETE set null`) + join `leadership_rels.campaign_user_id` (FK → campaign_users, cascade — a tabela join padrão do Payload para o hasMany). **Sem backfill** (intenção corta varredura por heurística).
- **Access / Consent:** nenhuma chave nova; fail-closed herdado (`AMBIGUOUS`, conflito de telefone). Locks de telefone nos padrões existentes.
- **UI:** Impeccable A (sem superfície nova) — a seção de assessores usa o shell/layout da ficha de liderança existente e o padrão B156 de células.
### Dados → forma (se aplicável)
N/A — C99 não apresenta dados; habilita o dedupe da C100 (um vínculo, não um número). A coluna "Assessorado" da C100 lê `leadership.advisors` / `stateDeputy.advisors`.
## Fases verificáveis
1. **Tracer / schema+server** — migration aditiva (campo em `campaignUser` + `leadership.advisors`), utility `contactIdentity`, hook `ensureCampaignUserContactIdentity`, hook/access de assessores compartilhados, action `setLeadershipAdvisorMembership`, `contact` no resgate de convite, `contact` no strip de self-service. Gates: `generate:types`, `tsc`, unit/int novos.
2. **UI** — célula `LeadershipAdvisorRelationCell` + seção na ficha de liderança + view-model com advisors + form action. Gates: `lint`, int de página/access.
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`. Testes novos: `contactIdentity` (int: cria/vincula por telefone, ambíguo, sync de identidade, e-mail stub não vai para a ficha, contato explícito respeitado, duas contas → mesma ficha sem conflito), `campaignLeadershipAdvisorMembership.int.spec.ts` (espelho do spec de dobradinha), extensão de `campaignAdvisorManagement.int.spec.ts` (vínculo no create) e do spec de access (criação/update do campo).
## Rabbit holes / Não escopo (engenharia)
- **Sync de username** (celular de login) para a ficha — username é credencial, não identidade.
- **Dedupe por e-mail** — `Contact.email` não é único e o precedente de dedupe é telefone.
- **Vincular staff antigo em varredura (backfill)** — intenção corta; só edição/criação por documento.
- **Relink/merge de fichas duplicadas** — pós-eleição (intenção).
- **Tocar `stateDeputy`/`supporter`** — já vinculados; `supporter` só ganha o import do utility extraído.
## Riscos e mitigação
- **Hook roda em toda escrita de `campaignUser`** (ex. troca de senha, avatar): sync só dispara quando identidade muda e há vínculo; avatar/senha/advisors não tocam a ficha. Login não passa por hooks (sessão via adapter, precedente WebAuthn).
- **Contato da ficha é `state` obrigatório**: cria com `'BA'` + `city: null` (precedente dobradinha); a ficha de staff nasce com telefone opcional, como hoje.
- **Dois `campaignUser` com o mesmo telefone** (permitido hoje): passam a compartilhar a mesma ficha — sem erro, e é o dedupe desejado da C100 (uma pessoa, duas contas).
- **Fixtures de teste criam `campaignUser` → hook cria `Contact`**: a purge de fixtures já varre `contact` por runID (nome com marcador) — verificado em `campaignFixtures.ts`; rodar suíte int completa para confirmar nenhum count de contact quebrou.
- **E-mail placeholder (`@planilha.invalid`/`@criado.invalid`) na ficha**: filtrado pelo predicate `isPlanilhaPlaceholderEmail` (só e-mails reais vão para a ficha).
- **`removePrivateAuthFields`** (afterRead): não remove `contact` — vínculo não é credencial; exposição do ID da ficha é inócua (a ficha em si é access-gated), e o field access read own/admin limita a superfície.
- **Recursão de hook**: o vínculo é injetado no `data` em `beforeChange` (sem segunda escrita); o sync de update escreve na ficha, que tem hooks próprios mas não chama hooks de `campaignUser` — sem loop.
- **Locks de telefone exigem transação ativa**: `beforeChange` de operação roda dentro da transação da operação (precedente `enforceUniqueContactPhone` em produção); `skipContactPhoneInvariant` do utility herda o fail-closed existente.
## Decisões de engenharia (formato obrigatório)
- **Mecanismo do vínculo:** Opções: hook `beforeChange` único | N ações + admin manual | hook só create. Recomendação: hook único — um dono da invariante, cobre admin e os 3 fluxos. Rejeitadas: N ações (invariante espalhada, admin fica de fora); só-create (ficha fica obsoleta na edição, contradiz "criação/edição").
- **Unicidade do vínculo:** Opções: `unique: true` no campo | sem unique (inverso pode ter N contas por ficha). Recomendação: sem unique — a mesma pessoa física com duas contas (ex. liderança + assessora) precisa do dedupe pela MESMA ficha; o "1:1" da intenção é a direção conta→ficha, que o campo único de relationship já garante. Rejeitado: unique (bloquearia o cenário real de papéis múltiplos com erro de duplicate key opaco).
- **Sync de identidade em update:** Opções: sync 1-via (conta → ficha) quando mudou | sem sync (ficha é só do admin). Recomendação: sync — `/campanha/assessores` é onde a coordenação edita o staff; sem ele a C100 leria telefone obsoleto. Falha em conflito = fail-closed com a mensagem padrão da ficha. Rejeitado: sem sync (ficha obsoleta, contradiz o fluxo "edição" do aceite).
- **Assessores da liderança — acesso/validação:** Opções: renomear+extrair `canAssign*/canManage*` e a validação para módulo compartilhado | copiar nomes de dobradinha na liderança. Recomendação: extrair (3ª cópia da validação, 3º consumidor do acesso — DRY ≥3 call sites; nomes honestos). Municípios mantém o próprio (semântica de carteira).
- **Cap de assessores da liderança:** espelhar o cap 10 da dobradinha (`MAX_ADVISORS_PER_LEADERSHIP`) no bounded-delta da action — consistência com o padrão existente.
- **Ficha com e-mail stub:** Opções: copiar `@criado.invalid` para a ficha | filtrar placeholders. Recomendação: filtrar (a ficha é a identidade pública; stub é artefato de conta de login).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (vínculo conta→ficha; assessores responsáveis na liderança; auth intacta; sem backfill)
- [ ] Invariantes AGENTS/engineering-standards (transações + `req.transactionID`; overrideAccess justificado; líderes não leem `estimatedVotes` — não tocado; identificadores em inglês / labels pt-BR)
- [ ] Testes de domínio previstos (int de `contactIdentity` + membership de assessores da liderança + extensão dos specs de advisor/access)
- [ ] Migration aditiva commitada + `generate:types` + importmap se preciso; gates completos (`tsc`, `lint`, `format:check`, `knip`, `check:cycles`, unit+int, build)

View file

@ -18,10 +18,21 @@ import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import {
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
CONTACT_PHONE_CONFLICT_MESSAGE,
} from '@/utilities/contactPhoneInvariant'
const INVALID_FIELD_MESSAGE = 'Campo inválido.'
const advisorSafeMessages = [...ADVISOR_ACTION_SAFE_MESSAGES, INVALID_FIELD_MESSAGE] as const
// The C99 ficha-sync fails the account edit with the ficha's own phone
// messages when the phone belongs to another ficha (or to two).
const advisorSafeMessages = [
...ADVISOR_ACTION_SAFE_MESSAGES,
INVALID_FIELD_MESSAGE,
CONTACT_PHONE_CONFLICT_MESSAGE,
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
] as const
export const updateAdvisorProfileFormAction = async (
_state: CampaignFormActionState,

View file

@ -3,14 +3,20 @@
import { revalidatePath } from 'next/cache'
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
import { updateLeadershipInternal } from '@/app/(campaign)/campanha/actions/leadership'
import {
setLeadershipAdvisorMembership,
updateLeadershipInternal,
} from '@/app/(campaign)/campanha/actions/leadership'
import {
nullableFormText,
optionalFormText,
repeatedRelationshipFormValues,
requiredFormBoolean,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
LEADERSHIP_ADVISORS_CAP_MESSAGE,
LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
leadershipSupportStatuses,
} from '@/lib/schemas/leadership'
@ -21,6 +27,8 @@ import {
const safeMessages = [
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE,
LEADERSHIP_ADVISORS_CAP_MESSAGE,
...leadershipStaffEditSafeMessages,
] as const
@ -51,3 +59,21 @@ export const updateLeadershipInternalFormAction = async (
safeMessages,
genericMessage: 'Não foi possível salvar a ficha. Verifique seu acesso e tente novamente.',
})
/** One chip toggle in the "Assessores responsáveis" section (C99). */
export const setLeadershipAdvisorMembershipFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setLeadershipAdvisorMembership({
leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Assessores atualizados.' }
},
safeMessages,
genericMessage: 'Não foi possível atualizar os assessores. Tente novamente.',
})

View file

@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { LeadershipInviteButtons } from '@/components/campaign/invite/LeadershipInviteButtons'
import { LeadershipAdvisorRelationCell } from '@/components/campaign/leadership/LeadershipAdvisorRelationCell'
import { LeadershipContactSection } from '@/components/campaign/leadership/LeadershipContactSection'
import { LeadershipInternalForm } from '@/components/campaign/leadership/LeadershipInternalForm'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
@ -13,15 +14,21 @@ import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyC
import { Badge } from '@/components/ui/Badge'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { resolvedPortfolioEntriesById } from '@/lib/municipalityPortfolio'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadEligibleAdvisorOptions,
loadMunicipalityOptions,
loadOrganizationOptions,
loadStateDeputyOptions,
} from '@/utilities/campaignRelationOptions'
import { loadLeadershipDetail } from '@/utilities/leadership/leadershipData'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
import { updateLeadershipInternalFormAction } from './formActions'
import { UserCogIcon } from 'lucide-react'
import {
setLeadershipAdvisorMembershipFormAction,
updateLeadershipInternalFormAction,
} from './formActions'
export async function generateMetadata({ params }: LeadershipDetailPageProps) {
const { id } = await params
@ -62,6 +69,11 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP
loadMunicipalityPortfolioIndex(),
])
// C99 — only coordinator/candidate assign leadership advisors; the rest of
// staff reads (same gate as the dobradinha section, B156).
const canEditAdvisors = isCampaignUnrestricted(user)
const advisorOptions = canEditAdvisors ? await loadEligibleAdvisorOptions(payload, user) : []
const municipalityById = resolvedPortfolioEntriesById(municipalityIndex)
const municipalitySlugs = leadership.municipalityIDs
.map((id) => municipalityById.get(id)?.slug)
@ -91,6 +103,39 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP
<StateDeputyChips deputies={leadership.stateDeputies} />
) : null}
<section
aria-labelledby="leadership-advisors-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex items-center gap-2">
<UserCogIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<h2 id="leadership-advisors-title" className="text-base font-medium">
Assessores responsáveis
</h2>
<Badge variant="outline">{leadership.advisors.length}</Badge>
</div>
<LeadershipAdvisorRelationCell
leadershipId={leadership.id}
leadershipName={leadership.name}
advisors={leadership.advisors}
options={
canEditAdvisors
? advisorOptions.map((option) => ({
id: option.id,
searchLabel: option.name,
item: {
id: option.id,
label: option.name,
href: `/campanha/assessores/${option.id}`,
},
}))
: []
}
membershipAction={setLeadershipAdvisorMembershipFormAction}
readOnly={!canEditAdvisors}
/>
</section>
<section
aria-labelledby="leadership-invites-title"
className="flex flex-col gap-3 rounded-xl border p-4"

View file

@ -3,7 +3,6 @@
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { upsertContactByPhone } from '@/app/(campaign)/campanha/actions/supporter'
import { SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys'
import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData'
import { sanitizeBrazilianPhoneInput } from '@/lib/phone'
@ -18,13 +17,10 @@ import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campa
import { requireSupporterRegistrationConsent } from '@/utilities/campaignConsent'
import { runCampaignFormAction } from '@/utilities/campaignFormActionError'
import { LEADER_CONTACTS_HOME } from '@/utilities/campaignPageActor'
import {
acquireContactPhoneLocks,
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
} from '@/utilities/contactPhoneInvariant'
import { findOrCreateContactByPhone } from '@/utilities/contactIdentity'
import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { POSTGRES_DEDUP_LOCK_MESSAGE } from '@/utilities/postgresTransactionLocks'
import { isUniqueSupporterConflict } from '@/utilities/supporter/supporterErrors'
export type LeaderSupporterFormState = {
@ -92,12 +88,7 @@ export const createLeaderSupporterRecord = async (
const registrationConsent = await requireSupporterRegistrationConsent(payload, req)
if (payload.db.name !== 'postgres') {
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
}
await acquireContactPhoneLocks(payload, req, [data.phone])
const { contactID, reused } = await upsertContactByPhone({
const { contactID, reused } = await findOrCreateContactByPhone({
payload,
req,
phone: data.phone,

View file

@ -3,14 +3,17 @@
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { nextLeadershipAdvisorIdsAfterMembership } from '@/lib/leadershipAdvisorMembership'
import { nextMunicipalityIdsAfterLeadershipMembership } from '@/lib/leadershipMunicipalityMembership'
import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import {
LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE,
LEADERSHIP_DUPLICATE_MESSAGE,
LEADERSHIP_INVALID_CONTACT_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
LEADERSHIP_STAFF_MESSAGE,
leadershipAdvisorMembershipSchema,
leadershipContactUpdateSchema,
leadershipCreateSchema,
leadershipInternalUpdateSchema,
@ -19,6 +22,7 @@ import {
leadershipWizardCreateSchema,
leadershipWizardUpdateSchema,
municipalityLeadershipCreateSchema,
type LeadershipAdvisorMembershipInput,
type LeadershipContactUpdateInput,
type LeadershipInternalUpdateInput,
type LeadershipMunicipalitiesMembershipInput,
@ -29,12 +33,13 @@ import {
} from '@/lib/schemas/leadership'
import type { CampaignUser, Contact } from '@/payload-types'
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import {
acquireContactPhoneLocks,
assertContactPhoneWritable,
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
} from '@/utilities/contactPhoneInvariant'
getCampaignActionContext,
reloadStaffActor,
reloadUnrestrictedActor,
} from '@/utilities/campaignActionContext'
import { findOrCreateContactByPhone } from '@/utilities/contactIdentity'
import { assertContactPhoneWritable } from '@/utilities/contactPhoneInvariant'
import { loadMunicipalityLeadershipSummaries } from '@/utilities/municipality/municipalityViewModels'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
@ -92,60 +97,34 @@ const createValidatedLeadershipRecord = async (
async ({ req }) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
await assertMunicipalitiesWithinScope(payload, currentActor, data.municipalities, req)
let contactID: number | undefined
if (data.phone) {
if (payload.db.name !== 'postgres') {
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
}
await acquireContactPhoneLocks(payload, req, [data.phone])
// Intentional admin bypass: staff scope was freshly checked; these internal reads and
// writes atomically maintain the normalized Contact ↔ Leadership join.
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: data.phone } },
let city: string | null = null
if (data.municipalities.length === 1) {
const municipality = await payload.findByID({
collection: 'municipality',
id: data.municipalities[0]!,
depth: 0,
limit: 2,
pagination: false,
select: { city: true },
// Intentional admin bypass: staff scope was freshly checked; this
// read only derives the ficha's default city from the município.
overrideAccess: true,
req,
})
if (contacts.totalDocs > 1) {
throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
}
contactID = contacts.docs[0]?.id
city = municipality.city
}
const contactReused = Boolean(contactID)
if (!contactID) {
let city: string | null = null
if (data.municipalities.length === 1) {
const municipality = await payload.findByID({
collection: 'municipality',
id: data.municipalities[0]!,
depth: 0,
select: { city: true },
overrideAccess: true,
req,
})
city = municipality.city
}
const contact = await payload.create({
collection: 'contact',
data: {
name: data.name,
phone: data.phone ?? null,
email: data.email,
gender: data.gender,
state: 'BA' as Contact['state'],
city,
},
depth: 0,
overrideAccess: true,
req,
})
contactID = contact.id
}
// Intentional admin bypass: staff scope was freshly checked; the
// find-or-create atomically maintains the normalized Contact ↔
// Leadership join (same policy as every phone-backed ficha write).
const { contactID, reused } = await findOrCreateContactByPhone({
payload,
req,
phone: data.phone ?? null,
name: data.name,
email: data.email,
city: city ?? undefined,
gender: data.gender,
})
const leadership = await payload.create({
collection: 'leadership',
@ -163,7 +142,7 @@ const createValidatedLeadershipRecord = async (
overrideAccess: true,
req,
})
return { ...leadership, contactReused }
return { ...leadership, contactReused: reused }
},
{ beginFailureMessage: 'Não foi possível iniciar a transação de cadastro da liderança.' },
)
@ -755,3 +734,77 @@ export const setLeadershipMunicipalitiesMembership = async (
}
return leadership
}
/**
* C99 — one chip toggle on `Leadership.advisors` ("Assessores responsáveis").
* Unrestricted staff only (coordinator/candidate), mirror of
* `setStateDeputyAdvisorMembershipRecord`: the read and write run under
* `overrideAccess` because the `advisors` field's update access is admin-only
* in the collection config; the fresh unrestricted actor exists solely to make
* the gate assertion, and the `beforeValidate` eligibility hook still runs
* under the bypass.
*/
export const setLeadershipAdvisorMembershipRecord = async (
payload: Payload,
actor: CampaignUser,
input: LeadershipAdvisorMembershipInput,
) => {
const { leadershipId, advisorId, assigned } = leadershipAdvisorMembershipSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
await reloadUnrestrictedActor(payload, actor, LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE, req)
await acquireTextAdvisoryLocks(payload, req, [`leadership-advisors:${leadershipId}`])
const leadership = await payload.findByID({
collection: 'leadership',
id: leadershipId,
depth: 0,
select: { advisors: true },
// Intentional admin bypass: the unrestricted role was verified above;
// the `advisors` field's update access is admin-only in the collection
// config (same contract as the dobradinha twin). The `beforeValidate`
// eligibility hook still runs under the bypass.
overrideAccess: true,
req,
})
const currentAdvisorIDs = uniqueRelationshipIds(leadership.advisors)
const nextAdvisorIDs = nextLeadershipAdvisorIdsAfterMembership(
currentAdvisorIDs,
advisorId,
assigned,
)
// No-op: nothing to write, and nothing for the caller to revalidate.
if (nextAdvisorIDs === null) {
return { leadershipID: undefined }
}
await payload.update({
collection: 'leadership',
id: leadershipId,
data: { advisors: nextAdvisorIDs },
depth: 0,
// Same intentional bypass as the read: the unrestricted role was
// verified above, and the eligibility hook still runs under it.
overrideAccess: true,
req,
})
return { leadershipID: leadershipId }
},
{ beginFailureMessage: 'Não foi possível atualizar os assessores da liderança.' },
)
}
export const setLeadershipAdvisorMembership = async (input: LeadershipAdvisorMembershipInput) => {
const { payload, actor } = await getCampaignActionContext()
const { leadershipID } = await setLeadershipAdvisorMembershipRecord(payload, actor, input)
// The detail page is the only surface of this relation (C99) — same targeted
// revalidate contract as the dobradinha twin.
if (leadershipID) revalidatePath(`/campanha/liderancas/${leadershipID}`, 'page')
return leadershipID
}

View file

@ -11,20 +11,17 @@ import {
supporterRemoveSchema,
supporterVoteIntentionSchema,
} from '@/lib/schemas/supporter'
import type { CampaignUser, Contact } from '@/payload-types'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import {
requireSupporterRegistrationConsent,
requireSupporterVoteIntentionConsent,
type ConsentDescriptor,
} from '@/utilities/campaignConsent'
import {
acquireContactPhoneLocks,
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
} from '@/utilities/contactPhoneInvariant'
import { findOrCreateContactByPhone } from '@/utilities/contactIdentity'
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { POSTGRES_DEDUP_LOCK_MESSAGE } from '@/utilities/postgresTransactionLocks'
import { isUniqueSupporterConflict } from '@/utilities/supporter/supporterErrors'
import {
@ -70,61 +67,6 @@ const assertCanManageSupporter = async (
req,
})
export const upsertContactByPhone = async ({
payload,
req,
phone,
name,
email,
city,
}: {
payload: Payload
req: PayloadTransactionRequest
phone: string
name: string
email?: string
city?: string
}): Promise<{ contactID: number; reused: boolean }> => {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
depth: 0,
limit: 2,
pagination: false,
overrideAccess: true,
req,
})
if (contacts.totalDocs > 1) {
throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
}
const existing = contacts.docs[0]
if (existing) {
return { contactID: existing.id, reused: true }
}
const contact = await payload.create({
collection: 'contact',
data: {
name,
phone,
email,
state: 'BA' as Contact['state'],
city,
},
depth: 0,
overrideAccess: true,
// Callers of upsertContactByPhone have already acquired the phone advisory
// lock in the same transaction, so the Contact phone-invariant hook can
// skip its redundant lock+availability check.
context: { skipContactPhoneInvariant: true },
req,
})
return { contactID: contact.id, reused: false }
}
const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, input: unknown) => {
const data = supporterCreateSchema.parse(input)
@ -147,12 +89,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i
voteIntentionConsent = await requireSupporterVoteIntentionConsent(payload, req)
}
if (payload.db.name !== 'postgres') {
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
}
await acquireContactPhoneLocks(payload, req, [data.phone])
const { contactID, reused } = await upsertContactByPhone({
const { contactID, reused } = await findOrCreateContactByPhone({
payload,
req,
phone: data.phone,

View file

@ -1,5 +1,7 @@
import { CAMPAIGN_SESSION_TTL_LONG } from '@/lib/campaignSessionTtl'
import { normalizeBrazilianPhone } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
import type { CampaignUser as CampaignUserDocument } from '@/payload-types'
import {
canCreateCampaignUserPhone,
@ -8,17 +10,21 @@ import {
canReadCampaignUserIdentity,
canReadCampaignUserPhone,
canReadCampaignUsers,
canSetCampaignSystemField,
canUpdateCampaignUser,
canUpdateCampaignUserAvatar,
canUpdateCampaignUserPhone,
} from '@/utilities/campaignAccess'
import { buildCampaignPasswordResetUrl } from '@/utilities/campaignPasswordReset'
import { findOrCreateContactByPhone } from '@/utilities/contactIdentity'
import {
APIError,
type CollectionAfterReadHook,
type CollectionBeforeChangeHook,
type CollectionBeforeDeleteHook,
type CollectionConfig,
type Payload,
type PayloadRequest,
} from 'payload'
const removePrivateAuthFields: CollectionAfterReadHook<CampaignUserDocument> = async ({
@ -58,21 +64,43 @@ const preventAssignedAdvisorDowngrade: CollectionBeforeChangeHook<CampaignUserDo
return data
}
const assignedMunicipality = await req.payload.find({
collection: 'municipality',
where: {
advisors: { contains: originalDoc.id },
},
depth: 0,
limit: 1,
select: { name: true },
overrideAccess: true,
req,
})
// 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 (assignedMunicipality.totalDocs > 0) {
if (assignments.some((result) => result.totalDocs > 0)) {
throw new APIError(
'Remova ou substitua este usuário da assessoria de todos os municípios antes de alterar o papel para liderança.',
'Remova ou substitua este usuário da assessoria de municípios, dobradinhas ou lideranças antes de alterar o papel para liderança.',
409,
)
}
@ -89,12 +117,138 @@ const preventSelfServicePrivilegedFields: CollectionBeforeChangeHook<
if (String(req.user.id) !== String(originalDoc.id)) return data
for (const field of ['role', 'name', 'email', 'username', 'phone'] as const) {
for (const field of ['role', 'name', 'email', 'username', 'phone', 'contact'] as const) {
if (field in data) delete data[field]
}
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,
phone: typeof phone === 'string' && phone.length > 0 ? phone : null,
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 conflict fails
* the operation fail-closed with the ficha's standard message.
*
* 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; phone?: 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
// phone is the person's dedupe key across every join (supporter,
// leadership, dobradinha), and the account edit is not a ficha edit.
if (
typeof data.phone === 'string' &&
data.phone.length > 0 &&
data.phone !== originalDoc?.phone
) {
contactData.phone = data.phone
}
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 phone uniqueness is enforced by the Contact's own hook.
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
@ -183,11 +337,27 @@ export const CampaignUser: CollectionConfig = {
delete: canManageCampaignUsers,
},
hooks: {
beforeChange: [preventAssignedAdvisorDowngrade, preventSelfServicePrivilegedFields],
beforeChange: [
preventAssignedAdvisorDowngrade,
preventSelfServicePrivilegedFields,
ensureCampaignUserContactIdentity,
],
beforeDelete: [deleteCampaignUserPasskeys, deleteCampaignUserNotifications],
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',

View file

@ -3,14 +3,18 @@ import { APIError } from 'payload'
import { relationshipId } from '@/lib/relationship'
import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership'
import { validateEligibleCampaignStaffAdvisors } from '@/utilities/access/campaignStaffAdvisors'
import {
canAssignCampaignStaffAdvisors,
canCreateLeadership,
canDeleteLeadership,
canManageCampaignStaffAdvisors,
canManageCampaignStaffField,
canManageLeadership,
canReadCampaignStaffField,
canReadLeadership,
canSetAdministrativeLeadershipField,
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields'
@ -59,7 +63,7 @@ export const Leadership: CollectionConfig = {
delete: canDeleteLeadership,
},
hooks: {
beforeValidate: [requireAtLeastOneMunicipality],
beforeValidate: [requireAtLeastOneMunicipality, validateEligibleCampaignStaffAdvisors],
beforeChange: [stampCampaignCreatedBy],
},
fields: [
@ -101,6 +105,19 @@ export const Leadership: CollectionConfig = {
hasMany: true,
index: true,
},
{
name: 'advisors',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Assessores responsáveis',
hasMany: true,
index: true,
access: {
create: canAssignCampaignStaffAdvisors,
update: canManageCampaignStaffAdvisors,
},
filterOptions: eligibleCampaignStaffWhere,
},
{
name: 'exclusive',
type: 'checkbox',

View file

@ -10,13 +10,14 @@ import {
engagementLevels,
formatEngagementLevelLabel,
} from '@/lib/engagementLevel'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { relationshipId } from '@/lib/relationship'
import { politicalTrendStatuses } from '@/lib/schemas/municipality'
import {
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
type VoteEstimateScenarioFields,
} from '@/lib/voteEstimate'
import { validateEligibleCampaignStaffAdvisors } from '@/utilities/access/campaignStaffAdvisors'
import {
canAssignMunicipalityAdvisors,
canCreateMunicipality,
@ -84,34 +85,6 @@ const validateExpectedVotes: CollectionBeforeValidateHook = ({ data, originalDoc
return data
}
const validateMunicipalityAdvisors: CollectionBeforeValidateHook = async ({ data, req }) => {
if (!data || data.advisors === undefined) return data
const advisorIDs = Array.isArray(data.advisors) ? uniqueRelationshipIds(data.advisors) : []
if (advisorIDs.length === 0) return data
const eligibleAdvisors = await req.payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
where: {
and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere],
},
select: { name: true },
overrideAccess: true,
req,
})
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
throw new APIError(
'Cada assessor deve ter papel de Coordenador Geral, Assessor ou Candidato.',
400,
)
}
return data
}
/**
* E14 — `levelChangedAt` is the clock the movement rules read (protection
* window, one movement per month), so it is derived here rather than trusted
@ -171,7 +144,7 @@ export const Municipality: CollectionConfig = {
delete: canDeleteMunicipality,
},
hooks: {
beforeValidate: [validateMunicipalityAdvisors, validateExpectedVotes],
beforeValidate: [validateEligibleCampaignStaffAdvisors, validateExpectedVotes],
beforeChange: [derivePoliticalTrendAudit, deriveEngagementLevelAudit],
},
fields: [

View file

@ -1,16 +1,17 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { relationshipId } from '@/lib/relationship'
import { STATE_DEPUTY_NAME_REQUIRED_MESSAGE } from '@/lib/schemas/stateDeputy'
import { slugify } from '@/lib/slug'
import { trimmedText } from '@/lib/text'
import { validateEligibleCampaignStaffAdvisors } from '@/utilities/access/campaignStaffAdvisors'
import {
canAssignStateDeputyAdvisors,
canAssignCampaignStaffAdvisors,
canCreateStateDeputy,
canDeleteStateDeputy,
canManageCampaignStaffAdvisors,
canManageStateDeputy,
canManageStateDeputyAdvisors,
canReadStateDeputy,
canSetCampaignSystemField,
eligibleCampaignStaffWhere,
@ -75,37 +76,6 @@ const setCanonicalStateDeputySlug: CollectionBeforeValidateHook = async ({
})
}
const validateStateDeputyAdvisors: CollectionBeforeValidateHook = async ({ data, req }) => {
if (!data || data.advisors === undefined) return data
const advisorIDs = Array.isArray(data.advisors) ? uniqueRelationshipIds(data.advisors) : []
if (advisorIDs.length === 0) return data
const eligibleAdvisors = await req.payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
where: {
and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere],
},
select: { name: true },
// Intentional admin bypass: eligibility is a pure role check that must run
// for every caller (including /admin), independent of the actor's read
// scope over `campaignUser` — mirrors `validateMunicipalityAdvisors`.
overrideAccess: true,
req,
})
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
throw new APIError(
'Cada assessor deve ter papel de Coordenador Geral, Assessor ou Candidato.',
400,
)
}
return data
}
export const StateDeputy: CollectionConfig = {
slug: 'stateDeputy',
labels: {
@ -126,7 +96,7 @@ export const StateDeputy: CollectionConfig = {
delete: canDeleteStateDeputy,
},
hooks: {
beforeValidate: [setCanonicalStateDeputySlug, validateStateDeputyAdvisors],
beforeValidate: [setCanonicalStateDeputySlug, validateEligibleCampaignStaffAdvisors],
beforeChange: [stampCampaignCreatedBy],
},
fields: [
@ -178,8 +148,8 @@ export const StateDeputy: CollectionConfig = {
hasMany: true,
index: true,
access: {
create: canAssignStateDeputyAdvisors,
update: canManageStateDeputyAdvisors,
create: canAssignCampaignStaffAdvisors,
update: canManageCampaignStaffAdvisors,
},
filterOptions: eligibleCampaignStaffWhere,
},

View file

@ -0,0 +1,96 @@
'use client'
import { useCallback } from 'react'
import type { RelationChipCellCopy } from '@/components/campaign/shared/RelationChipCell'
import {
RelationOptionCell,
type RelationCellOption,
} from '@/components/campaign/shared/RelationOptionCell'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
type LeadershipAdvisorRelationCellProps = {
leadershipId: number
leadershipName: string
/** Assigned advisors, names resolved server-side (C99). */
advisors: Array<{ id: number; name: string }>
/** The addable staff catalog (`loadEligibleAdvisorOptions`). Empty when read-only. */
options: RelationCellOption[]
membershipAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
/**
* Read-only for staff who may see but not assign (C99): linked chips lose
* their href — `/campanha/assessores/[id]` is unrestricted-only, so a link
* would send the read-only viewer into a redirect dead-end (B156 rule).
*/
readOnly?: boolean
}
type LeadershipAdvisorCopy = RelationChipCellCopy & {
drawerTitle: string
updateErrorMessage: string
}
const ADVISOR_COPY: LeadershipAdvisorCopy = {
drawerTitle: 'Assessores responsáveis da liderança',
searchPlaceholder: 'Buscar assessor…',
searchLabel: 'Buscar assessor',
suggestionsLabel: 'Sugestões de assessores',
emptyDrawerMessage: 'Nenhum assessor vinculado.',
savingMessage: 'Salvando assessores…',
savedMessage: 'Assessores salvos.',
removedMessage: (count: number) =>
count === 1 ? 'Assessor removido.' : `${count} assessores removidos.`,
updateErrorMessage: 'Não foi possível atualizar os assessores. Tente novamente.',
}
/**
* `Leadership.advisors` cell (C99) — the third thin wrapper over the shared
* `RelationOptionCell` (same shape as `StateDeputyAdvisorRelationCell`, B156):
* chips link to `/campanha/assessores/[id]` (unless read-only), search resolves
* the eligible staff catalog, and the write sends
* `leadershipId`/`advisorId`/`assigned` through the unrestricted-only
* membership action. Serves the "Assessores responsáveis" section of the
* leadership detail page.
*/
export const LeadershipAdvisorRelationCell = ({
leadershipId,
leadershipName,
advisors,
options,
membershipAction,
readOnly = false,
}: LeadershipAdvisorRelationCellProps) => {
const buildFormData = useCallback(
(changedIds: number[], assigned: boolean) => {
const formData = new FormData()
formData.set('leadershipId', String(leadershipId))
formData.set('advisorId', String(changedIds[0]))
formData.set('assigned', assigned ? 'true' : 'false')
return formData
},
[leadershipId],
)
return (
<RelationOptionCell
ownerId={leadershipId}
ownerName={leadershipName}
items={advisors.map((advisor) => ({
id: advisor.id,
label: advisor.name,
...(readOnly ? {} : { href: `/campanha/assessores/${advisor.id}` }),
}))}
options={options}
buildFormData={buildFormData}
commitAction={membershipAction}
copy={ADVISOR_COPY}
drawerTitle={ADVISOR_COPY.drawerTitle}
triggerLabel={`Editar assessores de ${leadershipName}`}
updateErrorMessage={ADVISOR_COPY.updateErrorMessage}
readOnly={readOnly}
/>
)
}

View file

@ -0,0 +1,19 @@
import { nextIdsAfterMembership } from '@/lib/relationMembershipDelta'
import { LEADERSHIP_ADVISORS_CAP_MESSAGE, MAX_LEADERSHIP_ADVISORS } from '@/lib/schemas/leadership'
/**
* Returns the next advisor-id list after applying ONE membership change on
* `Leadership.advisors`, or `null` when the leadership is already in the
* desired state (no write needed) — the same bounded-delta contract as
* `nextStateDeputyAdvisorIdsAfterMembership` (dobradinha) and the
* municipality/leadership twins.
*/
export const nextLeadershipAdvisorIdsAfterMembership = (
currentAdvisorIDs: readonly number[],
advisorId: number,
assigned: boolean,
): number[] | null =>
nextIdsAfterMembership(currentAdvisorIDs, advisorId, assigned, {
max: MAX_LEADERSHIP_ADVISORS,
message: LEADERSHIP_ADVISORS_CAP_MESSAGE,
})

View file

@ -26,6 +26,7 @@ export const isSupportStatus = (value: unknown): value is SupportStatus =>
export const MAX_LEADERSHIP_MUNICIPALITIES = 30
const MAX_LEADERSHIP_ORGANIZATIONS = 20
export const MAX_LEADERSHIP_STATE_DEPUTIES = 20
export const MAX_LEADERSHIP_ADVISORS = 10
export const LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE =
'Vincule a liderança a pelo menos um município.'
@ -35,6 +36,26 @@ export const LEADERSHIP_MUNICIPALITY_CAP_MESSAGE = `Cada liderança aceita no m
/** Thrown by the chip toggle on both sides of the relation (B31/B36), so both routes allowlist it. */
export const LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE = `Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`
/** Thrown by the assessor toggle on the leadership detail (C99), same allowlist contract. */
export const LEADERSHIP_ADVISORS_CAP_MESSAGE = `Cada liderança aceita no máximo ${MAX_LEADERSHIP_ADVISORS} assessores responsáveis.`
export const LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE =
'Somente a coordenação geral ou o candidato gerencia assessores de lideranças.'
/**
* Delta write for the "Assessores responsáveis" section of a liderança (C99) —
* one chip toggle on `Leadership.advisors`, the owning document (no batch:
* the relation lives on the leadership itself, mirror of
* `stateDeputyAdvisorMembershipSchema`).
*/
export const leadershipAdvisorMembershipSchema = z.object({
leadershipId: positiveRelationshipId,
advisorId: positiveRelationshipId,
assigned: z.boolean(),
})
export type LeadershipAdvisorMembershipInput = z.input<typeof leadershipAdvisorMembershipSchema>
/** Thrown by the actions and matched verbatim by every route's `safeMessages`. */
export const LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE =
'Você só pode vincular lideranças aos municípios que assessora.'

View file

@ -0,0 +1,23 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaign_user" ADD COLUMN "contact_id" integer;
ALTER TABLE "leadership_rels" ADD COLUMN "campaign_user_id" integer;
ALTER TABLE "campaign_user" ADD CONSTRAINT "campaign_user_contact_id_contact_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "leadership_rels" ADD CONSTRAINT "leadership_rels_campaign_user_fk" FOREIGN KEY ("campaign_user_id") REFERENCES "public"."campaign_user"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "campaign_user_contact_idx" ON "campaign_user" USING btree ("contact_id");
CREATE INDEX "leadership_rels_campaign_user_id_idx" ON "leadership_rels" USING btree ("campaign_user_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaign_user" DROP CONSTRAINT "campaign_user_contact_id_contact_id_fk";
ALTER TABLE "leadership_rels" DROP CONSTRAINT "leadership_rels_campaign_user_fk";
DROP INDEX "campaign_user_contact_idx";
DROP INDEX "leadership_rels_campaign_user_id_idx";
ALTER TABLE "campaign_user" DROP COLUMN "contact_id";
ALTER TABLE "leadership_rels" DROP COLUMN "campaign_user_id";`)
}

View file

@ -45,6 +45,7 @@ import * as migration_20260806_120000_c14_remodel_activity_agenda from './202608
import * as migration_20260807_030000_unify_municipality_update from './20260807_030000_unify_municipality_update';
import * as migration_20260808_134911_add_calendar_feed from './20260808_134911_add_calendar_feed';
import * as migration_20260808_184113_remodel_activity_responsible from './20260808_184113_remodel_activity_responsible';
import * as migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors from './20260809_204728_add_campaign_user_contact_and_leadership_advisors';
export const migrations = [
{
@ -280,6 +281,11 @@ export const migrations = [
{
up: migration_20260808_184113_remodel_activity_responsible.up,
down: migration_20260808_184113_remodel_activity_responsible.down,
name: '20260808_184113_remodel_activity_responsible'
name: '20260808_184113_remodel_activity_responsible',
},
{
up: migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors.up,
down: migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors.down,
name: '20260809_204728_add_campaign_user_contact_and_leadership_advisors'
},
];

View file

@ -265,6 +265,7 @@ export interface User {
*/
export interface CampaignUser {
id: number;
contact?: (number | null) | Contact;
name: string;
avatar?: (number | null) | Media;
role: 'coordinator' | 'advisor' | 'candidate' | 'leader';
@ -289,6 +290,49 @@ export interface CampaignUser {
password?: string | null;
collection: 'campaignUser';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contact".
*/
export interface Contact {
id: number;
name: string;
email?: string | null;
phone?: string | null;
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
state:
| 'AC'
| 'AL'
| 'AM'
| 'AP'
| 'BA'
| 'CE'
| 'DF'
| 'ES'
| 'GO'
| 'MA'
| 'MG'
| 'MS'
| 'MT'
| 'PA'
| 'PB'
| 'PE'
| 'PI'
| 'PR'
| 'RJ'
| 'RN'
| 'RO'
| 'RR'
| 'RS'
| 'SC'
| 'SE'
| 'SP'
| 'TO';
city?: string | null;
postalCode?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
@ -463,49 +507,6 @@ export interface StateDeputy {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contact".
*/
export interface Contact {
id: number;
name: string;
email?: string | null;
phone?: string | null;
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
state:
| 'AC'
| 'AL'
| 'AM'
| 'AP'
| 'BA'
| 'CE'
| 'DF'
| 'ES'
| 'GO'
| 'MA'
| 'MG'
| 'MS'
| 'MT'
| 'PA'
| 'PB'
| 'PE'
| 'PI'
| 'PR'
| 'RJ'
| 'RN'
| 'RO'
| 'RR'
| 'RS'
| 'SC'
| 'SE'
| 'SP'
| 'TO';
city?: string | null;
postalCode?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* Endpoints Web Push por dispositivo. Não armazena dados biométricos.
*
@ -580,6 +581,7 @@ export interface Leadership {
municipalities: (number | Municipality)[];
organizations?: (number | Organization)[] | null;
stateDeputies?: (number | StateDeputy)[] | null;
advisors?: (number | CampaignUser)[] | null;
exclusive?: boolean | null;
supportStatus: 'engajado' | 'a_abordar' | 'em_disputa' | 'negativo';
user?: (number | null) | CampaignUser;
@ -1472,6 +1474,7 @@ export interface UsersSelect<T extends boolean = true> {
* via the `definition` "campaignUser_select".
*/
export interface CampaignUserSelect<T extends boolean = true> {
contact?: T;
name?: T;
avatar?: T;
role?: T;
@ -1627,6 +1630,7 @@ export interface LeadershipSelect<T extends boolean = true> {
municipalities?: T;
organizations?: T;
stateDeputies?: T;
advisors?: T;
exclusive?: T;
supportStatus?: T;
user?: T;

View file

@ -0,0 +1,71 @@
// ---------------------------------------------------------------------------
// Campaign staff advisors (staff-to-entity relation: dobradinha, liderança)
// ---------------------------------------------------------------------------
import 'server-only'
import type { CollectionBeforeValidateHook, FieldAccess } from 'payload'
import { APIError } from 'payload'
import { uniqueRelationshipIds } from '@/lib/relationship'
import {
eligibleCampaignStaffWhere,
getFreshCampaignUser,
isCampaignUnrestricted,
isPayloadAdmin,
} from '@/utilities/access/shared'
/**
* Every entity that lists staff advisors (`StateDeputy.advisors`,
* `Leadership.advisors`) validates the same rule: each entry must be an
* eligible staff account (coordinator/advisor/candidate). This is the third
* copy of the hook — extracted so the policy has one owner.
*/
export const validateEligibleCampaignStaffAdvisors: CollectionBeforeValidateHook = async ({
data,
req,
}) => {
if (!data || data.advisors === undefined) return data
const advisorIDs = Array.isArray(data.advisors) ? uniqueRelationshipIds(data.advisors) : []
if (advisorIDs.length === 0) return data
const eligibleAdvisors = await req.payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
where: {
and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere],
},
select: { name: true },
// Intentional admin bypass: eligibility is a pure role check that must run
// for every caller (including /admin), independent of the actor's read
// scope over `campaignUser`.
overrideAccess: true,
req,
})
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
throw new APIError(
'Cada assessor deve ter papel de Coordenador Geral, Assessor ou Candidato.',
400,
)
}
return data
}
/**
* Advisor assignment on an entity is unrestricted staff (coordinator +
* candidate) — the same policy as `Municipality.advisors` (B156) and
* `StateDeputy.advisors`. The write path runs through the server action with
* `overrideAccess` after `reloadUnrestrictedActor`; this guards direct `/admin`
* edits.
*/
export const canAssignCampaignStaffAdvisors: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignUnrestricted(await getFreshCampaignUser(req))
}
export const canManageCampaignStaffAdvisors: FieldAccess = ({ req }) => isPayloadAdmin(req.user)

View file

@ -2,14 +2,9 @@
// State deputies (dobradinhas)
// ---------------------------------------------------------------------------
import type { Access, FieldAccess } from 'payload'
import type { Access } from 'payload'
import {
getFreshCampaignUser,
isCampaignStaff,
isCampaignUnrestricted,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { getFreshCampaignUser, isCampaignStaff, isPayloadAdmin } from '@/utilities/access/shared'
export const canCreateStateDeputy: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
@ -26,17 +21,3 @@ export const canReadStateDeputy: Access = async ({ req }) => {
export const canManageStateDeputy: Access = canReadStateDeputy
export const canDeleteStateDeputy: Access = ({ req }) => isPayloadAdmin(req.user)
/**
* Advisor assignment on a dobradinha is unrestricted staff (coordinator +
* candidate) — the same policy as `Municipality.advisors` (B156). The write
* path runs through the server action with `overrideAccess` after
* `reloadUnrestrictedActor`; this guards direct `/admin` edits.
*/
export const canAssignStateDeputyAdvisors: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignUnrestricted(await getFreshCampaignUser(req))
}
export const canManageStateDeputyAdvisors: FieldAccess = ({ req }) => isPayloadAdmin(req.user)

View file

@ -122,14 +122,17 @@ export {
} from '@/utilities/access/elections'
export {
canAssignStateDeputyAdvisors,
canCreateStateDeputy,
canDeleteStateDeputy,
canManageStateDeputy,
canManageStateDeputyAdvisors,
canReadStateDeputy,
} from '@/utilities/access/stateDeputies'
export {
canAssignCampaignStaffAdvisors,
canManageCampaignStaffAdvisors,
} from '@/utilities/access/campaignStaffAdvisors'
export {
canDeleteOwnWebAuthnCredentials,
canReadOwnWebAuthnCredentials,

View file

@ -245,6 +245,12 @@ export const redeemCampaignInviteLoginRecord = async (
username: data.phone,
password: data.password,
...(data.email !== undefined ? { email: data.email } : {}),
// C99 — the account points at the ficha that anchors the leadership:
// one person, one ficha, even when the same person also holds a staff
// account. Passed explicitly so the identity hook respects this choice
// instead of syncing the account's phone into whatever ficha it had
// (which would collide with the ficha this flow just updated).
contact: originalContact.id,
}
const hadAccount = Boolean(account)
account = account

View file

@ -0,0 +1,92 @@
import 'server-only'
import type { Payload, PayloadRequest } from 'payload'
import type { Contact } from '@/payload-types'
import {
acquireContactPhoneLocks,
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
} from '@/utilities/contactPhoneInvariant'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
type ContactIdentityRequest = PayloadTransactionRequest | PayloadRequest
/**
* Find-or-create the normalized `Contact` ficha for a person, inside the
* caller's transaction. With a phone, the advisory lock is acquired HERE and
* an existing ficha with that phone is reused (ambiguous phone fails
* closed) — the same critical section the supporter flows used to build
* around `upsertContactByPhone`. Without a phone, a fresh name-only ficha is
* created. Every "cria ou vincula a ficha" path shares one owner for the
* "BA default + ambiguous-phone fail-closed" policy.
*
* Callers must already be inside an active Payload transaction (the advisory
* lock requires it) and authorized to own the write (staff creation flows run
* with the operation already gated; the intentional admin bypass mirrors
* `createStateDeputyWithContact`).
*/
export const findOrCreateContactByPhone = async ({
payload,
req,
phone,
name,
email,
city,
gender,
}: {
payload: Payload
req: ContactIdentityRequest
phone: string | null
name: string
email?: string | null
city?: string | null
gender?: Contact['gender'] | null
}): Promise<{ contactID: number; reused: boolean }> => {
if (phone) {
await acquireContactPhoneLocks(payload, req, [phone])
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
depth: 0,
limit: 2,
pagination: false,
// Intentional admin bypass: the phone invariant must see every ficha,
// including ones outside the actor's campaign scope.
overrideAccess: true,
req,
})
if (contacts.totalDocs > 1) {
throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
}
const existing = contacts.docs[0]
if (existing) {
return { contactID: existing.id, reused: true }
}
}
const contact = await payload.create({
collection: 'contact',
data: {
name,
phone,
email: email ?? null,
state: 'BA' as Contact['state'],
city: city ?? null,
gender,
},
depth: 0,
overrideAccess: true,
// With a phone, the advisory lock is held for this transaction and the
// find above already proved the phone is free — the Contact
// phone-invariant hook can skip its redundant lock+availability check
// (same contract as the C6 bulk-import path, fail-closed on a missing
// transaction). Without a phone there is no uniqueness to enforce.
...(phone ? { context: { skipContactPhoneInvariant: true as const } } : {}),
req,
})
return { contactID: contact.id, reused: false }
}

View file

@ -15,7 +15,7 @@ import {
resolveLeadershipListSort,
type LeadershipListState,
} from '@/utilities/leadership/leadershipListUrl'
import { loadOrganizationNamesByIds } from '@/utilities/loadNamesByIds'
import { loadCampaignUserNamesByIds, loadOrganizationNamesByIds } from '@/utilities/loadNamesByIds'
import { loadStateDeputySummaries, type StateDeputySummary } from '@/utilities/stateDeputyData'
export type LeadershipRowViewModel = {
@ -340,6 +340,8 @@ export type LeadershipDetailViewModel = LeadershipRowViewModel & {
municipalityIDs: number[]
organizationIDs: number[]
stateDeputyIDs: number[]
/** Assessores responsáveis (C99) — names resolved for the detail section. */
advisors: Array<{ id: number; name: string }>
notes: string | null
}
@ -363,6 +365,11 @@ export const loadLeadershipDetail = async (
const [row] = await toLeadershipRows(payload, [doc])
if (!row) return null
const advisorIDs = (doc.advisors ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null)
const advisorNames = await loadCampaignUserNamesByIds(payload, advisorIDs)
return {
...row,
municipalityIDs: (doc.municipalities ?? [])
@ -372,6 +379,10 @@ export const loadLeadershipDetail = async (
.map(relationshipId)
.filter((id): id is number => id !== null),
stateDeputyIDs: row.stateDeputies.map((summary) => summary.id),
advisors: advisorIDs.flatMap((id) => {
const name = advisorNames.get(id)
return name === undefined ? [] : [{ id, name }]
}),
notes: doc.notes ?? null,
}
}

View file

@ -354,7 +354,7 @@ describe('campaign authentication foundation', () => {
overrideAccess: false,
}),
).rejects.toThrow(
'Remova ou substitua este usuário da assessoria de todos os municípios antes de alterar o papel para liderança.',
'Remova ou substitua este usuário da assessoria de municípios, dobradinhas ou lideranças antes de alterar o papel para liderança.',
)
if (admin) {

View file

@ -0,0 +1,443 @@
// @vitest-environment node
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import {
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
CONTACT_PHONE_CONFLICT_MESSAGE,
} from '@/utilities/contactPhoneInvariant'
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const fichaOf = async (accountId: number) =>
payload.findByID({
collection: 'campaignUser',
id: accountId,
depth: 0,
select: { contact: true },
overrideAccess: true,
})
describe('campaignUser → Contact identity (C99)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('creates a BA ficha when the account is created with a phone', async () => {
const fixtures = campaignFixtures()
const user = await fixtures.createCampaignUser('advisor', {
phone: fixtures.phone(),
name: 'Maria Assessora',
})
const account = await fichaOf(user.id)
const contact = await payload.findByID({
collection: 'contact',
id: relationId(account.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact).toMatchObject({
name: 'Maria Assessora',
phone: user.phone,
state: 'BA',
})
})
it('links an existing ficha with the same phone instead of duplicating', async () => {
const fixtures = campaignFixtures()
const existing = await fixtures.createContact({ name: 'Ficha Existente' })
const user = await fixtures.createCampaignUser('advisor', { phone: existing.phone })
const account = await fichaOf(user.id)
expect(relationId(account.contact)).toBe(existing.id)
})
it('lets two accounts share one ficha (one person, two roles)', async () => {
const fixtures = campaignFixtures()
const phone = fixtures.phone()
const first = await fixtures.createCampaignUser('leader', { phone })
const second = await fixtures.createCampaignUser('advisor', { phone })
const firstAccount = await fichaOf(first.id)
const secondAccount = await fichaOf(second.id)
expect(relationId(firstAccount.contact)).toBe(relationId(secondAccount.contact))
expect(relationId(secondAccount.contact)).not.toBeNull()
})
it('creates a name-only ficha when the account has no phone', async () => {
const fixtures = campaignFixtures()
const user = await fixtures.createCampaignUser('advisor', { name: 'Sem Telefone' })
const account = await fichaOf(user.id)
const contact = await payload.findByID({
collection: 'contact',
id: relationId(account.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact).toMatchObject({ name: 'Sem Telefone', phone: null, state: 'BA' })
})
it('never copies placeholder e-mails to the ficha', async () => {
const fixtures = campaignFixtures()
const user = await fixtures.createCampaignUser('advisor', {
phone: fixtures.phone(),
email: `${fixtures.value('assessor')}@criado.invalid`,
})
const account = await fichaOf(user.id)
const contact = await payload.findByID({
collection: 'contact',
id: relationId(account.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.email).toBeNull()
})
it('copies a real e-mail to the ficha', async () => {
const fixtures = campaignFixtures()
const realEmail = `${fixtures.value('maria')}@exemplo.com`
const user = await fixtures.createCampaignUser('advisor', {
phone: fixtures.phone(),
email: realEmail,
})
const account = await fichaOf(user.id)
const contact = await payload.findByID({
collection: 'contact',
id: relationId(account.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.email).toBe(realEmail)
})
it('respects an explicit contact on create', async () => {
const fixtures = campaignFixtures()
const explicit = await fixtures.createContact()
const user = await fixtures.createCampaignUser('advisor', {
phone: fixtures.phone(),
})
await payload.update({
collection: 'campaignUser',
id: user.id,
data: { contact: null },
depth: 0,
overrideAccess: true,
})
const relinked = await payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Usuário explícito'),
email: `${fixtures.value('explicito')}@example.com`,
password: fixtures.value('password'),
role: 'advisor',
contact: explicit.id,
},
depth: 0,
overrideAccess: true,
})
const account = await fichaOf(relinked.id)
expect(relationId(account.contact)).toBe(explicit.id)
})
it('syncs name and phone to the linked ficha on update', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const user = await fixtures.createCampaignUser('advisor', { phone: fixtures.phone() })
const nextPhone = fixtures.phone()
await payload.update({
collection: 'campaignUser',
id: user.id,
data: { name: 'Nome Atualizado', phone: nextPhone },
depth: 0,
user: actor,
overrideAccess: true,
})
const account = await fichaOf(user.id)
const contact = await payload.findByID({
collection: 'contact',
id: relationId(account.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact).toMatchObject({ name: 'Nome Atualizado', phone: nextPhone })
})
it('fails the update fail-closed when the new phone belongs to another ficha', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const user = await fixtures.createCampaignUser('advisor', { phone: fixtures.phone() })
const other = await fixtures.createContact()
await expect(
payload.update({
collection: 'campaignUser',
id: user.id,
data: { phone: other.phone },
depth: 0,
user: actor,
overrideAccess: true,
}),
).rejects.toThrow(CONTACT_PHONE_CONFLICT_MESSAGE)
// The failed update must not have half-applied.
const account = await fichaOf(user.id)
const contact = await payload.findByID({
collection: 'contact',
id: relationId(account.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phone).toBe(user.phone)
})
it('links a previously unlinked account by phone on update', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const unlinked = await payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Usuário sem vínculo'),
email: `${fixtures.value('semvinculo')}@example.com`,
password: fixtures.value('password'),
role: 'advisor',
contact: null,
},
depth: 0,
overrideAccess: true,
})
const phone = fixtures.phone()
const updated = await payload.update({
collection: 'campaignUser',
id: unlinked.id,
data: { phone },
depth: 0,
user: actor,
overrideAccess: true,
})
expect(relationId(updated.contact)).not.toBeNull()
})
it('fails closed on two fichas sharing the phone', async () => {
const fixtures = campaignFixtures()
const phone = fixtures.phone()
// The collection hook forbids duplicates, so the ambiguous state is planted
// directly (the same legacy-data shape `findOrCreateContactByPhone` guards).
await payload.db.drizzle.execute(
sql.raw(`
INSERT INTO "contact" ("name", "email", "phone", "state", "city")
VALUES ('${fixtures.value('Ambíguo A')}', '', '${phone}', 'BA', ''),
('${fixtures.value('Ambíguo B')}', '', '${phone}', 'BA', '')
`),
)
await expect(fixtures.createCampaignUser('advisor', { phone })).rejects.toThrow(
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
)
})
it('lets the admin unlink explicitly without auto-relinking', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const user = await fixtures.createCampaignUser('advisor', { phone: fixtures.phone() })
const unlinked = await payload.update({
collection: 'campaignUser',
id: user.id,
data: { contact: null },
depth: 0,
user: actor,
overrideAccess: true,
})
expect(relationId(unlinked.contact)).toBeUndefined()
})
it('never links or syncs on an unauthenticated update (password-reset guard)', async () => {
const fixtures = campaignFixtures()
const unlinked = await payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Usuário de resgate'),
email: `${fixtures.value('resgate')}@example.com`,
password: fixtures.value('password'),
role: 'leader',
contact: null,
},
depth: 0,
overrideAccess: true,
})
const phone = fixtures.phone()
// `payload.forgotPassword` updates the doc with `overrideAccess` and no
// `req.user` — the identity hook must be inert on that write.
const untouched = await payload.update({
collection: 'campaignUser',
id: unlinked.id,
data: { phone },
depth: 0,
overrideAccess: true,
})
expect(relationId(untouched.contact)).toBeUndefined()
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
expect(contacts.totalDocs).toBe(0)
})
it('clearing the account phone never clears the ficha phone', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const user = await fixtures.createCampaignUser('advisor', { phone: fixtures.phone() })
await payload.update({
collection: 'campaignUser',
id: user.id,
data: { phone: null },
depth: 0,
user: actor,
overrideAccess: true,
})
const account = await fichaOf(user.id)
const contact = await payload.findByID({
collection: 'contact',
id: relationId(account.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phone).toBe(user.phone)
})
it('does not rewrite the ficha on a non-identity update (username)', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const user = await fixtures.createCampaignUser('advisor', { phone: fixtures.phone() })
await payload.update({
collection: 'campaignUser',
id: user.id,
data: { username: fixtures.phone() },
depth: 0,
user: actor,
overrideAccess: true,
})
const account = await fichaOf(user.id)
const contact = await payload.findByID({
collection: 'contact',
id: relationId(account.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact).toMatchObject({ name: user.name, phone: user.phone })
})
it('swaps a placeholder e-mail for the real one on the ficha in update', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const user = await fixtures.createCampaignUser('advisor', {
phone: fixtures.phone(),
email: `${fixtures.value('assessor')}@criado.invalid`,
})
const realEmail = `${fixtures.value('maria')}@exemplo.com`
await payload.update({
collection: 'campaignUser',
id: user.id,
data: { email: realEmail },
depth: 0,
user: actor,
overrideAccess: true,
})
const account = await fichaOf(user.id)
const contact = await payload.findByID({
collection: 'contact',
id: relationId(account.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.email).toBe(realEmail)
})
it('unlink is transient: the next identity edit re-links the same ficha by phone', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const phone = fixtures.phone()
const user = await fixtures.createCampaignUser('advisor', { phone })
await payload.update({
collection: 'campaignUser',
id: user.id,
data: { contact: null },
depth: 0,
user: actor,
overrideAccess: true,
})
const edited = await payload.update({
collection: 'campaignUser',
id: user.id,
data: { name: 'Editado Depois' },
depth: 0,
user: actor,
overrideAccess: true,
})
// The phone dedupe re-anchors the account on the ficha it already owns.
expect(relationId(edited.contact)).toBe(relationId(user.contact))
})
it('respects an explicit relink on update (no sync into the old ficha)', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const user = await fixtures.createCampaignUser('advisor', {
phone: fixtures.phone(),
name: 'Nome da Conta',
})
const target = await fixtures.createContact({ name: 'Ficha de Destino' })
const relinked = await payload.update({
collection: 'campaignUser',
id: user.id,
data: { contact: target.id, name: 'Nome Atualizado' },
depth: 0,
user: actor,
overrideAccess: true,
})
expect(relationId(relinked.contact)).toBe(target.id)
// The old ficha was NOT the sync target of the account rename.
const oldFicha = await payload.findByID({
collection: 'contact',
id: relationId(user.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(oldFicha.name).toBe('Nome da Conta')
})
})

View file

@ -0,0 +1,172 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { setLeadershipAdvisorMembershipRecord } from '@/app/(campaign)/campanha/actions/leadership'
import config from '@/payload.config'
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const advisorIds = (leadership: { advisors?: (number | { id: number })[] | null }): number[] =>
relationIds(leadership.advisors)
describe('setLeadershipAdvisorMembershipRecord (C99)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
const createLeadership = async (
fixtures: ReturnType<typeof campaignFixtures>,
): Promise<{ id: number }> => {
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
return fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
})
}
it.each(['coordinator', 'candidate'] as const)(
'lets a %s assign and remove an advisor by delta',
async (role) => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser(role)
const leadership = await createLeadership(fixtures)
const advisor = await fixtures.createCampaignUser('advisor')
const { leadershipID } = await setLeadershipAdvisorMembershipRecord(payload, actor, {
leadershipId: leadership.id,
advisorId: advisor.id,
assigned: true,
})
expect(leadershipID).toBe(leadership.id)
const assigned = await payload.findByID({
collection: 'leadership',
id: leadership.id,
depth: 0,
select: { advisors: true },
overrideAccess: true,
})
expect(advisorIds(assigned)).toContain(advisor.id)
await setLeadershipAdvisorMembershipRecord(payload, actor, {
leadershipId: leadership.id,
advisorId: advisor.id,
assigned: false,
})
const removed = await payload.findByID({
collection: 'leadership',
id: leadership.id,
depth: 0,
select: { advisors: true },
overrideAccess: true,
})
expect(advisorIds(removed)).not.toContain(advisor.id)
},
)
it('denies a plain advisor actor and a leader actor', async () => {
const fixtures = campaignFixtures()
const advisorActor = await fixtures.createCampaignUser('advisor')
const leaderActor = await fixtures.createCampaignUser('leader')
const leadership = await createLeadership(fixtures)
const target = await fixtures.createCampaignUser('coordinator')
await expect(
setLeadershipAdvisorMembershipRecord(payload, advisorActor, {
leadershipId: leadership.id,
advisorId: target.id,
assigned: true,
}),
).rejects.toThrow(/coordenação geral ou o candidato/i)
await expect(
setLeadershipAdvisorMembershipRecord(payload, leaderActor, {
leadershipId: leadership.id,
advisorId: target.id,
assigned: true,
}),
).rejects.toThrow(/coordenação geral ou o candidato/i)
})
it('rejects a target account that is not eligible staff', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const leadership = await createLeadership(fixtures)
const leaderTarget = await fixtures.createCampaignUser('leader')
await expect(
setLeadershipAdvisorMembershipRecord(payload, coordinator, {
leadershipId: leadership.id,
advisorId: leaderTarget.id,
assigned: true,
}),
).rejects.toThrow(/Coordenador Geral, Assessor ou Candidato/i)
})
it('is idempotent (no-op does not re-write)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const leadership = await createLeadership(fixtures)
const advisor = await fixtures.createCampaignUser('advisor')
await setLeadershipAdvisorMembershipRecord(payload, coordinator, {
leadershipId: leadership.id,
advisorId: advisor.id,
assigned: true,
})
// No-op contract: no id back (the caller then skips the revalidate).
const { leadershipID } = await setLeadershipAdvisorMembershipRecord(payload, coordinator, {
leadershipId: leadership.id,
advisorId: advisor.id,
assigned: true,
})
expect(leadershipID).toBeUndefined()
const after = await payload.findByID({
collection: 'leadership',
id: leadership.id,
depth: 0,
select: { advisors: true },
overrideAccess: true,
})
expect(advisorIds(after)).toEqual([advisor.id])
})
it('rejects once the leadership is at the 10-advisor cap', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const leadership = await createLeadership(fixtures)
for (let index = 0; index < 10; index += 1) {
const advisor = await fixtures.createCampaignUser('advisor')
await setLeadershipAdvisorMembershipRecord(payload, coordinator, {
leadershipId: leadership.id,
advisorId: advisor.id,
assigned: true,
})
}
const eleventh = await fixtures.createCampaignUser('advisor')
await expect(
setLeadershipAdvisorMembershipRecord(payload, coordinator, {
leadershipId: leadership.id,
advisorId: eleventh.id,
assigned: true,
}),
).rejects.toThrow(/no máximo 10 assessores/i)
})
})

View file

@ -427,6 +427,7 @@ describe('src/utilities top-level is pinned', () => {
'campaignRelationOptions.ts',
'campaignUserProfile.ts',
'consentContentHash.ts',
'contactIdentity.ts',
'contactPhoneInvariant.ts',
'dashboardPriorityMunicipalities.ts',
'detailTabUi.ts',
@ -585,12 +586,12 @@ describe('admin bypasses carry a justification comment', () => {
const pinnedUndocumented = new Map<string, number>([
['src/app/(campaign)/campanha/actions/advisor.ts', 2],
['src/app/(campaign)/campanha/actions/leaderSupporter.ts', 1],
['src/app/(campaign)/campanha/actions/leadership.ts', 3],
['src/app/(campaign)/campanha/actions/leadership.ts', 1],
['src/app/(campaign)/campanha/actions/municipality.ts', 1],
['src/app/(campaign)/campanha/actions/password.ts', 2],
['src/app/(campaign)/campanha/actions/profile.ts', 4],
['src/app/(campaign)/campanha/actions/stateDeputy.ts', 0],
['src/app/(campaign)/campanha/actions/supporter.ts', 9],
['src/app/(campaign)/campanha/actions/supporter.ts', 7],
['src/app/(campaign)/campanha/actions/supporterImport.ts', 2],
['src/app/(campaign)/campanha/actions/votePledge.ts', 1],
['src/utilities/activityDetailPageData.ts', 1],
@ -614,10 +615,10 @@ describe('admin bypasses carry a justification comment', () => {
['src/utilities/access/webauthnCredentials.ts', 1],
['src/collections/Activity.ts', 0],
['src/collections/CampaignDemand.ts', 1],
['src/collections/CampaignUser.ts', 4],
['src/collections/CampaignUser.ts', 3],
['src/collections/CampaignWebAuthnCredential.ts', 1],
['src/collections/Media.ts', 1],
['src/collections/Municipality.ts', 1],
['src/collections/Municipality.ts', 0],
['src/collections/MunicipalityUpdate.ts', 3],
['src/collections/Supporter.ts', 1],
['src/collections/SupporterImportBatch.ts', 1],