Pular para o conteúdo principal

Spec — Correções de review TEC-541 (Enquetes)

Etapa 1 (spec-driven). Cada item abaixo tem diagnóstico confirmado no código real (arquivo + linha) e a abordagem de correção proposta. Nada foi implementado ainda — aguardando aprovação.

Referências já lidas: 23-polls.sql, polls.service.ts, polls.schema.ts, server-actions.ts (bloco Polls), polls-listing.tsx, poll-card.tsx, poll-vote-form.tsx, create-poll-modal.tsx, poll-visibility.ts, use-documents-list.ts, documents-filter-chips.tsx, polls-filter-modal.tsx, use-polls-filters.ts, 04-roles.sql, docs/development/intranet-enquetes-backend.mdoc, Figma (nodes 101-4157/101-4163 no arquivo Intranet, 1049-8857 no arquivo ABRACAF (cópia)).

Nota sobre a ordem real de execução (desvio da Etapa 3 original)

O rebase contra dev (item 16 / Etapa 3, passos 3-4) já foi executado nesta conversa, antes dos demais itens — não na ordem original pedida (que previa poll_audiences + revert do seed primeiro). Isso já está consolidado e pushado (--force-with-lease):

  • Conflito em app-sidebar.tsx: resolvido mesclando as duas versões (não "aceitando a desta branch" cegamente) — dev já tinha Eventos ativo (mergeado depois que esta branch nasceu) e esta branch tinha Enquetes ativo; o resultado final ativa as 6 telas (Documentos, Revista UNA, Mercado, Blog, Eventos, Enquetes), lista de desabilitados vazia.
  • Um segundo conflito (polls.schema.ts, poll-visibility.ts, add/add) veio de um commit duplicado na história (ca938c63, sobra de um merge auto-referenciado antigo) — pulado com git rebase --skip após confirmar que o conteúdo já estava presente via outro commit.
  • Um erro de typecheck pós-rebase (prop showDetailByModelOptionshowDetailByModel e ExportPollResultsOptions.columnIdscolumns, fallout de uma mudança no dev em CommonTableExportButton) já foi corrigido separadamente.
  • Ainda pendente da Etapa 3: reverter o seed (6be97209) — só depois que poll_audiences + a nova policy de voto estiverem validadas (item 1), conforme a ordem original. Ver seção "Etapa 3 restante" no final.

Como o rebase já aconteceu, os itens 2-15 abaixo serão implementados em cima do código já rebaseado (não há mais conflito de sidebar a resolver na Etapa 2).


Item 1 — poll_audiences (multi-select "Quem pode responder")

Diagnóstico confirmado:

  • 23-polls.sql:27-29: poll_audience é um enum com um único valor (todos_associados).
  • 23-polls.sql:279-289: policy poll_votes_insert checa public.has_role_on_account(p.account_id, 'member') — role fixo embutido na policy, exatamente a violação que a Matriz de Acesso aponta.
  • polls.schema.ts:12: PollAudienceSchema = z.enum(['todos_associados']).
  • create-poll-modal.tsx:304-328: Select de opção única, SelectContent só com todos_associados.
  • polls.service.ts:100-112 (isAssociado): RPC has_role_on_account com account_role: 'member' hardcoded — gate de aplicação, redundante com a policy mas igualmente hardcoded.
  • server-actions.ts:866,896: voteOnPollAction/removeVoteOnPollAction chamam service.isAssociado(accountId) — não sabem qual enquete, só a conta.

