feat(C119): status de apoio 'Lembrança' em lideranças — enum, badge e exclusão de pendências

This commit is contained in:
Francisco Solla 2026-08-11 12:50:06 -03:00
parent 9789bf36e6
commit 5fa4e22c9d
17 changed files with 140 additions and 26 deletions

View file

@ -0,0 +1,89 @@
# Impl: Status de apoio "Lembrança" em lideranças
Status: em execução
Atualizado em: 2026-08-11
Issue: #661
Intenção: docs/plans/lembranca-status-liderancas.md
Appetite restante: herdado (~1 dia eng) — item pequeno, cabe folgado
## Leitura da intenção
- **Outcome:** novo valor `lembranca` no enum `supportStatus` de `leadership` (entre `em_disputa` e `negativo` no seletor), visível e marcável em TODAS as superfícies onde os 4 status atuais aparecem (célula da lista com auto-save, ficha/wizard/admin, filtro das listas de lideranças e de pessoas, Sollinha); **nunca** entra no critério de pendência de abordagem (fail-closed na query, não só na UI); badge visualmente distinto; nenhuma leitura existente quebra.
- **O que NÃO negociar:** liderança "Lembrança" é favorável e não é pendente — exclusão das pendências espelhando o "Negativo" (na query `getPendingLeaderships`, que já é fail-closed); sem segundo eixo de classificação; sem fluxo próprio de votos (usa o campo de votos declarados existente); leader lockdown intacto (status é staff-only).
- **O que reavaliar:** a hipótese da intenção cobre as áreas certas, mas encontrei duas superfícies que ela não nomeou: (1) `peopleData.ts` `leadershipStatusesInOrder` — lista dura dos 4 valores que **silenciosamente omitiria** o novo status do facet da lista de pessoas; (2) `getLeaderships.ts` mantém uma tabela de rótulos duplicada (`SUPPORT_LABELS`) que também esqueceria o valor novo — candidata a DRY com `supportStatusLabels`.
## Abordagem recomendada
```mermaid
flowchart LR
A[leadershipSupportStatuses\n+ 'lembranca'] --> B[collection options\n+ migration ALTER TYPE]
A --> C[labels + badge variant\n+ tokens CSS]
A --> D[listas/filtros/forms\nvia enum array]
A --> E[getLeaderships usa\nsupportStatusLabels]
F[getPendingLeaderships\nINTOCADO + teste pin] --> G[pendências excluem\nlembranca fail-closed]
H[systemPrompt +1 linha\nreconhecimento] --> I[Sollinha responde\nsobre o status]
```
**Opções consideradas:** A (valor novo no enum existente, fonte única) | B (segundo campo/eixo de classificação) | C (status composto em string)
**Recomendação:** A — o produto decidiu no gate que é um valor a mais no enum; a engenharia tem um único source of truth (`leadershipSupportStatuses` + `supportStatusLabels`) e as superfícies que derivam dele (listas, forms, URLs, omnibox) ganham o valor automaticamente.
**Rejeitadas:** B porque a intenção corta explicitamente o segundo eixo (matriz de combinações); C porque destrói filtro `in` no Postgres e indexação.
### Decisões de engenharia
- **D1 — Migration do enum.** `ALTER TYPE "public"."enum_leadership_support_status" ADD VALUE 'lembranca' BEFORE 'negativo'` — o `BEFORE` preserva a ordem do enum no Postgres igual à do seletor (decisão do gate: entre "Em disputa" e "Negativo"), então `sort: 'supportStatus'` na lista ordena igual ao seletor.
- _Rejeitadas:_ `ADD VALUE` sem `BEFORE` (anexa no fim → ordenação por status divergiria do seletor); recriar o tipo inteiro (rename+create+cast, precedente C14) — destrutivo demais para um valor novo, risco desnecessário em prod.
- Precedentes: `20260723_025513` (ADD VALUE … BEFORE), `20260728_041547` / `20260724_180000` (ADD VALUE). Se o `migrate:create` gerar `ADD VALUE` sem `BEFORE`, editar o `.ts` da migration nova (migration não shipada — editável; nunca editar as antigas).
- **D2 — Badge distinto.** Novo variant `support-remembered` no `Badge` + tokens `--support-remembered`/`-foreground` (índigo suave: `#e0e7ff` / `#3730a3`) — distinto dos 4 atuais (verde/cinza/âmbar/vermelho) e semanticamente "resolvido e favorável".
- _Rejeitadas:_ reusar `estimate-*` (âmbar colide com "Em disputa"); variant `default` (não distinto).
- **D3 — `getLeaderships.ts`: matar a tabela duplicada.** Substituir `SUPPORT_LABELS` por `supportStatusLabels` de `leadershipLabels.ts` (tabela de strings pura, client-safe, importável do server). Um valor novo no enum seria propagado pela fonte única em vez de exigir lembrar da duplicata.
- _Rejeitada:_ só adicionar `lembranca` na duplicata — deixa a segunda fonte de verdade viva para o próximo status.
- **D4 — `peopleData.ts` `leadershipStatusesInOrder`:** trocar a lista dura `['engajado','a_abordar','em_disputa','negativo']` por `leadershipSupportStatuses.filter(s => statuses.has(s))`. Sem isso o status novo não apareceria no facet da lista de pessoas (miss silencioso, sem teste pegando).
- **D5 — Pendência: não tocar.** `PENDING_SUPPORT_STATUSES = ['a_abordar','em_disputa','engajado']` e `PENDING_CRITERION` ficam como estão — "Lembrança" e "Negativo" já são excluídos pela ausência da lista (fail-closed na query) e o critério declarado continua sem o novo estado (aceite: "pendências declaram o critério sem o novo estado"). Adicionar teste que pin a exclusão.
- **D6 — Sollinha reconhece o status:** +1 linha na seção "Lideranças pendentes de abordagem" do `systemPrompt.ts` (ex.: "Liderança com status 'Lembrança' é favorável sem compromisso — não conta como pendente de abordagem"). O rótulo na leitura vem de D3.
- **Semânticas que excluem "Lembrança" intencionalmente (verificar com testes, não mudar):** convite de login exige `engajado` (`campaignInvite*`), acesso ao app exige `engajado` (`access/contacts.ts`, `access/municipalities.ts`), opção de "responsável" de atividade exige `engajado` (`activityLeadershipOptions.ts`) — uma "Lembrança" não é engajada, comportamento correto.
### Componentes / mudanças
- **`leadershipSupportStatuses`** (`src/lib/schemas/leadership.ts`): `['engajado', 'a_abordar', 'em_disputa', 'lembranca', 'negativo']` — tipo `SupportStatus` e `isSupportStatus` derivam sozinhos.
- **`Leadership.ts`** (collection): opção `{ label: 'Lembrança', value: 'lembranca' }` na posição 4 (admin select + admin filter automáticos).
- **Migration:** `add_lembranca_support_status` via `pnpm migrate:create`, SQL conforme D1.
- **`payload-types.ts`:** regenerar via `pnpm generate:types`.
- **`leadershipLabels.ts`:** `lembranca: 'Lembrança'`.
- **`SupportStatusBadge.tsx`:** `lembranca: { variant: 'support-remembered' }`.
- **`Badge.tsx`** + **`styles.css`:** variant e tokens (D2).
- **`getLeaderships.ts`:** `SUPPORT_LABELS` → `supportStatusLabels` (D3).
- **`systemPrompt.ts`:** linha de reconhecimento (D6).
- **`peopleData.ts`:** `leadershipStatusesInOrder` via enum canônico (D4).
- **Automático (sem edição):** `LeadershipListSupportStatusControl`, `LeadershipForm`/`LeadershipInternalForm`/`WizardLeadershipForm`, `leadershipListFilters`/`peopleListFilters`, `leadershipListUrl`/`peopleListUrl` (parse enum exaustivo), omniboxes, `leadershipData`, `wizardLeadershipContract`, `municipalityV2Network*`, actions/routes zod (`z.enum(leadershipSupportStatuses)`), `support-status/route.ts`.
- **Access / Consent:** nenhum — status é staff-only (`canManageCampaignStaffField`), leader lockdown intocado.
### Dados → forma
Não se aplica (nenhuma superfície de dado nova; valor de enum flui por filtros/facets existentes — decisão de produto do gate).
## Fases verificáveis
1. **Schema+server** — enum array + collection options + migration + `generate:types`; verificação: `pnpm migrate:status` e `pnpm migrate` local.
2. **UI** — labels + Badge + tokens + `SupportStatusBadge`; verificação: badge unit test (it.each) renderiza `Lembrança`.
3. **IA + pessoas** — `getLeaderships` DRY, `systemPrompt`, `peopleData` ordem; verificação: testes de tool e listas.
4. **Testes** — atualizar os que enumeram status (badge it.each, "every status member" do `leadershipListUrl`, fixture e2e, int spec com `lembranca` persistindo, pin de exclusão de pendência); NENHUM teste novo de feature em volume.
5. **Gates** — `tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build` local.
## Rabbit holes / Não escopo (engenharia)
- Migração destrutiva do enum (recreate) — desnecessária com `BEFORE`.
- Reordenar `a_abordar` no seletor — produto não pediu.
- Tratamento especial de votos para "Lembrança" — decidido no gate: campo de votos declarados já existente.
- Qualquer mudança em convites/acesso-app/atividade (engajado-only é o comportamento correto).
## Riscos e mitigação
- **Migration `ADD VALUE` + transação Postgres:** permitido desde PG 12 dentro de transação (novo valor não usado na mesma transação); precedente shipado em prod (`20260723_025513`). Mitigação: verificar `pnpm migrate` local e `migrate:status` antes do push.
- **Superfície enumerada esquecida:** mitigado por D3/D4 (fonte única) + teste de badge it.each + teste "every status member" da URL.
- **Filtro `lembranca` vazando para pendências:** fail-closed já está na query; o teste unit do `getPendingLeaderships` passa a incluir `lembranca` na exclusão pinada.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (superfícies todas + pendências excluem + badge distinto + Sollinha reconhece)
- [x] Invariantes AGENTS/engineering-standards (sem transações novas, sem access novo, nomes EN, rótulos pt-BR)
- [x] Testes de domínio previstos (badge it.each, URL enum exaustivo, pin pendência, int persistence)

