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:
parent
966862c801
commit
2cc8f2c14d
28 changed files with 15001 additions and 97 deletions
1
docs/changelog/2026-08-19-c143.md
Normal file
1
docs/changelog/2026-08-19-c143.md
Normal 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`.
|
||||
89
docs/plans/demandas-responsaveis-impl.md
Normal file
89
docs/plans/demandas-responsaveis-impl.md
Normal 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)
|
||||
|
|
@ -199,6 +199,7 @@ export default async function CampaignActionWizardPage({
|
|||
municipalityName={municipality.name}
|
||||
municipalitySlug={municipality.slug}
|
||||
returnPath={returnPath}
|
||||
currentUser={{ id: user.id, name: user.name }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
216
src/components/campaign/demand/DemandResponsibleMultiSelect.tsx
Normal file
216
src/components/campaign/demand/DemandResponsibleMultiSelect.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
90
src/components/campaign/demand/DemandResponsiblesCard.tsx
Normal file
90
src/components/campaign/demand/DemandResponsiblesCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
13783
src/migrations/20260819_233503_add_campaign_demand_responsibles.json
Normal file
13783
src/migrations/20260819_233503_add_campaign_demand_responsibles.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
tests/e2e/campaignDemandVisibility.e2e.spec.ts
Normal file
102
tests/e2e/campaignDemandVisibility.e2e.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue