Como funcionam os Surveys no Decidim

Como funcionam os Surveys no Decidim

Uma leitura guiada do módulo decidim-surveys — da fundação de formulários à publicação de respostas

1. Contexto geral: o que é o Decidim

Se você já conhece a arquitetura do Decidim, clique para expandir — ou pule para a seção 2.

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:

  • Espaços participativos (processos participativos, assembleias, conferências) — os contêineres de maior nível, como "a consulta pública do plano diretor".
  • Componentes — as funcionalidades plugáveis dentro de um espaço: propostas, reuniões, orçamentos participativos e, no nosso caso, surveys. Um espaço pode ter vários surveys; cada um é uma instância de componente.
  • Recursos — as coisas concretas criadas dentro de um componente. No módulo de surveys, o recurso é o próprio 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.

2. Contexto específico: a fundação em decidim-forms

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.

Survey starts_at, ends_at allow_responses allow_unregistered clean_after_publish… Questionnaire title, description, tos salt (anonimização) questionnaire_for → polimórfico Question tipo, obrigatória… Response session_token, ip_hash ResponseChoice opção escolhida tem um tem N tem N tem N O que o survey adiciona de próprio é só comportamento: janela de datas, abrir/fechar, quem pode responder, e quando os resultados podem ser publicados. Perguntas e respostas: tudo em decidim_forms_*.
Figura 1 — O survey é uma casca fina sobre o questionário do 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.
Nota histórica Em 2025 o Decidim renomeou todo o vocabulário de answer para response (AnswerResponse, AnswerChoiceResponseChoice, 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.

3. A intuição: o ciclo de vida de um survey

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:

1. Rascunho admin monta perguntas 2. Publicado visível, ainda fechado 3. Aberto recebendo respostas 4. Fechado nada mais entra 5. Resultados publicados por pergunta As transições 2→3 e 3→4 são controladas por allow_responses + janela de datas (starts_at/ends_at)
Figura 2 — O ciclo de vida. Publicar ≠ abrir: um survey publicado ainda pode estar fechado para respostas.

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.

4. A intuição por trás de 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_atends_atAberto quando…Exemplo
semprepesquisa permanente de satisfação
1º de marçoa partir de 1º de marçoconsulta que abre em data marcada, sem prazo
31 de marçoaté 31 de marçopesquisa relâmpago com prazo final
1º de março31 de marçoentre as duas datasjanela 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.

5. Anonimato prático: session_token e ip_hash

Aqui está o problema mais delicado do desenho. Uma pesquisa pública quer duas coisas em tensão:

  • Um voto por pessoa — senão o resultado é manipulável;
  • Anonimato — inclusive para quem nem conta tem, se o admin ligar 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í:

  • Deduplicação. "Já existe resposta com este session_token?" é a pergunta que impede a mesma pessoa de responder duas vezes — visitor_already_responded? usa exatamente isso.
  • Irreversibilidade. O 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.
  • Portabilidade entre identidades. Quem começou anônimo e depois se logou não quebra nada: o token muda de base (de session_id para user.id), mas cada resposta carrega o token sob o qual foi feita.
Maria (logada) user.id = 42 Visitante anônimo session_id = "f8a2c…" Decidim::Tokenizer digest(valor, salt do questionário) session_token "9d41be…" (de Maria) session_token "c07a11…" (do visitante)
Figura 3 — Identidades distintas viram tokens incomparáveis entre si, mas estáveis por pessoa por questionário.

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.

Limite honesto do modelo "Uma pessoa, uma resposta" é uma garantia prática, não absoluta. Um visitante anônimo que limpa os cookies ganha um novo 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.

6. Publicar respostas, pergunta por pergunta

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.

7. O código: o model Survey

Com 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.

Contrato sutil 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).

8. O código: o fluxo de responder (controller → command)

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.

SurveysController QuestionnaireForm ResponseQuestionnaire Banco (forms_*) Mailer POST /surveys/7/respond from_params + session_token + ip_hash ResponseQuestionnaire.call(form, questionnaire) (edição?) clear_responses! → apaga anteriores INSERT Response por pergunta (em transação) INSERT ResponseChoice por opção escolhida evento "response_questionnaire:after" → e-mail de confirmação broadcast :ok redirect (flash de sucesso)
Figura 4 — Um POST de resposta, do controller ao e-mail de confirmaçã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).

9. O código: o lado administrativo

O admin é dividido em quatro telas, refletidas em quatro controllers sob decidim-surveys/app/controllers/decidim/surveys/admin/:

  • Survey — CRUD e publicação. Criar um survey cria, na mesma transação do command, o 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.
  • Perguntas — inteiramente herdada do concern Forms::Admin::Concerns::HasQuestionnaire. O módulo não escreve uma linha de editor de perguntas.
  • Respostas — listagem por participante (agrupadas por session_token via QuestionnaireParticipants), detalhe individual e exportação.
  • Publicar respostas — a tela dos toggles. 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.

10. O código: exportações e dados abertos

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_responsespublished_survey_user_responses
Públicoadmin, no painelqualquer um, via open data
Conteúdotodas as respostas, agrupadas por participantesó respostas de perguntas com survey_responses_published_at
FormatosCSV, JSON, Excel, FormPDFopen data (sem formatos de painel)
Identificaçãosession_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.

11. Síntese

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:

  • Quando participar — o predicado open?, com seu interruptor manual e sua janela de datas, deliberadamente separado da publicação;
  • Quem participa sem se identificar — a identidade pseudo-anônima via session_token com salt por questionário, que equilibra deduplicação e privacidade;
  • O que se torna público depois — a publicação por pergunta, proibida enquanto o survey está aberto, com tipos de resposta livre fora do jogo.

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?".