View file

@ -72,6 +72,8 @@ body,
--color-support-to-approach: var(--support-to-approach);
--color-support-disputed-foreground: var(--support-disputed-foreground);
--color-support-disputed: var(--support-disputed);
--color-support-remembered-foreground: var(--support-remembered-foreground);
--color-support-remembered: var(--support-remembered);
--color-support-negative-foreground: var(--support-negative-foreground);
--color-support-negative: var(--support-negative);
--color-estimate-confirmed-foreground: var(--estimate-confirmed-foreground);
@ -249,6 +251,8 @@ body,
--support-to-approach-foreground: #46505c;
--support-disputed: #fef3c7;
--support-disputed-foreground: #92400e;
--support-remembered: #e0e7ff;
--support-remembered-foreground: #3730a3;
--support-negative: #fee2e2;
--support-negative-foreground: #991b1b;
--estimate-confirmed: #dcfce7;

View file

@ -146,6 +146,7 @@ export const Leadership: CollectionConfig = {
{ label: 'Engajado', value: 'engajado' },
{ label: 'A abordar', value: 'a_abordar' },
{ label: 'Em disputa', value: 'em_disputa' },
{ label: 'Lembrança', value: 'lembranca' },
{ label: 'Negativo', value: 'negativo' },
],
},

View file

