Uma leitura guiada do módulo decidim-surveys — da fundação de formulários à publicação de respostas
O Decidim é uma plataforma livre de participação cidadã, escrita em Ruby on Rails e usada por governos como Barcelona, Helsinque e a Comissão Europeia. Sua característica arquitetural mais importante para esta leitura é que ela é modular: cada diretório decidim-* neste repositório é uma gem independente, com seus próprios models, controllers, views e testes.
Dentro dessa arquitetura há uma hierarquia de três níveis que vale fixar:
Survey.O Decidim também tem convenções fortes de código que aparecem em todo o módulo: a lógica de negócio vive em Commands (objetos que encapsulam um caso de uso e transmitem eventos como :ok ou :invalid), a validação de entrada vive em Form objects (separados dos models), e a autorização passa por um sistema de permissões próprio. Se um trecho de código parecer indireto demais à primeira vista, provavelmente é uma dessas convenções trabalhando.
O que nos interessa aqui é o componente de surveys — pesquisas ou questionários que um administrador cria para ouvir os participantes: "Qual a sua avaliação do transporte público?", "Que prioridade você dá a cada obra?". Parece simples. Mas por baixo há um desenho cuidadoso que resolve três problemas difíceis ao mesmo tempo: reutilizar toda a maquinaria de formulários sem duplicá-la, permitir respostas anônimas sem abrir brecha para fraude, e publicar resultados sem expor quem respondeu. É isso que vamos desmontar.
A decisão de design mais importante do módulo de surveys é que ele quase não tem dados próprios. Toda a estrutura de perguntas e respostas vive em outro módulo, o decidim-forms, que fornece um questionário genérico e reutilizável. O survey é apenas uma casca sobre um questionário — uma casca que adiciona as regras específicas de uma pesquisa: janela de datas, abrir/fechar, anonimato, publicação de resultados.
A ligação entre os dois é uma associação polimórfica. O concern Decidim::Forms::HasQuestionnaire declara (decidim-forms/app/models/concerns/decidim/forms/has_questionnaire.rb:11-17):
has_one :questionnaire, as: :questionnaire_for, dependent: :destroy
Ou seja: um Questionnaire pertence a "qualquer coisa que tenha questionário" — hoje um survey, amanhã uma reunião ou um processo de cadastro. É por isso que a coluna se chama questionnaire_for. E o survey chega a delegar seus próprios título, descrição e termos de uso ao questionário (decidim-surveys/app/models/decidim/surveys/survey.rb:17-19) — o título que você vê na página nem é uma coluna do survey.
decidim-forms.Dentro do decidim-forms, os três models que formam a espinha dorsal são:
Questionnaire — o formulário em si. Tem questions ordenadas por position, responses, e um campo curioso chamado salt, que veremos adiante.Question — uma pergunta, com um question_type entre oito possibilidades: short_response, long_response, single_option, multiple_option, sorting, files, matrix_single e matrix_multiple (mais dois tipos puramente estruturais, separator e title_and_description, que dividem o formulário em passos e seções). Perguntas podem ter opções de resposta, linhas de matriz e condições de exibição — "só mostre a pergunta 4 se a pergunta 2 foi respondida com 'Sim'".Response (com seus ResponseChoices) — a resposta de um participante a uma pergunta. Aqui mora o detalhe que sustenta o anonimato: user é opcional, e toda resposta carrega um session_token e um ip_hash.Answer → Response, AnswerChoice → ResponseChoice, etc., em migrations como 20250314150250_rename_answer_to_response_in_decidim_forms.rb). Se você encontrar artigos ou código antigo falando em "answers", é a mesma coisa.
Antes do código, vale ter na cabeça o filme completo. Um survey passa por cinco momentos, e quase todas as regras do módulo existem para guardar as fronteiras entre eles:
Três consequências desse ciclo não são óbvias e valem ser ditas já:
Primeiro, publicar e abrir são coisas independentes. Publicar (published_at) controla visibilidade; abrir (allow_responses + datas) controla participação. Isso existe para um caso real: o admin quer mostrar a pesquisa antes de aceitar respostas — e quer fechar a caixa de entrada antes de divulgar os resultados, para que ninguém altere o placar depois de publicado.
Segundo, perguntas só podem ser editadas enquanto não há respostas. A regra está em Questionnaire#questions_editable? (decidim-forms/app/models/decidim/forms/questionnaire.rb:24-27): componente não publicado ou sem respostas. Depois que o primeiro participante respondeu, as perguntas congelam — do contrário, a resposta de alguém à pergunta "Você concorda?" poderia passar a valer para "Você discorda?".
Terceiro, republicar pode limpar o histórico. Se a configuração clean_after_publish estiver ligada, o comando Admin::PublishSurvey apaga todas as respostas existentes ao publicar (decidim-surveys/app/commands/decidim/surveys/admin/publish_survey.rb:52-54). A ideia: se o survey foi despublicado, reformulado e publicado de novo, as respostas da rodada anterior não devem se misturar com as da nova.
open?O coração do módulo é um predicado de seis linhas. Um survey está aberto quando dois fatores concordam: o interruptor manual (allow_responses) está ligado, e o relógio está dentro da janela — ou não há janela alguma.
Pense nos quatro campos como um interruptor e dois alarmes. O interruptor allow_responses tem poder de veto: desligado, nada mais importa. Ligado, as datas decidem:
starts_at | ends_at | Aberto quando… | Exemplo |
|---|---|---|---|
| — | — | sempre | pesquisa permanente de satisfação |
| 1º de março | — | a partir de 1º de março | consulta que abre em data marcada, sem prazo |
| — | 31 de março | até 31 de março | pesquisa relâmpago com prazo final |
| 1º de março | 31 de março | entre as duas datas | janela de consulta pública |
Há um detalhe que o código torna sutil: open? não olha a publicação. Um survey não publicado pode ser open? == true. Isso não é descuido — é o que permite ao admin testar o formulário em preview antes de ir ao ar. O bloqueio ao público geral acontece em outra camada, no controller, como veremos na seção 8.
session_token e ip_hashAqui está o problema mais delicado do desenho. Uma pesquisa pública quer duas coisas em tensão:
allow_unregistered.A solução do Decidim é uma identidade pseudo-anônima. Ao responder, o controller calcula dois hashes (decidim-forms/app/controllers/decidim/forms/concerns/has_questionnaire.rb:137-156):
session_token = digest(usuario_logado.id OU session_id_da_sessao, salt_do_questionario)
ip_hash = digest(remote_ip, salt_do_questionario)
Três propriedades saem daí:
session_token?" é a pergunta que impede a mesma pessoa de responder duas vezes — visitor_already_responded? usa exatamente isso.salt é gerado por questionário e guardado no servidor. Do banco de dados sozinho, um session_token não revela quem respondeu — você não pode percorrer a tabela de usuários tentando adivinhar hashes sem o salt, e mesmo o hash diz respeito ao vínculo pessoa↔questionário, não à identidade em si.session_id para user.id), mas cada resposta carrega o token sob o qual foi feita.O ip_hash serve como segunda evidência: permite ao admin detectar padrões suspeitos (cinquenta respostas do mesmo IP) sem jamais armazenar o IP em claro.
session_id e, portanto, um novo token. O desenho aceita esse risco deliberadamente — pesquisas do Decidim são instrumentos de consulta, não eleições. Onde a fraude importa mais, o admin desliga allow_unregistered e o token passa a derivar do user.id, que é estável.
Fechado o survey, o admin pode querer divulgar os resultados. Mas uma pesquisa mistura perguntas cujos resultados são seguros de publicar ("quantos preferem a opção A?") com respostas livres que podem conter dados pessoais ("conte sua experiência"). A decisão do Decidim: a publicação é por pergunta, nunca do survey inteiro de uma vez.
O mecanismo é uma única coluna: survey_responses_published_at em decidim_forms_questions. Publicar as respostas da pergunta 3 é carimbar essa coluna com a hora atual; despublicar é zerá-la. Nenhuma resposta é movida, copiada ou marcada — a publicação é um atributo da pergunta, que as respostas herdam por pertencimento.
E há uma guarda de ordem: a permissão de publicar respostas é negada enquanto survey.allow_responses estiver ligado (decidim-surveys/app/permissions/decidim/surveys/admin/permissions.rb:25-33). Tradução institucional: só se publica resultado depois de fechada a urna.
Por fim, nem toda pergunta é publicável: short_response, long_response, separator e files não têm nem o botão no admin (decidim-surveys/app/helpers/decidim/surveys/publish_responses_helper.rb:6-10) — não há gráfico honesto para texto livre, e anexos são inerentemente identificáveis.
SurveyCom as intuições no lugar, o código lê quase sozinho. O model inteiro tem 106 linhas (decidim-surveys/app/models/decidim/surveys/survey.rb), e o trecho que concentra a regra de negócio é este (linhas 51–60):
def open?
return false if allow_responses.blank?
return true if time_indefinite?
return true if started_but_no_end?
return true if no_start_but_ends_later?
return within_time_range? if time_range_defined?
false
end
Cada linha é uma das fileiras da tabela da seção 4, com os predicados auxiliares privados nas linhas 85–103 (time_indefinite?, started_but_no_end?, etc.). O false final captura o caso que sobrou: janela definida, mas estamos fora dela.
open? é deliberadamente cego à publicação. Quem combina as duas coisas é o controller: allow_responses? retorna !current_component.published? || survey.open? (decidim-surveys/app/controllers/decidim/surveys/surveys_controller.rb:57-59). Leia em voz alta: "se o componente não está publicado, deixe responder (é um preview de admin); se está, só deixe se o survey estiver aberto". Duas preocupações, dois lugares.
O resto do model é composição de concerns que o ligam ao resto da plataforma: Publicable (o published_at), Searchable (indexação de busca, com o título pesando mais que a descrição), FilterableResource (os scopes .open/.closed que alimentam o filtro "estado" da listagem pública), HasReference (o identificador curto tipo SRV-2026-03-7) e Resourceable (vínculos com outros recursos do Decidim).
Aqui acontece a segunda grande demonstração de reúso: o SurveysController público não implementa nem show nem respond. Ele inclui o concern Decidim::Forms::Concerns::HasQuestionnaire e apenas responde às perguntas que o concern faz — "quem é o dono do questionário?" (questionnaire_for → o survey), "pode responder anônimo?" (allow_unregistered?), "para onde voltar depois?" (after_response_path). É um padrão de template method distribuído por convenção.
Seguindo o fio, quatro pontos merecem parada:
1. A dupla-checaragem de "já respondeu". O command Decidim::Forms::ResponseQuestionnaire (decidim-forms/app/commands/decidim/forms/response_questionnaire.rb:24-38) transmite :invalid se o usuário já respondeu — a menos que allow_editing_responses esteja ligado. Nesse caso, antes de gravar, clear_responses! (linhas 81–83) destrói as respostas anteriores do mesmo token. Editar não é corrigir: é apagar e reescrever, o que mantém o banco sempre com um único conjunto de respostas por pessoa.
2. Condições de exibição são reavaliadas no servidor. O formulário se divide em passos (nos separators), e o command só persiste respostas cujas display_conditions_fulfilled? se verificam (linhas 85–125). Isso impede que uma resposta "fantasma" — de uma pergunta que o fluxo lógico escondeu — entre no banco por manipulação do POST.
3. Tudo ou nada. A gravação acontece em transação; se um anexo falha na validação, nenhuma resposta é persistida. O participante não fica com meia pesquisa registrada.
4. O e-mail de confirmação é um efeito colateral por evento. O command emite decidim.forms.response_questionnaire:after, e é o engine de surveys quem se inscreve nele (decidim-surveys/lib/decidim/surveys/engine.rb:42-58) para disparar o SurveyConfirmationMailer. O forms não sabe que surveys existe; o acoplamento vai na direção certa.
E há duas proteções silenciosas no caminho: invisible_captcha (um honeypot contra bots, na linha 22 do concern) e o sistema de permissões/autorizações, que pode exigir que o participante esteja verificado (por exemplo, contra um censo municipal) antes de responder — ação respond registrada no manifest do componente (decidim-surveys/lib/decidim/surveys/component.rb:59).
O admin é dividido em quatro telas, refletidas em quatro controllers sob decidim-surveys/app/controllers/decidim/surveys/admin/:
Survey e seu Questionnaire aninhado (Admin::CreateSurvey). Atualizar delega a edição de título/descrição/termos ao Forms::Admin::UpdateQuestionnaire — de novo, o reúso.Forms::Admin::Concerns::HasQuestionnaire. O módulo não escreve uma linha de editor de perguntas.session_token via QuestionnaireParticipants), detalhe individual e exportação.PublishResponsesController#update chama PublishResponses.call(params[:id], current_user) e responde JSON — é um interruptor AJAX por pergunta.Os commands de publicação são deliberadamente pequenos. PublishResponses (decidim-surveys/app/commands/decidim/surveys/publish_responses.rb), em transação, carimba a coluna e registra um ActionLog — o rastro de auditoria que responde "quem publicou os resultados, e quando?":
question.update!(survey_responses_published_at: Time.current)
Decidim.traceability.perform_action!(
"publish_responses", question, user,
...
)
E as configurações por survey (announcement, allow_responses, allow_unregistered, clean_after_publish, allow_editing_responses, starts_at, ends_at) têm tela própria via SurveySettingsForm. Note a escolha de modelagem: elas são colunas do model, não settings de componente — porque são por survey, e um componente pode ter vários.
Duas exportações saem do manifest do componente (decidim-surveys/lib/decidim/surveys/component.rb:69-95), e a diferença entre elas resume a filosofia do módulo:
survey_user_responses | published_survey_user_responses | |
|---|---|---|
| Público | admin, no painel | qualquer um, via open data |
| Conteúdo | todas as respostas, agrupadas por participante | só respostas de perguntas com survey_responses_published_at |
| Formatos | CSV, JSON, Excel, FormPDF | open data (sem formatos de painel) |
| Identificação | session_token, ip_hash, status (registrado/anônimo) | idêntica — nunca e-mail nem nome |
Mesmo no arquivo mais interno, o do admin, a identidade exportada é o token pseudo-anônimo. O anonimato não é uma camada cosmética da interface — é uma propriedade do que o sistema sequer guarda.
O módulo de surveys é um estudo de caso em dizer pouco e reutilizar muito. Suas próprias linhas de código se concentram em três decisões que o questionário genérico não pode tomar:
open?, com seu interruptor manual e sua janela de datas, deliberadamente separado da publicação;session_token com salt por questionário, que equilibra deduplicação e privacidade;Tudo o mais — perguntas, respostas, condições de exibição, exportação, edição — é o decidim-forms trabalhando. Conhecer esse limite é conhecer o módulo: quando algo no comportamento de um survey surpreender você, a primeira pergunta certa é "isso é regra do survey ou da fundação de formulários?".