B156 — assessores por dobradinha (campo, coluna e seção; RelationOptionCell extraído)

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-08-04 04:15:13 -03:00
parent 8e434c14af
commit 6b8864debb
24 changed files with 14086 additions and 185 deletions

View file

@ -1,6 +1,6 @@
# Impl: B156 — Assessores por dobradinha
Status: rascunho
Status: aprovado
Atualizado em: 2026-08-04
Issue: #360
Intenção: docs/plans/assessores-dobradinhas.md
@ -84,9 +84,19 @@ flowchart LR
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: campo, coluna, seção, validação e access conforme travado
- [ ] Invariantes AGENTS/engineering-standards: Local API `user` + `overrideAccess: false` onde o padrão manda (bypass só com comentário, após `reloadUnrestrictedActor`); escrita em transação com lock aditivo; zero warnings; sem twin (extração no mesmo delivery)
- [ ] Testes previstos: int do membership (roles, no-op, cap, ineligible) + unit da célula nova; existentes verdes
- [ ] Migration commitada (`.ts` + `.json` + `index.ts`) e `generate:types` rodado
- [x] Aceite de produto da intenção coberto: campo, coluna, seção, validação e access conforme travado
- [x] Invariantes AGENTS/engineering-standards: Local API `user` + `overrideAccess: false` onde o padrão manda (bypass só com comentário, após `reloadUnrestrictedActor`); escrita em transação com lock aditivo; zero warnings; sem twin (extração no mesmo delivery)
- [x] Testes previstos: int do membership (roles, no-op, cap, ineligible) + unit da célula nova; existentes verdes
- [x] Migration commitada (`.ts` + `.json` + `index.ts`) e `generate:types` rodado
## Execução
- /simplify: 3 revisores read-only, sem BLOCKING. Fixes aplicados: chips sem href em readOnly (rota `/campanha/assessores/[id]` é unrestricted-only — dead-end para staff), gate da description do head por `canEditAdvisors`, `lg:col-span-2` na seção do detalhe, copy "Nenhum assessor vinculado.", `summaryById` Map no loader, `stateDeputySlug === undefined` no teste de no-op, `readOnly` removido da casca de lideranças (especulativo). Débitos deferidos registrados em Issues.
## Adiado com gatilho (débitos do /simplify)
- **Catálogo de staff duplicado** (`loadCampaignUserSummaries`/`loadEligibleAdvisorOptions` em `campaignRelationOptions` vs `loadAdvisorSummaries`/`getEligibleAdvisorOptions` em `municipalityViewModels`). Gatilho: 3º consumidor do catálogo (ex. advisors de atividade na lista) ou próximo toque no domínio de municípios — aí o `campaignRelationOptions` vira dono único e os loaders de municípios delegam.
- **`validateStateDeputyAdvisors` duplica `validateMunicipalityAdvisors`**. Gatilho: 3ª collection com campo `advisors` (ex. `activity`) — extrair `validateEligibleStaffAdvisors` compartilhado (cuidado com o pin de bypass do conventions test ao mover o `overrideAccess`).
- **Célula sem `maxItems`** (cap 10 só no servidor, com flicker+revert em vez de recusa pré-otimista). Gatilho: o toggle de assessores ganhar batch ou o cap virar atrito medido.
Self-score decision-quality: 5/5 — (1) decisões caras (cell, access, cap) com rejeitadas; (2) cabe no appetite (mecânica sobre padrões existentes); (3) rabbit holes nomeados; (4) reusa `RelationChipCell`, `withPayloadTransaction`, `reloadUnrestrictedActor`, `nextIdsAfterMembership`, shells de lista; (5) outcome de produto intacto.

View file