@ -10,6 +10,7 @@ const statusPresentation = {
engajado: { variant: 'support-engaged' },
a_abordar: { variant: 'support-to-approach' },
em_disputa: { variant: 'support-disputed' },
lembranca: { variant: 'support-remembered' },
negativo: { variant: 'support-negative' },
} as const

View file

@ -24,6 +24,8 @@ const badgeVariants = cva(
'bg-support-to-approach text-support-to-approach-foreground [a]:hover:bg-support-to-approach/80',
'support-disputed':
'bg-support-disputed text-support-disputed-foreground [a]:hover:bg-support-disputed/80',
'support-remembered':
'bg-support-remembered text-support-remembered-foreground [a]:hover:bg-support-remembered/80',
'support-negative':
'bg-support-negative text-support-negative-foreground [a]:hover:bg-support-negative/80',
'estimate-confirmed':

View file

@ -14,6 +14,7 @@ export const leadershipSupportStatuses = [
'engajado',
'a_abordar',
'em_disputa',
'lembranca',
'negativo',
] as const

View file

@ -47,8 +47,7 @@ import * as migration_20260808_134911_add_calendar_feed from './20260808_134911_
import * as migration_20260808_184113_remodel_activity_responsible from './20260808_184113_remodel_activity_responsible';
import * as migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors from './20260809_204728_add_campaign_user_contact_and_leadership_advisors';
import * as migration_20260810_010844_add_activity_all_day from './20260810_010844_add_activity_all_day';
import * as migration_20260811_111828_add_contact_phones_array from './20260811_111828_add_contact_phones_array';
import * as migration_20260811_133332_add_google_calendar_sync from './20260811_133332_add_google_calendar_sync';
import * as migration_20260811_153004_add_lembranca_support_status from './20260811_153004_add_lembranca_support_status';
export const migrations = [
{
@ -297,13 +296,8 @@ export const migrations = [
name: '20260810_010844_add_activity_all_day',
},
{
up: migration_20260811_111828_add_contact_phones_array.up,
down: migration_20260811_111828_add_contact_phones_array.down,
name: '20260811_111828_add_contact_phones_array',
},
{
up: migration_20260811_133332_add_google_calendar_sync.up,
down: migration_20260811_133332_add_google_calendar_sync.down,
name: '20260811_133332_add_google_calendar_sync',
up: migration_20260811_153004_add_lembranca_support_status.up,
down: migration_20260811_153004_add_lembranca_support_status.down,
name: '20260811_153004_add_lembranca_support_status'
},
];

View file

@ -590,7 +590,7 @@ export interface Leadership {
stateDeputies?: (number | StateDeputy)[] | null;
advisors?: (number | CampaignUser)[] | null;
exclusive?: boolean | null;
supportStatus: 'engajado' | 'a_abordar' | 'em_disputa' | 'negativo';
supportStatus: 'engajado' | 'a_abordar' | 'em_disputa' | 'lembranca' | 'negativo';
user?: (number | null) | CampaignUser;
consent?: (number | null) | Consent;
consentContentHash?: string | null;

View file

@ -43,6 +43,7 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
## Lideranças pendentes de abordagem
- Use a ferramenta "getPendingLeaderships" para "quais lideranças ainda precisam ser abordadas em X", "lideranças sem assessor responsável" ou "quais municípios não têm liderança cadastrada".
- A resposta da ferramenta traz o critério de pendência — declare-o sempre na sua resposta (ex.: "critério: status 'A abordar' ou 'Em disputa'; ou 'Engajado' sem compromisso de votos no escopo consultado").
- Liderança com status "Lembrança" é favorável sem compromisso — não conta como pendente de abordagem; se perguntarem, explique isso e, se houver, aponte essas lideranças pela leitura normal de lideranças.
- Escopos: território de identidade (aceita grafia sem acento, ex.: "Vale do Jiquiriça"), cidade ("Salvador" = as 19 zonas eleitorais juntas) ou município. Sem escopo, vale o escopo de acesso do usuário.
- Quando a resposta trouxer "escopoRestrito: true", deixe claro que os resultados estão limitados aos municípios do portfólio do usuário; quando trouxer "truncado: true", sugira estreitar o escopo para ver o restante.
- Ofereça links de navegação (buildCampaignLinks) para as lideranças citadas (por id) e municípios (por slug); assessores (por id) quando o perfil permitir.

View file

@ -3,13 +3,7 @@ import type { Where } from 'payload'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
const SUPPORT_LABELS: Record<string, string> = {
engajado: 'Engajado',
a_abordar: 'A abordar',
em_disputa: 'Em disputa',
negativo: 'Negativo',
}
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
export const getLeaderships = (ctx: AIToolContext) =>
tool({
@ -67,7 +61,8 @@ export const getLeaderships = (ctx: AIToolContext) =>
organizacoes: organizations.map((o) => o.name),
apoioExclusivo: doc.exclusive === true,
status:
SUPPORT_LABELS[(doc.supportStatus as string) ?? ''] ?? (doc.supportStatus as string),
supportStatusLabels[doc.supportStatus as keyof typeof supportStatusLabels] ??
(doc.supportStatus as string),
observacoes: (doc.notes as string) ?? null,
}
})

View file

@ -8,6 +8,7 @@ export const supportStatusLabels: Record<SupportStatus, string> = {
engajado: 'Engajado',
a_abordar: 'A abordar',
em_disputa: 'Em disputa',
lembranca: 'Lembrança',
negativo: 'Negativo',
}

View file

@ -4,7 +4,11 @@ import type { Payload } from 'payload'
import { primaryPhoneOf } from '@/lib/phone'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { isSupportStatus, type SupportStatus } from '@/lib/schemas/leadership'
import {
isSupportStatus,
leadershipSupportStatuses,
type SupportStatus,
} from '@/lib/schemas/leadership'
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
import { municipalityIdsByAdvisorIds } from '@/utilities/advisorData'
import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess'
@ -275,9 +279,7 @@ export const peopleFilterFacetsFromRows = (
}
const leadershipStatusesInOrder = (statuses: ReadonlySet<SupportStatus>): SupportStatus[] =>
(['engajado', 'a_abordar', 'em_disputa', 'negativo'] as const).filter((status) =>
statuses.has(status),
)
leadershipSupportStatuses.filter((status) => statuses.has(status))
const EMPTY_FACETS: PeopleListFilterFacets = { municipalityIDs: [], statuses: [] }

View file

@ -183,7 +183,7 @@ export class CampaignE2EOwnership {
}: {
namePrefix: string
municipalities: { id: number; name: string }[]
supportStatus?: 'a_abordar' | 'em_disputa' | 'engajado' | 'negativo'
supportStatus?: 'a_abordar' | 'em_disputa' | 'lembranca' | 'engajado' | 'negativo'
user?: number
}): Promise<{ contactName: string; contactId: number; leadershipId: number }> {
const contactName = this.value(namePrefix)

View file

@ -427,6 +427,27 @@ describe('campaign leadership domain', () => {
).rejects.toThrow()
})
it('persists the "lembranca" support status through create and internal update (C119)', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança Lembrança',
phone: campaignFixtures().phone(),
supportStatus: 'lembranca',
})
const doc = await payload.findByID({ collection: 'leadership', id: created.id, depth: 0 })
expect(doc.supportStatus).toBe('lembranca')
const updated = await updateLeadershipInternalRecord(payload, coordinator, {
id: created.id,
supportStatus: 'lembranca',
})
expect(updated.supportStatus).toBe('lembranca')
})
it('denies leadership creation to leader accounts', async () => {
const leader = await campaignFixtures().createCampaignUser('leader')
const municipality = await campaignFixtures().getMunicipality()

View file

@ -188,6 +188,7 @@ describe('campaign visual foundation', () => {
['engajado', 'Engajado'],
['a_abordar', 'A abordar'],
['em_disputa', 'Em disputa'],
['lembranca', 'Lembrança'],
['negativo', 'Negativo'],
] as const)('renders the %s support status with text and semantic state', (status, label) => {
const html = renderToStaticMarkup(createElement(SupportStatusBadge, { status }))

View file

@ -63,7 +63,7 @@ describe('leadership list URL contract', () => {
it('canonicalizes selecting every status member to the absent filter (B18)', () => {
expect(
parseLeadershipListParams({
status: ['engajado', 'a_abordar', 'em_disputa', 'negativo'],
status: ['engajado', 'a_abordar', 'em_disputa', 'lembranca', 'negativo'],
}),
).toEqual({ page: 1 })
})

View file

@ -86,7 +86,7 @@ describe('getPendingLeaderships gate (B185)', () => {
})
describe('getPendingLeaderships pending criterion (B185)', () => {
it('lists a_abordar/em_disputa e engajado sem pledge; exclui engajado com pledge e qualquer negativo', async () => {
it('lists a_abordar/em_disputa e engajado sem pledge; exclui engajado com pledge, qualquer negativo e qualquer lembranca (C119)', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
@ -95,6 +95,7 @@ describe('getPendingLeaderships pending criterion (B185)', () => {
leadershipDoc({ id: 3, supportStatus: 'em_disputa' }),
leadershipDoc({ id: 4, supportStatus: 'engajado' }),
leadershipDoc({ id: 5, supportStatus: 'negativo' }),
leadershipDoc({ id: 6, supportStatus: 'lembranca' }),
]),
)
find.mockResolvedValueOnce(findResult([{ leadership: 2 }]))