Merge pull request #435 from fsolla/C90-remodelar-responsaveis-da-atividade-campo-unico-polimorf

C90 — remodela responsáveis da Atividade (campo único polimórfico)
This commit is contained in:
Francisco Solla 2026-08-08 17:25:02 -03:00 committed by GitHub
commit 09d44c1b9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 14244 additions and 305 deletions

View file

@ -8,6 +8,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).
**Recently resolved (2026-08-08):** **C90** (Issue #426) — os três campos de responsável da Atividade (`responsible` Contact, `advisors` staff, `leadership`) viraram **um campo `responsible` polimórfico multi-valor** (`campaignUser` staff · `leadership` · `stateDeputy`), removendo os dois antigos do modelo/UI; assessor continua vendo/curando o compromisso onde está listado como responsável via notação-objeto `equals {relationTo,value}` no access (provado por int test com `payload.find` real); seletor `ResponsibleMultiSelect` (chips tipados) + busca única `searchActivityResponsibleOptions` (escopo de leitura do ator, liderança engajada) — reusado pelo C91 na agenda; migração esquema+dados (advisors/leadership → `responsible`, Contact antigo descartado, contagem via NOTICE; SQL de reconciliação ensaiado contra cenário misto); busca `q` da lista virou título-only (regressão assumida no gate); notificação de atenção notifica só staff em `responsible`. Intenção [`remodelar-responsaveis-atividade.md`](docs/plans/remodelar-responsaveis-atividade.md); impl [`remodelar-responsaveis-atividade-impl.md`](docs/plans/remodelar-responsaveis-atividade-impl.md).
**Recently resolved (2026-08-08):** **worktree next provisiona ambiente isolado por worktree** — agentes em paralelo não disputam mais porta 3000, o `teqo_test` compartilhado nem um segundo Postgres no 5432: `pnpm worktree next [--go] [--no-migrate]` deriva deterministicamente slot do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos) → dev server `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test` criados no container compartilhado com migrations aplicadas; escreve `.env.local` + `.env.test.local` (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD*SECRET/Blob/VAPID do main. `kill` dropa os bancos gerados (best-effort, WITH FORCE). Suites leem `.env.test.local` depois de `.env.test` (vitest.setup/playwright/db-doctor/gate-ci espelham o layering); `assertTestDatabase` aceita `teqo_test` ou `teqo*<worktree>\_test`em localhost só (fail-closed preservado).`db:start`/`db:stop`fixam`-p teqo`(um container, qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação pura`scripts/lib/worktree-env.mjs` (unit-tested).
**Recently resolved (2026-08-08):** **B164** (Issue #400) — barra de navegação inferior fixa no mobile para staff, com 5 itens (Início, Municípios, Atualizações, Agenda, Mais) e drawer overflow "Mais" para destinos secundários (Quadro, Territórios, Organizações, Líderanças, etc.) + rodapé Perfil/Sair. Leader e desktop mantêm sidebar-only (lockdown intacto). `CampaignContentScroll` e `CampaignQuickActionsHost` ajustados para safe-area e z-index do FAB sobre a barra. `/campanha/atualizacoes` é placeholder honesto (âncora C89). Impl [`barra-navegacao-inferior-mobile-impl.md`](docs/plans/barra-navegacao-inferior-mobile-impl.md).

View file

@ -0,0 +1,135 @@
# Impl: C90 — Remodelar responsáveis da Atividade — campo único polimórfico multi-valor
Status: em execução
Atualizado em: 2026-08-08
Issue: #426
Intenção: docs/plans/remodelar-responsaveis-atividade.md
Appetite restante: herdado (~1–1,5 dia eng). Sem cortes no appetite — a mudança é serializada (schema→server→UI→gates) e não cria entidade nova.
## Leitura da intenção
- **Outcome:** uma Atividade tem UM campo `responsible` polimórfico multi-valor (staff `campaignUser` nas funções assessor/candidato/coordenador, `leadership`, `stateDeputy`); `advisors` e `leadership` saem do modelo e da UI; a informação migra para `responsible`; o assessor mantém ver/editar quando está listado como responsável (mesmo direito que `advisors` dava).
- **O que NÃO negociar:** campo único (não 3 campos); sem segundo cadastro de pessoa (seletor só escolhe existentes); sem papel novo; assessor continua vendo/curando o compromisso em que está listado; `responsible` opcional (obrigatório só título/município); `tasks[].responsible` (Contact) NÃO muda.
- **O que reavaliar (engenharia):**
1. A leg do assessor em `canReadActivity` hoje é `advisors: { contains: user.id }`. Campo polimórfico hasMany **não aceita** `contains` escalar (o query-builder do adapter não suporta where por id escalar em relação polimórfica hasMany — só a notação-objeto `{ relationTo, value }` com `equals`). → decisão de acesso abaixo.
2. A busca `q` da lista usa `'responsible.name'`, impossível de manter em polimórfico → vira título-only (perda pequena, sinalizada no gate).
3. `notifyActivityNeedsAttention` usava `doc.advisors` → passa a filtrar entradas `campaignUser` de `responsible`.
4. O `responsible` antigo é um **Contact** — Contact não é tipo válido no novo union. O valor antigo não tem destino; decisão de dados no gate.
## Abordagem recomendada
```mermaid
flowchart LR
A[Activity.ts: responsible polimórfico hasMany<br/>relationTo campaignUser|leadership|stateDeputy<br/>remove advisors + leadership] --> B[validateActivityResponsibles hook<br/>+ auto-include assessor no create]
B --> C[access/activities.ts: leg assessor =<br/>responsible equals {relationTo campaignUser, value me}]
C --> D[lib/schemas + activityFormData: responsiblesJson<br/>bounded, shape {relationTo, value}]
D --> E[action searchActivityResponsibleOptions 3 catálogos<br/>+ ResponsibleMultiSelect novo componente]
E --> F[ActivityForm/OverviewTab/ActivityCard/viewModels<br/>chips tipados]
A --> G[migration: activity_rels +leadership_id+statedeputy_id<br/>drop activity.responsible_id/leadership_id<br/>migra advisors→responsible, leadership→responsible]
```
**Opções consideradas:** A) campo polimórfico puro (payload `relationTo: [...]`, `hasMany`) como a intenção manda | B) campo polimórfico + coluna derivada escondida `assignedStaffIds` para o acesso do assessor | C) manter sessões separadas e só juntar na UI.
**Recomendação: A** — é o modelo de domínio da intenção, sem twin de dados; o acesso por notação-objeto `equals {relationTo,value}` resolve o escopo do assessor dentro do próprio campo (provado por teste int que roda a query real).
**Rejeitadas:** B porque duplica o dado do `responsible` num campo derivado (viola "edit the owner, don't twin" e drift não é barato); C porque não atende o aceite de produto (campo único).
### Decisões de engenharia (forma obrigatória)
**D1 — Leg do assessor no read/update:**
- Opções: A) `{ responsible: { equals: { relationTo: 'campaignUser', value: id } } }` (notação-objeto; o único operador aceito pelo adapter em polimórfico é `equals`) | B) `{ responsible: { contains: id } }` escalar | C) consulta dupla (município scope + lookup pós-fetch).
- Recomendação: A — expressivo, suportado (verificado no fonte do `@payloadcms/drizzle`, `sanitizeQueryValue`/`getTableColumnFromPath`), PROVADO por teste int que roda `payload.find` como o assessor.
- Rejeitadas: B — o query-builder não tem caminho para id escalar em relação polimórfica hasMany (cairia em recursão sem tabela resolvida); C — fere a semântica de `Access` que precisa devolver `Where`.
**D2 — Edição do `responsible` (field access):**
- Opções: A) staff pode gravar o campo (mesmo esboço do `advisors` que era coordinator-only some) | B) manter gate coordinator-only | C) só hook de validate.
- Recomendação: A — o produto diz "staff cria; assessor edita o que administra + o que lhe é imputado" sem gate de coordenador; o row access (`canUpdateActivity`) já restringe o assessor ao escopo. `canCreateActivityAdvisors`/`canManageActivityAdvisors` saem; entra `canSetActivityResponsible` = staff.
- Rejeitadas: B pois `advisors` coordinator-only era uma UI conveniente do Admin, não um requisito; o assessor precisa conseguir atribuir/editar responsáveis no que administra. C sozinho não protege o Admin.
**D3 — Busca `q` da lista de Atividades:**
- Opções: A) título-only | B) manter `responsible.name` (quebra em polimórfico) | C) resolver nomes pós-fetch.
- Recomendação: A (título-only) — a query aninhada `responsible.name` em polimórfico não é suportada; pós-fetch é caro e proporcional a nada. **Sinalizado no gate** (mudança visível: buscar por nome de responsável na lista deixa de casar).
- Rejeitadas: B (runtime error), C (overhead injustificável).
**D4 — Dados antigos na migração:**
- Opções: A) `advisors`→`responsible` (campaignUser), `leadership`→`responsible` (leadership), Contact antigo em `responsible_id` **descartado** (sem tipo válido no union, sem destino confiável) | B) tentar mapear Contact antigo para campaignUser/leadership por heurística | C) manter Contact como 4º tipo.
- Recomendação: A — fiel à intenção (o union é staff/leadership/dobradinha); Contact é exatamente o campo que o produto quer eliminar (o "Contact avulso"). **Decisão de dados confirmada no gate** antes de executar.
- Rejeitadas: B (heurística especulativa, risco de atribuir responsável errado); C (viola o aceite — Contact não é papel da campanha).
**D5 — Componente do seletor:**
- Opções: A) novo `ResponsibleMultiSelect` (chips + Command dialog + busca polimórfica agrupada por tipo) | B) estender `AsyncSearchCombobox` para multi+polimórfico | C) três seletores.
- Recomendação: A — o picker polimórfico multi-tipo (resultado tipado, chip tipado) é uma forma nova que nenhum primitivo expressa; `AsyncSearchCombobox` é single-value e usado em vários lugares (município/liderança); mudá-lo arrisca os outros call sites. Colocado em `campaign/shared/` para o C91 o reusar no overlay da agenda.
- Rejeitadas: B (twin-arriscado num primitivo compartilhado), C (anti-aceite).
### Componentes / mudanças
- **`Activity.ts`** (`src/collections/Activity.ts`): `responsible` → relationship polimórfico hasMany `relationTo: ['campaignUser', 'leadership', 'stateDeputy']`, `hasMany: true`, `index: true`, `filterOptions` por collection (`campaignUser`: `eligibleCampaignStaffWhere`), `admin.label` "Responsáveis". Remove `advisors` e `leadership`. Hook `validateActivityAdvisors` → `validateActivityResponsibles` (valida entradas: staff elegível p/ `campaignUser`, existência p/ `leadership`/`stateDeputy`, cap). `deriveActivityFields`: auto-include `[{relationTo:'campaignUser', value: user.id}]` no create do assessor; `activityStaffFieldSnapshot` re-expresso sobre `responsible`.
- **`src/utilities/access/activities.ts`**: `canReadActivity`/`canUpdateActivity` leg assessor = `{ or: [{ responsible: { equals: { relationTo: 'campaignUser', value: id } } }, advisorMunicipalityScopeWhere('municipality', ids)] }`. Remove `canCreateActivityAdvisors`/`canManageActivityAdvisors`; adiciona `canSetActivityResponsible` (staff). Re-export em `campaignAccess.ts`.
- **`src/lib/schemas/activity.ts`**: `responsible: z.array(activityResponsibleSchema).max(20).optional()` (shape `{ relationTo: enum, value: positiveRelationshipId }`); remove `advisors`/`leadership` (create/update). `tasks[].responsible` intacto.
- **`src/utilities/activityFormData.ts`**: parseia `responsiblesJson` (bounded JSON, precedente `tagsJson`), replace de `advisors`/`leadership`/`responsible`.
- **`src/app/(campaign)/campanha/actions/activity.ts`**: create/update recebem `responsible` polimórfico; update sempre envia o array (limpar = `[]`); ajusta comentário de stripping de null (endAt/responsible mantêm null-anulável? → `responsible` agora é array, muda o strip).
- **`src/utilities/activityViewModels.ts`**: `ActivityFormViewModel` → `responsibles: Array<{ relationTo, id, name, typeLabel }>`; `ActivityDetailViewModel` → `responsibles` (chips tipados) no lugar de `advisors`+`responsibleName`; `ActivityListViewModel` → `responsibles` + resumo p/ card. Selects ajustados (depth 1 para popular).
- **Search actions** (`src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts` + `src/utilities/activityLeadershipOptions.ts`): novo `searchActivityResponsibleOptions(query)` (server action) que busca os 3 catálogos em paralelo com `overrideAccess: false` (escopo de leitura do ator), agrupando por tipo com rótulo; mantém filtro `supportStatus: 'engajado'` p/ leadership (comportamento atual — sinalizado). `searchActivityContactOptions` permanece (tarefas). Remove `searchActivityLeadershipOptionsAction` se ficar órfão (knip).
- **`src/components/campaign/shared/ResponsibleMultiSelect.tsx`** (novo, client): chips removíveis (padrão `RelationMultiSelect`) + trigger + `Command` dialog com grupos por tipo; submete `responsiblesJson`.
- **`src/components/campaign/activity/ActivityForm.tsx`**: card "Pessoas e organizações" → campos "Responsáveis" (novo seletor) + "Organizações apoiadoras"; some grupo de assessores e combobox de liderança; `searchContacts` permanece p/ tarefas; props `advisorOptions`/`canManageAdvisors`/`searchLeaderships` saem; entra `searchResponsibles`.
- **`nova`/`editar` pages**: drop `advisorOptions`/`canManageAdvisors`; passam `searchResponsibles`.
- **`ActivityOverviewTab.tsx`**: "Equipe e organizações" → "Responsáveis" (lista de chips tipados); remove linha "Responsável" do card Detalhes.
- **`ActivityCard.tsx`**: "Resp: X" → chips de responsáveis (até 2 + "+N") com tipo.
- **`src/utilities/notification/notificationEvents.ts`**: `notifyActivityNeedsAttention` recipients = entradas `campaignUser` de `responsible` (filtra `relationTo`).
- **`src/utilities/activityUi.ts`**: `buildActivityListWhere` — `q` vira título-only (remove `'responsible.name'`).
- **Migration:** `pnpm migrate:create remodel_activity_responsible` → schema diff (activity_rels +leadership_id/state_deputy_id; activity −responsible_id/−leadership_id com índices/FKs) + data step: `UPDATE activity_rels SET path='responsible' WHERE path='advisors'`; `INSERT … leadership_id` dos antigos `activity.leadership_id`; reorder por parent; dropar `responsible_id` antiga. Decisão D4 no gate.
### Dados → forma
Não aplica (intenção `Dados: N/A`). O "chips tipados por responsável" é a resposta à questão em aberto da intenção (recomendação B) — sem métrica/série/mapa.
## Fases verificáveis
1. **Tracer/schema+server:** Migration + `Activity.ts` + access + schemas/parse + actions + notificação. Rodar `pnpm migrate:create`, `pnpm migrate`, `generate:types`. Atualizar int spec (inclui teste real do Where do assessor via `payload.find` e teste de auto-include + teste de responsable protegendo contra staff não-elegível).
2. **UI:** `ResponsibleMultiSelect` + form + overview + card + list search. Impeccable B (shape→craft→critique); shells/Command existentes; tokens `data-theme='campaign'`.
3. **Gates:** `pnpm gate:fast` na iteração; entrega com `pnpm push`. `knip` (remover órfãos de access/leader options); `formatação`; `check:cycles`.
## Rabbit holes / Não escopo (engenharia)
- **"Twin" de campo derivado para acesso** — rejeitado (D1 usa notação-objeto, sem coluna extra).
- Consulta de `responsible` por nome em listas/filtros — título-only; não construir busca nome polimórfica (defer com gatilho: "voltar busca por responsável quando o produto pedir").
- Estender `AsyncSearchCombobox`/`RelationMultiSelect` para multi-polimórfico — novo componente, não mutação de primitivo compartilhado.
- Admin UI do `responsible`: `filterOptions` por collection só para conveniência do `/admin`; sem UI custom.
- Sem mudança em `tasks[].responsible`, `resultRecordedBy`, `createdBy`, `updates[].author`, do giro, e2e existente de agenda (não toca responsáveis).
## Riscos e mitigação
- **Notação-objeto `equals` em hasMany polimórfico não casar como esperado** → mitigação: teste int cria atividade com assessor em `responsible` e roda `payload.find`/`canReadActivity` como esse assessor; se falhar, implantar D1-B/CD como fallback no mesmo esforço.
- **Esquema de dados polimórfico em produção** (joins `_rels`, ordens) → migração idempotente por `path`/`parent`, NOTICE de rows afetadas, revisão do SQL antes do deploy (padrão remodel já aplicado em prod).
- **Perda do `responsible` (Contact) antigo** → decisão explícita no gate (D4) antes de executar.
- **Campo `responsible` com `same name` após mudança de tipo** no diff (era column, vira rels) → conferir o SQL gerado e ajustar manualmente se o diff dropar/criar errado.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (campo único polimórfico multi-valor; `advisors`/`leadership` fora; assessor mantém direito via `responsible`)
- [ ] Invariantes AGENTS/engineering-standards (transações, access `overrideAccess:false` onde actor, sem novo cadastro, sem Consent novo, identificadores inglês/copy pt-BR)
- [ ] Testes de domínio: int spec (acesso assessor por `responsible` provado via query real; auto-include; validação de staff; data migration verificada em `teqo_test`)
## Já resolvido no simplify (não reabrir)
- Parser polimórfico `{relationTo, value}` centralizado em `parseActivityResponsibleEntries` (`lib/schemas/activity.ts`) — Activity.ts, notificationEvents e viewModels consomem o mesmo.
- Seleção do `ResponsibleMultiSelect` inicial-only (bug de wipe no create após erro de submit).
- Existência de `leadership`/`stateDeputy` validada como verdade admin (com `// bypass:` justificado) — assessor responsável re-salva lista intacta mesmo com liderança fora do portfólio (int test cobre).
- a11y do seletor (`aria-invalid`/`aria-describedby` + `FieldError` com id); gate morto `atCapacity && selected.length > 0` colapsado.
## Adiado com gatilho (não registrar epic agora)
- **S1 — extrair `useDebouncedAsyncSearch(query, isReady, search)`** do efeito clonado entre `AsyncSearchCombobox` e `ResponsibleMultiSelect` (debounce 250ms + requestId + loading/failed). **Gatilho:** terceiro consumidor de busca assíncrona client, ou refactor do próprio `AsyncSearchCombobox`.
- **S2 — unificar nome-fallback de responsável não-populado** (`Responsável #id` / `Equipe #N` / `Liderança #N`). **Gatilho:** se algum fluxo depender do rótulo exato.
- **S3 — UX em capacidade máxima** (trigger disabled; remoção só via chips). **Gatilho:** feedback da mesa.
## Explicitamente fora (skips dos revisores + descartes deste triage)
- Estender `AsyncSearchCombobox`/`RelationMultiSelect` para multi-polimórfico (novo componente é a forma certa).
- Busca por nome de responsável na lista (`q`) — título-only aceito no gate (consulta aninhada em relação polimórfica não é suportada).
- `tasks[].responsible` (Contact), resultado, giro, agenda — intactos.
Self-score decision-quality: 5/5 (decisões caras com rejeitadas; cabe no appetite; rabbit holes nomeados; reusa loaders/Command/shells; intenção preservada).

