Backend do módulo Enquetes — Intranet (TEC-532)
Backend completo do módulo Enquetes da Intranet (TEC-532): gestores (owner/admin/comunicacao) criam e encerram enquetes de opinião; associados votam uma vez por enquete, podendo trocar de opção ou remover o voto enquanto ela estiver aberta. Só o backend foi entregue nesta etapa — a tela real ainda não existe, há um painel temporário de debug pra testar as Server Actions.
Atualização (TEC-541): a seção "Perfis e permissões" abaixo documenta o desenho ORIGINAL do TEC-532, que reaproveitava o role genérico
membercomo "associado". Isso foi superado pela revisão do TEC-541 (Matriz de Acesso, decisão D2):membernunca é atribuído em conta real de Emplacamento/Intranet, então deixou de ser usado. "Quem pode responder" agora é modelado por enquete via a tabelapoll_audiences(papéis elegíveis:conc_grupo,conc_loja), e a policypoll_votes_insertcruza o papel real do votante contra essa audiência — nunca um nome de papel fixo no SQL. VerPOLLS_TEC541_FIXES_SPEC.mdno módulo Enquetes pro detalhamento completo.
Decisões de arquitetura
Duas decisões foram tomadas durante o planejamento, divergindo levemente da primeira leitura da especificação funcional:
- Não existe um role "associado" dedicado. O catálogo de roles é global entre produtos (
owner,admin,comunicacao,montadora,conc_grupo,conc_loja,parceiro,member,consultor_fi— verapps/web/supabase/schemas/17-roles-seed.sql).O role genérico— superado no TEC-541:memberfoi reaproveitado como "associado"membernunca é atribuído em conta real de Emplacamento/Intranet (Matriz de Acesso, decisão D2), então "quem pode responder" passou a ser definido por enquete viapoll_audiences, cruzando o papel real do votante contra os papéis elegíveis daquela enquete específica (conc_grupo/conc_lojanesta primeira decisão) — nunca um role fixo embutido em código/policy. - Contagem de votos é denormalizada (
poll_options.vote_count,polls.total_votes,polls.last_vote_at), mantida por uma trigger — não por uma funçãoSECURITY DEFINERnem por RLS permissiva. Decisão de performance: evita escalonamento de privilégio ou contagem cara a cada leitura. Efeito colateral positivo: como a contagem nunca depende de ler voto alheio, a RLS depoll_votespôde ficar maximamente restritiva — ninguém, nem gestor, lê o voto individual de outra pessoa.
Estrutura de arquivos
apps/web/
├── supabase/
│ ├── schemas/23-polls.sql # Schema declarativo
│ ├── migrations/20260724201206_polls.sql # Migration (espelha o schema)
│ ├── seed.sql # Usuários de teste comunicacao/associado
│ └── tests/database/polls.test.sql # pgTAP
├── lib/polls/
│ ├── poll-visibility.ts # shouldRevealResults (função pura)
│ └── __tests__/poll-visibility.test.ts # vitest
└── app/[locale]/intranet/
├── _lib/
│ ├── schema/polls.schema.ts # Zod
│ └── server/
│ ├── polls.service.ts # PollsService
│ ├── notify-poll-published.ts # Notificação in-app
│ └── server-actions.ts # Bloco "Polls (Enquetes)"
└── home/[account]/enquetes/ # Painel TEMPORÁRIO de debug
├── page.tsx
└── _components/polls-debug-panel.tsx
Banco de dados
Três tabelas (apps/web/supabase/schemas/23-polls.sql):
create table public.polls (
id uuid primary key default extensions.uuid_generate_v4(),
account_id uuid references public.accounts(id) on delete cascade not null,
question varchar(500) not null,
status public.poll_status not null default 'aberta', -- 'aberta' | 'encerrada'
who_can_answer public.poll_audience not null default 'todos_associados',
start_at date not null,
end_at date not null,
closed_at timestamptz,
closed_by uuid references auth.users(id),
total_votes integer not null default 0, -- denormalizado
last_vote_at timestamptz, -- denormalizado
created_at timestamptz default now() not null,
created_by uuid references auth.users(id),
constraint polls_end_after_start check (end_at > start_at)
);
create table public.poll_options (
id uuid primary key default extensions.uuid_generate_v4(),
poll_id uuid references public.polls(id) on delete cascade not null,
label varchar(255) not null,
option_order integer not null default 0,
vote_count integer not null default 0 -- denormalizado
);
create table public.poll_votes (
poll_id uuid references public.polls(id) on delete cascade not null,
option_id uuid references public.poll_options(id) on delete cascade not null,
user_id uuid references auth.users(id) on delete cascade not null,
voted_at timestamptz default now() not null,
primary key (poll_id, user_id) -- unicidade de voto garantida no banco
);
Não existe estado "agendada" — toda enquete nasce aberta. start_at/end_at são só metadado informativo (exibidos no card e no cabeçalho de Resultados); não disparam transição automática de status — encerrar é sempre uma ação manual do gestor, mesmo com o prazo já vencido.
Sem soft delete em polls/poll_options — excluir enquete não faz parte do escopo desta entrega.
Perfis e permissões
(Seção original do TEC-532 — a coluna "Pode votar?" foi superada pelo TEC-541, ver nota no topo do doc.)
| Perfil (spec) | Role no banco | Pode gerenciar? | Pode votar? |
|---|---|---|---|
| admin | admin | Sim (content.manage) | Não |
| comunicação | comunicacao | Sim (content.manage) | Não |
| — | owner | Sim (content.manage) | Não |
| associado | conc_grupo/conc_loja (audiência da enquete, TEC-541) | Não | Sim, se o papel estiver em poll_audiences daquela enquete |
can_manage_polls(account_id) segue o mesmo template de can_manage_documents/can_manage_blog/can_manage_publications — reaproveita a permissão content.manage, já semeada pra owner/admin/comunicacao:
create or replace function public.can_manage_polls(p_account_id uuid)
returns boolean language sql set search_path = ''
as $$
select
public.has_role_on_account(p_account_id)
and public.has_permission(
(select auth.uid()), p_account_id, 'content.manage'::public.app_permissions
);
$$;
Elegibilidade pra votar (TEC-541) cruza o papel real do votante (accounts_memberships) contra a audiência da enquete específica (poll_audiences) — nunca um role fixo embutido em código/policy (Matriz de Acesso, §13):
exists (
select 1
from public.polls p
join public.accounts_memberships m
on m.account_id = p.account_id and m.user_id = (select auth.uid())
join public.poll_audiences pa
on pa.poll_id = p.id and pa.account_role = m.account_role
where p.id = poll_id and p.status = 'aberta'
and now() >= p.start_at and now() < p.end_at
)
Cada Server Action checa a elegibilidade explicitamente no código (PollsService.canVoteOnPoll), além da RLS — não confia só na RLS pra dar uma mensagem de erro clara por causa (enquete não existe, ainda não começou, já encerrou, ou fora da audiência).
Contagem de votos denormalizada
A trigger apply_poll_vote() roda em poll_votes (after insert or update or delete) e mantém os contadores:
- INSERT (primeiro voto):
poll_options.vote_count += 1,polls.total_votes += 1,polls.last_vote_at = voted_at. - UPDATE (trocar de opção —
option_idmuda): decrementa a opção antiga, incrementa a nova.total_votesnão muda (é o mesmo voto). - DELETE (remover o voto): decrementa a opção votada e
total_votes.
Como a contagem nunca depende de ler o voto de outra pessoa, a RLS de poll_votes_select_own pôde ficar restrita a user_id = auth.uid() sempre — nem gestor lê voto individual de terceiro. Isso satisfaz o requisito de anonimato dos votos sem precisar de nenhuma função SECURITY DEFINER de leitura.
Trocar de opção e remover o voto
Dois comportamentos que não estavam na redação original da especificação, adicionados durante o planejamento a pedido do usuário:
- Trocar de opção: votar numa opção diferente da já votada atualiza a mesma linha de
poll_votes(não cria uma segunda linha) — feito viaupsertno client (onConflict: 'poll_id,user_id'), que o Postgres resolve como umUPDATEquando já existe a linha. Não existe mais erro de "você já votou" pra uma opção diferente. - Remover o voto:
poll_votes_delete_ownpermite apagar a própria linha, só enquanto a enquete estáaberta.
Ambos são bloqueados pela RLS assim que a enquete é encerrada (status = 'encerrada').
Service e Server Actions
apps/web/app/[locale]/intranet/_lib/server/polls.service.ts — PollsService, sem checagem de permissão dentro de si (a RLS autoriza; o service só executa). Métodos: canManagePolls, isAssociado, listPolls, createPoll, vote, removeVote, closePoll, getPollResults, exportPollResults (lança not_implemented).
Server Actions (server-actions.ts, bloco // Polls (Enquetes)):
| Action | Quem pode chamar |
|---|---|
getPollsAction | Qualquer membro |
getPollsContextAction | Qualquer membro |
createPollAction | Gestor |
voteOnPollAction | Associado |
removeVoteOnPollAction | Associado |
closePollAction | Gestor |
getPollResultsAction | Gestor |
exportPollResultsAction | Gestor (retorna not_implemented) |
Visibilidade de resultados (RF004)
Um associado só vê a contagem/percentual agregado de uma enquete se já votou nela (em qualquer opção) ou se ela já está encerrada — antes disso recebe só as opções, sem números, pra montar o radio button. Gestor sempre vê o agregado completo.
Isso é decidido pela função pura shouldRevealResults (apps/web/lib/polls/poll-visibility.ts), testada isoladamente com vitest — mesmo padrão de lib/blog/blog-content-state.ts:
shouldRevealResults({ status, hasVoted, isManager }) =>
isManager || status === 'encerrada' || hasVoted
Pontos em aberto (TODO no código)
Sinalizados com // TODO: confirmar com Análise/freela nos arquivos correspondentes — decisões de produto que não deviam ser tomadas pela IA/dev sem validação:
- Fórmula de participação % (
polls.service.ts,getPollResults— hoje retornanull). - Filtros adicionais além das abas Abertas/Encerradas/Todas (
polls.schema.ts). - Formato de exportação (
polls.service.ts—exportPollResultslançanot_implemented). - Elegibilidade de "quem pode responder" além de
todos_associados(23-polls.sql, enumpoll_audience). - Encerramento automático por prazo — explicitamente fora de escopo; encerrar é sempre manual, mesmo com
end_atvencido.
Usuários de teste (seed)
A conta abracaf-intranet só tinha o Dev User como owner. Foram adicionados dois usuários (apps/web/supabase/seed.sql):
| Senha | Role | |
|---|---|---|
desenvolvimento.comunicacao@abracaf.com.br | DesenvolvimentoABRACAF | comunicacao (gestor) |
desenvolvimento.associado@abracaf.com.br | DesenvolvimentoABRACAF | member (associado) |
Use sempre o slug abracaf-intranet na URL (/intranet/home/abracaf-intranet/...), nunca abracaf. A conta abracaf é do produto emplacamento (site público) — um usuário dono dela também consegue carregar rotas de Intranet (a checagem de acesso só confere membership real pelo slug, não o produto da conta), mas os dados ficam salvos na conta errada, desconectados da Central de Publicações de verdade.
Como testar
Sem UI ainda — duas formas:
- Painel de debug temporário:
http://localhost:3000/intranet/home/abracaf-intranet/enquetes(não linkado na sidebar). Botões chamando cada Server Action direto, com o JSON de retorno cru — inclusive as opções já vêm comidno retorno de "Criar enquete". Apagar (app/[locale]/intranet/home/[account]/enquetes/) quando a tela real for construída. - Supabase Studio SQL Editor (
http://127.0.0.1:54323), simulando um usuário específico pra testar RLS/trigger direto:set local role authenticated;set local "request.jwt.claims" = '{"sub": "<user-uuid>", "role": "authenticated"}';-- suas queries aquireset role;
Testes automatizados: pnpm --filter web vitest run lib/polls (4 testes de shouldRevealResults, passando). Os testes pgTAP (apps/web/supabase/tests/database/polls.test.sql) não puderam ser executados neste ambiente — a extensão basejump-supabase_test_helpers não está disponível no Postgres local (confirmado que é uma limitação pré-existente, afeta qualquer teste pgTAP do repositório, não só este arquivo).