feat(C143): demandas visíveis apenas para responsáveis explícitos + candidato/coordenador

- campaignDemand.responsibles (hasMany → campaignUser, staff elegível) com
  hook beforeValidate que descarta ids não-staff (fail-closed)
- canRead/UpdateCampaignDemand por membership: assessor só vê demandas onde
  é responsável; unrestricted/admin sempre; leader nunca (fim do escopo
  municipal de demandas)
- criador entra automaticamente como responsável no hook de criação (form,
  wizard, rascunhos C90); migration com backfill created_by_id → responsável
- UI: picker multi-select na criação (nova + wizard) e card de gestão no
  detalhe com save full-replace + advisory lock; sugestões = assessores do
  município; rótulo 'Visível para: Responsáveis · Candidato · Coordenador'
- int tests reescritos para a regra nova + 5 casos novos; e2e novo de
  visibilidade (lista/URL/gestão); fixture e2e com createCampaignDemand
- impl plan + changelog; importMap regenerado com S3_* dummy (OPS69)
This commit is contained in:
Francisco Solla 2026-08-19 21:56:17 -03:00
parent 966862c801
commit 2cc8f2c14d
28 changed files with 15001 additions and 97 deletions

View file

@ -0,0 +1 @@
**C143** — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo `campaignDemand.responsibles` (hasMany → `campaignUser`, staff elegível via `eligibleCampaignStaffWhere` com hook `beforeValidate` que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável (`{ responsibles: { contains } }`), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em `src/utilities/access/demands.ts` e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via `overrideAccess: false`; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook `enforceDemandWorkflow` (cobre form, wizard e rascunhos C90); migration `20260819_233503_add_campaign_demand_responsibles` adiciona `campaign_user_id` a `campaign_demand_rels` (FK cascade) e faz backfill de `created_by_id` → responsável para demandas pré-C143. UI: picker multi-select `DemandResponsibleMultiSelect` (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (`/demandas/nova` em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão `DemandResponsiblesCard` no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por `searchDemandResponsibleOptions` (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo `campaignDemandVisibility.e2e.spec.ts` (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou `createCampaignDemand`. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com `S3_*` dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod `SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles'` = nº de demandas com `created_by_id`. Plano de implementação: `docs/plans/demandas-responsaveis-impl.md`.

View file

@ -0,0 +1,89 @@
# Impl: Demandas visíveis apenas para responsáveis explícitos + candidato/coordenador
Status: aprovado
Atualizado em: 2026-08-19
Issue: #106
Intenção: docs/plans/demandas-responsaveis.md
Appetite restante: herdado (~1–1,5 dias eng; tracer primeiro, polish depois)
## Leitura da intenção
- **Outcome:** a demanda tem um conjunto de **responsáveis** (staff da campanha); candidato e coordenador sempre a veem; assessor do município relacionado que não é responsável **não vê** — nem lista, nem URL, nem busca. Criador entra automaticamente como responsável. Quem atualiza a demanda gerencia os responsáveis. Leader lockdown intacto; custo/comprovantes seguem a visibilidade da demanda.
- **O que NÃO negociar:** visibilidade **fail-closed** por vínculo explícito (nada de herança do município); regra nos dados (access), não escondida no cliente; leader nunca vê demandas; escalada continua candidato/coordenador.
- **O que reavaliar:** a hipótese da intenção era "`canReadCampaignDemand` passa a ser unrestricted ou responsável". Confirma-se no código: hoje o escopo vive **só** em `src/utilities/access/demands.ts:33` (`resolveActorScopedRead` com `municipality`), e **todos** os consumidores (lista, detalhe, busca global do dashboard, demandas vinculadas da atividade, C90, omnibox) herdam via Local API `overrideAccess: false` — não há REST nem client query. O corte é cirúrgico: regra nova + campo + migration + UI de gestão.
## Abordagem recomendada
```mermaid
flowchart LR
F[CampaignDemand.responsibles<br/>hasMany → campaignUser] --> A[canRead/UpdateCampaignDemand<br/>advisor → responsibles contains id]
H[enforceDemandWorkflow<br/>criador vira responsável na criação] --> F
M[Migration: campaign_demand_rels<br/>+campaign_user_id + backfill createdBy] --> F
S[updateCampaignDemandResponsibles<br/>tx + advisory lock] --> A
U[DemandResponsibleMultiSelect<br/>nova + wizard + detalhe] --> S
```
**Opções consideradas:**
- **A — Campo `responsibles` hasMany → `campaignUser` + regra `where` de membership** (espelho de `municipality.advisors` / `{ advisors: { contains } }`).
- **B — Relação polimórfica `responsible` (union campaignUser/leadership/stateDeputy) como a `activity`.**
- **C — `or: [responsibles contains, createdBy equals]`** (criador visível mesmo se removido dos responsáveis).
**Recomendação:** **A** — single collection, sem union desnecessária (responsável é sempre staff da campanha — `eligibleCampaignStaffWhere`), query escalar `{ responsibles: { contains: id } }` (mesmo mecanismo do `advisors`), e a join table `campaign_demand_rels` **já existe** (hoje usada por `receipts`/media) — a migration só adiciona a coluna `campaign_user_id` + FK + índice.
**Rejeitadas:** **B** porque responsável é sempre `campaignUser` (não há liderança/dobradinha como responsável de demanda — a intenção fala de "pessoas da campanha"/staff; union polimórfica traria o custo do `equals { relationTo, value }` e catalog triplo sem ganho). **C** porque fura o modelo de responsável explícito: remover o criador da lista não o tiraria da visibilidade, e a intenção fixou "criador entra **como responsável**" (vínculo explícito, único source of truth). O criador entra na lista por hook **na criação** + **backfill** dos existentes.
### Decisões de engenharia
1. **Regra de leitura/atualização** — `canReadCampaignDemand` deixa de usar `resolveActorScopedRead` (que é o prólogo municipal) e vira função própria:
`admin → true; leader → false; unrestricted → true; advisor → { responsibles: { contains: id } }; resto → false`. `canUpdateCampaignDemand = canReadCampaignDemand` (regra única "quem vê gerencia" — aceite: "quem pode atualizar pode gerenciar os responsáveis"). `canCreateCampaignDemand` **inalterado** (staff no município da carteira). `canDeleteCampaignDemand` admin-only inalterado. `canReadCampaignStaffField`/`canManageCampaignStaffField` inalterados.
2. **Criador auto-responsável** — no branch `create` do hook `enforceDemandWorkflow` (`CampaignDemand.ts:129-143`), quando `actor` é campaignUser: `data.responsibles = union(data.responsibles, [actor.id])`. Cobre **todos** os caminhos de criação: form `/demandas/nova`, wizard (A5), rascunhos C90 (activity.ts passa `user: currentActor`), admin UI (payload admin → actor null → sem responsáveis → fail-closed: invisível a assessores, o que é aceitável — quem criou via admin vê tudo). Sem auto-add no update.
3. **Backfill dos existentes** — na própria migration (padrão C90 `20260808_184113_remodel_activity_responsible.ts`, `RAISE NOTICE` + SQL no up): `INSERT INTO campaign_demand_rels (order, parent_id, path, campaign_user_id) SELECT 0, id, 'responsibles', created_by_id FROM campaign_demand WHERE created_by_id IS NOT NULL`. Preserva "a criação não some do criador" para as demandas pré-c143. Campo novo → nenhuma linha tem responsável → sem dedup.
4. **Gestão (server)** — `updateCampaignDemandResponsiblesRecord` em `actions/demand.ts`, mesmo shape dos irmãos: schema zod novo (`{ id, responsibles: array de positiveRelationshipId }`), `withPayloadTransaction`, `reloadCampaignActor`, staff check, advisory lock `campaign-demand:{id}`, `findByID` scoped (`overrideAccess: false` — a row access autoriza antes do write), `payload.update` com a lista **completa** (replace). Replace é a forma mais simples e segura: o estado do client já é a lista inteira e o advisory lock serializa editores concorrentes (add/remove atômico seria 2 formas de escrita para o mesmo dado).
5. **Busca de candidatos (UI)** — server action `searchDemandResponsibleOptions(query, municipalityId)` (padrão `searchDemandActivityOptions`): query < 2 chars → sugestões = assessores do município da demanda (read scoped de `municipality.advisors`); senão → busca por nome em `campaignUser` com `eligibleCampaignStaffWhere` (reusa o padrão de `loadEligibleAdvisorOptions`), `overrideAccess: false` (campaign users leem `campaignUser` — `canReadCampaignUsers`). Sugestão NÃO é herança: é atalho de preenchimento, a visibilidade vem só do campo salvo.
6. **Componente de multi-select** — `DemandResponsibleMultiSelect` novo em `src/components/campaign/demand/` (domain, não shared): single collection, chips removíveis, chip do criador rotulado "Você (criador)", sugestões no diálogo quando query curta, hidden inputs repetidos `responsibles` (contrato de `RelationMultiSelect`, não o JSON de `ResponsibleMultiSelect`). **Não reusar `ResponsibleMultiSelect` (C90)**: é polimórfico (typeLabel/relationTo), tem cap hardcoded `MAX_ACTIVITY_RESPONSIBLES=20`, e o contrato de FormData é JSON tipado — forçar modo single-collection seria 4 props novas + branch, um módulo raso; dois componentes com contratos distintos é honesto (mesmo critério que separou `RelationMultiSelect` de `ResponsibleMultiSelect`).
7. **Detalhe** — card `DemandResponsiblesCard` (client) com a lista (nome + avatar via `loadCampaignUserDisplayByIds`, helper existente), add por combobox, remove por chip, e o rótulo estático "Visível para: Responsáveis · Candidato · Coordenador". Layout do rascunho: grade `lg:grid-cols-3` — coluna 1–2 com os blocos atuais (descrição, decisão, workflow, controle interno), coluna 3 com o card. Todos os readers são updaters (update access = read access), então o card de gestão aparece para qualquer staff que abra a demanda — sem flag.
8. **Criação** — `DemandFields` ganha o campo responsáveis (opcional via prop `responsiblesMunicipalityId`/`currentUser`): `/demandas/nova` e wizard compartilham o mesmo parse (`parseCampaignDemandCreateFormData` ganha `responsibles: formData.getAll('responsibles')`), schema de create ganha `responsibles: z.array(positiveRelationshipId)` (sem cap — como `municipality.advisors`; o hook faz o union, dedup implícito). Draft: `/demandas/nova` em grade 2 colunas (form | responsáveis); wizard empilhado (o wizard herda via `DemandFields`).
### Componentes / mudanças
- **`CampaignDemand.responsibles`** (`src/collections/CampaignDemand.ts`): `relationship hasMany → campaignUser`, label "Responsáveis", index; sem field access (row access já gateia; staff nativo não abre `/admin`).
- **`enforceDemandWorkflow`** (`CampaignDemand.ts`): union do criador no create (campaignUser only).
- **`canReadCampaignDemand`/`canUpdateCampaignDemand`** (`src/utilities/access/demands.ts`): regra de membership; remove `resolveActorScopedRead`/`getAccessibleMunicipalityIds` deste módulo.
- **Migration:** `add_campaign_demand_responsibles` (drizzle): `ALTER TABLE campaign_demand_rels ADD COLUMN campaign_user_id` + FK (`ON DELETE cascade`, padrão `state_deputy_rels`) + índice; + backfill no up. Gerar com `pnpm migrate:create`.
- **Access / Consent:** sem Consent (dado interno staff). Fail-closed no where da access.
- **Server actions:** `updateCampaignDemandResponsiblesRecord` + `setDemandResponsiblesFormAction` (`[slug]/formActions.ts`, `revalidatePath`); `searchDemandResponsibleOptions` (`demandas/responsibleSearchActions.ts`).
- **UI:** `DemandResponsibleMultiSelect` + `DemandResponsiblesCard` em `src/components/campaign/demand/`; `DemandFields`/`DemandForm`/`WizardRegisterDemandStep` ganham o campo; `[slug]/page.tsx` ganha o card + grade lg. Impeccable B — seguir o rascunho (`demandas-responsaveis-ui-draft-*.png`): chips, placeholder "Buscar assessor…"/"Adicionar responsável…", textos informativos do rascunho.
- **View-model:** `DemandDetailViewModel.responsibles: Array<{ id, name, avatarUrl }>` via `loadCampaignUserDisplayByIds`; `nova/page.tsx` carrega o `currentUser` (id/nome) para seed do picker.
### Dados → forma
- Sem dados apresentados ao usuário além de nomes/avatares de responsáveis — forma já existente (chips com nome + avatar, padrão do código). Lista da demanda **não** ganha coluna de responsáveis (fora do aceite — apenas visibilidade muda).
## Fases verificáveis
1. **Schema + server (tracer)** — migration + campo + hook do criador + access rule; `pnpm generate:types`; int tests de access reescritos/adicionados (`campaignDemandWorkflow.int.spec.ts`, `homeSearchDemands.int.spec.ts`); backfill verificado no local.
2. **Ações + busca** — `updateCampaignDemandResponsiblesRecord` + zod + `searchDemandResponsibleOptions`; int de gestão (add/remove/self-removal, advisor não-responsável não lê).
3. **UI** — multi-select na criação (nova + wizard) e card no detalhe; shape → critique → polish contra o rascunho; e2e: assessor não-responsável não vê (lista vazia + URL → 404); ajustar e2e existentes se o aceite mudou o comportamento (criador auto-responsável preserva os fluxos atuais).
4. **Gates** — `tsc`, `lint`, `format:check`, `knip`, `check:cycles`, unit+int, `build` local; e2e afetado no CI. Aikido nos arquivos tocados.
## Rabbit holes / Não escopo (engenharia)
- **Notificação ao ser marcado responsável** (fora de escopo da intenção).
- **Coluna de responsáveis na lista / omnibox** — visibilidade herda a access, sem mudança de UI de lista.
- **C141 "Visão Tudo"** — não existe ainda; a regra nova já falha fechado para assessor em qualquer perfil futuro.
- **Generalizar `ResponsibleMultiSelect`/`RelationMultiSelect`** — rejeitado (contratos distintos, módulo raso).
- **Overflow "+N" dos chips no detalhe** — o padrão do código é wrap; o "+2" do rascunho é affordance de wireframe, não spec.
## Riscos e mitigação
- **Regra nova muda o que assessores veem em todos os pontos de demanda** (lista, detalhe, busca home, activity detail, C90). Mitigação: tudo herda por `overrideAccess: false` — um único ponto de corte; int tests cobrem cada superfície; C90 cria com `user: currentActor` → criador auto-responsável → a atividade continua listando as próprias demandas.
- **Self-removal deixa a demanda sem responsável** → visível só a candidato/coordenador (fail-closed, aceitável e coerente com o modelo explícito — gestão é dos responsáveis).
- **Join table já existente** (`campaign_demand_rels` com `path='media'`) — a migration adiciona coluna/FK; conferir o SQL gerado (não regenerar a tabela).
- **`codebaseConventions.unit.spec.ts`** — o guard anti-fragmento municipal não é violado (a regra nova não re-escreve `{ municipality: { in } }`); rodar o guard no gate.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (visibilidade fail-closed por responsáveis explícitos; criador automático; gestão por quem atualiza; leader lockdown; escalada inalterada)
- [x] Invariantes AGENTS/engineering-standards (Local API com `user`/`overrideAccess: false`; transação + advisory lock nas escritas multi-step; pt-BR em labels, identificadores em inglês)
- [x] Testes de domínio previstos (int: access rule, gestão de responsáveis, backfill; e2e: invisibilidade para não-responsável; adjust: workflow/homeSearch/listData)

View file

@ -199,6 +199,7 @@ export default async function CampaignActionWizardPage({
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
returnPath={returnPath}
currentUser={{ id: user.id, name: user.name }}
/>
)
}

View file

@ -5,12 +5,14 @@ import { revalidatePath } from 'next/cache'
import {
attachCampaignDemandReceiptRecord,
setCampaignDemandCost,
setCampaignDemandResponsibles,
transitionCampaignDemand,
updateCampaignDemand,
} from '@/app/(campaign)/campanha/actions/demand'
import {
FormDataBoundaryError,
nullableFormText,
repeatedRelationshipFormValues,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
@ -19,6 +21,7 @@ import {
CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE,
CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE,
CAMPAIGN_DEMAND_RECEIPT_SAFE_MESSAGES,
CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE,
CAMPAIGN_DEMAND_TRANSITION_SAFE_MESSAGES,
campaignDemandStatuses,
type CampaignDemandStatus,
@ -100,6 +103,23 @@ export const setDemandCostFormAction = async (
genericMessage: 'Não foi possível registrar o custo. Tente novamente.',
})
export const setDemandResponsiblesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setCampaignDemandResponsibles({
id: requiredRelationshipFormValue(formData, 'demandId'),
responsibles: repeatedRelationshipFormValues(formData, 'responsibles'),
})
revalidatePath('/campanha/demandas/[slug]', 'page')
return { message: 'Responsáveis atualizados.' }
},
safeMessages: [CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE],
genericMessage: 'Não foi possível atualizar os responsáveis. Tente novamente.',
})
export const attachDemandReceiptFormAction = async (
_state: CampaignFormActionState,
formData: FormData,

View file

@ -5,6 +5,7 @@ import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { DemandDescriptionEditor } from '@/components/campaign/demand/DemandDescriptionEditor'
import { DemandResponsiblesCard } from '@/components/campaign/demand/DemandResponsiblesCard'
import { DemandWorkflowCard } from '@/components/campaign/demand/DemandWorkflowCard'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
@ -22,6 +23,7 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
attachDemandReceiptFormAction,
setDemandCostFormAction,
setDemandResponsiblesFormAction,
transitionDemandFormAction,
updateDemandFormAction,
} from './formActions'
@ -118,87 +120,106 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps
{dateTimeFormatter.format(new Date(demand.createdAt))}
</p>
<DemandDescriptionEditor
demandId={demand.id}
initialDescription={demand.description ?? ''}
formAction={updateDemandFormAction}
/>
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div className="flex min-w-0 flex-col gap-4">
<DemandDescriptionEditor
demandId={demand.id}
initialDescription={demand.description ?? ''}
formAction={updateDemandFormAction}
/>
{demand.decisionNote && (demand.status === 'aprovada' || demand.status === 'rejeitada') ? (
<section aria-label="Decisão" className="rounded-xl border p-4">
<h2 className="text-sm font-medium">Nota da decisão</h2>
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
{demand.decisionNote}
</p>
{demand.decidedAt ? (
<p className="mt-1 text-xs text-muted-foreground">
Decidida em {dateTimeFormatter.format(new Date(demand.decidedAt))}
</p>
{demand.decisionNote &&
(demand.status === 'aprovada' || demand.status === 'rejeitada') ? (
<section aria-label="Decisão" className="rounded-xl border p-4">
<h2 className="text-sm font-medium">Nota da decisão</h2>
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
{demand.decisionNote}
</p>
{demand.decidedAt ? (
<p className="mt-1 text-xs text-muted-foreground">
Decidida em {dateTimeFormatter.format(new Date(demand.decidedAt))}
</p>
) : null}
</section>
) : null}
</section>
) : null}
<DemandWorkflowCard
demandID={demand.id}
status={demand.status}
canDecideEscalated={isCampaignUnrestricted(user)}
currentCost={demand.cost}
transitionFormAction={transitionDemandFormAction}
costFormAction={setDemandCostFormAction}
receiptFormAction={attachDemandReceiptFormAction}
/>
<DemandWorkflowCard
demandID={demand.id}
status={demand.status}
canDecideEscalated={isCampaignUnrestricted(user)}
currentCost={demand.cost}
transitionFormAction={transitionDemandFormAction}
costFormAction={setDemandCostFormAction}
receiptFormAction={attachDemandReceiptFormAction}
/>
<section
aria-labelledby="demand-internal-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<h2 id="demand-internal-title" className="text-base font-medium">
Controle interno
</h2>
<p className="text-sm text-muted-foreground">
Custo: {demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
</p>
{demand.receipts.length ? (
<ul className="flex flex-col gap-1">
{demand.receipts.map((receipt) => (
<li key={receipt.id} className="flex items-center gap-2 text-sm">
<PaperclipIcon className="size-4 text-muted-foreground" aria-hidden="true" />
{receipt.url ? (
<a
href={receipt.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{receipt.filename ?? `Comprovante ${receipt.id}`}
</a>
) : (
<span>{receipt.filename ?? `Comprovante ${receipt.id}`}</span>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum comprovante anexado.</p>
)}
{demand.statusHistory.length ? (
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Histórico</h3>
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{demand.statusHistory.map((entry, index) => (
<li key={`${entry.status}-${index}`}>
{campaignDemandStatusLabels[entry.status]}
{entry.authorName ? ` — ${entry.authorName}` : ''}
{entry.createdAt
? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
: ''}
{entry.note ? ` · ${entry.note}` : ''}
</li>
))}
</ul>
</div>
) : null}
</section>
<section
aria-labelledby="demand-internal-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<h2 id="demand-internal-title" className="text-base font-medium">
Controle interno
</h2>
<p className="text-sm text-muted-foreground">
Visível para: <span className="font-medium">Responsáveis</span> ·{' '}
<span className="font-medium">Candidato</span> ·{' '}
<span className="font-medium">Coordenador</span>
</p>
<p className="text-sm text-muted-foreground">
Custo:{' '}
{demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
</p>
{demand.receipts.length ? (
<ul className="flex flex-col gap-1">
{demand.receipts.map((receipt) => (
<li key={receipt.id} className="flex items-center gap-2 text-sm">
<PaperclipIcon className="size-4 text-muted-foreground" aria-hidden="true" />
{receipt.url ? (
<a
href={receipt.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{receipt.filename ?? `Comprovante ${receipt.id}`}
</a>
) : (
<span>{receipt.filename ?? `Comprovante ${receipt.id}`}</span>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum comprovante anexado.</p>
)}
{demand.statusHistory.length ? (
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Histórico</h3>
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{demand.statusHistory.map((entry, index) => (
<li key={`${entry.status}-${index}`}>
{campaignDemandStatusLabels[entry.status]}
{entry.authorName ? ` — ${entry.authorName}` : ''}
{entry.createdAt
? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
: ''}
{entry.note ? ` · ${entry.note}` : ''}
</li>
))}
</ul>
</div>
) : null}
</section>
</div>
<DemandResponsiblesCard
demandId={demand.id}
municipalityId={demand.municipalityId > 0 ? demand.municipalityId : null}
creatorUserId={demand.createdBy}
initialResponsibles={demand.responsibles}
formAction={setDemandResponsiblesFormAction}
/>
</div>
</CampaignPageShell>
)
}

View file

@ -52,6 +52,7 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps
municipalityOptions={municipalityOptions}
initialActivity={initialActivity}
initialMunicipalityId={initialMunicipalityId}
currentUser={{ id: user.id, name: user.name }}
searchActivities={searchDemandActivityOptions}
formAction={createDemandFormAction}
/>

View file

@ -0,0 +1,61 @@
'use server'
import config from '@payload-config'
import { getPayload } from 'payload'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
import { loadCampaignUserSummaries } from '@/utilities/campaignRelationOptions'
const RESPONSIBLE_SEARCH_LIMIT = 20
/**
* C143 — candidate catalog for the demand responsible picker. Short queries
* (or empty) return the municipality's advisors as suggestions — a fill-in
* shortcut, never an automatic responsibility. Longer queries search staff by
* name. Both paths run access-scoped reads (`overrideAccess: false`), so the
* catalog only ever offers what the actor may already read.
*/
export const searchDemandResponsibleOptions = async (
query: string,
municipalityId: number | null,
): Promise<RelationOption[]> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
if (query.trim().length < 2) {
if (!municipalityId) return []
const municipality = await payload.find({
collection: 'municipality',
where: { id: { equals: municipalityId } },
depth: 0,
limit: 1,
pagination: false,
select: { advisors: true },
user,
overrideAccess: false,
})
const advisors = (municipality.docs[0]?.advisors ?? [])
.map((advisor) => (typeof advisor === 'object' && advisor !== null ? advisor.id : advisor))
.filter((id): id is number => Number.isInteger(id))
return loadCampaignUserSummaries(payload, user, advisors)
}
const result = await payload.find({
collection: 'campaignUser',
where: {
and: [{ name: { contains: query.trim() } }, eligibleCampaignStaffWhere],
},
depth: 0,
limit: RESPONSIBLE_SEARCH_LIMIT,
pagination: false,
sort: 'name',
select: { name: true },
user,
overrideAccess: false,
})
return result.docs.map(({ id, name }) => ({ id, name }))
}

View file

@ -11,15 +11,18 @@ import {
CAMPAIGN_DEMAND_RECEIPT_SIZE_MESSAGE,
CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE,
CAMPAIGN_DEMAND_RECEIPT_TYPE_MESSAGE,
CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE,
CAMPAIGN_DEMAND_TRANSITION_STAFF_MESSAGE,
} from '@/lib/schemas/campaignDemand'
import {
campaignDemandCostSchema,
campaignDemandCreateSchema,
campaignDemandResponsiblesSchema,
campaignDemandTransitionSchema,
campaignDemandUpdateSchema,
type CampaignDemandCostInput,
type CampaignDemandCreateInput,
type CampaignDemandResponsiblesInput,
type CampaignDemandTransitionInput,
type CampaignDemandUpdateInput,
} from '@/lib/schemas/campaignDemandInput'
@ -84,8 +87,9 @@ export const updateCampaignDemandRecord = async (
throw new Error(CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE)
}
// Access-scoped read (staff see only their municipalities). The AI call
// stays before the advisory lock, so the lock is never held open by it.
// Access-scoped read (C143: responsibles + unrestricted see the demand).
// The AI call stays before the advisory lock, so the lock is never held
// open by it.
const demand = await payload.findByID({
collection: 'campaignDemand',
id,
@ -256,6 +260,47 @@ export const attachCampaignDemandReceiptRecord = async (
)
}
export const setCampaignDemandResponsiblesRecord = async (
payload: Payload,
actor: CampaignUser,
input: CampaignDemandResponsiblesInput,
) => {
const { id, responsibles } = campaignDemandResponsiblesSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (!isCampaignStaff(currentActor)) {
throw new Error(CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE)
}
await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`])
// Access-scoped read (responsibles + unrestricted see it) before the
// replace, so an advisor cannot manage a demand they cannot read.
await payload.findByID({
collection: 'campaignDemand',
id,
depth: 0,
select: { title: true },
user: currentActor,
overrideAccess: false,
req,
})
return payload.update({
collection: 'campaignDemand',
id,
data: { responsibles: [...new Set(responsibles)] },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização dos responsáveis.' },
)
}
export const createCampaignDemand = async (input: CampaignDemandCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
return createCampaignDemandRecord(payload, actor, input)
@ -275,3 +320,8 @@ export const setCampaignDemandCost = async (input: CampaignDemandCostInput) => {
const { payload, actor } = await getCampaignActionContext()
return setCampaignDemandCostRecord(payload, actor, input)
}
export const setCampaignDemandResponsibles = async (input: CampaignDemandResponsiblesInput) => {
const { payload, actor } = await getCampaignActionContext()
return setCampaignDemandResponsiblesRecord(payload, actor, input)
}

View file

@ -26,6 +26,7 @@ import {
canReadCampaignStaffField,
canSetCampaignSystemField,
canUpdateCampaignDemand,
eligibleCampaignStaffWhere,
isCampaignUnrestricted,
} from '@/utilities/campaignAccess'
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
@ -41,6 +42,40 @@ const DEMAND_STATUS_OPTIONS = campaignDemandStatuses.map((value) => ({
label: campaignDemandStatusLabels[value],
}))
/**
* C143 — responsibles are always eligible staff (coordinator/advisor/candidate).
* The picker catalog already filters, but the invariant belongs in the data
* path, fail-closed: forged/non-eligible ids (a leader, a deleted account) are
* dropped from any create/update. Intentional admin bypass — the hook must SEE
* the non-eligible rows to drop them (same precedent as the slug probe).
*/
const restrictResponsiblesToStaff: CollectionBeforeValidateHook = async ({ data, req }) => {
if (!data) return data
const rawResponsibles = data.responsibles
if (!Array.isArray(rawResponsibles) || rawResponsibles.length === 0) return data
const ids = rawResponsibles
.map((entry) => relationshipId(entry))
.filter((id): id is number => id !== null)
if (ids.length === 0) return data
// Bypass (documented in the hook's comment): the eligibility probe must see
// the non-eligible rows (leaders, deleted accounts) to drop them.
const eligible = await req.payload.find({
collection: 'campaignUser',
where: { and: [{ id: { in: ids } }, eligibleCampaignStaffWhere] },
depth: 0,
limit: 0,
pagination: false,
select: { name: true },
overrideAccess: true,
req,
})
const eligibleIDs = new Set(eligible.docs.map((doc) => doc.id))
data.responsibles = [...new Set(ids.filter((id) => eligibleIDs.has(id)))]
return data
}
const setCanonicalDemandSlug: CollectionBeforeValidateHook = ({ data, operation, originalDoc }) => {
if (!data) return data
const title = trimmedText(data.title ?? originalDoc?.title)
@ -127,7 +162,19 @@ const enforceDemandWorkflow: CollectionBeforeChangeHook = async ({
const actor = req.user?.collection === 'campaignUser' ? req.user : null
if (operation === 'create') {
if (actor) data.createdBy = actor.id
if (actor) {
data.createdBy = actor.id
// C143 — the creator is always a responsible (explicit-responsible
// visibility): union with any client-provided list so a create can
// never leave its author outside the demand's own visibility.
const existingResponsibles = Array.isArray(data.responsibles)
? data.responsibles
.map((entry) => relationshipId(entry))
.filter((id): id is number => id !== null)
: []
data.responsibles = [...new Set([...existingResponsibles, actor.id])]
}
const initialStatus = isDemandStatus(data.status) ? data.status : 'aberta'
data.status = initialStatus
@ -255,6 +302,7 @@ export const CampaignDemand: CollectionConfig = {
},
hooks: {
beforeValidate: [
restrictResponsiblesToStaff,
setCanonicalDemandSlug,
ensureUniqueDemandSlug,
validateDemandActivityMunicipality,
@ -322,6 +370,19 @@ export const CampaignDemand: CollectionConfig = {
label: 'Liderança solicitante',
index: true,
},
{
name: 'responsibles',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Responsáveis',
hasMany: true,
index: true,
filterOptions: eligibleCampaignStaffWhere,
admin: {
description:
'Só responsáveis, candidato e coordenador veem a demanda. O criador entra automaticamente.',
},
},
{
name: 'status',
type: 'select',

View file

@ -1,5 +1,9 @@
'use client'
import {
DemandResponsibleMultiSelect,
type DemandResponsibleOption,
} from '@/components/campaign/demand/DemandResponsibleMultiSelect'
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { Button } from '@/components/ui/button'
@ -16,6 +20,8 @@ import {
CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
CAMPAIGN_DEMAND_BODY_PLACEHOLDER,
CAMPAIGN_DEMAND_KIND_LABEL,
CAMPAIGN_DEMAND_RESPONSIBLES_DESCRIPTION,
CAMPAIGN_DEMAND_RESPONSIBLES_LABEL,
campaignDemandKindLabels,
campaignDemandKinds,
} from '@/lib/schemas/campaignDemand'
@ -38,6 +44,12 @@ type DemandFieldsProps = {
value: string
onValueChange: (value: string) => void
}
/** C143 — explicit-responsible picker; `aside` splits a right column (nova). */
responsibles?: {
currentUser: { id: number; name: string } | null
search: (query: string) => Promise<RelationOption[]>
layout: 'aside' | 'stacked'
}
}
/**
@ -53,6 +65,7 @@ export const DemandFields = ({
onActivityChange,
searchActivities,
municipality,
responsibles,
}: DemandFieldsProps) => {
const municipalityReady = municipality ? municipality.value !== '' : true
@ -114,7 +127,7 @@ export const DemandFields = ({
</Field>
)
return (
const coreFields = (
<>
{municipality ? (
<div className="grid gap-4 sm:grid-cols-2">
@ -172,4 +185,47 @@ export const DemandFields = ({
</Field>
</>
)
if (!responsibles) return coreFields
const creatorValue: DemandResponsibleOption[] = responsibles.currentUser
? [
{
id: responsibles.currentUser.id,
name: responsibles.currentUser.name,
},
]
: []
const responsiblesField = (
<Field>
<FieldLabel>{CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}</FieldLabel>
<DemandResponsibleMultiSelect
name="responsibles"
value={creatorValue}
creatorUserId={responsibles.currentUser?.id ?? null}
search={responsibles.search}
triggerPlaceholder="Buscar assessor…"
triggerAriaLabel={CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}
disabled={disabled}
/>
<FieldDescription>{CAMPAIGN_DEMAND_RESPONSIBLES_DESCRIPTION}</FieldDescription>
</Field>
)
if (responsibles.layout === 'aside') {
return (
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div className="flex flex-col gap-4">{coreFields}</div>
{responsiblesField}
</div>
)
}
return (
<>
{coreFields}
{responsiblesField}
</>
)
}

View file

@ -2,6 +2,7 @@
import { useActionState, useCallback, useState } from 'react'
import { searchDemandResponsibleOptions } from '@/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions'
import { DemandFields, type DemandActivityValue } from '@/components/campaign/demand/DemandFields'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
@ -15,6 +16,7 @@ type DemandFormProps = {
municipalityOptions: RelationOption[]
initialActivity?: ActivityRelationOption | null
initialMunicipalityId?: number
currentUser?: { id: number; name: string } | null
searchActivities: (
query: string,
municipalityId: number | null,
@ -29,6 +31,7 @@ export const DemandForm = ({
municipalityOptions,
initialActivity = null,
initialMunicipalityId,
currentUser = null,
searchActivities,
formAction,
}: DemandFormProps) => {
@ -45,9 +48,13 @@ export const DemandForm = ({
(query: string) => searchActivities(query, parsedMunicipalityId),
[parsedMunicipalityId, searchActivities],
)
const searchResponsibles = useCallback(
(query: string) => searchDemandResponsibleOptions(query, parsedMunicipalityId),
[parsedMunicipalityId],
)
return (
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
<form action={submitAction} className="flex max-w-3xl flex-col gap-4">
<DemandFields
idPrefix="demand"
state={state}
@ -59,6 +66,11 @@ export const DemandForm = ({
value: municipalityId,
onValueChange: setMunicipalityId,
}}
responsibles={{
currentUser,
search: searchResponsibles,
layout: 'aside',
}}
/>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<Button type="submit" disabled={isPending} className="min-h-11 self-start">

View file

@ -0,0 +1,216 @@
'use client'
import { ChevronsUpDownIcon, XIcon } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandDialog,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/Command'
import { Spinner } from '@/components/ui/Spinner'
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
export type DemandResponsibleOption = RelationOption & { avatarUrl?: string | null }
type DemandResponsibleMultiSelectProps = {
/** FormData field name — repeated hidden inputs, one per selected id. */
name: string
value: DemandResponsibleOption[]
/** When set, this id renders as "(criador)" (informational marker). */
creatorUserId?: number | null
/** Create flow: the creator chip cannot be removed (the hook re-adds it). */
lockCreator?: boolean
/** Server action — short queries return municipality-advisor suggestions. */
search: (query: string) => Promise<RelationOption[]>
/** Trigger text while empty, e.g. "Buscar assessor…". */
triggerPlaceholder: string
/** Accessible name of the add trigger (the list renders its own sr-only names). */
triggerAriaLabel: string
disabled?: boolean
/** Fires on every change with the full selected id list (dirty tracking). */
onChange?: (ids: number[]) => void
}
/**
* C143 — multi-value responsible picker for campaign demands (staff only).
* The chips carry the selected list as repeated hidden inputs; the search
* catalog is the `searchDemandResponsibleOptions` action, whose short-query
* branch returns the municipality's advisors as suggestions (a fill-in
* shortcut — visibility comes only from the saved list).
*/
export const DemandResponsibleMultiSelect = ({
name,
value,
creatorUserId = null,
lockCreator = true,
search,
triggerPlaceholder,
triggerAriaLabel,
disabled,
onChange,
}: DemandResponsibleMultiSelectProps) => {
const [selected, setSelected] = useState<DemandResponsibleOption[]>(value)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [options, setOptions] = useState<RelationOption[]>([])
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
const requestId = useRef(0)
const searchRef = useRef(search)
searchRef.current = search
useEffect(() => {
if (!open) return
const currentRequestId = ++requestId.current
const timeout = window.setTimeout(() => {
setLoading(true)
setFailed(false)
void searchRef
.current(query.trim())
.then((nextOptions) => {
if (requestId.current !== currentRequestId) return
setOptions(nextOptions)
})
.catch(() => {
if (requestId.current !== currentRequestId) return
setFailed(true)
})
.finally(() => {
if (requestId.current === currentRequestId) setLoading(false)
})
}, 250)
return () => window.clearTimeout(timeout)
}, [open, query])
const isCreator = (id: number) => creatorUserId != null && creatorUserId === id
const selectedIds = new Set(selected.map((option) => option.id))
// Discrete user events re-render between calls, so reading `selected` here
// is safe (same shape as `RelationMultiSelect`); an updater side effect
// would double-fire onChange under StrictMode.
const add = (option: RelationOption) => {
if (selectedIds.has(option.id)) return
const next = [...selected, { ...option, avatarUrl: undefined }]
setSelected(next)
onChange?.(next.map((entry) => entry.id))
}
const remove = (option: DemandResponsibleOption) => {
if (isCreator(option.id) && lockCreator) return
const next = selected.filter((entry) => entry.id !== option.id)
setSelected(next)
onChange?.(next.map((entry) => entry.id))
}
const visibleOptions = options.filter((option) => !selectedIds.has(option.id))
const shortQuery = query.trim().length < 2
const selectedLabel =
selected.length === 0
? triggerPlaceholder
: `${selected.length} ${selected.length === 1 ? 'responsável' : 'responsáveis'}`
return (
<>
{selected.length ? (
<ul className="flex flex-wrap gap-1.5">
{selected.map((option) => (
<li key={option.id}>
<Badge variant="secondary" className="gap-1.5 pr-1">
{option.avatarUrl ? (
<Avatar size="sm" className="shrink-0">
<AvatarImage src={option.avatarUrl} alt="" />
<AvatarFallback>{campaignUserInitials(option.name)}</AvatarFallback>
</Avatar>
) : null}
<input type="hidden" name={name} value={option.id} />
<span className="font-normal">{option.name}</span>
{isCreator(option.id) ? (
<span className="text-muted-foreground">(criador)</span>
) : null}
{!isCreator(option.id) ? (
<button
type="button"
aria-label={`Remover ${option.name}`}
className="inline-flex size-6 items-center justify-center rounded-full hover:bg-foreground/10"
disabled={disabled}
onClick={() => remove(option)}
>
<XIcon className="size-3.5" aria-hidden="true" />
</button>
) : null}
</Badge>
</li>
))}
</ul>
) : null}
<Button
type="button"
variant="outline"
className="min-h-11 w-full justify-between font-normal"
aria-label={`${triggerAriaLabel}: ${selectedLabel}`}
disabled={disabled}
onClick={() => setOpen(true)}
>
<span className="truncate">{triggerPlaceholder}</span>
<ChevronsUpDownIcon aria-hidden="true" />
</Button>
<CommandDialog
open={open}
onOpenChange={setOpen}
title={triggerAriaLabel}
description="Busque por nome e escolha um ou mais responsáveis."
showCloseButton
>
<Command shouldFilter={false} label={triggerAriaLabel}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar assessor…"
aria-label={triggerAriaLabel}
/>
<CommandList aria-busy={loading}>
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner aria-hidden="true" />
Buscando…
</div>
) : failed ? (
<p className="py-6 text-center text-sm text-destructive">
Não foi possível concluir a busca.
</p>
) : visibleOptions.length > 0 ? (
<CommandGroup heading={shortQuery ? 'Sugestões do município' : 'Assessores'}>
{visibleOptions.map((option) => (
<CommandItem
key={option.id}
value={`${option.name}-${option.id}`}
onSelect={() => add(option)}
>
<span className="truncate">{option.name}</span>
</CommandItem>
))}
</CommandGroup>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
{shortQuery
? 'Sem assessores sugeridos para este município.'
: 'Nenhum resultado encontrado.'}
</p>
)}
</CommandList>
</Command>
</CommandDialog>
</>
)
}

View file

@ -0,0 +1,90 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { searchDemandResponsibleOptions } from '@/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { CAMPAIGN_DEMAND_RESPONSIBLES_LABEL } from '@/lib/schemas/campaignDemand'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
DemandResponsibleMultiSelect,
type DemandResponsibleOption,
} from './DemandResponsibleMultiSelect'
type DemandResponsiblesCardProps = {
demandId: number
municipalityId: number | null
/** The creator id, shown as "(criador)" on the chip (removal stays allowed — hand-off). */
creatorUserId: number | null
initialResponsibles: DemandResponsibleOption[]
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}
/**
* C143 — responsible management on the demand detail. Everyone who can read
* the demand can update it (same row scope), so the card's edit controls are
* available to whoever opens the page; the save submits the full list, the
* action revalidates the page and the local state keeps the saved chips
* (the success message is the confirmation, so no remount key is used).
*/
export const DemandResponsiblesCard = ({
demandId,
municipalityId,
creatorUserId,
initialResponsibles,
formAction,
}: DemandResponsiblesCardProps) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
const [dirty, setDirty] = useState(false)
useEffect(() => {
if (state.status === 'success') setDirty(false)
}, [state.status])
const search = (query: string) => searchDemandResponsibleOptions(query, municipalityId)
return (
<form action={submitAction} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex flex-col gap-1">
<h2 className="text-base font-medium">{CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}</h2>
<p className="text-sm text-muted-foreground">Só quem é responsável vê esta demanda.</p>
</div>
<DemandResponsibleMultiSelect
name="responsibles"
value={initialResponsibles}
creatorUserId={creatorUserId}
lockCreator={false}
search={search}
triggerPlaceholder="Adicionar responsável…"
triggerAriaLabel={CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}
disabled={isPending}
onChange={() => setDirty(true)}
/>
<p className="text-xs text-muted-foreground">
O criador já entra como responsável automaticamente. Assessores do município aparecem como
sugestão, mas só quem for marcado enxerga.
</p>
<CampaignFormActionMessage state={state} />
<Button
type="submit"
variant="outline"
className="min-h-9 self-start"
disabled={isPending || !dirty}
>
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar responsáveis
</Button>
<input type="hidden" name="demandId" value={demandId} />
</form>
)
}

View file

@ -5,6 +5,7 @@ import { useActionState, useCallback, useState } from 'react'
import { createWizardDemandFormAction } from '@/app/(campaign)/campanha/(app)/acoes/formActions'
import { searchDemandActivityOptions } from '@/app/(campaign)/campanha/(app)/demandas/activitySearchActions'
import { searchDemandResponsibleOptions } from '@/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions'
import { DemandFields, type DemandActivityValue } from '@/components/campaign/demand/DemandFields'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
@ -21,6 +22,7 @@ type WizardRegisterDemandStepProps = {
municipalityName: string
municipalitySlug: string
returnPath?: string
currentUser?: { id: number; name: string } | null
}
/**
@ -34,6 +36,7 @@ export const WizardRegisterDemandStep = ({
municipalityName,
municipalitySlug,
returnPath,
currentUser = null,
}: WizardRegisterDemandStepProps) => {
const router = useRouter()
const [state, submitAction, isPending] = useActionState(createWizardDemandFormAction, {})
@ -43,6 +46,10 @@ export const WizardRegisterDemandStep = ({
(query: string) => searchDemandActivityOptions(query, municipalityId),
[municipalityId],
)
const searchResponsibles = useCallback(
(query: string) => searchDemandResponsibleOptions(query, municipalityId),
[municipalityId],
)
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
@ -79,6 +86,11 @@ export const WizardRegisterDemandStep = ({
activity={activity}
onActivityChange={setActivity}
searchActivities={searchActivities}
responsibles={{
currentUser,
search: searchResponsibles,
layout: 'stacked',
}}
/>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}

View file

@ -93,6 +93,11 @@ export const CAMPAIGN_DEMAND_COST_STAFF_MESSAGE =
'Somente a coordenação, a assessoria e o candidato registram custos.'
export const CAMPAIGN_DEMAND_EDIT_STAFF_MESSAGE =
'Somente a coordenação, a assessoria e o candidato editam demandas.'
export const CAMPAIGN_DEMAND_RESPONSIBLES_STAFF_MESSAGE =
'Somente quem vê a demanda pode gerenciar os responsáveis.'
export const CAMPAIGN_DEMAND_RESPONSIBLES_LABEL = 'Responsáveis pela demanda'
export const CAMPAIGN_DEMAND_RESPONSIBLES_DESCRIPTION =
'Sugestões: assessores do município. Se ninguém for marcado além de você, só você, candidato e coordenador verão a demanda.'
export const CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE =
'Somente a coordenação e a assessoria anexam comprovantes.'
export const CAMPAIGN_DEMAND_RECEIPT_TYPE_MESSAGE = 'Envie uma imagem (JPEG, PNG, WebP) ou PDF.'

View file

@ -18,6 +18,8 @@ export const campaignDemandCreateSchema = z.object({
activity: positiveRelationshipId.optional(),
/** Staff may record on behalf of a leadership; leaders get their own. */
leadership: positiveRelationshipId.optional(),
/** C143 — explicit responsibles; the creator is unioned in by the collection hook. */
responsibles: z.array(positiveRelationshipId).default([]),
})
export const campaignDemandUpdateSchema = z.object({
@ -36,7 +38,14 @@ export const campaignDemandCostSchema = z.object({
cost: z.number().min(0).max(100_000_000).nullable(),
})
/** C143 — full-replace of the responsible list (advisory-locked by the action). */
export const campaignDemandResponsiblesSchema = z.object({
id: positiveRelationshipId,
responsibles: z.array(positiveRelationshipId).default([]),
})
export type CampaignDemandCreateInput = z.input<typeof campaignDemandCreateSchema>
export type CampaignDemandUpdateInput = z.input<typeof campaignDemandUpdateSchema>
export type CampaignDemandTransitionInput = z.input<typeof campaignDemandTransitionSchema>
export type CampaignDemandCostInput = z.input<typeof campaignDemandCostSchema>
export type CampaignDemandResponsiblesInput = z.input<typeof campaignDemandResponsiblesSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
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_demand_rels" ADD COLUMN "campaign_user_id" integer;
ALTER TABLE "campaign_demand_rels" ADD CONSTRAINT "campaign_demand_rels_campaign_user_fk" FOREIGN KEY ("campaign_user_id") REFERENCES "public"."campaign_user"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "campaign_demand_rels_campaign_user_id_idx" ON "campaign_demand_rels" USING btree ("campaign_user_id");
-- C143 backfill: pre-existing demands keep their creator as responsible, so
-- the explicit-responsible rule does not hide a demand from its own author.
DO $$
DECLARE backfilled integer;
BEGIN
INSERT INTO "campaign_demand_rels" ("order", "parent_id", "path", "campaign_user_id")
SELECT 0, "id", 'responsibles', "created_by_id"
FROM "campaign_demand"
WHERE "created_by_id" IS NOT NULL;
GET DIAGNOSTICS backfilled = ROW_COUNT;
RAISE NOTICE 'add_campaign_demand_responsibles: backfilling % demanda(s) com o criador como responsável', backfilled;
END $$;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaign_demand_rels" DROP CONSTRAINT "campaign_demand_rels_campaign_user_fk";
DROP INDEX "campaign_demand_rels_campaign_user_id_idx";
ALTER TABLE "campaign_demand_rels" DROP COLUMN "campaign_user_id";`)
}

View file

@ -57,6 +57,7 @@ import * as migration_20260811_234822_add_state_deputy_ballot_name from './20260
import * as migration_20260818_111748_add_social_feed_settings from './20260818_111748_add_social_feed_settings';
import * as migration_20260818_192948_add_instagram_social_feed_fields from './20260818_192948_add_instagram_social_feed_fields';
import * as migration_20260819_192605_add_campaign_newsletter_capture from './20260819_192605_add_campaign_newsletter_capture';
import * as migration_20260819_233503_add_campaign_demand_responsibles from './20260819_233503_add_campaign_demand_responsibles';
export const migrations = [
{
@ -354,4 +355,9 @@ export const migrations = [
down: migration_20260819_192605_add_campaign_newsletter_capture.down,
name: '20260819_192605_add_campaign_newsletter_capture'
},
{
up: migration_20260819_233503_add_campaign_demand_responsibles.up,
down: migration_20260819_233503_add_campaign_demand_responsibles.down,
name: '20260819_233503_add_campaign_demand_responsibles'
},
];

View file

@ -717,6 +717,10 @@ export interface CampaignDemand {
municipality: number | Municipality;
activity?: (number | null) | Activity;
leadership?: (number | null) | Leadership;
/**
* Só responsáveis, candidato e coordenador veem a demanda. O criador entra automaticamente.
*/
responsibles?: (number | CampaignUser)[] | null;
status: 'aberta' | 'em_analise' | 'escalada' | 'aprovada' | 'rejeitada';
decisionNote?: string | null;
decidedBy?: (number | null) | CampaignUser;
@ -1781,6 +1785,7 @@ export interface CampaignDemandSelect<T extends boolean = true> {
municipality?: T;
activity?: T;
leadership?: T;
responsibles?: T;
status?: T;
decisionNote?: T;
decidedBy?: T;

View file

@ -2,7 +2,7 @@
// Campaign demands
// ---------------------------------------------------------------------------
import type { Access } from 'payload'
import type { Access, Where } from 'payload'
import { relationshipId } from '@/lib/relationship'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
@ -11,7 +11,6 @@ import {
isCampaignLeader,
isCampaignUnrestricted,
isPayloadAdmin,
resolveActorScopedRead,
} from '@/utilities/access/shared'
export const canCreateCampaignDemand: Access = async ({ data, req }) => {
@ -30,11 +29,28 @@ export const canCreateCampaignDemand: Access = async ({ data, req }) => {
return municipalityIDs?.includes(municipalityID) ?? false
}
export const canReadCampaignDemand: Access = ({ req }) =>
resolveActorScopedRead(req, 'municipality', getAccessibleMunicipalityIds)
/**
* C143 — explicit-responsible visibility, fail-closed. An advisor reads ONLY
* demands where they are a listed responsible (the creator is auto-added on
* create); a municipality-related advisor who is not responsible sees nothing
* — not the list, not the URL, not the search (all reads run the collection
* access). Candidate and coordinator (unrestricted) always see everything.
*/
export const canReadCampaignDemand: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser || isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser.role !== 'advisor') return false
return { responsibles: { contains: currentUser.id } }
}
/**
* Staff manage demands in their municipalities; leaders have no demand access.
* Staff manage demands they can see — same row scope as read, which is also
* who may manage the `responsibles` list (C143).
*/
export const canUpdateCampaignDemand: Access = canReadCampaignDemand

View file

@ -13,6 +13,7 @@ import {
type DemandListState,
} from '@/utilities/demand/demandListUrl'
import {
loadCampaignUserDisplayByIds,
loadCampaignUserNamesByIds,
loadLeadershipContactNamesByIds,
loadMunicipalityLabelsByIds,
@ -148,6 +149,10 @@ export type DemandDetailViewModel = DemandRowViewModel & {
authorName: string | null
createdAt: string | null
}>
/** C143 — explicit responsibles (creator included by the collection hook). */
responsibles: Array<{ id: number; name: string; avatarUrl: string | null }>
/** C143 — the creator id, for the "(criador)" chip marker on the detail. */
createdBy: number | null
canLeaderEdit: boolean
activity: { title: string; slug: string } | null
}
@ -183,11 +188,21 @@ export const loadDemandDetail = async (
]
const authorNameById = await loadCampaignUserNamesByIds(payload, authorIDs)
const responsibleIDs = (demand.responsibles ?? [])
.map((responsible) => relationshipId(responsible))
.filter((id): id is number => id !== null)
const responsibleById = await loadCampaignUserDisplayByIds(payload, responsibleIDs)
return {
...row,
description: demand.description ?? null,
decisionNote: demand.decisionNote ?? null,
decidedAt: demand.decidedAt ?? null,
createdBy: relationshipId(demand.createdBy),
responsibles: responsibleIDs.flatMap((id) => {
const display = responsibleById.get(id)
return display ? [{ id, ...display }] : []
}),
cost: isCampaignStaff(user) ? (demand.cost ?? null) : null,
receipts: isCampaignStaff(user)
? (demand.receipts ?? []).flatMap((receipt) =>

View file

@ -1,5 +1,6 @@
import {
nullableRelationshipFormValue,
repeatedRelationshipFormValues,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
@ -22,5 +23,6 @@ export const parseCampaignDemandCreateFormData = (
description: requiredFormText(formData, 'description'),
municipality: requiredRelationshipFormValue(formData, 'municipalityId'),
activity: nullableRelationshipFormValue(formData, 'activityId') ?? undefined,
responsibles: repeatedRelationshipFormValues(formData, 'responsibles'),
}
}

View file

@ -0,0 +1,102 @@
import {
campaignPageChrome,
expect,
test,
type CampaignE2EFixture,
} from './fixtures/campaignE2EFixtures.js'
test.describe('C143 — demand visibility (explicit responsibles)', () => {
const setupDemand = async (
campaign: CampaignE2EFixture,
fixtures: CampaignE2EFixture['fixtures'],
) => {
const municipality = await fixtures.claimMunicipality()
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora C143'),
})
const responsible = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessora Responsável'),
})
const peer = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessor Colega'),
})
await campaign.payload.update({
collection: 'municipality',
id: municipality.id,
data: { advisors: [responsible.id, peer.id] },
depth: 0,
})
const demand = await fixtures.createCampaignDemand({
municipality: municipality.id,
createdBy: coordinator.id,
responsibles: [responsible.id],
title: fixtures.value('Demanda de acesso restrito'),
})
return { municipality, coordinator, responsible, peer, demand }
}
test('hides the demand from a same-municipality advisor who is not responsible', async ({
campaign,
context,
page,
}) => {
const { fixtures } = campaign
const { peer, demand } = await setupDemand(campaign, fixtures)
await campaign.sessionFor(context, peer)
await page.goto(`${campaign.baseURL}/campanha/demandas`)
// The list rendered for him: the empty state proves the page settled
// (and that the demand is absent — the link assertion below is the
// fail-closed complement).
await expect(page.getByText('Nenhuma demanda por aqui')).toBeVisible()
await expect(page.getByRole('link', { name: demand.title })).toHaveCount(0)
// The URL does not open either — the demand simply does not exist for him
// (the default 404 boundary is the root one, no campaign-specific page).
await page.goto(`${campaign.baseURL}/campanha/demandas/${demand.slug}`)
await expect(page.getByText('This page could not be found.')).toBeVisible()
})
test('lets a responsible hand access to a peer and revoke it again', async ({
campaign,
context,
page,
}) => {
const { fixtures } = campaign
const { peer, responsible, demand } = await setupDemand(campaign, fixtures)
const demandURL = `${campaign.baseURL}/campanha/demandas/${demand.slug}`
// The responsible manages the list: add the peer through the search dialog.
await campaign.sessionFor(context, responsible)
await page.goto(demandURL)
await expect(page.getByText('Só quem é responsável vê esta demanda.')).toBeVisible()
// The trigger announces the current selection (e.g. "…: 1 responsável").
await page.getByRole('button', { name: /Responsáveis pela demanda/ }).click()
await page.getByPlaceholder('Buscar assessor…').fill(peer.name)
await page.getByRole('option', { name: peer.name }).click()
// The multi-add dialog stays open (modal) — close it before saving.
await page.keyboard.press('Escape')
await page.getByRole('button', { name: 'Salvar responsáveis' }).click()
await expect(page.getByText('Responsáveis atualizados.')).toBeVisible()
await expect(page.getByRole('button', { name: `Remover ${peer.name}` })).toBeVisible()
// The peer now opens the demand.
await campaign.sessionFor(context, peer)
await page.goto(demandURL)
await expect(campaignPageChrome(page, demand.title)).toBeVisible()
// Back as the responsible, remove the peer and revoke the access again.
await campaign.sessionFor(context, responsible)
await page.goto(demandURL)
await page.getByRole('button', { name: `Remover ${peer.name}` }).click()
await page.getByRole('button', { name: 'Salvar responsáveis' }).click()
await expect(page.getByRole('button', { name: `Remover ${peer.name}` })).toHaveCount(0)
await campaign.sessionFor(context, peer)
await page.goto(demandURL)
await expect(page.getByText('This page could not be found.')).toBeVisible()
})
})

View file

@ -220,6 +220,32 @@ export class CampaignE2EOwnership {
return { contactName, contactId: contact.id, leadershipId: leadership.id }
}
/**
* C143 — demand factory with explicit ownership: the row is registered for
* cleanup and the municipality touched (mirror of the int fixture helper).
*/
async createCampaignDemand(input: {
municipality: number
title: string
createdBy?: number
responsibles?: number[]
}): Promise<{ id: number; slug: string; title: string }> {
const demand = await this.payload.create({
collection: 'campaignDemand',
data: {
kind: 'material',
slug: this.value('demanda'),
status: 'aberta',
...input,
},
depth: 0,
draft: false,
})
this.own('campaignDemand', demand.id)
this.touchMunicipality(input.municipality)
return demand
}
private isOwnedCollection(collection: CollectionSlug): collection is OwnedCollection {
return this.owned.has(collection as OwnedCollection)
}

View file

@ -106,7 +106,14 @@ type StateDeputyInput = Partial<
type CampaignDemandInput = Partial<
Pick<
CampaignDemand,
'title' | 'slug' | 'kind' | 'description' | 'status' | 'leadership' | 'createdBy'
| 'title'
| 'slug'
| 'kind'
| 'description'
| 'status'
| 'leadership'
| 'createdBy'
| 'responsibles'
>
> &
Pick<CampaignDemand, 'municipality'>
@ -659,6 +666,7 @@ export class CampaignFixtures {
municipality: relationId(input.municipality),
...(input.leadership ? { leadership: relationId(input.leadership) } : {}),
...(input.createdBy ? { createdBy: relationId(input.createdBy) } : {}),
...(input.responsibles ? { responsibles: input.responsibles } : {}),
},
depth: 0,
})

View file

@ -7,6 +7,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
import {
createCampaignDemandRecord,
setCampaignDemandCostRecord,
setCampaignDemandResponsiblesRecord,
transitionCampaignDemandRecord,
updateCampaignDemandRecord,
} from '@/app/(campaign)/campanha/actions/demand'
@ -253,9 +254,10 @@ describe('campaign demand workflow (staff-only)', () => {
expect(staffRead.cost).toBe(1500.5)
})
it('scopes advisors to demands of administered municipalities', async () => {
it('shows demands only to explicit responsibles (C143)', async () => {
const { fixtures, municipality, advisor } = await createScenario()
const outsideAdvisor = await fixtures.createCampaignUser('advisor')
const peerAdvisor = await fixtures.createCampaignUser('advisor')
await fixtures.assignMunicipalityAdvisors(municipality.id, [advisor.id, peerAdvisor.id])
const demand = await createCampaignDemandRecord(payload, advisor, {
kind: 'material',
@ -264,15 +266,213 @@ describe('campaign demand workflow (staff-only)', () => {
})
fixtures.own('campaignDemand', demand.id)
// The creator enters the responsible list automatically (C143 contract).
const raw = await payload.findByID({
collection: 'campaignDemand',
id: demand.id,
depth: 0,
overrideAccess: true,
})
expect(raw.responsibles).toContain(advisor.id)
const creatorRead = await payload.find({
collection: 'campaignDemand',
where: { id: { equals: demand.id } },
depth: 0,
pagination: false,
user: advisor,
overrideAccess: false,
})
expect(creatorRead.docs).toHaveLength(1)
// Same-municipality advisor who is not a responsible sees nothing —
// fail-closed, no municipality inheritance.
const peerRead = await payload.find({
collection: 'campaignDemand',
where: { id: { equals: demand.id } },
depth: 0,
pagination: false,
user: peerAdvisor,
overrideAccess: false,
})
expect(peerRead.docs).toHaveLength(0)
})
it('keeps demands without responsibles readable only by unrestricted roles (C143 fail-closed)', async () => {
const { fixtures, municipality, coordinator, candidate, advisor, leaderAccount } =
await createScenario()
const demand = await fixtures.createCampaignDemand({
municipality: municipality.id,
createdBy: coordinator.id,
})
for (const actor of [coordinator, candidate]) {
const visible = await payload.find({
collection: 'campaignDemand',
where: { id: { equals: demand.id } },
depth: 0,
pagination: false,
user: actor,
overrideAccess: false,
})
expect(visible.docs).toHaveLength(1)
}
// Advisor: the Where scope filters the row out; leader: access is a hard
// false, so Payload refuses the read outright.
const advisorRead = await payload.find({
collection: 'campaignDemand',
where: { id: { equals: demand.id } },
depth: 0,
pagination: false,
user: advisor,
overrideAccess: false,
})
expect(advisorRead.docs).toHaveLength(0)
await expect(
payload.find({
collection: 'campaignDemand',
where: { id: { equals: demand.id } },
depth: 0,
pagination: false,
user: leaderAccount,
overrideAccess: false,
}),
).rejects.toThrow(/permissão/i)
})
it('manages the responsible list within the visibility rule (C143)', async () => {
const { fixtures, municipality, coordinator, advisor } = await createScenario()
const secondAdvisor = await fixtures.createCampaignUser('advisor')
await fixtures.assignMunicipalityAdvisors(municipality.id, [advisor.id, secondAdvisor.id])
const demand = await createCampaignDemandRecord(payload, advisor, {
kind: 'material',
description: fixtures.value('Comunicação'),
municipality: municipality.id,
})
fixtures.own('campaignDemand', demand.id)
// The responsible adds a peer.
await setCampaignDemandResponsiblesRecord(payload, advisor, {
id: demand.id,
responsibles: [advisor.id, secondAdvisor.id],
})
const raw = await payload.findByID({
collection: 'campaignDemand',
id: demand.id,
depth: 0,
overrideAccess: true,
})
expect(raw.responsibles).toEqual(expect.arrayContaining([advisor.id, secondAdvisor.id]))
// The added peer can now read the demand.
const peerRead = await payload.find({
collection: 'campaignDemand',
where: { id: { equals: demand.id } },
depth: 0,
pagination: false,
user: secondAdvisor,
overrideAccess: false,
})
expect(peerRead.docs).toHaveLength(1)
// A non-responsible cannot manage the list (update scope = read scope).
const outsideAdvisor = await fixtures.createCampaignUser('advisor')
await expect(
setCampaignDemandResponsiblesRecord(payload, outsideAdvisor, {
id: demand.id,
responsibles: [outsideAdvisor.id],
}),
).rejects.toThrow()
// Self-removal hands the demand off: the former responsible loses access.
await setCampaignDemandResponsiblesRecord(payload, advisor, {
id: demand.id,
responsibles: [secondAdvisor.id],
})
const afterRemoval = await payload.find({
collection: 'campaignDemand',
where: { id: { equals: demand.id } },
depth: 0,
pagination: false,
user: advisor,
overrideAccess: false,
})
expect(afterRemoval.docs).toHaveLength(0)
// An unrestricted coordinator can always restore/read it.
const coordinatorRead = await payload.find({
collection: 'campaignDemand',
where: { id: { equals: demand.id } },
depth: 0,
pagination: false,
user: coordinator,
overrideAccess: false,
})
expect(coordinatorRead.docs).toHaveLength(1)
})
it('lets a responsible outside the municipality portfolio read and manage (C143)', async () => {
const { fixtures, municipality, advisor } = await createScenario()
const remoteAdvisor = await fixtures.createCampaignUser('advisor')
const demand = await createCampaignDemandRecord(payload, advisor, {
kind: 'material',
description: fixtures.value('Demanda fora da carteira'),
municipality: municipality.id,
})
fixtures.own('campaignDemand', demand.id)
// The remote advisor administers no municipality of this demand, but an
// explicit responsibility grants visibility by design (no municipality
// inheritance — the link is the responsibility itself).
await setCampaignDemandResponsiblesRecord(payload, advisor, {
id: demand.id,
responsibles: [advisor.id, remoteAdvisor.id],
})
const visible = await payload.find({
collection: 'campaignDemand',
where: { id: { equals: demand.id } },
depth: 0,
pagination: false,
user: outsideAdvisor,
user: remoteAdvisor,
overrideAccess: false,
})
expect(visible.docs).toHaveLength(0)
expect(visible.docs).toHaveLength(1)
const managed = await setCampaignDemandResponsiblesRecord(payload, remoteAdvisor, {
id: demand.id,
responsibles: [remoteAdvisor.id],
})
expect(managed.responsibles).toEqual([remoteAdvisor.id])
})
it('drops non-eligible ids from the responsible list (C143 fail-closed)', async () => {
const { fixtures, municipality, advisor, leaderAccount } = await createScenario()
const demand = await createCampaignDemandRecord(payload, advisor, {
kind: 'material',
description: fixtures.value('Lista limpa'),
municipality: municipality.id,
})
fixtures.own('campaignDemand', demand.id)
// A forged request naming a leader as responsible: the beforeValidate
// hook intersects with eligible staff, so the leader never lands on the
// list (they read hard-false anyway — this pins the invariant server-side).
await setCampaignDemandResponsiblesRecord(payload, advisor, {
id: demand.id,
responsibles: [advisor.id, leaderAccount.id],
})
const raw = await payload.findByID({
collection: 'campaignDemand',
id: demand.id,
depth: 0,
overrideAccess: true,
})
expect(raw.responsibles).toEqual([advisor.id])
})
it('keeps the previous title and slug when the edited description has no AI', async () => {

View file

@ -64,7 +64,7 @@ describe('searchHomeDemands (B53)', () => {
expect(result.some((row) => row.id === demand.id)).toBe(false)
})
it('scopes demand hits to the advisor portfolio by municipality', async () => {
it('scopes demand hits to explicit responsibles (C143)', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const coordinator = await fixtures.createCampaignUser('coordinator')
@ -74,23 +74,30 @@ describe('searchHomeDemands (B53)', () => {
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const scopeToken = fixtures.value('scope-demanda')
const inScope = await fixtures.createCampaignDemand({
const responsible = await fixtures.createCampaignDemand({
municipality: administered,
title: `Material ${scopeToken} ${administered.name}`,
createdBy: coordinator,
responsibles: [advisor.id],
})
const outOfScope = await fixtures.createCampaignDemand({
const notResponsible = await fixtures.createCampaignDemand({
// Same administered municipality — but no explicit responsibility.
municipality: administered,
title: `Material ${scopeToken} ${administered.name} fora`,
createdBy: coordinator,
})
const outOfPortfolio = await fixtures.createCampaignDemand({
municipality: other,
title: `Material ${scopeToken} ${other.name}`,
createdBy: coordinator,
})
const advisorResult = await searchHomeDemands(payload, advisor, scopeToken)
expect(advisorResult.map((hit) => hit.id)).toEqual([inScope.id])
expect(advisorResult.map((hit) => hit.id)).toEqual([responsible.id])
const coordinatorResult = await searchHomeDemands(payload, coordinator, scopeToken)
expect(coordinatorResult.map((hit) => hit.id).sort((a, b) => a - b)).toEqual(
[inScope.id, outOfScope.id].sort((a, b) => a - b),
[responsible.id, notResponsible.id, outOfPortfolio.id].sort((a, b) => a - b),
)
})