@ -0,0 +1,30 @@
# Reconciliar drift — `allocation_decision.rationale` NOT NULL vs config
Status: rascunho
Atualizado em: 2026-08-04
Issue: #364 (registrada via agent:register)
Origem: débito B156 (triage S5/S14) — descoberto no `migrate:create` de `20260804_061017_add_state_deputy_advisors`
Appetite: ~0,25 dia eng
Model: `composer-2.5`
## Contexto
- `20260724_180000_add_campaign_foundation_records` criou `allocation_decision.rationale` como `varchar NOT NULL`.
- B134 (#288) tornou o motivo **opcional** na config (`AllocationDecision.rationale.required: false`), sem migration reconciliando a coluna.
- Consequência: todo `pnpm migrate:create` volta a emitir `ALTER TABLE allocation_decision ALTER COLUMN rationale DROP NOT NULL` (diff recorrente, fora do escopo da migration em questão — foi removido manualmente da migration do B156).
- Risco de runtime: uma escrita E14 com motivo omitido envia `null` e o Postgres rejeita (coluna `NOT NULL`) — o fluxo "motivo opcional" do B134 pode falhar em prod.
## Fases verificáveis
1. **Migration própria e idempotente** — `pnpm migrate:create reconcile_allocation_decision_rationale_nullable`; validar que o SQL gerado é apenas o `DROP NOT NULL` (guard: só executar quando `is_nullable = 'NO'`, precedente do consent `varchar → jsonb`); aplicar em dev e test.
2. **Verificação do drift** — rodar `pnpm migrate:create <probe>` e confirmar que não re-emite mais o ALTER (ou conferir `migrate:status` + diff); manter o probe descartado.
3. **Teste** — int spec do caminho E14 com movimento sem motivo (ou unit do hook/action que escreve `rationale` nulo) para provar o write aceito; gates completos (`pnpm gate:fast`, `pnpm push`).
## Já resolvido no simplify/critique (não reabrir)
- N/A — débito novo desta triage.
## Explicitamente fora
- Não alterar o fluxo E14 em si (motivo opcional do wizard/lista) — escopo do #288 (B134), do qual este item é a reconciliação de schema.
- Não tocar `snapshot`/`reversalSignals` (vivem só no JSON do snapshot; sem migration).

View file

@ -2,9 +2,19 @@
import { revalidatePath } from 'next/cache'
import { updateStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy'
import { nullableFormText, requiredRelationshipFormValue } from '@/lib/formData'
import { STATE_DEPUTY_STAFF_MESSAGE } from '@/lib/schemas/stateDeputy'
import {
setStateDeputyAdvisorMembership,
updateStateDeputy,
} from '@/app/(campaign)/campanha/actions/stateDeputy'
import {
nullableFormText,
requiredFormBoolean,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
STATE_DEPUTY_ADVISOR_SAFE_MESSAGES,
STATE_DEPUTY_STAFF_MESSAGE,
} from '@/lib/schemas/stateDeputy'
import {
runCampaignFormAction,
type CampaignFormActionState,
@ -27,3 +37,21 @@ export const updateStateDeputyFormAction = async (
safeMessages: [STATE_DEPUTY_STAFF_MESSAGE],
genericMessage: 'Não foi possível salvar a dobradinha. Verifique seu acesso e tente novamente.',
})
/** One chip toggle in the "Assessores responsáveis" section (B156). */
export const setStateDeputyAdvisorMembershipFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setStateDeputyAdvisorMembership({
stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Assessores atualizados.' }
},
safeMessages: STATE_DEPUTY_ADVISOR_SAFE_MESSAGES,
genericMessage: 'Não foi possível atualizar os assessores. Tente novamente.',
})

View file

@ -1,17 +1,23 @@
import config from '@payload-config'
import { HandshakeIcon, MapPinIcon } from 'lucide-react'
import { HandshakeIcon, MapPinIcon, UserCogIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { StateDeputyAdvisorRelationCell } from '@/components/campaign/stateDeputy/StateDeputyAdvisorRelationCell'
import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm'
import { Badge } from '@/components/ui/Badge'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadEligibleAdvisorOptions } from '@/utilities/campaignRelationOptions'
import { loadStateDeputyDetail } from '@/utilities/stateDeputyData'
import { updateStateDeputyFormAction } from './formActions'
import {
setStateDeputyAdvisorMembershipFormAction,
updateStateDeputyFormAction,
} from './formActions'
export async function generateMetadata({ params }: StateDeputyDetailPageProps) {
const { slug } = await params
@ -44,6 +50,10 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai
const stateDeputy = await loadStateDeputyDetail(payload, user, slug)
if (!stateDeputy) notFound()
// B156 — only coordinator/candidate assign advisors; the rest of staff reads.
const canEditAdvisors = isCampaignUnrestricted(user)
const advisorOptions = canEditAdvisors ? await loadEligibleAdvisorOptions(payload, user) : []
return (
<CampaignPageShell>
<SetCampaignPageChrome
@ -119,6 +129,40 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai
</p>
)}
</section>
<section
aria-labelledby="state-deputy-advisors-title"
className="flex flex-col gap-3 rounded-xl border p-4 lg:col-span-2"
>
<div className="flex items-center gap-2">
<UserCogIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<h2 id="state-deputy-advisors-title" className="text-base font-medium">
Assessores responsáveis
</h2>
<Badge variant="outline">{stateDeputy.advisors.length}</Badge>
</div>
<StateDeputyAdvisorRelationCell
stateDeputyId={stateDeputy.id}
stateDeputyName={stateDeputy.name}
advisors={stateDeputy.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={setStateDeputyAdvisorMembershipFormAction}
readOnly={!canEditAdvisors}
measureOverflow={false}
/>
</section>
</div>
<section aria-labelledby="state-deputy-edit-title" className="flex flex-col gap-3">

View file

@ -1,7 +1,10 @@
'use server'
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
import { setStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy'
import {
setStateDeputyAdvisorMembership,
setStateDeputyMunicipalitiesBatch,
} from '@/app/(campaign)/campanha/actions/stateDeputy'
import {
repeatedRelationshipFormValues,
requiredFormBoolean,
@ -11,7 +14,10 @@ import {
LEADERSHIP_STAFF_MESSAGE,
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
} from '@/lib/schemas/leadership'
import { STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES } from '@/lib/schemas/stateDeputy'
import {
STATE_DEPUTY_ADVISOR_SAFE_MESSAGES,
STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
} from '@/lib/schemas/stateDeputy'
import {
runCampaignFormAction,
type CampaignFormActionState,
@ -37,6 +43,24 @@ export const setLeadershipStateDeputyMembershipFormAction = async (
genericMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
})
/** One chip toggle in the "Assessores" column/section of a dobradinha (B156). */
export const setStateDeputyAdvisorMembershipFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setStateDeputyAdvisorMembership({
stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Assessores atualizados.' }
},
safeMessages: STATE_DEPUTY_ADVISOR_SAFE_MESSAGES,
genericMessage: 'Não foi possível atualizar os assessores. Tente novamente.',
})
/**
* Add or remove municipalities in the "Municípios" column of
* `/campanha/dobradinhas` (B37) — one chip or a whole território/ZE. `ownerId`

View file

@ -23,6 +23,7 @@ import {
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { StateDeputyAdvisorRelationCell } from '@/components/campaign/stateDeputy/StateDeputyAdvisorRelationCell'
import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters'
import { StateDeputySortableHead } from '@/components/campaign/stateDeputy/StateDeputySortableHead'
import { Button, buttonVariants } from '@/components/ui/button'
@ -38,10 +39,13 @@ import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import { cn } from '@/lib/utils'
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
import { getAdvisorMunicipalityIds, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions'
import {
loadEligibleAdvisorOptions,
loadLeadershipOptions,
} from '@/utilities/campaignRelationOptions'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
import {
loadStateDeputyListPageData,
@ -63,6 +67,7 @@ import {
import {
setLeadershipStateDeputyMembershipFormAction,
setStateDeputyAdvisorMembershipFormAction,
setStateDeputyMunicipalitiesFormAction,
} from './formActions'
@ -102,6 +107,8 @@ const stateDeputyColumns = (
partyFilterOptions: StateDeputyFilterOption[],
hasNoPartyOption: boolean,
leadershipOptions: RelationCellOption[],
advisorOptions: RelationCellOption[],
canEditAdvisors: boolean,
municipalityIndex: MunicipalityPortfolioIndexEntry[],
addableMunicipalityIds: ReadonlySet<number> | undefined,
): Array<CampaignTableColumn<StateDeputyRowViewModel>> => [
@ -174,6 +181,30 @@ const stateDeputyColumns = (
/>
),
},
{
id: 'advisors',
label: 'Assessores',
head: canEditAdvisors ? (
<CampaignTableHead description="Edite aqui: passe o mouse em um chip para remover, ou busque para adicionar. Quem responde por esta dobradinha.">
Assessores
</CampaignTableHead>
) : (
<CampaignTableHead description="Quem responde por esta dobradinha.">
Assessores
</CampaignTableHead>
),
cellClassName: 'max-w-72 whitespace-normal',
cell: (row) => (
<StateDeputyAdvisorRelationCell
stateDeputyId={row.id}
stateDeputyName={row.name}
advisors={row.advisors}
options={canEditAdvisors ? advisorOptions : []}
membershipAction={setStateDeputyAdvisorMembershipFormAction}
readOnly={!canEditAdvisors}
/>
),
},
]
export default async function StateDeputiesPage({ searchParams }: StateDeputiesPageProps) {
@ -188,15 +219,22 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
const columnVisibility = await readCampaignColumnVisibility('dobradinhas')
const isLeadershipVisible = !columnVisibility.hiddenColumnIds.includes('leaderships')
const isAdvisorsVisible = !columnVisibility.hiddenColumnIds.includes('advisors')
// Advisor assignment is unrestricted-only (B156); the rest of staff reads.
const canEditAdvisors = isCampaignUnrestricted(user)
const [
{ rows, totalDocs, totalPages, filterFacets },
leadershipOptions,
advisorOptions,
municipalityIndex,
administeredIds,
] = await Promise.all([
loadStateDeputyListPageData(payload, user, canonicalUrl.state),
isLeadershipVisible ? loadLeadershipOptions(payload, user) : Promise.resolve([]),
isAdvisorsVisible && canEditAdvisors
? loadEligibleAdvisorOptions(payload, user)
: Promise.resolve([]),
loadMunicipalityPortfolioIndex(),
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
])
@ -220,6 +258,16 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
href: `/campanha/liderancas/${option.id}`,
},
})),
advisorOptions.map((option) => ({
id: option.id,
searchLabel: option.name,
item: {
id: option.id,
label: option.name,
href: `/campanha/assessores/${option.id}`,
},
})),
canEditAdvisors,
municipalityIndex,
administeredIds ? new Set(administeredIds) : undefined,
)

View file

@ -6,19 +6,27 @@ import type { Payload } from 'payload'
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
import { uniqueRelationshipIds } from '@/lib/relationship'
import {
STATE_DEPUTY_ADVISORS_UNRESTRICTED_MESSAGE,
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_STAFF_MESSAGE,
municipalityStateDeputyCreateSchema,
stateDeputyAdvisorMembershipSchema,
stateDeputyCreateSchema,
stateDeputyMunicipalitiesBatchSchema,
stateDeputyUpdateSchema,
type MunicipalityStateDeputyCreateInput,
type StateDeputyAdvisorMembershipInput,
type StateDeputyCreateInput,
type StateDeputyMunicipalitiesBatchInput,
type StateDeputyUpdateInput,
} from '@/lib/schemas/stateDeputy'
import { nextStateDeputyAdvisorIdsAfterMembership } from '@/lib/stateDeputyAdvisorMembership'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import {
getCampaignActionContext,
reloadStaffActor,
reloadUnrestrictedActor,
} from '@/utilities/campaignActionContext'
import {
mapStaffEntityConflict,
runStaffEntityMutation,
@ -110,6 +118,76 @@ const revalidateStateDeputyMunicipalityPaths = (
* (`canManageCampaignStaffField`) is staff-wide, so `overrideAccess: false`
* on both the read and the write is what refuses an out-of-scope município.
*/
export const setStateDeputyAdvisorMembershipRecord = async (
payload: Payload,
actor: CampaignUser,
input: StateDeputyAdvisorMembershipInput,
) => {
const { stateDeputyId, advisorId, assigned } = stateDeputyAdvisorMembershipSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
// Role gate only — the read/write below run under overrideAccess (the
// `advisors` field's update access is admin-only in the collection
// config), so the fresh actor exists solely to make the assertion.
await reloadUnrestrictedActor(payload, actor, STATE_DEPUTY_ADVISORS_UNRESTRICTED_MESSAGE, req)
await acquireTextAdvisoryLocks(payload, req, [`state-deputy-advisors:${stateDeputyId}`])
const stateDeputy = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputyId,
depth: 0,
select: { advisors: true, slug: 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 `setAdvisorMunicipalitiesBatchRecord`. The
// `beforeValidate` eligibility hook still runs under overrideAccess.
overrideAccess: true,
req,
})
const currentAdvisorIDs = uniqueRelationshipIds(stateDeputy.advisors)
const nextAdvisorIDs = nextStateDeputyAdvisorIdsAfterMembership(
currentAdvisorIDs,
advisorId,
assigned,
)
// No-op: nothing to write, and nothing for the caller to revalidate —
// the slug lookup below exists only to target that revalidate.
if (nextAdvisorIDs === null) {
return { stateDeputySlug: undefined }
}
// Same intentional bypass as the read: the unrestricted role was verified
// above, and the `beforeValidate` eligibility hook still runs under it.
await payload.update({
collection: 'stateDeputy',
id: stateDeputyId,
data: { advisors: nextAdvisorIDs },
depth: 0,
overrideAccess: true,
req,
})
return { stateDeputySlug: stateDeputy.slug }
},
{ beginFailureMessage: 'Não foi possível atualizar os assessores da dobradinha.' },
)
}
export const setStateDeputyAdvisorMembership = async (input: StateDeputyAdvisorMembershipInput) => {
const { payload, actor } = await getCampaignActionContext()
const { stateDeputySlug } = await setStateDeputyAdvisorMembershipRecord(payload, actor, input)
// The list is deliberately absent: the chip cell that calls this IS on it and
// already shows the toggle. The detail page is the route an actor cannot see
// from here — same reasoning as the leadership twin (B31).
if (stateDeputySlug) revalidatePath(`/campanha/dobradinhas/${stateDeputySlug}`, 'page')
return stateDeputySlug
}
export const setStateDeputyMunicipalitiesBatchRecord = async (
payload: Payload,
actor: CampaignUser,

View file

@ -1,15 +1,19 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import { uniqueRelationshipIds } from '@/lib/relationship'
import { STATE_DEPUTY_NAME_REQUIRED_MESSAGE } from '@/lib/schemas/stateDeputy'
import { slugify } from '@/lib/slug'
import { trimmedText } from '@/lib/text'
import {
canAssignStateDeputyAdvisors,
canCreateStateDeputy,
canDeleteStateDeputy,
canManageStateDeputy,
canManageStateDeputyAdvisors,
canReadStateDeputy,
canSetCampaignSystemField,
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields'
@ -32,6 +36,37 @@ const setCanonicalStateDeputySlug: CollectionBeforeValidateHook = ({
return data
}
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: {
@ -52,7 +87,7 @@ export const StateDeputy: CollectionConfig = {
delete: canDeleteStateDeputy,
},
hooks: {
beforeValidate: [setCanonicalStateDeputySlug],
beforeValidate: [setCanonicalStateDeputySlug, validateStateDeputyAdvisors],
beforeChange: [stampCampaignCreatedBy],
},
fields: [
@ -94,6 +129,19 @@ export const StateDeputy: CollectionConfig = {
label: 'Observações',
maxLength: 4000,
},
{
name: 'advisors',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Assessores',
hasMany: true,
index: true,
access: {
create: canAssignStateDeputyAdvisors,
update: canManageStateDeputyAdvisors,
},
filterOptions: eligibleCampaignStaffWhere,
},
systemStampedActorField({ setAccess: canSetCampaignSystemField }),
],
}

View file

@ -2,27 +2,18 @@
import { useCallback, useMemo } from 'react'
import type { RelationChipCellCopy } from '@/components/campaign/shared/RelationChipCell'
import {
RelationChipCell,
type RelationChip,
type RelationChipCellCopy,
type RelationSearchHit,
} from '@/components/campaign/shared/RelationChipCell'
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
RelationOptionCell,
type RelationCellItem,
type RelationCellOption,
} from '@/components/campaign/shared/RelationOptionCell'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
export type RelationCellItem = {
id: number
label: string
href: string
party?: string
}
export type RelationCellOption = {
id: number
searchLabel: string
item: RelationCellItem
}
export type {
RelationCellItem,
RelationCellOption,
} from '@/components/campaign/shared/RelationOptionCell'
type LeadershipStateDeputyRelationDirection = 'fromLeadership' | 'fromStateDeputy'
@ -41,28 +32,6 @@ type LeadershipStateDeputyRelationCellProps = {
measureOverflow?: boolean
}
const itemLabel = (item: RelationCellItem): string =>
item.party ? `${item.label} (${item.party})` : item.label
/**
* Normalized search labels, once per `options` array rather than per row: the
* RSC builds one array and every row of the table gets that same reference, so a
* `WeakMap` shares the work where a per-row `useMemo` would repeat it (same
* reasoning as `municipalityPortfolio`'s derivation cache).
*/
const normalizedLabelsByOptions = new WeakMap<readonly RelationCellOption[], Map<number, string>>()
const normalizedOptionLabels = (options: readonly RelationCellOption[]): Map<number, string> => {
const cached = normalizedLabelsByOptions.get(options)
if (cached) return cached
const normalized = new Map(
options.map((option) => [option.id, normalizeSearchPhrase(option.searchLabel)] as const),
)
normalizedLabelsByOptions.set(options, normalized)
return normalized
}
const DIRECTION_COPY: Record<
LeadershipStateDeputyRelationDirection,
{
@ -107,10 +76,10 @@ const DIRECTION_COPY: Record<
}
/**
* Bidirectional `leadership.stateDeputies` edge, on the same `RelationChipCell`
* that serves município portfolios (B37). Both directions write
* Bidirectional `leadership.stateDeputies` edge (B36), now a thin wrapper over
* the shared `RelationOptionCell` (extracted at B156). Both directions write
* `leadershipId`/`stateDeputyId`/`assigned` through the same membership action;
* every chip here is a single item — no batch, no floor/ceiling.
* every chip is a single item — no batch, no floor/ceiling.
*/
export const LeadershipStateDeputyRelationCell = ({
direction,
@ -123,50 +92,6 @@ export const LeadershipStateDeputyRelationCell = ({
}: LeadershipStateDeputyRelationCellProps) => {
const copy = DIRECTION_COPY[direction]
const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items])
// Additions resolve through `options` (the addable catalog), same asymmetry
// as the município cell's `optionById`: only what can still be added needs to
// be looked up there, and a removal always has its item in `items` already.
const optionItemById = useMemo(
() => new Map(options.map((option) => [option.id, option.item])),
[options],
)
const buildChips = useCallback(
(ids: number[]): RelationChip[] => {
const chips: RelationChip[] = []
for (const id of ids) {
const item = itemById.get(id) ?? optionItemById.get(id)
if (!item) continue
chips.push({ key: String(id), label: itemLabel(item), href: item.href, ids: [id] })
}
return chips
},
[itemById, optionItemById],
)
const searchHits = useCallback(
(query: string, assignedIds: ReadonlySet<number>): RelationSearchHit[] => {
// Normalize the query ONCE and read pre-normalized labels, instead of an
// NFD pass plus three Unicode-property regexes per candidate per
// keystroke — the cost `wordStartFilter`'s own doc comment warns about,
// and which matters here because `options` in the `fromStateDeputy`
// direction is the whole (unpaginated) leadership catalog.
const normalizedQuery = normalizeSearchPhrase(query)
const normalizedLabels = normalizedOptionLabels(options)
const hits: RelationSearchHit[] = []
for (const option of options) {
if (assignedIds.has(option.id)) continue
if (!matchesNormalizedAtWordStart(normalizedLabels.get(option.id) ?? '', normalizedQuery)) {
continue
}
hits.push({ key: String(option.id), label: option.searchLabel, ids: [option.id] })
}
return hits
},
[options],
)
const buildFormData = useCallback(
(changedIds: number[], assigned: boolean) => {
const itemId = changedIds[0]
@ -198,18 +123,17 @@ export const LeadershipStateDeputyRelationCell = ({
)
return (
<RelationChipCell
<RelationOptionCell
ownerId={fixedId}
ownerName={ownerName}
ids={items.map((item) => item.id)}
buildChips={buildChips}
searchHits={searchHits}
items={items}
options={options}
buildFormData={buildFormData}
commitAction={membershipAction}
copy={chipCellCopy}
drawerTitle={copy.drawerTitle}
triggerLabel={`Editar ${copy.triggerVerb} de ${ownerName}`}
updateErrorMessage={copy.updateErrorMessage}
copy={chipCellCopy}
measureOverflow={measureOverflow}
/>
)

View file

@ -157,6 +157,14 @@ type RelationChipCellProps = {
buildCreateFormData?: (rawName: string) => FormData
createLabel?: (rawName: string) => string
createErrorMessage?: string
// Read-only (B156) ─────────────────────────────────────────────────────────
/**
* Read-only rendering (B156): chips render linked but the cell carries no
* search input, remove affordance, or Drawer trigger — for an actor who may
* see the relation but not edit it (staff viewing `StateDeputy.advisors`).
*/
readOnly?: boolean
}
/**
@ -193,6 +201,7 @@ export const RelationChipCell = ({
buildCreateFormData,
createLabel,
createErrorMessage = updateErrorMessage,
readOnly = false,
}: RelationChipCellProps) => {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
@ -732,7 +741,7 @@ export const RelationChipCell = ({
{chipBody(chip)}
</Badge>
)}
{isPendingCreate ? null : (
{isPendingCreate || readOnly ? null : (
<button
type="button"
data-chip-remove
@ -940,6 +949,7 @@ export const RelationChipCell = ({
// Reached only on a fine pointer: the coarse overlay below covers the cell
// and stops the click, so the pointer policy stays in the media queries.
onClick={(event) => {
if (readOnly) return
const target = event.target as HTMLElement
if (target.closest('[data-chip-remove]') || target.closest('a')) return
inputRef.current?.focus()
@ -957,43 +967,52 @@ export const RelationChipCell = ({
)}
>
{chips.length === 0 && !searching ? (
<span className="px-1 text-sm text-muted-foreground pointer-fine:hidden">—</span>
<span
className={cn(
'px-1 text-sm text-muted-foreground',
!readOnly && 'pointer-fine:hidden',
)}
>
—
</span>
) : null}
{visibleChips.map(inlineChip)}
{expandToggle}
<input
ref={inputRef}
value={query}
role="combobox"
aria-expanded={inlineSuggesting}
// Only while expanded: the listbox lives inside the Popover, which
// Radix unmounts on close, so an unconditional `aria-controls`
// points at an id that is not in the document.
aria-controls={inlineSuggesting ? listboxId('inline') : undefined}
aria-autocomplete="list"
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onBlur={() => {
if (blurCloseTimeoutRef.current != null) {
window.clearTimeout(blurCloseTimeoutRef.current)
}
blurCloseTimeoutRef.current = window.setTimeout(() => {
blurCloseTimeoutRef.current = null
if (!rootRef.current?.contains(document.activeElement)) {
closeSuggestions()
{readOnly ? null : (
<input
ref={inputRef}
value={query}
role="combobox"
aria-expanded={inlineSuggesting}
// Only while expanded: the listbox lives inside the Popover, which
// Radix unmounts on close, so an unconditional `aria-controls`
// points at an id that is not in the document.
aria-controls={inlineSuggesting ? listboxId('inline') : undefined}
aria-autocomplete="list"
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onBlur={() => {
if (blurCloseTimeoutRef.current != null) {
window.clearTimeout(blurCloseTimeoutRef.current)
}
}, 120)
}}
onKeyDown={onSearchKeyDown}
placeholder={chips.length ? 'Adicionar…' : copy.searchPlaceholder}
aria-label={copy.searchLabel}
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
/>
blurCloseTimeoutRef.current = window.setTimeout(() => {
blurCloseTimeoutRef.current = null
if (!rootRef.current?.contains(document.activeElement)) {
closeSuggestions()
}
}, 120)
}}
onKeyDown={onSearchKeyDown}
placeholder={chips.length ? 'Adicionar…' : copy.searchPlaceholder}
aria-label={copy.searchLabel}
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
/>
)}
</div>
</PopoverAnchor>
{/* Portaled: the table's scroll container would clip an in-flow listbox. */}
@ -1021,39 +1040,41 @@ export const RelationChipCell = ({
* undoes the shared class's hover: this trigger is an invisible pane over
* the whole cell, and tinting it would tint the chips it covers.
*/}
<CampaignCellEditOverlay
variant="sheet"
open={drawerOpen}
// The container's trigger lets the click bubble to the cell root, which
// opens the inline suggestions — harmless while the sheet is up, but it
// would surface a portaled listbox on a touch device the moment the
// sheet closed. Clearing the search on close settles both.
//
// Guarded on the sheet having actually been open: a closed Drawer still
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
// sweeps the layer stack, and unguarded this erased whatever the fine
// pointer had typed into the inline input.
onOpenChange={(next) => {
setDrawerOpen(next)
if (!next && drawerOpen) closeSuggestions()
}}
title={drawerTitle}
description={ownerName}
trigger={null}
triggerLabel={resolvedTriggerLabel}
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
triggerBusy={isPending}
sheetBodyClassName="gap-3"
>
{/*
* Rendered unmounted, like the repo's other in-cell sheets: base-ui
* keeps the popup mounted through a ~450 ms exit transition, so gating
* this on `drawerOpen` animated an empty collapsing box out of view. The
* allocation it saved was measured and is not a cost — the closed portal
* emits no DOM, and the class string is a tailwind-merge cache hit.
*/}
<>{editorBody('drawer', drawerSuggesting)}</>
</CampaignCellEditOverlay>
{readOnly ? null : (
<CampaignCellEditOverlay
variant="sheet"
open={drawerOpen}
// The container's trigger lets the click bubble to the cell root, which
// opens the inline suggestions — harmless while the sheet is up, but it
// would surface a portaled listbox on a touch device the moment the
// sheet closed. Clearing the search on close settles both.
//
// Guarded on the sheet having actually been open: a closed Drawer still
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
// sweeps the layer stack, and unguarded this erased whatever the fine
// pointer had typed into the inline input.
onOpenChange={(next) => {
setDrawerOpen(next)
if (!next && drawerOpen) closeSuggestions()
}}
title={drawerTitle}
description={ownerName}
trigger={null}
triggerLabel={resolvedTriggerLabel}
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
triggerBusy={isPending}
sheetBodyClassName="gap-3"
>
{/*
* Rendered unmounted, like the repo's other in-cell sheets: base-ui
* keeps the popup mounted through a ~450 ms exit transition, so gating
* this on `drawerOpen` animated an empty collapsing box out of view. The
* allocation it saved was measured and is not a cost — the closed portal
* emits no DOM, and the class string is a tailwind-merge cache hit.
*/}
<>{editorBody('drawer', drawerSuggesting)}</>
</CampaignCellEditOverlay>
)}
<p className="sr-only" role="status" aria-live="polite">
{statusMessage}

View file

@ -0,0 +1,154 @@
'use client'
import { useCallback, useMemo } from 'react'
import {
RelationChipCell,
type RelationChip,
type RelationChipCellCopy,
type RelationSearchHit,
} from '@/components/campaign/shared/RelationChipCell'
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
export type RelationCellItem = {
id: number
label: string
href?: string
party?: string
}
export type RelationCellOption = {
id: number
searchLabel: string
item: RelationCellItem
}
type RelationOptionCellProps = {
/** `null` in draft mode (a row that does not exist yet). */
ownerId: number | null
/** Whose relation this is — spoken in the aria-labels and the Drawer. */
ownerName: string
items: RelationCellItem[]
options: RelationCellOption[]
buildFormData: (changedIds: number[], assigned: boolean) => FormData
commitAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
copy: RelationChipCellCopy
drawerTitle: string
triggerLabel: string
updateErrorMessage: string
/** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */
measureOverflow?: boolean
/** Read-only rendering (B156): linked chips, no search/remove/Drawer affordances. */
readOnly?: boolean
}
const itemLabel = (item: RelationCellItem): string =>
item.party ? `${item.label} (${item.party})` : item.label
/**
* Normalized search labels, once per `options` array rather than per row: the
* RSC builds one array and every row of the table gets that same reference, so a
* `WeakMap` shares the work where a per-row `useMemo` would repeat it (same
* reasoning as `municipalityPortfolio`'s derivation cache).
*/
const normalizedLabelsByOptions = new WeakMap<readonly RelationCellOption[], Map<number, string>>()
const normalizedOptionLabels = (options: readonly RelationCellOption[]): Map<number, string> => {
const cached = normalizedLabelsByOptions.get(options)
if (cached) return cached
const normalized = new Map(
options.map((option) => [option.id, normalizeSearchPhrase(option.searchLabel)] as const),
)
normalizedLabelsByOptions.set(options, normalized)
return normalized
}
/**
* Generic "items + options + membership action" specialization of
* `RelationChipCell` (extracted at B156, the second use of the
* leadership↔dobradinha shape): every chip is a single linked item from `items`
* (additions resolve through `options`, so an optimistic add labels from the
* addable catalog), search matches `options` at normalized word start, and the
* write is one `changedId` delta through `commitAction`. All that differs
* between relations — chips' href, the write's field names, copy — arrives as
* props; nothing domain-specific lives here.
*/
export const RelationOptionCell = ({
ownerId,
ownerName,
items,
options,
buildFormData,
commitAction,
copy,
drawerTitle,
triggerLabel,
updateErrorMessage,
measureOverflow = true,
readOnly = false,
}: RelationOptionCellProps) => {
const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items])
// Additions resolve through `options` (the addable catalog), same asymmetry
// as the município cell's `optionById`: only what can still be added needs to
// be looked up there, and a removal always has its item in `items` already.
const optionItemById = useMemo(
() => new Map(options.map((option) => [option.id, option.item])),
[options],
)
const buildChips = useCallback(
(ids: number[]): RelationChip[] => {
const chips: RelationChip[] = []
for (const id of ids) {
const item = itemById.get(id) ?? optionItemById.get(id)
if (!item) continue
chips.push({ key: String(id), label: itemLabel(item), href: item.href, ids: [id] })
}
return chips
},
[itemById, optionItemById],
)
const searchHits = useCallback(
(query: string, assignedIds: ReadonlySet<number>): RelationSearchHit[] => {
// Normalize the query ONCE and read pre-normalized labels, instead of an
// NFD pass plus three Unicode-property regexes per candidate per
// keystroke — the cost `wordStartFilter`'s own doc comment warns about.
const normalizedQuery = normalizeSearchPhrase(query)
const normalizedLabels = normalizedOptionLabels(options)
const hits: RelationSearchHit[] = []
for (const option of options) {
if (assignedIds.has(option.id)) continue
if (!matchesNormalizedAtWordStart(normalizedLabels.get(option.id) ?? '', normalizedQuery)) {
continue
}
hits.push({ key: String(option.id), label: option.searchLabel, ids: [option.id] })
}
return hits
},
[options],
)
return (
<RelationChipCell
ownerId={ownerId}
ownerName={ownerName}
ids={items.map((item) => item.id)}
buildChips={buildChips}
searchHits={searchHits}
buildFormData={buildFormData}
commitAction={commitAction}
drawerTitle={drawerTitle}
triggerLabel={triggerLabel}
updateErrorMessage={updateErrorMessage}
copy={copy}
measureOverflow={measureOverflow}
readOnly={readOnly}
/>
)
}

View file

@ -0,0 +1,99 @@
'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 StateDeputyAdvisorRelationCellProps = {
stateDeputyId: number
stateDeputyName: string
/** Assigned advisors, names resolved server-side (B156). */
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 (B156): 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.
*/
readOnly?: boolean
/** Clamp rest chips to 3 rows + "Ver mais…". Default true. */
measureOverflow?: boolean
}
type StateDeputyAdvisorCopy = RelationChipCellCopy & {
drawerTitle: string
updateErrorMessage: string
}
const ADVISOR_COPY: StateDeputyAdvisorCopy = {
drawerTitle: 'Assessores da dobradinha',
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.',
}
/**
* `StateDeputy.advisors` cell (B156) — the second thin wrapper over the shared
* `RelationOptionCell` extracted at B156: chips link to
* `/campanha/assessores/[id]` (unless read-only), search resolves the eligible
* staff catalog, and the write sends `stateDeputyId`/`advisorId`/`assigned`
* through the unrestricted-only membership action. Serves the "Assessores"
* column of the list and the section of the detail page.
*/
export const StateDeputyAdvisorRelationCell = ({
stateDeputyId,
stateDeputyName,
advisors,
options,
membershipAction,
readOnly = false,
measureOverflow = true,
}: StateDeputyAdvisorRelationCellProps) => {
const buildFormData = useCallback(
(changedIds: number[], assigned: boolean) => {
const formData = new FormData()
formData.set('stateDeputyId', String(stateDeputyId))
formData.set('advisorId', String(changedIds[0]))
formData.set('assigned', assigned ? 'true' : 'false')
return formData
},
[stateDeputyId],
)
return (
<RelationOptionCell
ownerId={stateDeputyId}
ownerName={stateDeputyName}
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 ${stateDeputyName}`}
updateErrorMessage={ADVISOR_COPY.updateErrorMessage}
measureOverflow={measureOverflow}
readOnly={readOnly}
/>
)
}

View file

@ -88,9 +88,39 @@ export const municipalityStateDeputyCreateSchema = z
message: 'O partido pode ter no máximo 32 caracteres.',
})
/**
* Cap for `StateDeputy.advisors` (B156) — mirrors `MAX_ADVISORS_PER_MUNICIPALITY`:
* a dobradinha has a handful of responsible advisors, and every bounded
* relation in `/campanha` carries a cap so `nextIdsAfterMembership` can refuse
* with a spoken reason before the optimistic apply.
*/
export const MAX_ADVISORS_PER_STATE_DEPUTY = 10
export const STATE_DEPUTY_ADVISORS_CAP_MESSAGE = `Cada dobradinha aceita no máximo ${MAX_ADVISORS_PER_STATE_DEPUTY} assessores.`
export const STATE_DEPUTY_ADVISORS_UNRESTRICTED_MESSAGE =
'Somente a coordenação geral ou o candidato gerencia assessores de dobradinhas.'
/**
* Delta write for the "Assessores" column/section of a dobradinha (B156) —
* one chip toggle on `StateDeputy.advisors`, the owning document (no batch:
* the relation lives on the dobradinha itself).
*/
export const stateDeputyAdvisorMembershipSchema = z.object({
stateDeputyId: positiveRelationshipId,
advisorId: positiveRelationshipId,
assigned: z.boolean(),
})
export const STATE_DEPUTY_ADVISOR_SAFE_MESSAGES = [
STATE_DEPUTY_ADVISORS_UNRESTRICTED_MESSAGE,
STATE_DEPUTY_ADVISORS_CAP_MESSAGE,
] as const
export type StateDeputyCreateInput = z.input<typeof stateDeputyCreateSchema>
export type StateDeputyUpdateInput = z.input<typeof stateDeputyUpdateSchema>
export type StateDeputyMunicipalitiesBatchInput = z.input<
typeof stateDeputyMunicipalitiesBatchSchema
>
export type MunicipalityStateDeputyCreateInput = z.input<typeof municipalityStateDeputyCreateSchema>
export type StateDeputyAdvisorMembershipInput = z.input<typeof stateDeputyAdvisorMembershipSchema>

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "state_deputy_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"campaign_user_id" integer
);
ALTER TABLE "state_deputy_rels" ADD CONSTRAINT "state_deputy_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."state_deputy"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "state_deputy_rels" ADD CONSTRAINT "state_deputy_rels_campaign_user_fk" FOREIGN KEY ("campaign_user_id") REFERENCES "public"."campaign_user"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "state_deputy_rels_order_idx" ON "state_deputy_rels" USING btree ("order");
CREATE INDEX "state_deputy_rels_parent_idx" ON "state_deputy_rels" USING btree ("parent_id");
CREATE INDEX "state_deputy_rels_path_idx" ON "state_deputy_rels" USING btree ("path");
CREATE INDEX "state_deputy_rels_campaign_user_id_idx" ON "state_deputy_rels" USING btree ("campaign_user_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "state_deputy_rels" DISABLE ROW LEVEL SECURITY;
DROP TABLE "state_deputy_rels" CASCADE;`)
}

View file

@ -39,6 +39,7 @@ import * as migration_20260730_043306_simplify_leadership_fields from './2026073
import * as migration_20260731_014319_add_campaign_notifications from './20260731_014319_add_campaign_notifications';
import * as migration_20260801_062558_add_campaign_vote_summary_snapshot from './20260801_062558_add_campaign_vote_summary_snapshot';
import * as migration_20260802_230000_strip_engagement_reversal_signals from './20260802_230000_strip_engagement_reversal_signals';
import * as migration_20260804_061017_add_state_deputy_advisors from './20260804_061017_add_state_deputy_advisors';
export const migrations = [
{
@ -239,11 +240,16 @@ export const migrations = [
{
up: migration_20260801_062558_add_campaign_vote_summary_snapshot.up,
down: migration_20260801_062558_add_campaign_vote_summary_snapshot.down,
name: '20260801_062558_add_campaign_vote_summary_snapshot'
name: '20260801_062558_add_campaign_vote_summary_snapshot',
},
{
up: migration_20260802_230000_strip_engagement_reversal_signals.up,
down: migration_20260802_230000_strip_engagement_reversal_signals.down,
name: '20260802_230000_strip_engagement_reversal_signals',
},
{
up: migration_20260804_061017_add_state_deputy_advisors.up,
down: migration_20260804_061017_add_state_deputy_advisors.down,
name: '20260804_061017_add_state_deputy_advisors'
},
];

View file

@ -456,6 +456,7 @@ export interface StateDeputy {
slug: string;
party?: string | null;
notes?: string | null;
advisors?: (number | CampaignUser)[] | null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
@ -1630,6 +1631,7 @@ export interface StateDeputySelect<T extends boolean = true> {
slug?: T;
party?: T;
notes?: T;
advisors?: T;
createdBy?: T;
updatedAt?: T;
createdAt?: T;

View file

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

View file

@ -122,9 +122,11 @@ export {
} from '@/utilities/access/elections'
export {
canAssignStateDeputyAdvisors,
canCreateStateDeputy,
canDeleteStateDeputy,
canManageStateDeputy,
canManageStateDeputyAdvisors,
canReadStateDeputy,
} from '@/utilities/access/stateDeputies'

View file

@ -5,6 +5,7 @@ import type { Payload } from 'payload'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { populatedContactName } from '@/lib/relationship'
import type { CampaignUser } from '@/payload-types'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
/** Municipalities the actor may operate on (coordinator: all 436; advisor: administered). */
export const loadMunicipalityOptions = async (
@ -106,3 +107,65 @@ export const loadLeadershipOptions = async (
}))
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
}
export type CampaignUserSummary = {
id: number
name: string
}
/**
* Names for a set of assigned advisor ids (B156) — the same catalog read the
* municipios list does with `loadAdvisorSummaries`, but without the phone and
* in the shared relation-options module (the dobradinhas cells need id+name
* only). Honours `canReadCampaignUsers` and the eligibility filter, so a
* stale id that fell out of staff is dropped — same fail-closed contract as
* `loadAdvisorSummaries`.
*/
export const loadCampaignUserSummaries = async (
payload: Payload,
user: CampaignUser,
ids: number[],
): Promise<CampaignUserSummary[]> => {
if (ids.length === 0) return []
const result = await payload.find({
collection: 'campaignUser',
where: {
and: [{ id: { in: ids } }, eligibleCampaignStaffWhere],
},
depth: 0,
pagination: false,
select: { name: true },
user,
overrideAccess: false,
})
const byId = new Map(result.docs.map((doc) => [doc.id, { id: doc.id, name: doc.name }]))
return ids.flatMap((id) => {
const summary = byId.get(id)
return summary ? [summary] : []
})
}
/**
* The addable staff catalog for an advisor-relation cell (B156): every
* eligible account (coordinator/advisor/candidate), sorted by name. The cell
* resolves optimistic adds through it, same role as `getEligibleAdvisorOptions`
* on the municipios list.
*/
export const loadEligibleAdvisorOptions = async (
payload: Payload,
user: CampaignUser,
): Promise<RelationOption[]> => {
const result = await payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
sort: 'name',
where: eligibleCampaignStaffWhere,
select: { name: true },
user,
overrideAccess: false,
})
return result.docs.map(({ id, name }) => ({ id, name }))
}

View file

@ -2,8 +2,9 @@ import 'server-only'
import type { Payload } from 'payload'
import { populatedContactName, relationshipId } from '@/lib/relationship'
import { populatedContactName, relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import type { CampaignUser, StateDeputy } from '@/payload-types'
import { loadCampaignUserSummaries } from '@/utilities/campaignRelationOptions'
import {
buildStateDeputyListWhere,
NO_PARTY_FILTER_VALUE,
@ -13,7 +14,7 @@ import {
type StateDeputyListState,
} from '@/utilities/stateDeputyListUrl'
type LeadershipRelationSummary = {
type NamedRelationSummary = {
id: number
name: string
}
@ -24,7 +25,9 @@ export type StateDeputyRowViewModel = {
slug: string
party: string | null
municipalityIDs: number[]
leaderships: LeadershipRelationSummary[]
leaderships: NamedRelationSummary[]
/** B156 — the staff responsible for this dobradinha, names resolved. */
advisors: NamedRelationSummary[]
}
/** Values still reachable under the OTHER active filters (the Partido popover). */
@ -126,7 +129,7 @@ export const loadStateDeputyListPageData = async (
limit: stateDeputyPageSize,
page: state.page,
sort: resolveStateDeputyListPayloadSort(sort, dir),
select: { name: true, slug: true, party: true },
select: { name: true, slug: true, party: true, advisors: true },
user,
overrideAccess: false,
}),
@ -134,7 +137,8 @@ export const loadStateDeputyListPageData = async (
])
const stateDeputyIDs = result.docs.map((doc) => doc.id)
const leadershipsByDeputy = new Map<number, LeadershipRelationSummary[]>()
const leadershipsByDeputy = new Map<number, NamedRelationSummary[]>()
const advisorsByDeputy = new Map<number, NamedRelationSummary[]>()
const municipalityIDsByDeputy =
stateDeputyIDs.length > 0
@ -142,6 +146,23 @@ export const loadStateDeputyListPageData = async (
: new Map<number, number[]>()
if (stateDeputyIDs.length) {
// B156 — one name lookup for every row's assigned advisors, same
// aggregate-over-ids shape as the leaderships query below. The `advisors`
// field is already on each doc (depth 0 → ids).
const advisorIDs = [
...new Set(result.docs.flatMap((doc) => uniqueRelationshipIds(doc.advisors))),
]
if (advisorIDs.length) {
const summaries = await loadCampaignUserSummaries(payload, user, advisorIDs)
const summaryById = new Map(summaries.map((summary) => [summary.id, summary]))
for (const doc of result.docs) {
const list = uniqueRelationshipIds(doc.advisors)
.map((id) => summaryById.get(id))
.filter((summary): summary is NamedRelationSummary => summary !== undefined)
advisorsByDeputy.set(doc.id, list)
}
}
const leaderships = await payload.find({
collection: 'leadership',
where: { stateDeputies: { in: stateDeputyIDs } },
@ -156,7 +177,7 @@ export const loadStateDeputyListPageData = async (
})
for (const leadership of leaderships.docs) {
const summary: LeadershipRelationSummary = {
const summary: NamedRelationSummary = {
id: leadership.id,
name: populatedContactName(leadership.contact),
}
@ -184,6 +205,7 @@ export const loadStateDeputyListPageData = async (
party: doc.party ?? null,
municipalityIDs: municipalityIDsByDeputy.get(doc.id) ?? [],
leaderships: leadershipsByDeputy.get(doc.id) ?? [],
advisors: advisorsByDeputy.get(doc.id) ?? [],
})),
totalDocs: result.totalDocs,
totalPages: result.totalPages,
@ -202,6 +224,8 @@ export type StateDeputyDetailViewModel = StateDeputySummary & {
notes: string | null
municipalities: Array<{ id: number; name: string; slug: string }>
leaderships: Array<{ id: number; name: string }>
/** B156 — staff responsible for this dobradinha. */
advisors: Array<{ id: number; name: string }>
}
export const loadStateDeputyDetail = async (
@ -221,7 +245,7 @@ export const loadStateDeputyDetail = async (
const stateDeputy = result.docs[0] as StateDeputy | undefined
if (!stateDeputy) return null
const [municipalities, leaderships] = await Promise.all([
const [municipalities, leaderships, advisorSummaries] = await Promise.all([
payload.find({
collection: 'municipality',
where: { stateDeputies: { in: [stateDeputy.id] } },
@ -243,6 +267,7 @@ export const loadStateDeputyDetail = async (
user,
overrideAccess: false,
}),
loadCampaignUserSummaries(payload, user, uniqueRelationshipIds(stateDeputy.advisors)),
])
return {
@ -260,6 +285,7 @@ export const loadStateDeputyDetail = async (
id: leadership.id,
name: populatedContactName(leadership.contact),
})),
advisors: advisorSummaries,
}
}
@ -277,6 +303,8 @@ export const loadStateDeputySummaries = async (
pagination: false,
sort: 'name',
select: { name: true, slug: true, party: true },
// Intentional admin bypass: id lookups for the home-search card, no
// actor-scoping possible or needed (B52 precedent).
overrideAccess: true,
})

View file

@ -0,0 +1,161 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { setStateDeputyAdvisorMembershipRecord } from '@/app/(campaign)/campanha/actions/stateDeputy'
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 = (stateDeputy: { advisors?: (number | { id: number })[] | null }): number[] =>
relationIds(stateDeputy.advisors)
describe('setStateDeputyAdvisorMembershipRecord (B156)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
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 stateDeputy = await fixtures.createStateDeputy()
const advisor = await fixtures.createCampaignUser('advisor')
const { stateDeputySlug } = await setStateDeputyAdvisorMembershipRecord(payload, actor, {
stateDeputyId: stateDeputy.id,
advisorId: advisor.id,
assigned: true,
})
expect(stateDeputySlug).toBe(stateDeputy.slug)
const assigned = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputy.id,
depth: 0,
select: { advisors: true },
overrideAccess: true,
})
expect(advisorIds(assigned)).toContain(advisor.id)
await setStateDeputyAdvisorMembershipRecord(payload, actor, {
stateDeputyId: stateDeputy.id,
advisorId: advisor.id,
assigned: false,
})
const removed = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputy.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 stateDeputy = await fixtures.createStateDeputy()
const target = await fixtures.createCampaignUser('coordinator')
await expect(
setStateDeputyAdvisorMembershipRecord(payload, advisorActor, {
stateDeputyId: stateDeputy.id,
advisorId: target.id,
assigned: true,
}),
).rejects.toThrow(/coordenação geral ou o candidato/i)
await expect(
setStateDeputyAdvisorMembershipRecord(payload, leaderActor, {
stateDeputyId: stateDeputy.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 stateDeputy = await fixtures.createStateDeputy()
const leaderTarget = await fixtures.createCampaignUser('leader')
await expect(
setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
stateDeputyId: stateDeputy.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 stateDeputy = await fixtures.createStateDeputy()
const advisor = await fixtures.createCampaignUser('advisor')
await setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
stateDeputyId: stateDeputy.id,
advisorId: advisor.id,
assigned: true,
})
// No-op contract: no slug back (the caller then skips the revalidate).
const { stateDeputySlug } = await setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
stateDeputyId: stateDeputy.id,
advisorId: advisor.id,
assigned: true,
})
expect(stateDeputySlug).toBeUndefined()
const after = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputy.id,
depth: 0,
select: { advisors: true },
overrideAccess: true,
})
expect(advisorIds(after)).toEqual([advisor.id])
})
it('rejects once the dobradinha is at the 10-advisor cap', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy()
for (let index = 0; index < 10; index += 1) {
const advisor = await fixtures.createCampaignUser('advisor')
await setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
stateDeputyId: stateDeputy.id,
advisorId: advisor.id,
assigned: true,
})
}
const eleventh = await fixtures.createCampaignUser('advisor')
await expect(
setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
stateDeputyId: stateDeputy.id,
advisorId: eleventh.id,
assigned: true,
}),
).rejects.toThrow(/no máximo 10 assessores/i)
})
})