View file

@ -71,10 +71,6 @@ export const ActivityOverviewTab = ({
<dt className="text-muted-foreground">Término</dt>
<dd>{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Responsável</dt>
<dd>{view.responsibleName ?? 'Não definido'}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-muted-foreground">Tags</dt>
<dd className="flex flex-wrap gap-1 pt-1">
@ -97,15 +93,20 @@ export const ActivityOverviewTab = ({
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div>
<p className="text-muted-foreground">Assessores responsáveis</p>
{view.advisors.length ? (
<ul role="list" className="flex flex-col gap-1">
{view.advisors.map((advisor) => (
<li key={advisor.id}>{advisor.name}</li>
<p className="text-muted-foreground">Responsáveis</p>
{view.responsibles.length ? (
<ul role="list" className="flex flex-wrap gap-1.5 pt-1">
{view.responsibles.map((responsible) => (
<li key={`${responsible.relationTo}:${responsible.id}`}>
<Badge variant="outline" className="gap-1.5">
<span>{responsible.name}</span>
<span className="text-muted-foreground">{responsible.typeLabel}</span>
</Badge>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground">Nenhum assessor vinculado.</p>
<p className="text-muted-foreground">Nenhum responsável definido.</p>
)}
</div>
<div>

View file

@ -4,7 +4,7 @@ import { getPayload } from 'payload'
import {
searchActivityContactOptions,
searchActivityLeadershipOptionsAction,
searchActivityResponsibleOptionsAction,
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
import { updateActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
@ -16,7 +16,6 @@ import {
loadMunicipalityOptions,
loadOrganizationOptions,
} from '@/utilities/campaignRelationOptions'
import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipalityViewModels'
type EditActivityPageProps = {
params: Promise<{ slug: string }>
@ -47,15 +46,13 @@ export default async function EditActivityPage({ params }: EditActivityPageProps
])
if (!slug) notFound()
const canManageAdvisors = user.role === 'coordinator'
const [view, municipalityOptions, organizationOptions, advisorOptions] = await Promise.all([
const [view, municipalityOptions, organizationOptions] = await Promise.all([
getActivityEditPageData(payload, user, slug).catch((error) => {
if (error instanceof ActivityNotFoundError) notFound()
throw error
}),
loadMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
canManageAdvisors ? getEligibleAdvisorOptions(payload, user) : Promise.resolve([]),
])
return (
@ -66,11 +63,9 @@ export default async function EditActivityPage({ params }: EditActivityPageProps
activity={view}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
submitLabel="Salvar alterações"
searchContacts={searchActivityContactOptions}
searchLeaderships={searchActivityLeadershipOptionsAction}
searchResponsibles={searchActivityResponsibleOptionsAction}
/>
</div>
)

View file

@ -6,9 +6,9 @@ import { getPayload } from 'payload'
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import {
searchActivityLeadershipOptions,
type ActivityLeadershipOption,
} from '@/utilities/activityLeadershipOptions'
searchActivityResponsibleOptions,
type ActivityResponsibleSearchOption,
} from '@/utilities/activityResponsibleSearch'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
@ -42,11 +42,11 @@ export const searchActivityContactOptions = async (
return result.docs.map(({ id, name, phone }) => ({ id, name, phone: phone ?? null }))
}
export const searchActivityLeadershipOptionsAction = async (
export const searchActivityResponsibleOptionsAction = async (
query: string,
): Promise<ActivityLeadershipOption[]> => {
): Promise<ActivityResponsibleSearchOption[]> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
return searchActivityLeadershipOptions(payload, user, query)
return searchActivityResponsibleOptions(payload, user, query)
}

View file

@ -3,7 +3,7 @@ import { getPayload } from 'payload'
import {
searchActivityContactOptions,
searchActivityLeadershipOptionsAction,
searchActivityResponsibleOptionsAction,
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
import { createActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
@ -15,7 +15,6 @@ import {
loadMunicipalityOptions,
loadOrganizationOptions,
} from '@/utilities/campaignRelationOptions'
import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipalityViewModels'
export const metadata = campaignPageMetadataFromCatalog('atividadesNova')
@ -30,11 +29,9 @@ export default async function NewActivityPage({ searchParams }: NewActivityPageP
getPayload({ config }),
])
const canManageAdvisors = user.role === 'coordinator'
const [municipalityOptions, organizationOptions, advisorOptions, knownTags] = await Promise.all([
const [municipalityOptions, organizationOptions, knownTags] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
canManageAdvisors ? getEligibleAdvisorOptions(payload, user) : Promise.resolve([]),
loadAccessibleActivityTags(payload, user),
])
const accessibleMunicipalityIDs = new Set(municipalityOptions.map((option) => option.id))
@ -52,14 +49,12 @@ export default async function NewActivityPage({ searchParams }: NewActivityPageP
action={createActivityFormAction}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
initialValues={initialValues}
cancelHref={cancelHref}
knownTags={knownTags}
submitLabel="Criar atividade"
searchContacts={searchActivityContactOptions}
searchLeaderships={searchActivityLeadershipOptionsAction}
searchResponsibles={searchActivityResponsibleOptionsAction}
/>
</div>
)

View file

@ -232,7 +232,7 @@ const updateActivityRecord = async (
const { id, ...rawData } = activityUpdateSchema.parse(input)
// Payload date fields accept string | undefined, not null.
// Strip nulls only for required-ish fields; keep null for optional
// fields (endAt, responsible, leadership) so clearing them works.
// fields (endAt) so clearing them works.
const data = {
...Object.fromEntries(
Object.entries(rawData).filter(([key, value]) => {

View file

@ -8,8 +8,10 @@ import { APIError } from 'payload'
import { relationshipId } from '@/lib/relationship'
import {
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
activityStatusLabels,
activityStatuses,
activityStatusLabels,
MAX_ACTIVITY_RESPONSIBLES,
parseActivityResponsibleEntries,
} from '@/lib/schemas/activity'
import { slugify } from '@/lib/slug'
import { trimmedText } from '@/lib/text'
@ -17,10 +19,9 @@ import { getFreshCampaignUser } from '@/utilities/access/shared'
import {
canCampaignUserRescheduleActivity,
canCreateActivity,
canCreateActivityAdvisors,
canDeleteActivity,
canManageActivityAdvisors,
canReadActivity,
canSetActivityResponsible,
canSetActivityStatus,
canSetActivitySystemField,
canUpdateActivity,
@ -51,9 +52,11 @@ const activityStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
locality: trimmedText(doc.locality),
deputyPresent: Boolean(doc.deputyPresent),
organizations: relationshipIds(doc.organizations),
advisors: relationshipIds(doc.advisors),
responsible: relationshipId(doc.responsible),
leadership: relationshipId(doc.leadership),
responsible: JSON.stringify(
parseActivityResponsibleEntries(doc.responsible)
.map(({ relationTo, value }) => `${relationTo}:${value}`)
.sort(),
),
resultSummary: trimmedText(doc.resultSummary),
resultMedia: relationshipIds(doc.resultMedia),
})
@ -110,33 +113,101 @@ const validateActivitySchedule: CollectionBeforeValidateHook = ({
return data
}
const validateActivityAdvisors: CollectionBeforeValidateHook = async ({ data, req, context }) => {
/**
* C90 — every `responsible` entry must point at a real, allowed entity:
* staff `campaignUser` (assessor/candidato/coordenador) must stay eligible,
* and `leadership`/`stateDeputy` ids must exist. Existence is verified as
* admin truth so a staff member responsible for a commitment can always
* re-save it as-is — the form's selector scopes the choice to the actor's
* portfolio, not the save validation.
*/
const validateActivityResponsibles: CollectionBeforeValidateHook = async ({
data,
req,
context,
}) => {
if (isActivityMutationShortcut(context)) return data
if (!data) return data
if (data.advisors === undefined) return data
if (data.responsible === undefined) return data
const advisorIDs = [...new Set(relationshipIds(data.advisors))]
if (advisorIDs.length === 0) return data
const eligibleAdvisors = await req.payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
where: {
and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere],
},
select: { name: true },
overrideAccess: true,
req,
})
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
const entries = parseActivityResponsibleEntries(data.responsible)
if (entries.length === 0) return data
if (entries.length > MAX_ACTIVITY_RESPONSIBLES) {
throw new APIError(
'Cada responsável deve ter papel de Coordenador Geral, Assessor ou Candidato.',
`Informe no máximo ${MAX_ACTIVITY_RESPONSIBLES} responsáveis pelo compromisso.`,
400,
)
}
const staffIDs = [
...new Set(
entries.filter((entry) => entry.relationTo === 'campaignUser').map((entry) => entry.value),
),
]
const leadershipIDs = [
...new Set(
entries.filter((entry) => entry.relationTo === 'leadership').map((entry) => entry.value),
),
]
const deputyIDs = [
...new Set(
entries.filter((entry) => entry.relationTo === 'stateDeputy').map((entry) => entry.value),
),
]
if (staffIDs.length > 0) {
// bypass: eligibility is an admin truth — row access would reject
// no-user/admin creates and empty requests; the where filter is the gate.
const eligibleStaff = await req.payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
where: {
and: [{ id: { in: staffIDs } }, eligibleCampaignStaffWhere],
},
select: { name: true },
overrideAccess: true,
req,
})
if (eligibleStaff.docs.length !== staffIDs.length) {
throw new APIError(
'Cada responsável deve ter papel de Coordenador Geral, Assessor ou Candidato.',
400,
)
}
}
if (leadershipIDs.length > 0) {
// bypass: existence check only — the actor must be able to save an intact
// responsible list even for leaderships outside their read scope.
const leaderships = await req.payload.find({
collection: 'leadership',
depth: 0,
pagination: false,
where: { id: { in: leadershipIDs } },
overrideAccess: true,
req,
})
if (leaderships.docs.length !== leadershipIDs.length) {
throw new APIError('Responsável de liderança inválido.', 400)
}
}
if (deputyIDs.length > 0) {
// bypass: existence check only — see the leadership leg above.
const deputies = await req.payload.find({
collection: 'stateDeputy',
depth: 0,
pagination: false,
where: { id: { in: deputyIDs } },
overrideAccess: true,
req,
})
if (deputies.docs.length !== deputyIDs.length) {
throw new APIError('Responsável de dobradinha inválido.', 400)
}
}
return data
}
@ -188,9 +259,10 @@ const deriveActivityFields: CollectionBeforeChangeHook = ({
data.createdBy = req.user.id
if (
req.user.role === 'advisor' &&
(data.advisors === undefined || (Array.isArray(data.advisors) && data.advisors.length === 0))
(data.responsible === undefined ||
(Array.isArray(data.responsible) && data.responsible.length === 0))
) {
data.advisors = [req.user.id]
data.responsible = [{ relationTo: 'campaignUser', value: req.user.id }]
}
}
@ -305,7 +377,7 @@ export const Activity: CollectionConfig = {
beforeValidate: [
setCanonicalActivitySlug,
validateActivitySchedule,
validateActivityAdvisors,
validateActivityResponsibles,
validateDeputyPresentTimeGate,
],
beforeChange: [deriveActivityFields],
@ -427,31 +499,20 @@ export const Activity: CollectionConfig = {
index: true,
},
{
name: 'advisors',
// C90 — one polymorphic multi-value field replaces the old `advisors`
// (staff hasMany), `leadership` (hasOne) and `responsible` (Contact)
// fields. Anyone conducting the commitment — staff, a leadership or a
// dobradinha — lives here.
name: 'responsible',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Assessores responsáveis',
relationTo: ['campaignUser', 'leadership', 'stateDeputy'],
label: 'Responsáveis',
hasMany: true,
index: true,
access: {
create: canCreateActivityAdvisors,
update: canManageActivityAdvisors,
create: canSetActivityResponsible,
update: canSetActivityResponsible,
},
filterOptions: eligibleCampaignStaffWhere,
},
{
name: 'responsible',
type: 'relationship',
relationTo: 'contact',
label: 'Responsável',
index: true,
},
{
name: 'leadership',
type: 'relationship',
relationTo: 'leadership',
label: 'Liderança',
index: true,
},
{
name: 'tasks',

View file

@ -39,9 +39,27 @@ export const ActivityCard = ({ activity }: { activity: ActivityListViewModel })
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2 pt-1 text-sm">
<span className="flex items-center gap-1.5 text-muted-foreground">
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
<UserIcon aria-hidden="true" className="size-4 shrink-0" />
Resp: {activity.responsibleName ?? 'Não definido'}
{activity.responsibles.length ? (
<span className="flex min-w-0 flex-wrap gap-1">
{activity.responsibles.slice(0, 2).map((responsible) => (
<Badge
key={`${responsible.relationTo}:${responsible.id}`}
variant="outline"
className="min-w-0 gap-1"
>
<span className="truncate">{responsible.name}</span>
<span className="shrink-0 text-muted-foreground">{responsible.typeLabel}</span>
</Badge>
))}
{activity.responsibles.length > 2 ? (
<Badge variant="outline">+{activity.responsibles.length - 2}</Badge>
) : null}
</span>
) : (
'Resp: Não definido'
)}
</span>
{activity.taskProgress.total > 0 ? (
<span className="font-medium tabular-nums text-muted-foreground">

View file

@ -5,40 +5,29 @@ import { useActionState, useState } from 'react'
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
import { ActivityTaskFields } from '@/components/campaign/activity/ActivityTaskFields'
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
import {
ContactCombobox,
type ContactComboboxOption,
} from '@/components/campaign/shared/ContactCombobox'
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
import {
RelationMultiSelect,
type RelationOption,
} from '@/components/campaign/shared/RelationMultiSelect'
import {
ResponsibleMultiSelect,
type ResponsibleOption,
} from '@/components/campaign/shared/ResponsibleMultiSelect'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from '@/components/ui/field'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import { MAX_ACTIVITY_TAG_LENGTH, MAX_ACTIVITY_TAGS } from '@/lib/schemas/activity'
import type { ActivityLeadershipOption } from '@/utilities/activityLeadershipOptions'
import type { ActivityCreatePrefill } from '@/utilities/activityUi'
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
import { fieldError } from '@/utilities/campaignFormFields'
import type { EligibleAdvisorOption } from '@/utilities/municipality/municipalityViewModels'
export type ActivityFormState = {
message?: string
@ -55,14 +44,12 @@ type ActivityFormAction = (
export type ActivityFormFieldsProps = {
municipalityOptions: RelationOption[]
organizationOptions: RelationOption[]
advisorOptions?: EligibleAdvisorOption[]
canManageAdvisors?: boolean
activity?: ActivityFormViewModel
initialValues?: ActivityCreatePrefill
fieldErrors?: Record<string, string[]>
submittedTitle?: string
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
searchLeaderships: (query: string) => Promise<ActivityLeadershipOption[]>
searchResponsibles: (query: string) => Promise<ResponsibleOption[]>
/** Tags already used across activities, for autocomplete. */
knownTags?: string[]
}
@ -152,29 +139,16 @@ const TagInput = ({
const ActivityFormFields = ({
municipalityOptions,
organizationOptions,
advisorOptions = [],
canManageAdvisors = false,
activity,
initialValues,
fieldErrors = {},
submittedTitle,
searchContacts,
searchLeaderships,
searchResponsibles,
knownTags = [],
}: ActivityFormFieldsProps) => {
const errorFor = (name: string) => fieldError(fieldErrors, name)
const [responsible, setResponsible] = useState<ContactComboboxOption | null>(
activity?.responsible
? {
id: activity.responsible.id,
name: activity.responsible.name,
phone: activity.responsible.phone,
}
: null,
)
const [leadership, setLeadership] = useState<ActivityLeadershipOption | null>(
activity?.leadership ? { id: activity.leadership.id, label: activity.leadership.label } : null,
)
const initialResponsibles = activity?.responsibles ?? []
const initialStartAt = activity?.startAt ?? initialValues?.startAt
const initialEndAt = activity?.endAt ?? initialValues?.endAt
const initialMunicipalityId = activity?.municipalityId ?? initialValues?.municipalityId
@ -336,41 +310,14 @@ const ActivityFormFields = ({
</CardHeader>
<CardContent>
<FieldGroup>
<Field data-invalid={Boolean(errorFor('responsible'))}>
<FieldLabel>Responsável</FieldLabel>
<ContactCombobox
name="responsible"
label="Responsável pela atividade"
current={responsible}
search={searchContacts}
onChange={setResponsible}
/>
{errorFor('responsible') ? (
<FieldError id="responsible-error">{errorFor('responsible')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('leadership'))}>
<FieldLabel>Liderança vinculada</FieldLabel>
<AsyncSearchCombobox
name="leadership"
label="Liderança vinculada"
value={leadership}
emptyOptionLabel="Nenhuma"
dialogDescription="Busque lideranças engajadas por nome ou celular."
isQueryReady={isContactSearchQueryReady}
queryTooShortMessage="Digite ao menos dois caracteres para buscar."
search={searchLeaderships}
onChange={setLeadership}
/>
<FieldDescription>
Quando vinculada, a liderança engajada vê a atividade e pode marcar tarefas e
registrar atualizações.
</FieldDescription>
{errorFor('leadership') ? (
<FieldError id="leadership-error">{errorFor('leadership')}</FieldError>
) : null}
</Field>
<ResponsibleMultiSelect
name="responsiblesJson"
label="Responsáveis"
value={initialResponsibles}
error={errorFor('responsiblesJson')}
description="Quem conduz este compromisso: equipe da campanha, liderança ou dobradinha — um ou vários."
search={searchResponsibles}
/>
<RelationMultiSelect
name="organizations"
@ -380,47 +327,6 @@ const ActivityFormFields = ({
error={errorFor('organizations')}
placeholder="Adicionar organização…"
/>
{canManageAdvisors ? (
<FieldSet
aria-invalid={Boolean(errorFor('advisors'))}
aria-describedby={errorFor('advisors') ? 'advisors-error' : undefined}
>
<FieldLegend>Assessores responsáveis</FieldLegend>
<FieldDescription>
Assessores selecionados podem editar esta atividade e gerenciar suas tarefas.
</FieldDescription>
<input type="hidden" name="advisorsSubmitted" value="1" />
{advisorOptions.length ? (
<div data-slot="checkbox-group" className="grid gap-2 sm:grid-cols-2">
{advisorOptions.map((advisor) => (
<FieldLabel key={advisor.id}>
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
<Checkbox
name="advisors"
value={String(advisor.id)}
defaultChecked={
activity
? activity.advisorIDs.includes(advisor.id)
: advisor.isCurrent
}
/>
<span>
{advisor.name}
{advisor.isCurrent ? ' (você)' : ''}
</span>
</Field>
</FieldLabel>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Nenhum assessor disponível.</p>
)}
{errorFor('advisors') ? (
<FieldError id="advisors-error">{errorFor('advisors')}</FieldError>
) : null}
</FieldSet>
) : null}
</FieldGroup>
</CardContent>
</Card>
@ -454,14 +360,12 @@ export const ActivityForm = ({
action,
municipalityOptions,
organizationOptions,
advisorOptions,
canManageAdvisors,
activity,
initialValues,
fieldErrors: externalFieldErrors,
submittedTitle,
searchContacts,
searchLeaderships,
searchResponsibles,
knownTags,
submitLabel,
cancelHref,
@ -477,14 +381,12 @@ export const ActivityForm = ({
<ActivityFormFields
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
activity={activity}
initialValues={initialValues}
fieldErrors={state.fieldErrors ?? externalFieldErrors}
submittedTitle={state.submittedTitle ?? submittedTitle}
searchContacts={searchContacts}
searchLeaderships={searchLeaderships}
searchResponsibles={searchResponsibles}
knownTags={knownTags}
/>

View file

@ -0,0 +1,226 @@
'use client'
import { ChevronsUpDownIcon, XIcon } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandDialog,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/Command'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import {
MAX_ACTIVITY_RESPONSIBLES,
type ActivityResponsibleCollection,
} from '@/lib/schemas/activity'
export type ResponsibleOption = {
relationTo: ActivityResponsibleCollection
id: number
name: string
typeLabel: string
}
type ResponsibleMultiSelectProps = {
/** FormData field name — a hidden JSON field with the typed entries. */
name: string
label: string
value?: ResponsibleOption[]
error?: string
description?: string
search: (query: string) => Promise<ResponsibleOption[]>
}
/**
* C90 — polymorphic multi-value responsible picker for the activity form
* (later reused by the agenda inline creation, C91). One search catalog across
* staff/leadership/dobradinha, results grouped by type, removable typed chips.
*/
export const ResponsibleMultiSelect = ({
name,
label,
value = [],
error,
description,
search,
}: ResponsibleMultiSelectProps) => {
// Initial-only (like RelationMultiSelect's initialSelectedIDs): on the create
// page `value` is a fresh `[]` every render and a sync effect would wipe the
// user's picks on any failed submit.
const [selected, setSelected] = useState<ResponsibleOption[]>(value)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [options, setOptions] = useState<ResponsibleOption[]>([])
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
if (!isContactSearchQueryReady(query)) {
setOptions([])
setLoading(false)
setFailed(false)
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 selectedKey = (option: ResponsibleOption) => `${option.relationTo}:${option.id}`
const selectedKeys = new Set(selected.map(selectedKey))
const atCapacity = selected.length >= MAX_ACTIVITY_RESPONSIBLES
const add = (option: ResponsibleOption) => {
if (atCapacity) return
setSelected((current) =>
current.some((entry) => selectedKey(entry) === selectedKey(option))
? current
: [...current, option],
)
}
const remove = (option: ResponsibleOption) => {
const key = selectedKey(option)
setSelected((current) => current.filter((entry) => selectedKey(entry) !== key))
}
const visibleOptions = options.filter((option) => !selectedKeys.has(selectedKey(option)))
const groups = visibleOptions.reduce<Record<string, ResponsibleOption[]>>((acc, option) => {
const group = acc[option.typeLabel] ?? []
group.push(option)
acc[option.typeLabel] = group
return acc
}, {})
const selectedLabel =
selected.length === 0
? 'Nenhum'
: `${selected.length} ${selected.length === 1 ? 'responsável' : 'responsáveis'}`
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<input
type="hidden"
name={name}
value={JSON.stringify(selected.map(({ relationTo, id }) => ({ relationTo, value: id })))}
/>
{selected.length ? (
<ul className="flex flex-wrap gap-1.5">
{selected.map((option) => (
<li key={selectedKey(option)}>
<Badge variant="secondary" className="gap-1 pr-1">
<span className="font-normal">{option.name}</span>
<span className="text-muted-foreground">{option.typeLabel}</span>
<button
type="button"
aria-label={`Remover ${option.name}`}
className="inline-flex size-6 items-center justify-center rounded-full hover:bg-foreground/10"
onClick={() => remove(option)}
>
<XIcon className="size-3.5" aria-hidden="true" />
</button>
</Badge>
</li>
))}
</ul>
) : null}
<Button
type="button"
variant="outline"
className="min-h-11 w-full justify-between font-normal"
aria-label={`${label}: ${selectedLabel}`}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${name}-error` : undefined}
disabled={atCapacity}
onClick={() => setOpen(true)}
>
<span className="truncate">
{atCapacity ? 'Limite de responsáveis alcançado' : selectedLabel}
</span>
<ChevronsUpDownIcon aria-hidden="true" />
</Button>
<CommandDialog
open={open}
onOpenChange={setOpen}
title={label}
description="Busque por nome e escolha um ou mais responsáveis."
showCloseButton
>
<Command shouldFilter={false} label={label}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar responsável"
aria-label={label}
/>
<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>
) : Object.keys(groups).length > 0 ? (
Object.entries(groups).map(([typeLabel, groupOptions]) => (
<CommandGroup key={typeLabel} heading={typeLabel}>
{groupOptions.map((option) => (
<CommandItem
key={selectedKey(option)}
value={`${option.typeLabel}-${option.id}`}
onSelect={() => add(option)}
disabled={atCapacity}
>
<span className="truncate">{option.name}</span>
</CommandItem>
))}
</CommandGroup>
))
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
{!isContactSearchQueryReady(query)
? 'Digite ao menos dois caracteres para buscar.'
: 'Nenhum resultado encontrado.'}
</p>
)}
</CommandList>
</Command>
</CommandDialog>
{description ? <FieldDescription>{description}</FieldDescription> : null}
{error ? <FieldError id={`${name}-error`}>{error}</FieldError> : null}
</Field>
)
}

View file

@ -1,5 +1,6 @@
import { z } from 'zod'
import { relationshipId } from '@/lib/relationship'
import { campaignDemandCreateSchema } from '@/lib/schemas/campaignDemandInput'
import {
positiveRelationshipId,
@ -46,6 +47,9 @@ export const activityStatusLabels: Record<ActivityStatus, string> = {
const MAX_ACTIVITY_ORGANIZATIONS = 20
export const MAX_ACTIVITY_DEMAND_DRAFTS = 20
/** C90 — cap for the polymorphic multi-value `responsible` field. */
export const MAX_ACTIVITY_RESPONSIBLES = 20
const activityDemandDraftSchema = campaignDemandCreateSchema.pick({
title: true,
kind: true,
@ -69,6 +73,52 @@ const activityTagsSchema = z
.array(z.string().trim().min(1).max(MAX_ACTIVITY_TAG_LENGTH))
.max(MAX_ACTIVITY_TAGS)
const activityResponsibleCollections = ['campaignUser', 'leadership', 'stateDeputy'] as const
export type ActivityResponsibleCollection = (typeof activityResponsibleCollections)[number]
export const activityResponsibleTypeLabels: Record<ActivityResponsibleCollection, string> = {
campaignUser: 'Equipe',
leadership: 'Liderança',
stateDeputy: 'Dobradinha',
}
export type ActivityResponsibleEntry = {
relationTo: ActivityResponsibleCollection
value: number
}
/**
* Normalizes a polymorphic `responsible` value (ids or `{relationTo, value}`
* objects at any depth) into typed entries, dropping malformed rows. Shared by
* the collection validation, the notification recipients and the view models —
* one place to add a future responsible kind.
*/
export const parseActivityResponsibleEntries = (value: unknown): ActivityResponsibleEntry[] =>
(Array.isArray(value) ? value : [])
.map((entry): ActivityResponsibleEntry | null => {
if (typeof entry !== 'object' || entry === null) return null
const record = entry as { relationTo?: unknown; value?: unknown }
const relationTo = activityResponsibleCollections.find(
(candidate) => candidate === record.relationTo,
)
if (!relationTo) return null
const id = relationshipId(record.value)
return id === null ? null : { relationTo, value: id }
})
.filter((entry): entry is ActivityResponsibleEntry => entry !== null)
/**
* C90 — one typed responsible entry. `value` must be a positive id and the
* `relationTo` one of the allowed collections; the collection hook re-verifies
* the entity exists / stays eligible.
*/
export const activityResponsibleSchema = z.object({
relationTo: z.enum(activityResponsibleCollections),
value: positiveRelationshipId,
})
const activityResponsiblesSchema = z.array(activityResponsibleSchema).max(MAX_ACTIVITY_RESPONSIBLES)
const activityFieldsSchema = z.object({
title: z.string().trim().min(2).max(160),
tags: activityTagsSchema.optional(),
@ -80,9 +130,7 @@ const activityFieldsSchema = z.object({
municipality: positiveRelationshipId,
locality: trimmedOptionalText(160),
organizations: z.array(positiveRelationshipId).max(MAX_ACTIVITY_ORGANIZATIONS).optional(),
advisors: z.array(positiveRelationshipId).optional(),
responsible: positiveRelationshipId.optional(),
leadership: positiveRelationshipId.optional(),
responsible: activityResponsiblesSchema.optional(),
tasks: z.array(taskSchema).optional(),
})
@ -182,8 +230,6 @@ export const activityUpdateSchema = activityFieldsSchema
description: trimmedNullableText(4000),
startAt: z.string().datetime().nullable().optional(),
endAt: z.string().datetime().nullable().optional(),
responsible: positiveRelationshipId.nullable().optional(),
leadership: positiveRelationshipId.nullable().optional(),
status: z.enum(activityStatuses).optional(),
})
.superRefine((data, context) => {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,78 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "activity" DROP CONSTRAINT "activity_responsible_id_contact_id_fk";
ALTER TABLE "activity" DROP CONSTRAINT "activity_leadership_id_leadership_id_fk";
DROP INDEX "activity_responsible_idx";
DROP INDEX "activity_leadership_idx";
ALTER TABLE "activity_rels" ADD COLUMN "leadership_id" integer;
ALTER TABLE "activity_rels" ADD COLUMN "state_deputy_id" integer;
ALTER TABLE "activity_rels" ADD CONSTRAINT "activity_rels_leadership_fk" FOREIGN KEY ("leadership_id") REFERENCES "public"."leadership"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "activity_rels" ADD CONSTRAINT "activity_rels_state_deputy_fk" FOREIGN KEY ("state_deputy_id") REFERENCES "public"."state_deputy"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "activity_rels_leadership_id_idx" ON "activity_rels" USING btree ("leadership_id");
CREATE INDEX "activity_rels_state_deputy_id_idx" ON "activity_rels" USING btree ("state_deputy_id");
-- C90 data reconciliation (must run before the columns are dropped).
-- 1) The old advisors (staff hasMany) rows move to the single responsible path.
UPDATE "activity_rels"
SET "path" = 'responsible'
WHERE "path" = 'advisors' AND "campaign_user_id" IS NOT NULL;
-- 2) Each old activity.leadership_id becomes a responsible row.
INSERT INTO "activity_rels" ("parent_id", "path", "order", "leadership_id")
SELECT a."id", 'responsible', COALESCE(m.max_order + 1, 1), a."leadership_id"
FROM "activity" a
LEFT JOIN (
SELECT r."parent_id", max(r."order") AS max_order
FROM "activity_rels" r
WHERE r."path" = 'responsible'
GROUP BY r."parent_id"
) m ON m."parent_id" = a."id"
WHERE a."leadership_id" IS NOT NULL;
-- 3) Renumber responsible rows sequentially per parent so sparse orders
-- left by the migration never confuse reading.
UPDATE "activity_rels" r
SET "order" = t.new_order
FROM (
SELECT r2."id", r2."parent_id", row_number() OVER (
PARTITION BY r2."parent_id" ORDER BY r2."order", r2."id"
) AS new_order
FROM "activity_rels" r2
WHERE r2."path" = 'responsible'
) t
WHERE r."id" = t."id";
-- 4) The old responsible (Contact) has no valid type in the new union;
-- C90 gate decided to discard it. Surface the count at deploy time.
DO $$
DECLARE contacts_dropped integer;
BEGIN
SELECT count(*) INTO contacts_dropped FROM "activity" WHERE "responsible_id" IS NOT NULL;
RAISE NOTICE 'remodel_activity_responsible: descartando % responsável(is) Contact antigo(s)', contacts_dropped;
END $$;
ALTER TABLE "activity" DROP COLUMN "responsible_id";
ALTER TABLE "activity" DROP COLUMN "leadership_id";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "activity_rels" DROP CONSTRAINT "activity_rels_leadership_fk";
ALTER TABLE "activity_rels" DROP CONSTRAINT "activity_rels_state_deputy_fk";
DROP INDEX "activity_rels_leadership_id_idx";
DROP INDEX "activity_rels_state_deputy_id_idx";
ALTER TABLE "activity" ADD COLUMN "responsible_id" integer;
ALTER TABLE "activity" ADD COLUMN "leadership_id" integer;
ALTER TABLE "activity" ADD CONSTRAINT "activity_responsible_id_contact_id_fk" FOREIGN KEY ("responsible_id") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "activity" ADD CONSTRAINT "activity_leadership_id_leadership_id_fk" FOREIGN KEY ("leadership_id") REFERENCES "public"."leadership"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "activity_responsible_idx" ON "activity" USING btree ("responsible_id");
CREATE INDEX "activity_leadership_idx" ON "activity" USING btree ("leadership_id");
ALTER TABLE "activity_rels" DROP COLUMN "leadership_id";
ALTER TABLE "activity_rels" DROP COLUMN "state_deputy_id";`)
}

View file

@ -44,6 +44,7 @@ import * as migration_20260806_082110_add_state_deputy_contact from './20260806_
import * as migration_20260806_120000_c14_remodel_activity_agenda from './20260806_120000_c14_remodel_activity_agenda';
import * as migration_20260807_030000_unify_municipality_update from './20260807_030000_unify_municipality_update';
import * as migration_20260808_134911_add_calendar_feed from './20260808_134911_add_calendar_feed';
import * as migration_20260808_184113_remodel_activity_responsible from './20260808_184113_remodel_activity_responsible';
export const migrations = [
{
@ -274,6 +275,11 @@ export const migrations = [
{
up: migration_20260808_134911_add_calendar_feed.up,
down: migration_20260808_134911_add_calendar_feed.down,
name: '20260808_134911_add_calendar_feed'
name: '20260808_134911_add_calendar_feed',
},
{
up: migration_20260808_184113_remodel_activity_responsible.up,
down: migration_20260808_184113_remodel_activity_responsible.down,
name: '20260808_184113_remodel_activity_responsible'
},
];

View file

@ -747,9 +747,22 @@ export interface Activity {
municipality: number | Municipality;
locality?: string | null;
organizations?: (number | Organization)[] | null;
advisors?: (number | CampaignUser)[] | null;
responsible?: (number | null) | Contact;
leadership?: (number | null) | Leadership;
responsible?:
| (
| {
relationTo: 'campaignUser';
value: number | CampaignUser;
}
| {
relationTo: 'leadership';
value: number | Leadership;
}
| {
relationTo: 'stateDeputy';
value: number | StateDeputy;
}
)[]
| null;
tasks?:
| {
title: string;
@ -1787,9 +1800,7 @@ export interface ActivitySelect<T extends boolean = true> {
municipality?: T;
locality?: T;
organizations?: T;
advisors?: T;
responsible?: T;
leadership?: T;
tasks?:
| T
| {

View file

@ -9,7 +9,6 @@ import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
advisorMunicipalityScopeWhere,
getFreshCampaignUser,
isCampaignCoordinator,
isCampaignLeader,
isCampaignStaff,
isCampaignUnrestricted,
@ -41,9 +40,12 @@ export const canReadActivity: Access = async ({ req }): Promise<boolean | Where>
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return {
or: [
// C90 — polymorphic `responsible`: the only scalar query the adapter
// supports on a multi-relation relationship is the object notation
// with `equals` (see @payloadcms/drizzle sanitizeQueryValue).
{
advisors: {
contains: currentUser.id,
responsible: {
equals: { relationTo: 'campaignUser', value: currentUser.id },
},
},
advisorMunicipalityScopeWhere('municipality', municipalityIDs),
@ -63,10 +65,5 @@ export const canSetActivitySystemField: FieldAccess = ({ req }) => isPayloadAdmi
export const canSetActivityStatus: FieldAccess = canStaffCreateActivity
export const canCreateActivityAdvisors: FieldAccess = canStaffCreateActivity
export const canManageActivityAdvisors: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignCoordinator(await getFreshCampaignUser(req))
}
/** C90 — the unified `responsible` field is editable by any staff with row access. */
export const canSetActivityResponsible: FieldAccess = canStaffCreateActivity

View file

@ -5,7 +5,6 @@ import {
boundedJsonFormValue,
checkboxFormValue,
FormDataBoundaryError,
nullableRelationshipFormValue,
optionalFormText,
repeatedRelationshipFormValues,
requiredFormText,
@ -13,13 +12,16 @@ import {
} from '@/lib/formData'
import {
activityDemandDraftsSchema,
activityResponsibleSchema,
activityStatuses,
MAX_ACTIVITY_DEMAND_DRAFTS,
MAX_ACTIVITY_RESPONSIBLES,
MAX_ACTIVITY_TAG_LENGTH,
MAX_ACTIVITY_TAGS,
tourStopDraftsSchema,
type ActivityCreateInput,
type ActivityDemandDraft,
type ActivityResponsibleCollection,
type ActivityUpdateInput,
type TourStopDraft,
} from '@/lib/schemas/activity'
@ -114,10 +116,41 @@ const parseTagsFormData = (formData: FormData): string[] => {
return tags
}
const parseSharedActivityFormData = (formData: FormData) => {
const responsible = nullableRelationshipFormValue(formData, 'responsible')
const leadership = nullableRelationshipFormValue(formData, 'leadership')
type ParsedResponsible = {
relationTo: ActivityResponsibleCollection
value: number
}
/**
* C90 — the polymorphic multi-value `responsible` submits as a bounded JSON
* field (same precedent as `tasksJson`/`tagsJson`): repeated hidden inputs
* would lose the `relationTo` correlation.
*/
const parseResponsiblesFormData = (formData: FormData): ParsedResponsible[] => {
const raw = boundedJsonFormValue(formData, 'responsiblesJson', 8_000)
if (raw === undefined) return []
if (!Array.isArray(raw)) {
throw new FormDataBoundaryError('responsiblesJson', 'Lista de responsáveis inválida.')
}
if (raw.length > MAX_ACTIVITY_RESPONSIBLES) {
throw new FormDataBoundaryError(
'responsiblesJson',
`Informe no máximo ${MAX_ACTIVITY_RESPONSIBLES} responsáveis.`,
)
}
return raw.map((item, index) => {
const parsed = activityResponsibleSchema.safeParse(item)
if (!parsed.success) {
throw new FormDataBoundaryError(
'responsiblesJson',
`Responsável ${index + 1} inválido. Atualize a página e refaça a escolha.`,
)
}
return parsed.data
})
}
const parseSharedActivityFormData = (formData: FormData) => {
return {
title: optionalFormText(formData, 'title') ?? '',
tags: parseTagsFormData(formData),
@ -129,8 +162,7 @@ const parseSharedActivityFormData = (formData: FormData) => {
municipality: requiredRelationshipFormValue(formData, 'municipality'),
locality: optionalFormText(formData, 'locality'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
responsible,
leadership,
responsible: parseResponsiblesFormData(formData),
tasks: parseTasksFormData(formData),
demands: parseDemandDraftsFormData(formData),
}
@ -142,15 +174,12 @@ export type ParsedActivityCreateFormData = ActivityCreateInput & {
export const parseActivityCreateFormData = (formData: FormData): ParsedActivityCreateFormData => {
const shared = parseSharedActivityFormData(formData)
const advisorIds = repeatedRelationshipFormValues(formData, 'advisors')
return {
...shared,
status: (shared.status ?? 'confirmado') as ActivityCreateInput['status'],
responsible: shared.responsible ?? undefined,
leadership: shared.leadership ?? undefined,
responsible: shared.responsible.length ? shared.responsible : undefined,
organizations: shared.organizations.length ? shared.organizations : undefined,
advisors: advisorIds.length ? advisorIds : undefined,
}
}
@ -194,10 +223,6 @@ export type ParsedActivityUpdateFormData = ActivityUpdateInput & {
export const parseActivityUpdateFormData = (formData: FormData): ParsedActivityUpdateFormData => {
const shared = parseSharedActivityFormData(formData)
const advisorIds = repeatedRelationshipFormValues(formData, 'advisors')
// Checkboxes only submit when checked, so a hidden marker distinguishes
// "advisors section rendered but all unchecked" from "not editable" (advisor view).
const hasAdvisors = formData.has('advisorsSubmitted')
return {
...shared,
@ -207,9 +232,10 @@ export const parseActivityUpdateFormData = (formData: FormData): ParsedActivityU
description: shared.description ?? null,
startAt: shared.startAt ?? null,
endAt: shared.endAt ?? null,
responsible: shared.responsible,
leadership: shared.leadership,
organizations: shared.organizations,
advisors: hasAdvisors ? advisorIds : undefined,
// Update always submits the full list (possibly empty) so clearing works.
// Invariant: the form always renders `responsiblesJson` (even empty) — a
// partial form that omits it would silently clear `responsible`.
responsible: shared.responsible,
}
}

View file

@ -0,0 +1,117 @@
import 'server-only'
import type { Payload } from 'payload'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import { populatedContactName } from '@/lib/relationship'
import {
activityResponsibleTypeLabels,
type ActivityResponsibleCollection,
} from '@/lib/schemas/activity'
import type { CampaignUser } from '@/payload-types'
import { searchActivityLeadershipOptions } from '@/utilities/activityLeadershipOptions'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
export type ActivityResponsibleSearchOption = {
relationTo: ActivityResponsibleCollection
id: number
name: string
typeLabel: string
}
const RESPONSIBLE_SEARCH_LIMIT = 20
const toResponsibleOption = (
relationTo: ActivityResponsibleCollection,
id: number,
name: string,
): ActivityResponsibleSearchOption => ({
relationTo,
id,
name,
typeLabel: activityResponsibleTypeLabels[relationTo],
})
/**
* C90 — one async catalog across the three responsible types: eligible staff,
* engaged leaderships and dobradinhas, each read through the actor's own
* access (`overrideAccess: false`), so a leadership outside an advisor's
* portfolio never shows up as a responsible option.
*/
export const searchActivityResponsibleOptions = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
query: string,
): Promise<ActivityResponsibleSearchOption[]> => {
const normalizedQuery = typeof query === 'string' ? query : ''
if (!isContactSearchQueryReady(normalizedQuery)) return []
const { trimmed, digits } = normalizeContactSearchQuery(normalizedQuery)
const [staff, leaderships, deputies] = await Promise.all([
searchStaffResponsibles(payload, user, trimmed),
searchActivityLeadershipOptions(payload, user, trimmed),
searchStateDeputyResponsibles(payload, user, trimmed, digits),
])
const staffOptions = staff.map(({ id, name }) => toResponsibleOption('campaignUser', id, name))
const leadershipOptions = leaderships.map(({ id, label }) =>
toResponsibleOption('leadership', id, label),
)
const deputyOptions = deputies.map(({ id, name }) => toResponsibleOption('stateDeputy', id, name))
return [...staffOptions, ...leadershipOptions, ...deputyOptions].sort((left, right) =>
left.name.localeCompare(right.name, 'pt-BR'),
)
}
const searchStaffResponsibles = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
trimmed: string,
) => {
const result = await payload.find({
collection: 'campaignUser',
where: {
and: [eligibleCampaignStaffWhere, ...(trimmed ? [{ name: { contains: trimmed } }] : [])],
},
depth: 0,
limit: RESPONSIBLE_SEARCH_LIMIT,
page: 1,
select: { name: true },
user,
overrideAccess: false,
})
return result.docs.map(({ id, name }) => ({ id, name: name ?? `Equipe #${id}` }))
}
const searchStateDeputyResponsibles = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
trimmed: string,
digits: string,
) => {
const result = await payload.find({
collection: 'stateDeputy',
where: {
or: [
{ 'contact.name': { contains: trimmed } },
{ 'contact.phone': { contains: digits || trimmed } },
],
},
depth: 1,
limit: RESPONSIBLE_SEARCH_LIMIT,
page: 1,
sort: 'contact.name',
select: { contact: true, party: true },
user,
overrideAccess: false,
})
return result.docs.map((stateDeputy) => {
const plainName = populatedContactName(stateDeputy.contact)
return {
id: stateDeputy.id,
name: stateDeputy.party ? `${plainName} (${stateDeputy.party})` : plainName,
}
})
}

View file

@ -262,9 +262,10 @@ export const buildActivityListWhere = (state: ActivityListState, now: Date): Whe
if (state.q) {
const q = isActivityListSearchReady(state.q)
if (q) {
filters.push({
or: [{ title: { contains: q } }, { 'responsible.name': { contains: q } }],
})
// C90 — `responsible` is a polymorphic multi-relation; a nested
// `responsible.name` query is not supported on it, so the list search is
// title-only (see impl plan).
filters.push({ title: { contains: q } })
}
}

View file

@ -1,6 +1,20 @@
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { isPopulatedRelationship, relationshipId } from '@/lib/relationship'
import type { Activity, CampaignUser, Contact, Municipality, Organization } from '@/payload-types'
import { isPopulatedRelationship, populatedContactName, relationshipId } from '@/lib/relationship'
import {
activityResponsibleTypeLabels,
parseActivityResponsibleEntries,
type ActivityResponsibleCollection,
type ActivityResponsibleEntry,
} from '@/lib/schemas/activity'
import type {
Activity,
CampaignUser,
Contact,
Leadership,
Municipality,
Organization,
StateDeputy,
} from '@/payload-types'
import { canCampaignUserRescheduleActivity } from '@/utilities/access/activities'
import type { ActivityDetailTab } from '@/utilities/activityDetailTabUi'
@ -17,6 +31,60 @@ export const activityMunicipalitySummary = (
? { id: municipality.id, name: municipality.name, slug: municipality.slug }
: null
// ---------------------------------------------------------------------------
// C90 — polymorphic `responsible` display (typed chips)
// ---------------------------------------------------------------------------
export type ActivityResponsibleDisplay = {
relationTo: ActivityResponsibleCollection
id: number
name: string
typeLabel: string
}
const responsibleEntryName = (
relationTo: ActivityResponsibleEntry['relationTo'],
value: unknown,
): string | null => {
switch (relationTo) {
case 'campaignUser':
return isPopulatedRelationship<CampaignUser>(value) ? value.name : null
case 'leadership':
return isPopulatedRelationship<Leadership>(value)
? populatedContactName((value as Leadership).contact)
: null
case 'stateDeputy':
return isPopulatedRelationship<StateDeputy>(value)
? populatedContactName((value as StateDeputy).contact)
: null
}
}
/** Maps a polymorphic `responsible` value (ids or populated docs) to display chips. */
const mapActivityResponsibles = (
value: readonly unknown[] | null | undefined,
): ActivityResponsibleDisplay[] => {
const populatedByKey = new Map<string, unknown>()
for (const raw of Array.isArray(value) ? value : []) {
const record = raw as { relationTo?: unknown; value?: unknown }
const id = relationshipId(record.value)
if (typeof record.relationTo === 'string' && id !== null) {
populatedByKey.set(`${record.relationTo}:${id}`, record.value)
}
}
return parseActivityResponsibleEntries(value).map((entry) => ({
relationTo: entry.relationTo,
id: entry.value,
name:
responsibleEntryName(
entry.relationTo,
populatedByKey.get(`${entry.relationTo}:${entry.value}`),
) ?? `Responsável #${entry.value}`,
typeLabel: activityResponsibleTypeLabels[entry.relationTo],
}))
}
export const formatActivityWhenLabel = (startAt: string | null | undefined): string =>
startAt ? formatBahiaDateTimeLabel(startAt) : 'Data a definir'
@ -107,7 +175,7 @@ export type ActivityListViewModel = {
municipalityName: string | null
locality: string | null
locationLabel: string
responsibleName: string | null
responsibles: ActivityResponsibleDisplay[]
taskProgress: { done: number; total: number }
}
@ -130,7 +198,7 @@ export const toActivityListViewModel = (activity: Activity): ActivityListViewMod
municipalityName,
locality: activity.locality ?? null,
locationLabel: formatActivityLocationLabel({ municipalityName, locality: activity.locality }),
responsibleName: relationshipName(activity.responsible),
responsibles: mapActivityResponsibles(activity.responsible),
taskProgress: {
done: activity.taskDoneCount ?? 0,
total: activity.taskTotal ?? 0,
@ -150,9 +218,7 @@ export const activityFormSelect = {
municipality: true,
locality: true,
organizations: true,
advisors: true,
responsible: true,
leadership: true,
tasks: true,
} as const
@ -177,9 +243,7 @@ export type ActivityFormViewModel = {
municipalityId: number | null
locality: string | null
organizationIDs: number[]
advisorIDs: number[]
responsible: { id: number; name: string; phone: string | null } | null
leadership: { id: number; label: string } | null
responsibles: ActivityResponsibleDisplay[]
tasks: ActivityFormTaskViewModel[]
}
@ -199,26 +263,7 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
municipalityId: relationshipId(activity.municipality),
locality: activity.locality ?? null,
organizationIDs: relationshipIds(activity.organizations),
advisorIDs: relationshipIds(activity.advisors),
responsible: isPopulatedRelationship<Contact>(activity.responsible)
? {
id: activity.responsible.id,
name: activity.responsible.name,
phone: activity.responsible.phone ?? null,
}
: null,
leadership: (() => {
const leadershipId = relationshipId(activity.leadership)
if (!leadershipId) return null
if (isPopulatedRelationship(activity.leadership)) {
const contact = activity.leadership.contact
const contactName = isPopulatedRelationship<Contact>(contact)
? contact.name
: `Liderança #${leadershipId}`
return { id: leadershipId, label: contactName }
}
return { id: leadershipId, label: `Liderança #${leadershipId}` }
})(),
responsibles: mapActivityResponsibles(activity.responsible),
tasks: (activity.tasks ?? []).map((task) => ({
id: task.id ?? null,
title: task.title,
@ -246,7 +291,6 @@ const activityDetailContextSelect = {
municipality: true,
locality: true,
organizations: true,
advisors: true,
responsible: true,
createdBy: true,
updatedAt: true,
@ -313,9 +357,7 @@ export type ActivityDetailViewModel = {
locality: string | null
locationLabel: string
organizations: Array<{ id: number; name: string }>
advisors: Array<{ id: number; name: string }>
responsibleId: number | null
responsibleName: string | null
responsibles: ActivityResponsibleDisplay[]
taskProgress: { done: number; total: number }
tasks: ActivityTaskViewModel[]
updates: ActivityUpdateViewModel[]
@ -390,14 +432,7 @@ export const toActivityDetailViewModel = (
isPopulatedRelationship<Organization>(organization),
)
.map(({ id, name }) => ({ id, name })) ?? [],
advisors:
activity.advisors
?.filter((advisor): advisor is CampaignUser =>
isPopulatedRelationship<CampaignUser>(advisor),
)
.map(({ id, name }) => ({ id, name })) ?? [],
responsibleId: relationshipId(activity.responsible),
responsibleName: relationshipName(activity.responsible),
responsibles: mapActivityResponsibles(activity.responsible),
taskProgress: {
done: activity.taskDoneCount ?? 0,
total: activity.taskTotal ?? 0,

View file

@ -107,10 +107,9 @@ export {
export {
canCampaignUserRescheduleActivity,
canCreateActivity,
canCreateActivityAdvisors,
canDeleteActivity,
canManageActivityAdvisors,
canReadActivity,
canSetActivityResponsible,
canSetActivityStatus,
canSetActivitySystemField,
canUpdateActivity,

View file

@ -9,6 +9,7 @@ import {
SKIP_CAMPAIGN_NOTIFICATION_CONTEXT_KEY,
} from '@/lib/notificationContract'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { parseActivityResponsibleEntries } from '@/lib/schemas/activity'
import type { MunicipalityUpdatePolarity } from '@/lib/schemas/municipalityUpdate'
import { municipalityUpdatePolarityLabels } from '@/lib/schemas/municipalityUpdate'
import { createCampaignNotifications } from '@/utilities/notification/createCampaignNotification'
@ -161,7 +162,7 @@ export const notifyActivityNeedsAttention = async (
title: string
slug: string
municipality: unknown
advisors?: unknown
responsible?: unknown
},
): Promise<void> => {
if (shouldSkipCampaignNotification(req.context)) return
@ -178,9 +179,15 @@ export const notifyActivityNeedsAttention = async (
req,
})
const advisorIDs = uniqueRelationshipIds(doc.advisors as readonly unknown[] | null | undefined)
const recipientIDs = [...new Set(advisorIDs)]
// C90 — recipients are the staff members listed as responsible for the
// commitment (leadership/dobradinha entries have no notification account).
const recipientIDs = [
...new Set(
parseActivityResponsibleEntries(doc.responsible)
.filter((entry) => entry.relationTo === 'campaignUser')
.map((entry) => entry.value),
),
]
await createCampaignNotifications(
req.payload,

View file

@ -26,6 +26,11 @@ test.describe('Atividades — registro-fundação', () => {
await page.getByLabel('Início *').fill('2026-08-15T10:00')
await page.getByLabel('Município *').selectOption(String(municipality.id))
// C90 — the unified polymorphic selector replaces the old Contact
// responsável + assessores/liderança fields.
await expect(page.getByRole('button', { name: /Responsáveis: Nenhum/ })).toBeVisible()
await expect(page.getByText('Assessores responsáveis')).toHaveCount(0)
await page.getByRole('button', { name: 'Adicionar demanda' }).click()
await page.getByLabel('Título da demanda 1').fill(transportDemand)
await page.getByLabel('Tipo da demanda 1').selectOption('transporte')

View file

@ -14,6 +14,7 @@ import {
loadActivityAgendaEventsRecord,
rescheduleActivityRecord,
} from '@/app/(campaign)/campanha/actions/activity'
import { relationshipId } from '@/lib/relationship'
import {
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
activityCreateSchema,
@ -28,7 +29,7 @@ import {
getAccessibleLeadershipIds,
} from '@/utilities/campaignAccess'
import { installCampaignFixtures, relationId, relationIds } from '../helpers/campaignFixtures'
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
/** Create data with server-derived fields (e.g. `slug`) intentionally omitted. */
@ -246,9 +247,10 @@ describe('activity domain', () => {
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(municipality.id),
advisors: [advisor.id],
leadership: leadership.id,
responsible: contact.id,
responsible: [
{ relationTo: 'campaignUser', value: advisor.id },
{ relationTo: 'leadership', value: leadership.id },
],
createdBy: coordinator.id,
}),
overrideAccess: true,
@ -264,14 +266,20 @@ describe('activity domain', () => {
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
})
expect(advisorRead).toEqual({
or: [{ advisors: { contains: advisor.id } }, { municipality: { in: [municipality.id] } }],
or: [
{ responsible: { equals: { relationTo: 'campaignUser', value: advisor.id } } },
{ municipality: { in: [municipality.id] } },
],
})
const otherAdvisorRead = await canReadActivity({
req: stub<PayloadRequest>({ user: otherAdvisor, payload, context: {} }),
})
expect(otherAdvisorRead).toEqual({
or: [{ advisors: { contains: otherAdvisor.id } }, { municipality: { in: [] } }],
or: [
{ responsible: { equals: { relationTo: 'campaignUser', value: otherAdvisor.id } } },
{ municipality: { in: [] } },
],
})
const leaderRead = await canReadActivity({
@ -289,7 +297,10 @@ describe('activity domain', () => {
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
})
expect(advisorUpdate).toEqual({
or: [{ advisors: { contains: advisor.id } }, { municipality: { in: [municipality.id] } }],
or: [
{ responsible: { equals: { relationTo: 'campaignUser', value: advisor.id } } },
{ municipality: { in: [municipality.id] } },
],
})
const visibleToAdvisor = await payload.find({
@ -321,7 +332,102 @@ describe('activity domain', () => {
).rejects.toThrow(/permissão/i)
})
it('auto-includes the creating advisor in advisors', async () => {
it('grants an advisor access to an activity they are responsible for outside their portfolio (C90 polymorphic leg)', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const [ownMunicipality, otherMunicipality] = await Promise.all([
fixtures.getMunicipality(),
fixtures.getMunicipality(),
])
await fixtures.assignMunicipalityAdvisors(ownMunicipality, [advisor])
const visibleByResponsibility = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(otherMunicipality.id),
title: fixtures.value('Fora do portfólio, mas responsável'),
responsible: [{ relationTo: 'campaignUser', value: advisor.id }],
}),
overrideAccess: true,
})
fixtures.own('activity', visibleByResponsibility.id)
const invisible = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(otherMunicipality.id),
title: fixtures.value('Fora do portfólio e sem o assessor'),
}),
overrideAccess: true,
})
fixtures.own('activity', invisible.id)
const result = await payload.find({
collection: 'activity',
where: { id: { in: [visibleByResponsibility.id, invisible.id] } },
user: advisor,
overrideAccess: false,
depth: 0,
})
// The polymorphic `responsible` object-notation equals is the exact leg
// that keeps the advisor's right when the municipality scope does not.
expect(result.docs.map((doc) => doc.id)).toEqual([visibleByResponsibility.id])
})
it('lets a responsible advisor re-save an activity whose leadership they cannot read (C90 existence validation)', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const foreignMunicipality = await fixtures.getMunicipality()
const foreignLeadership = await fixtures.createLeadership({
contact: (await fixtures.createContact()).id,
municipalities: [foreignMunicipality.id],
supportStatus: 'engajado',
})
const activity = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(foreignMunicipality.id),
title: fixtures.value('Atividade que o assessor precisa editar'),
responsible: [
{ relationTo: 'campaignUser', value: advisor.id },
{ relationTo: 'leadership', value: foreignLeadership.id },
],
}),
overrideAccess: true,
})
fixtures.own('activity', activity.id)
// The advisor has no portfolio in the foreign municipality, so the
// leadership is outside their read scope — but they are responsible and
// re-submitting the intact list must not dead-end the save.
const updated = await payload.update({
collection: 'activity',
id: activity.id,
data: {
description: 'Ajuste do assessor responsável.',
responsible: [
{ relationTo: 'campaignUser', value: advisor.id },
{ relationTo: 'leadership', value: foreignLeadership.id },
],
},
user: advisor,
overrideAccess: false,
})
expect(updated.description).toBe('Ajuste do assessor responsável.')
const persisted = await payload.findByID({
collection: 'activity',
id: activity.id,
depth: 0,
overrideAccess: true,
})
const responsibleIDs = (persisted.responsible ?? []).map((entry) => relationshipId(entry.value))
expect(responsibleIDs).toEqual(expect.arrayContaining([advisor.id, foreignLeadership.id]))
})
it('auto-includes the creating advisor as responsible', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
@ -338,7 +444,8 @@ describe('activity domain', () => {
})
fixtures.own('activity', activity.id)
expect(relationIds(activity.advisors)).toEqual([advisor.id])
const responsibleIDs = (activity.responsible ?? []).map((entry) => relationId(entry.value))
expect(responsibleIDs).toEqual([advisor.id])
expect(relationId(activity.createdBy)).toBe(advisor.id)
})
@ -537,7 +644,7 @@ describe('activity domain', () => {
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const leadership = await fixtures.createLeadership({
await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
user: leaderAccount.id,
@ -549,8 +656,7 @@ describe('activity domain', () => {
data: stub<ActivityCreateData>({
...validActivityInput(municipality.id),
title: fixtures.value('Atividade Tarefas'),
advisors: [advisor.id],
leadership: leadership.id,
responsible: [{ relationTo: 'campaignUser', value: advisor.id }],
tasks: [{ title: 'Levar faixas', done: false }],
}),
overrideAccess: true,

View file

@ -93,13 +93,11 @@ describe('buildActivityListWhere (tab → where matrix)', () => {
})
})
it('filters by title or responsible name when q is set', () => {
it('filters by title when q is set (C90 — responsible is polymorphic, title-only)', () => {
const where = buildActivityListWhere({ page: 1, tab: 'proximos', q: 'Maria' }, NOW)
expect(where).toEqual({
and: [
{
or: [{ title: { contains: 'Maria' } }, { 'responsible.name': { contains: 'Maria' } }],
},
{ title: { contains: 'Maria' } },
{ status: { equals: 'confirmado' } },
{ startAt: { greater_than_equal: NOW.toISOString() } },
],

View file

@ -383,6 +383,7 @@ describe('src/utilities top-level is pinned', () => {
'activityLeadershipOptions.ts',
'activityPageData.ts',
'activityRelationOptions.ts',
'activityResponsibleSearch.ts',
'activityUi.ts',
'activityViewModels.ts',
'advisorData.ts',
@ -598,7 +599,7 @@ describe('admin bypasses carry a justification comment', () => {
['src/utilities/access/municipalities.ts', 3],
['src/utilities/access/shared.ts', 2],
['src/utilities/access/webauthnCredentials.ts', 1],
['src/collections/Activity.ts', 1],
['src/collections/Activity.ts', 0],
['src/collections/CampaignDemand.ts', 1],
['src/collections/CampaignUser.ts', 4],
['src/collections/CampaignWebAuthnCredential.ts', 1],