From 2cc8f2c14db29c275268ca566d599f2c6ad31a72 Mon Sep 17 00:00:00 2001
From: Francisco Solla
Date: Wed, 19 Aug 2026 21:56:17 -0300
Subject: [PATCH] =?UTF-8?q?feat(C143):=20demandas=20vis=C3=ADveis=20apenas?=
=?UTF-8?q?=20para=20respons=C3=A1veis=20expl=C3=ADcitos=20+=20candidato/c?=
=?UTF-8?q?oordenador?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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)
---
docs/changelog/2026-08-19-c143.md | 1 +
docs/plans/demandas-responsaveis-impl.md | 89 +
.../campanha/(app)/acoes/[slug]/page.tsx | 1 +
.../(app)/demandas/[slug]/formActions.ts | 20 +
.../campanha/(app)/demandas/[slug]/page.tsx | 175 +-
.../campanha/(app)/demandas/nova/page.tsx | 1 +
.../demandas/responsibleSearchActions.ts | 61 +
src/app/(campaign)/campanha/actions/demand.ts | 54 +-
src/collections/CampaignDemand.ts | 63 +-
.../campaign/demand/DemandFields.tsx | 58 +-
src/components/campaign/demand/DemandForm.tsx | 14 +-
.../demand/DemandResponsibleMultiSelect.tsx | 216 +
.../demand/DemandResponsiblesCard.tsx | 90 +
.../shared/WizardRegisterDemandStep.tsx | 12 +
src/lib/schemas/campaignDemand.ts | 5 +
src/lib/schemas/campaignDemandInput.ts | 9 +
...3503_add_campaign_demand_responsibles.json | 13783 ++++++++++++++++
...233503_add_campaign_demand_responsibles.ts | 29 +
src/migrations/index.ts | 6 +
src/payload-types.ts | 5 +
src/utilities/access/demands.ts | 26 +-
src/utilities/campaignDemandData.ts | 15 +
.../demand/campaignDemandFormData.ts | 2 +
.../e2e/campaignDemandVisibility.e2e.spec.ts | 102 +
tests/e2e/fixtures/campaignE2EFixtures.ts | 26 +
tests/helpers/campaignFixtures.ts | 10 +-
tests/int/campaignDemandWorkflow.int.spec.ts | 208 +-
tests/int/homeSearchDemands.int.spec.ts | 17 +-
28 files changed, 15001 insertions(+), 97 deletions(-)
create mode 100644 docs/changelog/2026-08-19-c143.md
create mode 100644 docs/plans/demandas-responsaveis-impl.md
create mode 100644 src/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions.ts
create mode 100644 src/components/campaign/demand/DemandResponsibleMultiSelect.tsx
create mode 100644 src/components/campaign/demand/DemandResponsiblesCard.tsx
create mode 100644 src/migrations/20260819_233503_add_campaign_demand_responsibles.json
create mode 100644 src/migrations/20260819_233503_add_campaign_demand_responsibles.ts
create mode 100644 tests/e2e/campaignDemandVisibility.e2e.spec.ts
diff --git a/docs/changelog/2026-08-19-c143.md b/docs/changelog/2026-08-19-c143.md
new file mode 100644
index 00000000..300da9a1
--- /dev/null
+++ b/docs/changelog/2026-08-19-c143.md
@@ -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`.
diff --git a/docs/plans/demandas-responsaveis-impl.md b/docs/plans/demandas-responsaveis-impl.md
new file mode 100644
index 00000000..ea8a1502
--- /dev/null
+++ b/docs/plans/demandas-responsaveis-impl.md
@@ -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
hasMany → campaignUser] --> A[canRead/UpdateCampaignDemand
advisor → responsibles contains id]
+ H[enforceDemandWorkflow
criador vira responsável na criação] --> F
+ M[Migration: campaign_demand_rels
+campaign_user_id + backfill createdBy] --> F
+ S[updateCampaignDemandResponsibles
tx + advisory lock] --> A
+ U[DemandResponsibleMultiSelect
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)
diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx
index 247dbaf3..71ae3758 100644
--- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx
@@ -199,6 +199,7 @@ export default async function CampaignActionWizardPage({
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
returnPath={returnPath}
+ currentUser={{ id: user.id, name: user.name }}
/>
)
}
diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts b/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts
index cdbcf193..321661d1 100644
--- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts
+++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts
@@ -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 =>
+ 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,
diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx
index 15a9619e..a1c0ce17 100644
--- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx
@@ -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))}
-
+
+
+
- {demand.decisionNote && (demand.status === 'aprovada' || demand.status === 'rejeitada') ? (
-
- Nota da decisão
-
- {demand.decisionNote}
-
- {demand.decidedAt ? (
-
- Decidida em {dateTimeFormatter.format(new Date(demand.decidedAt))}
-
+ {demand.decisionNote &&
+ (demand.status === 'aprovada' || demand.status === 'rejeitada') ? (
+
+ Nota da decisão
+
+ {demand.decisionNote}
+
+ {demand.decidedAt ? (
+
+ Decidida em {dateTimeFormatter.format(new Date(demand.decidedAt))}
+
+ ) : null}
+
) : null}
-
- ) : null}
-
+
-
-
- Controle interno
-
-
- Custo: {demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
-
- {demand.receipts.length ? (
-
- ) : (
- Nenhum comprovante anexado.
- )}
- {demand.statusHistory.length ? (
-
-
Histórico
-
- {demand.statusHistory.map((entry, index) => (
- -
- {campaignDemandStatusLabels[entry.status]}
- {entry.authorName ? ` — ${entry.authorName}` : ''}
- {entry.createdAt
- ? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
- : ''}
- {entry.note ? ` · ${entry.note}` : ''}
-
- ))}
-
-
- ) : null}
-
+
+
+ Controle interno
+
+
+ Visível para: Responsáveis ·{' '}
+ Candidato ·{' '}
+ Coordenador
+
+
+ Custo:{' '}
+ {demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
+
+ {demand.receipts.length ? (
+
+ ) : (
+ Nenhum comprovante anexado.
+ )}
+ {demand.statusHistory.length ? (
+
+
Histórico
+
+ {demand.statusHistory.map((entry, index) => (
+ -
+ {campaignDemandStatusLabels[entry.status]}
+ {entry.authorName ? ` — ${entry.authorName}` : ''}
+ {entry.createdAt
+ ? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
+ : ''}
+ {entry.note ? ` · ${entry.note}` : ''}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
0 ? demand.municipalityId : null}
+ creatorUserId={demand.createdBy}
+ initialResponsibles={demand.responsibles}
+ formAction={setDemandResponsiblesFormAction}
+ />
+
)
}
diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
index 91b08834..be7565a8 100644
--- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
@@ -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}
/>
diff --git a/src/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions.ts b/src/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions.ts
new file mode 100644
index 00000000..42e211db
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/demandas/responsibleSearchActions.ts
@@ -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 => {
+ 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 }))
+}
diff --git a/src/app/(campaign)/campanha/actions/demand.ts b/src/app/(campaign)/campanha/actions/demand.ts
index 5c011378..1916ca84 100644
--- a/src/app/(campaign)/campanha/actions/demand.ts
+++ b/src/app/(campaign)/campanha/actions/demand.ts
@@ -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)
+}
diff --git a/src/collections/CampaignDemand.ts b/src/collections/CampaignDemand.ts
index 7bd523a3..d834ccb8 100644
--- a/src/collections/CampaignDemand.ts
+++ b/src/collections/CampaignDemand.ts
@@ -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',
diff --git a/src/components/campaign/demand/DemandFields.tsx b/src/components/campaign/demand/DemandFields.tsx
index 20fe57cc..9a36dce6 100644
--- a/src/components/campaign/demand/DemandFields.tsx
+++ b/src/components/campaign/demand/DemandFields.tsx
@@ -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
+ 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 = ({
)
- return (
+ const coreFields = (
<>
{municipality ? (
@@ -172,4 +185,47 @@ export const DemandFields = ({
>
)
+
+ if (!responsibles) return coreFields
+
+ const creatorValue: DemandResponsibleOption[] = responsibles.currentUser
+ ? [
+ {
+ id: responsibles.currentUser.id,
+ name: responsibles.currentUser.name,
+ },
+ ]
+ : []
+
+ const responsiblesField = (
+
+ {CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}
+
+ {CAMPAIGN_DEMAND_RESPONSIBLES_DESCRIPTION}
+
+ )
+
+ if (responsibles.layout === 'aside') {
+ return (
+
+
{coreFields}
+ {responsiblesField}
+
+ )
+ }
+
+ return (
+ <>
+ {coreFields}
+ {responsiblesField}
+ >
+ )
}
diff --git a/src/components/campaign/demand/DemandForm.tsx b/src/components/campaign/demand/DemandForm.tsx
index a7811038..bd7d10ba 100644
--- a/src/components/campaign/demand/DemandForm.tsx
+++ b/src/components/campaign/demand/DemandForm.tsx
@@ -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 (
-