View file

@ -0,0 +1,141 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { StateDeputyAdvisorRelationCell } from '@/components/campaign/stateDeputy/StateDeputyAdvisorRelationCell'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
/**
* B156: pins what the advisor wrapper contributes over the shared
* `RelationOptionCell` — the write's field names, the link target, the
* read-only rendering for non-unrestricted staff, and the success feedback.
*/
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
const OPTIONS = [
{
id: 10,
searchLabel: 'Ana Bastos',
item: { id: 10, label: 'Ana Bastos', href: '/campanha/assessores/10' },
},
{
id: 11,
searchLabel: 'Beto Lima',
item: { id: 11, label: 'Beto Lima', href: '/campanha/assessores/11' },
},
]
const advisors = [{ id: 10, name: 'Ana Bastos' }]
const cell = (
overrides: Partial<{
advisors: Array<{ id: number; name: string }>
readOnly: boolean
membershipAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}> = {},
) =>
createElement(StateDeputyAdvisorRelationCell, {
stateDeputyId: 7,
stateDeputyName: 'Dobradinha Teste',
advisors: overrides.advisors ?? advisors,
options: OPTIONS,
membershipAction: overrides.membershipAction ?? (async () => ({ status: 'success' as const })),
readOnly: overrides.readOnly ?? false,
measureOverflow: false,
})
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterEach(cleanup)
afterAll(() => vi.unstubAllGlobals())
describe('state deputy advisor relation cell (B156)', () => {
it('renders each assigned advisor as a linked chip', () => {
render(cell())
const link = screen.getByRole('link', { name: 'Ana Bastos' })
expect(link.getAttribute('href')).toBe('/campanha/assessores/10')
})
it('sends stateDeputyId/advisorId/assigned=false on removal', async () => {
const membershipAction = vi.fn(
async (_state: CampaignFormActionState, _formData: FormData) =>
({ status: 'success' as const }) satisfies CampaignFormActionState,
)
render(cell({ membershipAction }))
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
await waitFor(() => expect(membershipAction).toHaveBeenCalledTimes(1))
const formData = membershipAction.mock.calls[0]?.[1] as FormData
expect(formData.get('stateDeputyId')).toBe('7')
expect(formData.get('advisorId')).toBe('10')
expect(formData.get('assigned')).toBe('false')
})
it('adds a searched option and reports the live region as saved', async () => {
render(cell())
fireEvent.click(screen.getByRole('button', { name: 'Editar assessores de Dobradinha Teste' }))
const dialog = await screen.findByRole('dialog')
const searchInput = within(dialog).getByRole('combobox', { name: 'Buscar assessor' })
fireEvent.change(searchInput, { target: { value: 'Beto' } })
fireEvent.click(await within(dialog).findByRole('option', { name: /Beto Lima/ }))
await waitFor(() => expect(within(dialog).getByText('Beto Lima')).toBeTruthy())
await waitFor(() => expect(screen.getByRole('status').textContent).toBe('Assessores salvos.'))
})
it('readOnly renders chips with no edit affordances', () => {
render(cell({ readOnly: true }))
// No link: `/campanha/assessores/[id]` is unrestricted-only, so a read-only
// viewer must not be handed a dead-end redirect (B156 simplify fix).
expect(screen.queryByRole('link', { name: 'Ana Bastos' })).toBeNull()
expect(screen.getByText('Ana Bastos')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Remover Ana Bastos' })).toBeNull()
expect(
screen.queryByRole('button', { name: 'Editar assessores de Dobradinha Teste' }),
).toBeNull()
expect(screen.queryByRole('combobox')).toBeNull()
})
it('shows the empty dash when there are no advisors yet', () => {
render(cell({ advisors: [] }))
expect(screen.getByText('—')).toBeTruthy()
})
it('undoes a failed removal without reverting a sibling change from the same render', async () => {
const membershipAction = vi.fn(
async (
_state: CampaignFormActionState,
_formData: FormData,
): Promise<CampaignFormActionState> => ({
message: 'Não foi possível atualizar os assessores. Tente novamente.',
}),
)
render(cell({ membershipAction }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
})
await waitFor(() => expect(screen.getByText('Ana Bastos')).toBeTruthy())
})
})