Pular para o conteúdo principal

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 member como "associado". Isso foi superado pela revisão do TEC-541 (Matriz de Acesso, decisão D2): member nunca é atribuído em conta real de Emplacamento/Intranet, então deixou de ser usado. "Quem pode responder" agora é modelado por enquete via a tabela poll_audiences (papéis elegíveis: conc_grupo, conc_loja), e a policy poll_votes_insert cruza o papel real do votante contra essa audiência — nunca um nome de papel fixo no SQL. Ver POLLS_TEC541_FIXES_SPEC.md no 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:

  1. 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 — ver apps/web/supabase/schemas/17-roles-seed.sql). O role genérico member foi reaproveitado como "associado"superado no TEC-541: member nunca é 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 via poll_audiences, cruzando o papel real do votante contra os papéis elegíveis daquela enquete específica (conc_grupo/conc_loja nesta primeira decisão) — nunca um role fixo embutido em código/policy.
  2. Contagem de votos é denormalizada (poll_options.vote_count, polls.total_votes, polls.last_vote_at), mantida por uma trigger — não por uma função SECURITY DEFINER nem 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 de poll_votes pô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 bancoPode gerenciar?Pode votar?
adminadminSim (content.manage)Não
comunicaçãocomunicacaoSim (content.manage)Não
ownerSim (content.manage)Não
associadoconc_grupo/conc_loja (audiência da enquete, TEC-541)NãoSim, 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_id muda): decrementa a opção antiga, incrementa a nova. total_votes nã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:

  1. 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 via upsert no client (onConflict: 'poll_id,user_id'), que o Postgres resolve como um UPDATE quando já existe a linha. Não existe mais erro de "você já votou" pra uma opção diferente.
  2. Remover o voto: poll_votes_delete_own permite 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.tsPollsService, 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)):

ActionQuem pode chamar
getPollsActionQualquer membro
getPollsContextActionQualquer membro
createPollActionGestor
voteOnPollActionAssociado
removeVoteOnPollActionAssociado
closePollActionGestor
getPollResultsActionGestor
exportPollResultsActionGestor (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 retorna null).
  • Filtros adicionais além das abas Abertas/Encerradas/Todas (polls.schema.ts).
  • Formato de exportação (polls.service.tsexportPollResults lança not_implemented).
  • Elegibilidade de "quem pode responder" além de todos_associados (23-polls.sql, enum poll_audience).
  • Encerramento automático por prazo — explicitamente fora de escopo; encerrar é sempre manual, mesmo com end_at vencido.

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):

E-mailSenhaRole
desenvolvimento.comunicacao@abracaf.com.brDesenvolvimentoABRACAFcomunicacao (gestor)
desenvolvimento.associado@abracaf.com.brDesenvolvimentoABRACAFmember (associado)
Use o slug abracaf-intranet, não abracaf

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:

  1. 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 com id no retorno de "Criar enquete". Apagar (app/[locale]/intranet/home/[account]/enquetes/) quando a tela real for construída.
  2. 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 aqui
    reset 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).