mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
commit
aa7da74c22
28 changed files with 14775 additions and 292 deletions
102
docs/plans/pessoas-identidade-contact-impl.md
Normal file
102
docs/plans/pessoas-identidade-contact-impl.md
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
19
src/lib/leadershipAdvisorMembership.ts
Normal file
19
src/lib/leadershipAdvisorMembership.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -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.'
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
71
src/utilities/access/campaignStaffAdvisors.ts
Normal file
71
src/utilities/access/campaignStaffAdvisors.ts
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
92
src/utilities/contactIdentity.ts
Normal file
92
src/utilities/contactIdentity.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
443
tests/int/campaignContactIdentity.int.spec.ts
Normal file
443
tests/int/campaignContactIdentity.int.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
172
tests/int/campaignLeadershipAdvisorMembership.int.spec.ts
Normal file
172
tests/int/campaignLeadershipAdvisorMembership.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in a new issue