Lalabase
Tickets, Doku, Zeiterfassung und Kundengespräche an einem Ort, durchsuchbar per KI. Für Softwarefirmen mit Kundenprojekten. Open Source: auf deinem eigenen Server oder in unserer Cloud in Deutschland.
Translation pending
Eleição da Comissão de Formatura da Turma 36 de Medicina — UniEVANGÉLICA: cronograma, candidatos e votação.
Translation pending
BusinessTypeScript · PLpgSQLGitHub @vcsilOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
Eleição da Comissão de Formatura — Turma 36 (Medicina UniEVANGÉLICA) Aplicação web para centralizar informações e a votação da Comissão de Formatura: cronograma, cargos, candidatos, votação por distribuição de votos, apuração, publicação de resultados, administração e auditoria. Arquitetura completa, schema do banco e o racional de cada decisão de segurança estão documentados em docs/TECHNICAL_DESIGN.md — leia esse arquivo para entender o "porquê" das escolhas abaixo. Estratégia de cache, invalidação por tag e as medições de performance estão em docs/PERFORMANCE_V2.md.Read the full README ↗
Aplicação web para centralizar informações e a votação da Comissão de Formatura: cronograma, cargos, candidatos, votação por distribuição de votos, apuração, publicação de resultados, administração e auditoria.
Arquitetura completa, schema do banco e o racional de cada decisão de
segurança estão documentados em docs/TECHNICAL_DESIGN.md —
leia esse arquivo para entender o "porquê" das escolhas abaixo.
Estratégia de cache, invalidação por tag e as medições de performance
estão em docs/PERFORMANCE_V2.md.
app/globals.css)SECURITY DEFINER no Postgres, não só no código Next.jspg para os testes de integração)npm install
cp .env.example .env.local
Preencha .env.local com os valores do seu projeto Supabase (veja a seção
"Variáveis de ambiente"). Importante: hashes bcrypt contêm $, que o
Next.js tenta expandir como referência a outra variável — escape cada $
como \$ no .env.local (o .env.example traz um exemplo).
| Variável | Pública? | Descrição |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | Sim | URL do projeto Supabase |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Sim | Chave anônima (sujeita a RLS) |
SUPABASE_SERVICE_ROLE_KEY | Não | Só em Server Actions/Route Handlers; nunca no navegador |
ADMIN_PASSWORD_HASH | Não | Hash bcrypt da senha administrativa — gere com node -e "console.log(require('bcryptjs').hashSync('SUA_SENHA', 12))" |
APP_SECRET_KEY | Não | Segredo para HMAC de IPs (pseudonimização em security_events) |
AUDIT_ENCRYPTION_KEY | Não | Reservado para a futura camada de criptografia da auditoria (não usado ainda) |
Nunca use o prefixo NEXT_PUBLIC_ em nenhuma das variáveis marcadas "Não".
anon e service_role) em Project Settings → API
para o .env.local.psql "$DATABASE_URL" -f supabase/migrations/000X_*.sql para
cada arquivo, ou a Supabase CLI: supabase db push):
supabase/migrations/0001_extensions.sql
supabase/migrations/0002_schema.sql
supabase/migrations/0003_status_and_helpers.sql
supabase/migrations/0004_voting_functions.sql
supabase/migrations/0005_results_functions.sql
supabase/migrations/0006_rls.sql
supabase/migrations/0007_storage.sql
supabase/migrations/0008_result_snapshot_denormalization.sql
supabase/migrations/0009_analytics.sql
supabase/migrations/0010_fix_service_role_grants.sql
supabase/migrations/0011_grant_service_role_tables.sql
supabase/migrations/0012_dashboard_and_live_state.sql
supabase/migrations/0013_runoff_multi_position_schema.sql
supabase/migrations/0014_runoff_functions.sql
supabase/migrations/0015_live_state_follows_active_election.sql
supabase/migrations/0016_admin_session_idle_timeout.sql
supabase/migrations/0017_import_voters.sql
supabase/migrations/0018_admin_idle_timeout_10min.sql
supabase/migrations/0019_fix_runoff_overlap_and_dual_winner_cascade.sql
supabase/migrations/0020_manual_release_and_uncontested.sql
supabase/migrations/0021_fix_ballot_acl_and_composition_atomicity.sql
supabase/migrations/0022_ordered_seats_and_composition_freeze.sql
supabase/seed.sql para cadastrar a eleição principal, os 6 cargos
(13 vagas) e o cronograma oficial (seção 7 do documento técnico) — os
horários exatos de cada fase ficam com o valor padrão 00:00–23:59:59 até
serem configurados em /admin/cronograma.ADMIN_PASSWORD_HASH./admin/eleitores, importando a
lista oficial em .csv ou .xlsx (matrícula + nome completo). A lista
fica fora de qualquer rota pública — voters não tem policy de leitura
para anon. Também é possível inserir direto no banco, se preferir.O bucket de Storage candidate-photos (fotos dos candidatos) é criado pela
migration 0007_storage.sql.
Já aplicou as migrations 0001-0009 antes de 10/2026? Um
REVOKE ALL ... FROM PUBLICem várias funções também removia, sem querer, o acesso implícito queservice_roleherdava dePUBLIC(service_rolesó temBYPASSRLS— ignora policies de RLS — mas continua sujeito à ACL normal deGRANT/REVOKE), ecompute_election_statusnão eraSECURITY DEFINER. Isso quebrava login, validação do eleitor, envio de voto, apuração, publicação e desempates com "permission denied for function/ table ...". Basta rodarsupabase/migrations/0010_fix_service_role_grants.sqlno projeto existente — não precisa reaplicar nada anterior.Já aplicou até a 0010 e ainda vê "permission denied for table ..." (ex.: ao logar em
/admin)? Diferente de funções (que recebemEXECUTEaPUBLICpor padrão na criação), tabelas não recebem nenhum privilégio aPUBLIC— e nenhuma migration anterior jamais concedeu nada aservice_roleem nenhuma tabela, então todo acesso direto (.from(tabela), fora das funçõesSECURITY DEFINER) estava quebrado. Rodesupabase/migrations/0011_grant_service_role_tables.sqlno projeto existente.Já aplicou até a 0011? A
0012_dashboard_and_live_state.sqlsó adiciona duas funções de leitura consolidada (get_admin_dashboard_metrics, usada pelo painel, eget_live_election_state, usada pela home durante a votação). Não altera nenhuma tabela nem nenhuma função existente; aplicar é seguro a qualquer momento. Antes de aplicar, o painel e a home falham com "permission denied for function ..." — as duas passaram a depender dela.Já aplicou até a 0017? A
0018_admin_idle_timeout_10min.sqlestende a janela de inatividade do painel administrativo de 5 para 10 minutos. Ela substituicheck_admin_session(a0016não é editada, porque já está aplicada) e estende as sessões abertas no momento para a janela nova, em vez de deixá-las com o prazo antigo até o próximo heartbeat.O intervalo vive em dois lugares — dentro de
check_admin_sessione emADMIN_IDLE_TIMEOUT_SECONDS(lib/admin/session-config.ts) — e os dois precisam coincidir: se o servidor conceder menos tempo do que a interface acredita ter, o administrador é deslogado no meio de uma operação, sem aviso. Aplicar o código sem esta migration produz exatamente essa divergência. Um teste de integração compara a constante com o intervalo que o banco de fato concede e quebra se divergirem.
A 0020 introduz voting_released_at, que nasce nulo. Os três cenários
abaixo foram verificados contra um PostgreSQL de teste:
| Situação no momento da migration | O que acontece | O que fazer |
|---|---|---|
| Votação em andamento, com votos registrados | O status volta a aguardando_votacao e a urna fecha. Os votos já gravados permanecem intactos; novos eleitores recebem voting_not_open. | Liberar em /admin/votacao logo após aplicar. A votação retoma exatamente de onde parou, com os votos preservados. Prefira aplicar fora da janela de votação. |
| Eleição já apurada ou publicada | Nada muda: o status continua aguardando_divulgacao / resultado_disponivel, e os snapshots seguem preservados. voting_released_at nulo não afeta eleição encerrada. | Nada. |
| Cargo que já recebeu votos e que, pela regra nova, ficaria "sem disputa" | compute_results falha com UNCONTESTED_POSITION_HAS_VOTES e não grava nada. | Decisão humana — veja abaixo. |
O último caso é o único que exige decisão, e ela não foi tomada no código de propósito. Sem a guarda, a apuração gravaria "eleito sem disputa, 0 votos" para candidatos que receberam votos de verdade: a contagem real desapareceria do resultado, e com ela a ordem que define os rótulos de assento (Primeiro/Segundo Tesoureiro). A guarda recusa e preserva os votos; qual regra aplicar — apurar pela contagem antiga, declarar sem disputa assumindo a perda da ordem, ou ajustar as vagas do cargo — é uma decisão da Comissão, não do sistema.
Procedimento seguro: aplique a 0020, a 0021 e a 0022 antes de liberar
a votação da eleição em que forem usadas. Aí nenhum dos três casos ocorre,
porque a composição congela na abertura — e, desde a 0022, o congelamento
é feito por trigger no banco: candidates, candidate_positions e
positions recusam INSERT, DELETE e qualquer UPDATE estrutural depois da
liberação, mesmo vindo de SQL direto no SQL Editor. Foto, frase,
apresentação, propostas e vídeo continuam editáveis, e nos cargos também o
nome e os textos descritivos.
npm run dev # servidor de desenvolvimento (Turbopack)
npm run lint # eslint
npx tsc --noEmit # checagem de tipos
npm test # unitários — sem dependências externas
npm run test:integration # integração — requer Postgres local com as migrations aplicadas
npm run test:all # ambos
Para os testes de integração, aponte TEST_DATABASE_URL para um Postgres
local com as migrations de supabase/migrations/ já aplicadas (schema
idêntico ao do Supabase — as mesmas funções/RLS podem ser testadas em
qualquer Postgres 15+, sem precisar de um projeto Supabase real):
createdb eleicao_test
for f in supabase/migrations/*.sql; do psql "$TEST_DATABASE_URL" -f "$f"; done
TEST_DATABASE_URL="postgresql://usuario:senha@localhost:5432/eleicao_test" npm run test:integration
Os testes cobrem: normalização de nome (acentos/maiúsculas/espaços), validação do eleitor (matrícula inexistente, nome incorreto, inativo, já votou), toda a matriz de distribuição de votos da seção 93 do documento técnico (repetição no mesmo candidato, votos nulos parciais/totais, soma incorreta, valores negativos/decimais, candidato inválido ou de outro cargo, payload adulterado), atomicidade e concorrência (duas submissões simultâneas da mesma sessão — só uma vence), apuração (empates, nulos contabilizados à parte, votos repetidos somados corretamente), a promoção do próximo colocado quando um candidato eleito em dois cargos escolhe um deles, e a política de RLS (resultados só visíveis após publicação).
npm run build
npm start
Ambiente alvo: Vercel (Next.js) + Supabase Cloud.
Configure as mesmas variáveis de ambiente do .env.local no painel do
provedor de hospedagem. Como todas as páginas que dependem de dados da
eleição são renderizadas dinamicamente (export const dynamic = "force-dynamic" — status, participação e resultados nunca podem vir de
cache estático), nenhuma etapa extra de revalidação é necessária.
service_role; o navegador nunca
fala diretamente com regras de negócio.cast_ballot é uma função SECURITY DEFINER no
Postgres que trava a linha do eleitor, revalida a fase da eleição pela
hora do servidor, recalcula a soma de cada cargo a partir das
quantidades individuais (nunca aceita um total pré-calculado do
cliente) e registra tudo em uma única transação.audit_vote_links tem UNIQUE (election_id, voter_id).ballots não tem voter_id; a
única tabela que liga eleitor a voto é audit_vote_links, com RLS que
nega qualquer leitura pública — nenhuma rota administrativa comum a
consulta.anon/authenticated.docs/TECHNICAL_DESIGN.md.A urna abre por decisão, não por relógio. O horário configurado no
cronograma é condição necessária, não suficiente: até o administrador
clicar em "Liberar votação" em /admin/votacao, o status continua
aguardando_votacao e /votar não oferece urna nenhuma. O estado vive em
elections.voting_released_at e é escrito por release_voting, que recusa
liberar antes da hora inicial, fora de uma eleição geral ou depois do
encerramento. A liberação é irreversível — não existe ação para
desfazê-la, e voting_released_at nunca volta a ser nulo.
Liberar congela a composição. Depois da abertura não se cadastra,
remove, ativa, inativa, renomeia, reordena nem troca o cargo de nenhum
candidato: mudar quem concorre mudaria a cédula debaixo de quem já votou, e
pode até fazer um cargo inteiro entrar ou sair dela. Foto, frase,
apresentação, propostas e vídeo continuam editáveis. A recusa acontece na
Server Action (upsertCandidateAction, setCandidateActiveAction), não só
na tela — um POST montado à mão é recusado igual.
Quais cargos vão à urna. A regra é uma só, em
position_requires_voting no Postgres:
candidatos ativos > 0 E (seat_labels IS NOT NULL OU candidatos > vagas)
positions.seat_labels preenchido —
Tesouraria e Secretaria): vota sempre que houver candidato, mesmo com
um único nome para duas vagas. Ali a votação não decide quem entra, e sim
a ordem: quem é Primeiro e quem é Segundo.A distinção sai de seat_labels, nunca do slug do cargo. A mesma função
monta a cédula, alimenta get_current_voting_election, é exigida por
cast_ballot e classifica a apuração — nenhuma contagem é recalculada em
TypeScript, e compute_results não repete a comparação por conta própria.
Voto nulo não conta como candidato.
Empate que afeta só a ORDEM também vira desempate. Num cargo de
assentos nomeados, Ana 50 × Bruno 50 para 2 vagas elege os dois de
qualquer forma — mas não diz quem é Primeiro. Esse empate marca
tie_break_needed e bloqueia a publicação até ser resolvido. O
desempate de cargo ordenado dá 1 voto por eleitor, qualquer que seja o
número de vagas em disputa: ali se escolhe uma ordem, não um conjunto, e
dois votos permitiriam votar nos dois e não decidir nada. Cargo sem
assentos nomeados mantém a regra anterior (votos por eleitor = vagas em
disputa). Empate parcial não reabre assento já definido: com Ana já
confirmada como Primeira, o desempate disputa apenas o Segundo.
Quem não teve concorrência é eleito na apuração, não antes.
compute_results declara esses candidatos eleitos com
result_snapshots.unopposed = true; as telas mostram "Eleito sem disputa"
e omitem a contagem, porque exibir "0 votos" descreveria uma derrota, e não
uma formalização. Nenhum voto artificial é criado e nenhuma linha de nulos
é gerada. Vagas que sobram aparecem como não preenchidas (vagas − eleitos). Se nenhum cargo tiver disputa, /votar informa que não há
cargos em disputa, ninguém se identifica e nenhuma cédula é criada — a
eleição é decidida inteiramente na apuração.
Como o empate é detectado. compute_results marca tie_break_needed
apenas no grupo de candidatos que cruza a linha de corte das vagas —
empate fora dela não gera pendência.
Como a publicação é bloqueada. publish_results recusa enquanto
houver tie_break_needed, decisão de cargo duplo pendente, ou eleição
filha não publicada.
Como o desempate é criado. create_runoff_election(pai, cargos[], motivo, cronograma) recebe apenas quais cargos e quando. Os
candidatos saem de result_snapshots (os empatados daquele cargo) e as
vagas em disputa são derivadas: vagas do cargo menos os já eleitos. Nada
que decida o resultado vem do navegador. Um índice parcial em
runoff_positions impede dois desempates abertos para o mesmo cargo —
duplo clique é barrado no banco, não no botão.
Um desempate cobre N cargos. Se houver empate em cargos diferentes,
tudo entra numa única eleição de desempate: uma sessão, uma cédula, um
audit_vote_links, como na geral. Por isso no máximo uma eleição fica
aberta por vez, e get_current_voting_election() resolve qual é sem
ambiguidade.
Como o eleitor vota de novo. A unicidade é (election_id, voter_id)
em audit_vote_links, então quem votou na geral vota uma vez no
desempate — e só uma.
Como o desempate resolve a eleição principal. publish_results de um
desempate chama resolve_parent_ties_from_runoff na mesma transação:
os N mais votados viram elected = true nos snapshots do pai, o grupo
empatado deixa de ser pendência, os perdedores ficam não eleitos, e cada
vaga resolvida gera uma linha em runoff_resolutions (com os votos do
desempate). Os votes_count da eleição original não são alterados —
os dois pleitos são registros distintos. Ser transacional é o que impede
o estado que existia antes: desempate publicado e pai travado em
TIE_PENDING para sempre.
Como o eleitor chega à urna do desempate. /votar não resolve mais
"a eleição geral", e sim a votação efetivamente aberta
(get_current_voting_election). A urna e a revisão, por sua vez, usam a
eleição da sessão do eleitor, não a que estiver aberta no momento: se
a votação virasse entre a validação e o envio, a pessoa veria uma cédula
que sua sessão não autoriza. Na urna do desempate aparecem apenas os
cargos em disputa, os candidatos empatados e a opção Nulo.
O que a home mostra. Havendo votação aberta — geral ou desempate —,
get_live_election_state devolve o estado dela, e a home exibe
"Votação de desempate em andamento" com o botão apontando para a urna.
Antes ficava em "Desempate necessário", verdade sobre a eleição principal
e inútil para quem precisava votar.
O que o resultado público mostra. Quem foi eleito por desempate aparece com o selo "Eleito por desempate", e a votação de desempate é exibida como um bloco próprio, abaixo da votação original — as duas rodadas lado a lado, cada uma com seus números.
Se o desempate empatar de novo. Nada é decidido automaticamente: a
pendência permanece, publish_results recusa, e o administrador cria uma
nova rodada — filha do desempate empatado. A cadeia de RUNOFF_PENDING
resolve em ordem.
/admin/eleitores carrega a lista oficial a partir de .csv ou .xlsx.
Matrícula é sempre string. No XLSX é lido o texto formatado da célula,
não o valor numérico — converter perderia zeros à esquerda, e 001234
virar 1234 significa um eleitor que não consegue votar. Se a planilha já
salvou a matrícula como número, o zero se perdeu no arquivo e nenhuma
biblioteca o recupera.
O preview não é a importação. O navegador lê o arquivo para dar resposta imediata, mas a Server Action relê o arquivo original e revalida antes de gravar. O que o cliente mostra nunca é insumo da gravação.
Colunas: reconhecidas por variações comuns de cabeçalho (Matrícula,
matricula, RA, registration_number / Nome, Nome completo,
Aluno, full_name), comparando sem acento e sem caixa. Quando não há
correspondência única — nenhuma, ou mais de uma candidata —, a tela pede
que o administrador escolha. Adivinhar por posição importaria a lista
inteira trocada.
Gravação transacional via import_voters (migration 0017): um bloco
plpgsql, todas as linhas ou nenhuma. Inserts sequenciais pelo cliente
Supabase seriam ~100 requisições sem transação, e uma falha no meio
deixaria a lista pela metade.
O que a importação NUNCA faz: remover eleitores ausentes do arquivo,
resetar has_voted/voted_at, ou reativar quem está inativo. Eleitor já
cadastrado tem apenas o full_name atualizado — e isso muda
normalized_name, que é o campo comparado por validate_voter, então a
pessoa passa a precisar da grafia nova para votar. A pré-visualização
mostra cada alteração de nome antes da confirmação.
Votação aberta não bloqueia, mas exige confirmação explícita — revalidada
server-side — porque alterar a lista muda o total de habilitados e, com
ele, o percentual de participação do pleito em curso. O fato fica
registrado em admin_logs como VOTERS_IMPORTED com during_open_voting.
CSP difere entre ambientes. Em desenvolvimento, script-src inclui
'unsafe-eval'; em produção, não. Não é descuido: o Fast Refresh do
Turbopack aplica hot updates via eval, e sem a diretiva toda edição de
componente força recarga completa da página (perdendo, por exemplo, a
distribuição de votos em andamento na urna). Em produção o bundle não
contém eval algum — a sonda do React está atrás de um gate
"development" —, então conceder a diretiva lá seria superfície de ataque
sem contrapartida. Travado por teste (tests/unit/csp.test.ts); nonce,
strict-dynamic, frame-ancestors e object-src são idênticos nos dois
ambientes.
vote_confirmed é lido, não consumido. A página /voto-confirmado
apenas lê o cookie — Server Components não podem modificar cookies. O
cookie expira sozinho em 5 minutos e é apagado quando um novo eleitor
valida os dados. Na prática: recarregar ou voltar para a tela de sucesso
funciona por alguns minutos. O cookie não autoriza nada eleitoral, só
decide se a tela de confirmação aparece.
O rascunho da urna é isolado por sessão de voto. A chave no
sessionStorage inclui um identificador derivado da sessão, e rascunhos
de outras sessões são apagados quando a urna abre. Isso existe para o
dispositivo compartilhado: sem o isolamento, um eleitor que abandona a
urna deixaria sua distribuição para o próximo, que a veria na tela e — se
estivesse completa — poderia enviá-la como se fosse dele.
Rate limit da validação tem tetos distintos por dimensão. Por IP, 100 tentativas/5 min: a turma inteira costuma sair pelo mesmo IP público, e um teto baixo barraria eleitores legítimos em vez de atacantes. Por matrícula, 10/5 min — este é o controle anti-força-bruta que importa.
Públicas: /, /cargos, /candidatos, /votar, /votar/urna,
/votar/revisao, /voto-confirmado, /resultados.
Administrativas (autenticação por senha única, sessão HttpOnly):
/admin, /admin/dashboard, /admin/candidatos, /admin/cronograma,
/admin/votacao, /admin/resultados, /admin/desempates,
/admin/seguranca.
Esta lista reflete o estado do código no HEAD, não o histórico do projeto. Nenhum item conhecido bloqueia a realização da eleição.
[P1] Sem integração contínua
Não há .github/workflows. npm test, npm run test:integration,
npm run lint, npx tsc --noEmit e npm run build só rodam quando
alguém lembra. Todos passam no HEAD (361 testes).
[P1] Documentação de operação defasada
docs/DEPLOY_V2.md descreve o deploy da migration
0012 e fala em "migrations já aplicadas (0001–0011)"; o repositório está
na 0019. docs/TECHNICAL_DESIGN.md ainda
lista a interface de votação de desempate como pendente, embora ela exista.
Não há procedimento documentado de backup e restauração do banco.
[P2] Cache invalidado apenas por ações do painel
Cargos, candidatos e cronograma são servidos do Data Cache. Se você
alterar esses dados direto no banco (SQL Editor do Supabase, psql),
clique em "Atualizar dados do site" em /admin/dashboard — senão o site
continua mostrando o valor antigo. Detalhes em
docs/PERFORMANCE_V2.md.
[P2] A aplicação assume uma única eleição geral
getMainElection() e get_current_voting_election() selecionam type = 'general' pela data de criação mais antiga, e não existe ação de finalizar
ou cancelar uma eleição. Uma segunda eleição geral não assumiria o lugar da
primeira. Desempates encadeados funcionam normalmente — a limitação é sobre
pleitos distintos ao longo do tempo.
[P2] Participação histórica acompanha o cadastro atual
get_participation_percentage divide pelo número de eleitores ativos no
momento da consulta. Importar ou inativar eleitores muda o percentual
exibido de uma eleição já encerrada.
[P2] voters.has_voted e voters.voted_at não são usados
São gravados por cast_ballot, mas nenhuma consulta os lê: a garantia de
voto único é audit_vote_links unique (election_id, voter_id). Também não
têm significado claro com desempates — o eleitor vota na geral e no
desempate, e voted_at é sobrescrito. São colunas enganosas para quem
inspecionar o banco diretamente.
[P2] Versão do Node não fixada
Sem engines, .nvmrc ou .node-version. Desenvolvido em Node 22.
[P3] Contas administrativas individuais A autenticação por senha única está desenhada para migrar para Supabase Auth com contas individuais (seção 62 do documento técnico) — não implementado nesta fase, por decisão explícita.
[P3] Criptografia da camada de auditoria
audit_vote_links está isolada por RLS e sem nenhum GRANT para
anon/authenticated, mas a criptografia adicional e a credencial de
acesso excepcional citadas na seção 44 não foram implementadas.
[P3] exceljs no pacote do navegador
A pré-visualização da importação roda no cliente, então o exceljs
(~912 KB) é baixado ao selecionar um arquivo .xlsx. O carregamento é sob
demanda e só na rota administrativa; o arquivo é reprocessado no servidor
de qualquer forma.
Já aplicou até a 0018? A
0019_fix_runoff_overlap_and_dual_winner_cascade.sql corrige dois
defeitos encontrados em auditoria, ambos reproduzidos contra o banco:
duas votações de desempate com janelas sobrepostas derrubavam /votar
para todos os eleitores, e a promoção feita ao resolver "candidato eleito
em dois cargos" podia deixar o promovido acumulando dois cargos, com o
resultado sendo publicado assim mesmo. Aplicar é seguro a qualquer
momento: substitui quatro funções e troca uma restrição por um índice
parcial, sem tocar em voto, apuração ou resultado já gravado.
Já aplicou até a 0019? A 0020_manual_release_and_uncontested.sql
muda duas regras eleitorais e precisa ser aplicada junto com o código
desta versão:
elections.voting_released_at for nulo, o status continua
aguardando_votacao e a urna não existe. O administrador libera em
/admin/votacao, uma única vez — a ação é irreversível e congela a
composição (não se cadastra, remove, ativa, inativa, renomeia,
reordena nem troca o cargo de candidato depois dela; foto, frase,
apresentação, propostas e vídeo continuam editáveis). Sem aplicar a
migration, o botão de liberar falha com "permission denied for function
release_voting". Aplicando a migration sem subir o código, uma eleição
cujo horário já chegou fica em aguardando_votacao até alguém liberar
— e a tela para liberar ainda não existe.cast_ballot não o exige, e na
apuração seus candidatos são declarados eleitos com a marca
result_snapshots.unopposed — sem voto artificial e sem exibir "0
votos". Vagas sem candidato ficam registradas como não preenchidas.Nenhum voto, cédula ou resultado já gravado é alterado: a migration acrescenta duas colunas (com valor padrão), duas funções novas e substitui quatro existentes.
A 0021_fix_ballot_acl_and_composition_atomicity.sql é obrigatória
junto com a 0020 — não é opcional nem adiável. Ela corrige quatro
problemas da 0020, todos reproduzidos contra o banco:
cast_ballot voltou a ser exclusiva de service_role. A 0020
concedeu EXECUTE a anon/authenticated por engano, copiando o
bloco de permissões de uma função pública. A consequência não era votar
sem token: era chamar a função direto do navegador, com um token
legítimo, pulando a Server Action — e portanto o schema Zod, o
tratamento de erro e o registro de evento. O bypass foi demonstrado com
uma cédula de 22 allocations, acima do teto de 20 que o Zod impõe e
que a função nunca precisou ter.save_candidate).
Antes eram três idas ao banco — ler o congelamento, apagar os vínculos,
recriá-los — e cada intervalo era uma janela real: cargos duplicados
([A,A]) passavam pela comparação e quebravam no INSERT depois do
DELETE, deixando o candidato sem cargo; uma edição só de texto apagava
e recriava os vínculos, e nesse intervalo um cargo podia deixar de ter
disputa e sair da cédula; e a liberação da votação podia acontecer
entre a verificação e a escrita. A função toma o mesmo lock que
release_voting, então uma edição concorrente ou termina inteira antes
da abertura, ou é recusada.release_voting recusa liberar depois do fim da janela e passou a
aceitar a impressão digital da composição que o administrador revisou,
conferida dentro da mesma transação.