Abordagem proposta:

  1. Migration nova (schema 23-polls.sql + migration versionada):

    • Remove who_can_answer poll_audience de polls (ou mantém como metadado histórico? — decisão: remover, já que a fonte de verdade passa a ser a tabela de junção; manter os dois criaria duas fontes de verdade divergentes).
    • Drop do tipo poll_audience (não é mais usado).
    • Nova tabela:
      create table if not exists public.poll_audiences (
      poll_id uuid references public.polls(id) on delete cascade not null,
      account_role varchar(50) references public.roles(name) not null,
      primary key (poll_id, account_role)
      );
    • RLS: select liberado pra authenticated que tem acesso à poll pai (mesmo padrão de poll_options_select); insert só junto da criação da enquete (mesmo padrão de poll_options_insert, gate por can_manage_polls). Sem update/delete — a audiência é definida na criação, não editável depois (mesmo princípio de "sem edição de pergunta/opções" já documentado pra polls).
    • Seed de roles elegíveis nesta primeira decisão: conc_grupo e conc_loja (constraint via check não é viável direto no enum-like varchar+FK — a restrição "só esses 2 por ora" fica na camada de aplicação/Zod, não no banco, pra não exigir migration toda vez que um papel novo for habilitado; o banco só garante que o valor é um roles.name válido).
    • Nova policy poll_votes_insert — substitui a atual (cruza contra get_own_account_role(), uma função SECURITY DEFINER — ver nota de implementação abaixo):
      create policy "poll_votes_insert" on public.poll_votes
      for insert to authenticated
      with check (
      user_id = (select auth.uid())
      and exists (
      select 1
      from public.polls p
      join public.poll_audiences pa
      on pa.poll_id = p.id
      and pa.account_role = public.get_own_account_role(p.account_id)
      where p.id = poll_id
      and p.status = 'aberta'
      )
      );
      Isso satisfaz o princípio: a policy nunca menciona um nome de papel — cruza a audiência daquela enquete específica (dado, não código) contra o papel real do votante.
    • poll_votes_update_own/poll_votes_delete_own não precisam mudar (já não checam papel, só que a enquete está aberta — a elegibilidade só importa no momento de votar pela primeira vez).
  2. Zod (polls.schema.ts):

    • Novo PollEligibleRoleSchema = z.enum(['conc_grupo', 'conc_loja']) (a lista restrita desta decisão — ver item 6, member fica de fora).
    • CreatePollBaseSchema.whoCanAnsweraudienceRoles: z.array(PollEligibleRoleSchema).min(1, 'Selecione ao menos um papel').
    • Remove PollAudienceSchema/PollAudience (não existe mais um enum único).
  3. create-poll-modal.tsx: troca o Select único por multi-select — implementado com TreeSelect (@kit/ui/tree-select), não FilterMultiSelect como o plano original previa (aquele componente é feito pra barra de filtro estreita, com várias camadas de restrição de largura incompatíveis com um campo de formulário full-width — ver "Notas de implementação" abaixo).

  4. polls.service.ts:

    • createPoll: insere em poll_audiences junto (mesmo padrão de rollback de poll_options — se falhar, desfaz a poll).
    • isAssociado(accountId)substituído por canVoteOnPoll(accountId, userId, pollId): busca o account_role do usuário atual na conta e verifica se está em poll_audiences daquela poll, além de checar a janela [start_at, end_at) — mesma lógica da policy, replicada na aplicação só pra dar uma mensagem de erro amigável por causa (not_found/ended/not_started/audience) antes de bater no banco.
    • listPolls/buildListItem: passa a incluir a lista de audienceRoles no PollListItem.
  5. server-actions.ts: voteOnPollAction/removeVoteOnPollAction trocam service.isAssociado(accountId) por service.canVoteOnPoll(accountId, user.id, parsedInput.pollId), mapeando o reason pra uma mensagem amigável por causa.

Nota de implementação — bug descoberto na validação: a primeira versão da policy fazia join public.accounts_memberships m on m.account_id = p.account_id and m.user_id = auth.uid() direto — isso quebrou em teste real porque accounts_memberships tem sua própria RLS restritiva (restrict_mfa_accounts_memberships, checagem de MFA) que bloqueia esse acesso quando avaliado de dentro de outra policy. Corrigido criando public.get_own_account_role(p_account_id uuid), uma função SECURITY DEFINER (mesmo mecanismo de has_role_on_account), e usando essa função na policy em vez do join direto. Validado com 4 cenários via psql/transação com rollback (elegível vota, inelegível bloqueado, antes do início bloqueado, após o prazo bloqueado) e com a suíte pgTAP reescrita (37 asserções).

Ordem de implementação (Etapa 2): este item primeiro, já com member fora da lista desde o início (não implementar com member incluído pra depois remover — ver item 6).


Item 2 — Período com data e hora

Diagnóstico confirmado:

  • 23-polls.sql:42-43: start_at date not null, end_at date not null.
  • polls.schema.ts:38-39: startAt: z.string().date(), endAt: z.string().date().
  • create-poll-modal.tsx:72-73: new Date().toISOString().slice(0, 10) — só data.
  • create-poll-modal.tsx (SingleDateField, linhas ~85-125): Calendar do shadcn, sem seletor de hora.
  • Leitores da coluna a mapear: poll-card.tsx (formatEndAt/formatClosedAt), poll-results-view.tsx (formatDateTime pra createdAt; ${results.endAt}T00:00:00 pra endAt), poll-results-stat-cards.tsx (daysUntil), export-poll-results.ts (formatDate em Encerra em).

