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) —devjá 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 comgit rebase --skipapós confirmar que o conteúdo já estava presente via outro commit. - Um erro de typecheck pós-rebase (prop
showDetailByModelOption→showDetailByModeleExportPollResultsOptions.columnIds→columns, fallout de uma mudança nodevemCommonTableExportButton) já foi corrigido separadamente. - Ainda pendente da Etapa 3: reverter o seed (
6be97209) — só depois quepoll_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: policypoll_votes_insertchecapublic.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:Selectde opção única,SelectContentsó comtodos_associados.polls.service.ts:100-112(isAssociado): RPChas_role_on_accountcomaccount_role: 'member'hardcoded — gate de aplicação, redundante com a policy mas igualmente hardcoded.server-actions.ts:866,896:voteOnPollAction/removeVoteOnPollActionchamamservice.isAssociado(accountId)— não sabem qual enquete, só a conta.
Abordagem proposta:
-
Migration nova (schema
23-polls.sql+ migration versionada):- Remove
who_can_answer poll_audiencedepolls(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:
selectliberado praauthenticatedque tem acesso à poll pai (mesmo padrão depoll_options_select);insertsó junto da criação da enquete (mesmo padrão depoll_options_insert, gate porcan_manage_polls). Semupdate/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 prapolls). - Seed de roles elegíveis nesta primeira decisão:
conc_grupoeconc_loja(constraint viachecknã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 é umroles.nameválido). - Nova policy
poll_votes_insert— substitui a atual (cruza contraget_own_account_role(), uma funçãoSECURITY DEFINER— ver nota de implementação abaixo):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.create policy "poll_votes_insert" on public.poll_votesfor insert to authenticatedwith check (user_id = (select auth.uid())and exists (select 1from public.polls pjoin public.poll_audiences paon pa.poll_id = p.idand pa.account_role = public.get_own_account_role(p.account_id)where p.id = poll_idand p.status = 'aberta')); poll_votes_update_own/poll_votes_delete_ownnã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).
- Remove
-
Zod (
polls.schema.ts):- Novo
PollEligibleRoleSchema = z.enum(['conc_grupo', 'conc_loja'])(a lista restrita desta decisão — ver item 6,memberfica de fora). CreatePollBaseSchema.whoCanAnswer→audienceRoles: z.array(PollEligibleRoleSchema).min(1, 'Selecione ao menos um papel').- Remove
PollAudienceSchema/PollAudience(não existe mais um enum único).
- Novo
-
create-poll-modal.tsx: troca oSelectúnico por multi-select — implementado comTreeSelect(@kit/ui/tree-select), nãoFilterMultiSelectcomo 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). -
polls.service.ts:createPoll: insere empoll_audiencesjunto (mesmo padrão de rollback depoll_options— se falhar, desfaz a poll).isAssociado(accountId)→ substituído porcanVoteOnPoll(accountId, userId, pollId): busca oaccount_roledo usuário atual na conta e verifica se está empoll_audiencesdaquela 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 deaudienceRolesnoPollListItem.
-
server-actions.ts:voteOnPollAction/removeVoteOnPollActiontrocamservice.isAssociado(accountId)porservice.canVoteOnPoll(accountId, user.id, parsedInput.pollId), mapeando oreasonpra 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):Calendardo shadcn, sem seletor de hora.- Leitores da coluna a mapear:
poll-card.tsx(formatEndAt/formatClosedAt),poll-results-view.tsx(formatDateTimepracreatedAt;${results.endAt}T00:00:00praendAt),poll-results-stat-cards.tsx(daysUntil),export-poll-results.ts(formatDateemEncerra em).
Implementado:
- Migration:
alter column start_at type timestamptz using start_at::timestamptz, idemend_at. - Zod:
startAt/endAt→z.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)empolls.service.tscomputa "encerrada" assim queend_atpassa, sem gravar nada — usado emlistPolls/buildListItem/getPollResults.- Filtro de aba (Abertas/Encerradas) na listagem também usa o status efetivo, não só a coluna
status. - Policy
poll_votes_insertganhaand now() < p.end_at;poll_votes_update_own/poll_votes_delete_owntambé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_associacao → comunicacao
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
- ✅ Validado que
poll_audiences+ a policy nova funcionam de verdade (4 cenários viapsql, rollback; suíte pgTAP reescrita, 37 asserções). - ✅ Seed revertido — só depois da validação acima:
conc_lojade volta na contaabracaf(usuário concessionário), 2 usuários de teste de volta praabracaf-intranet. Confirmado viasupabase db resetcompleto. - ✅ Rebase contra
dev(feito antes da Etapa 2, fora da ordem original — ver nota no topo). - ✅ Conflito em
app-sidebar.tsxresolvido (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_insertcruzapoll_audiences(dado) contra o papel do votante viaget_own_account_role()— nenhum literal de role no SQL da policy.canVoteOnPollno 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.messageempolls.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") ematchTriggerWidth(dropdown do mesmo tamanho do trigger viaw-(--anchor-width)do Base UI).@kit/ui/popover:collisionPaddingdo Base UI passou a ser repassado (existia no primitivo, não estava exposto no wrapper do projeto) — usado empoll-date-time-field.tsxe emFilterDateRangePicker(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 reaproveitardownloadXlsx/downloadPdf(jsPDF + autoTable) dereports/_lib/export-utils.tsem 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.