- 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)
13 KiB
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 "
canReadCampaignDemandpassa a ser unrestricted ou responsável". Confirma-se no código: hoje o escopo vive só emsrc/utilities/access/demands.ts:33(resolveActorScopedReadcommunicipality), e todos os consumidores (lista, detalhe, busca global do dashboard, demandas vinculadas da atividade, C90, omnibox) herdam via Local APIoverrideAccess: false— não há REST nem client query. O corte é cirúrgico: regra nova + campo + migration + UI de gestão.
Abordagem recomendada
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
responsibleshasMany →campaignUser+ regrawherede membership (espelho demunicipality.advisors/{ advisors: { contains } }). - B — Relação polimórfica
responsible(union campaignUser/leadership/stateDeputy) como aactivity. - 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
- Regra de leitura/atualização —
canReadCampaignDemanddeixa de usarresolveActorScopedRead(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").canCreateCampaignDemandinalterado (staff no município da carteira).canDeleteCampaignDemandadmin-only inalterado.canReadCampaignStaffField/canManageCampaignStaffFieldinalterados. - Criador auto-responsável — no branch
createdo hookenforceDemandWorkflow(CampaignDemand.ts:129-143), quandoactoré campaignUser:data.responsibles = union(data.responsibles, [actor.id]). Cobre todos os caminhos de criação: form/demandas/nova, wizard (A5), rascunhos C90 (activity.ts passauser: 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. - 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. - Gestão (server) —
updateCampaignDemandResponsiblesRecordemactions/demand.ts, mesmo shape dos irmãos: schema zod novo ({ id, responsibles: array de positiveRelationshipId }),withPayloadTransaction,reloadCampaignActor, staff check, advisory lockcampaign-demand:{id},findByIDscoped (overrideAccess: false— a row access autoriza antes do write),payload.updatecom 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). - Busca de candidatos (UI) — server action
searchDemandResponsibleOptions(query, municipalityId)(padrãosearchDemandActivityOptions): query < 2 chars → sugestões = assessores do município da demanda (read scoped demunicipality.advisors); senão → busca por nome emcampaignUsercomeligibleCampaignStaffWhere(reusa o padrão deloadEligibleAdvisorOptions),overrideAccess: false(campaign users leemcampaignUser—canReadCampaignUsers). Sugestão NÃO é herança: é atalho de preenchimento, a visibilidade vem só do campo salvo. - Componente de multi-select —
DemandResponsibleMultiSelectnovo emsrc/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 repetidosresponsibles(contrato deRelationMultiSelect, não o JSON deResponsibleMultiSelect). Não reusarResponsibleMultiSelect(C90): é polimórfico (typeLabel/relationTo), tem cap hardcodedMAX_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 separouRelationMultiSelectdeResponsibleMultiSelect). - Detalhe — card
DemandResponsiblesCard(client) com a lista (nome + avatar vialoadCampaignUserDisplayByIds, helper existente), add por combobox, remove por chip, e o rótulo estático "Visível para: Responsáveis · Candidato · Coordenador". Layout do rascunho: gradelg: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. - Criação —
DemandFieldsganha o campo responsáveis (opcional via propresponsiblesMunicipalityId/currentUser):/demandas/novae wizard compartilham o mesmo parse (parseCampaignDemandCreateFormDataganharesponsibles: formData.getAll('responsibles')), schema de create ganharesponsibles: z.array(positiveRelationshipId)(sem cap — comomunicipality.advisors; o hook faz o union, dedup implícito). Draft:/demandas/novaem grade 2 colunas (form | responsáveis); wizard empilhado (o wizard herda viaDemandFields).
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; removeresolveActorScopedRead/getAccessibleMunicipalityIdsdeste módulo.- Migration:
add_campaign_demand_responsibles(drizzle):ALTER TABLE campaign_demand_rels ADD COLUMN campaign_user_id+ FK (ON DELETE cascade, padrãostate_deputy_rels) + índice; + backfill no up. Gerar compnpm 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+DemandResponsiblesCardemsrc/components/campaign/demand/;DemandFields/DemandForm/WizardRegisterDemandStepganham o campo;[slug]/page.tsxganha 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 }>vialoadCampaignUserDisplayByIds;nova/page.tsxcarrega ocurrentUser(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
- 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. - Ações + busca —
updateCampaignDemandResponsiblesRecord+ zod +searchDemandResponsibleOptions; int de gestão (add/remove/self-removal, advisor não-responsável não lê). - 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).
- Gates —
tsc,lint,format:check,knip,check:cycles, unit+int,buildlocal; 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 comuser: 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_relscompath='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
- 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)
- 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) - 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)