Implementado:

  • Migration: alter column start_at type timestamptz using start_at::timestamptz, idem end_at.
  • Zod: startAt/endAtz.iso.datetime().
  • UI: novo componente poll-date-time-field.tsx (não existia date+time picker pronto no design system) — Calendar (data) + <input type="time"> nativo (hora), compondo um único valor ISO.
  • Todos os leitores mapeados foram atualizados pra usar o valor ISO completo diretamente, sem compor T00:00:00.

Item 3 — Encerramento automático ao atingir o prazo (RF030)

Diagnóstico confirmado:

  • 23-polls.sql:36-41 (comentário): "NÃO é gatilho automático de transição de status. Encerramento é sempre manual, mesmo com o prazo já vencido." — comportamento atual documentado e confirmado.
  • Nenhum job/cron existe no projeto pra Enquetes.

Implementado — checagem lazy, sem cron/job:

  • getEffectiveStatus(status, endAt) em polls.service.ts computa "encerrada" assim que end_at passa, sem gravar nada — usado em listPolls/buildListItem/getPollResults.
  • Filtro de aba (Abertas/Encerradas) na listagem também usa o status efetivo, não só a coluna status.
  • Policy poll_votes_insert ganha and now() < p.end_at; poll_votes_update_own/poll_votes_delete_own também.

Item 4 — Bloquear voto antes do start_at

Implementado: policy poll_votes_insert ganha and now() >= p.start_at; canVoteOnPoll no service checa a janela antes de chegar no banco, retornando reason: 'not_started' pra mensagem amigável específica.


Item 5 — Erro interno do Postgres vazando ao usuário (auditoria completa)

Diagnóstico confirmado — polls.service.ts, todas as interpolações de error.message encontradas: canManagePolls, isAssociado (removido), listPolls (hasVoted + query principal), createPoll (poll + opções), vote, removeVote, closePoll, getPollResults (opções), fetchOptionsByPoll/fetchOwnVotesByPoll — ~11 pontos, não só a linha reportada na review.

Confirmado também: handleServerError: (error) => error.message em packages/next/src/actions/safe-action-client.ts — qualquer Error lançado por um service chega ao serverError do client tal qual, framework-wide.

Implementado: helper central friendlyError(error, fallback) em polls.service.ts — loga o erro técnico completo via getLogger() e retorna mensagem de negócio genérica, exceto quando a causa é RLS negando poll_votes (código 42501 ou mensagem contendo "row-level security"), onde retorna "Esta enquete foi encerrada e não aceita mais votos." Usado em todos os ~11 pontos. canVoteOnPoll distingue a causa exata (não encontrada/encerrada/não iniciada/fora da audiência) antes de bater no banco.


Item 6 — Remover member da lista de papéis elegíveis (auditoria completa)

Diagnóstico confirmado: só 2 pontos de código real assumiam member (a policy poll_votes_insert e o método isAssociado), ambos resolvidos pelo item 1. Restante eram comentários e a doc docs/development/intranet-enquetes-backend.mdoc.

Implementado: comentários atualizados em 23-polls.sql, polls.service.ts, polls.schema.ts, server-actions.ts. Doc atualizada com nota de atualização explícita (TEC-541) + seção de perfis/permissões revisada.


Item 7 — Fórmula de participação (RF017)

Implementado: getPollResults(pollId) conta accounts_memberships da conta dona da poll com account_role presente em poll_audiences daquela poll específica (não hardcoded), dividido pelos votos recebidos.


Item 8 — Expor "Remover voto"

Implementado: botão "Remover voto" ao lado de "Trocar voto" em poll-card.tsx, sem modal de confirmação (ação reversível), com toast de sucesso.


Item 9 — Migrar polls-listing.tsx para React Query

Implementado: novo hook use-polls-list.ts com useQuery (não useInfiniteQuery — sem scroll infinito), refetchOnWindowFocus + refetchInterval: 30s. polls-listing.tsx usa invalidateQueries nos 3 fluxos (criar, votar, encerrar) em vez de refetch manual. Reconciliação no erro de voto: invalida a query da listagem, o card reflete "Encerrada" automaticamente.


Item 10 — Toast de sucesso ao votar e encerrar

Implementado: toasts de sucesso/erro em votar, trocar voto, remover voto, encerrar enquete.


Item 11 — Pluralização de "votos"

Implementado: formatVoteCount(count) local em poll-card.tsx — confirmado que não existe utilitário genérico de pluralização no projeto.


Item 12 — Formato de data com ano e hora

Implementado: novo formatShortDateTime em ~/lib/format-date.ts (função nova, não parâmetro opcional em formatShortDate, pra não arriscar os ~10 consumidores existentes sem hora).


Item 13 — Badges de status conforme Figma

Implementado: confirmado via Figma MCP — "Aberta" (success) já estava correto; "Encerrada" corrigida de secondary pra outline.


Item 14 — Filtros e chips (RF002/RUX012)

Implementado: novo componente polls-filter-chips.tsx, estrutura de documents-filter-chips.tsx adaptada, visual copiado de revista-una-filter-chips.tsx/mercado-filter-chips.tsx (TEC-545) — confirmado via Figma que é o mesmo padrão visual, não o de Documentos.


Item 15 — Nomenclatura comunicacao_associacaocomunicacao

Diagnóstico confirmado: zero ocorrências no módulo Enquetes — nome já correto. Achados 3 comentários stale em Blog/Publicações/Documentos (fora do escopo desta PR, não corrigidos). Doc de requisitos externo fora do alcance deste repo.


Item 16 — Sidebar (resolvido via rebase)

Conflito em app-sidebar.tsx resolvido mesclando as duas versões (Eventos + Enquetes ativos, nenhum item desabilitado).


Etapa 3 — ordem real de execução

  1. ✅ Validado que poll_audiences + a policy nova funcionam de verdade (4 cenários via psql, rollback; suíte pgTAP reescrita, 37 asserções).
  2. ✅ Seed revertido — só depois da validação acima: conc_loja de volta na conta abracaf (usuário concessionário), 2 usuários de teste de volta pra abracaf-intranet. Confirmado via supabase db reset completo.
  3. ✅ Rebase contra dev (feito antes da Etapa 2, fora da ordem original — ver nota no topo).
  4. ✅ Conflito em app-sidebar.tsx resolvido (mesclado, não "aceito de um lado só").

Requisitos transversais — como cada um foi atendido

  • Nenhuma policy/gate novo checa nome de papel diretamente: a policy poll_votes_insert cruza poll_audiences (dado) contra o papel do votante via get_own_account_role() — nenhum literal de role no SQL da policy. canVoteOnPoll no service faz o mesmo cruzamento em código de aplicação, só pra dar erro amigável antes do banco.
  • Nenhum erro de banco chega ao usuário: item 5 auditou e corrigiu as ~11 interpolações de error.message em polls.service.ts, com um helper central de erro amigável + log completo no servidor.
  • Regra 7 de packages/ui/AGENTS.md: aplicada via o mesmo helper do item 5 — mensagens de negócio, não stack trace/mensagem do Postgres.

Ajustes visuais adicionais (pós-implementação, feedback de UI)

Depois da implementação inicial, uma série de ajustes visuais foi feita a partir de prints do usuário:

  • TreeSelect (@kit/ui/tree-select) ganhou duas props novas, opt-in, sem quebrar os outros 12 consumidores existentes: triggerTextMode ('labels' padrão vs 'count' — "N selecionados") e matchTriggerWidth (dropdown do mesmo tamanho do trigger via w-(--anchor-width) do Base UI).
  • @kit/ui/popover: collisionPadding do Base UI passou a ser repassado (existia no primitivo, não estava exposto no wrapper do projeto) — usado em poll-date-time-field.tsx e em FilterDateRangePicker (reports/filter-button.tsx) pra evitar o calendário vazar da tela.
  • poll-card.tsx: header (badges + "encerra em"/votos) usa container query (@container/poll-card + @sm/poll-card:) em vez de breakpoint de viewport (sm:) — o card fica em grid de 2 colunas, então um breakpoint de viewport não reflete a largura real do card.
  • FilterDateRangePicker: ícone de calendário reposicionado pra antes do texto (padrão do resto do app); um hack de margem/translate (-ml-4 translate-x-4, pensado pra outro contexto de barra sticky) estava desalinhando os campos "Criada entre"/"Encerra entre" no modal de filtros de Enquetes — corrigido cancelando as duas metades do hack (ml-0 translate-x-0) na chamada específica.
  • Exportação de resultados (export-poll-results.ts) reescrita pra reaproveitar downloadXlsx/downloadPdf (jsPDF + autoTable) de reports/_lib/export-utils.ts em vez de um "XLSX falso" (CSV renomeado) — adicionou suporte real a PDF.
  • Empty state (polls-empty-state.tsx): texto diferenciado por perfil — associados sem permissão de gerenciar não veem mais o convite "Crie uma enquete...".
  • Enquete ainda não iniciada: opções de voto desabilitadas, botão "Votar" oculto (não só desabilitado), card mostra "inicia em..." em vez de "encerra em..." até o horário de início passar.