Skip to content

Survey questions in the database, and candidate responses - #100

Open
mikaalnaik wants to merge 4 commits into
mikaal/voter-surveyfrom
mikaal/survey-questions-in-db
Open

Survey questions in the database, and candidate responses#100
mikaalnaik wants to merge 4 commits into
mikaal/voter-surveyfrom
mikaal/survey-questions-in-db

Conversation

@mikaalnaik

@mikaalnaik mikaalnaik commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Stacked on #93 — base is mikaal/voter-survey, not main, because main has no survey code and both branches touch election.rb, routes.rb and structure.sql. Merge #93 first; this diff shows only the new work.

Why

Survey questions lived in the tracker (surveyData.ts). They move here because candidates now answer surveys too, and an admin-entered candidate questionnaire has to render the question set as a form in this app. A question set that only exists in the front end can't do that.

This inverts a previously deliberate split — the old migration and model comments both said questions stay in the tracker so rewording one needs no migration here. Editing in the CMS needs no deploy at all, so that property is kept, but it's a real change of direction worth noticing in review.

Three tables

table
election_surveys One per survey per election, told apart by audience. Toronto 2026 gets city-priorities (resident) and candidate-questionnaire.
election_survey_questions One row per question, carrying its step. Steps are denormalised rather than given their own table — a step is a title, an intro and an order with no behaviour, so a table would only guarantee that two questions agree on a title, at the cost of another level of CRUD.
election_candidate_survey_responses Separate from election_survey_responses. Residents are anonymous subscribers published in aggregate and replaced silently; a candidate's answers are attributed public statements entered by staff.

Questions are rows rather than one jsonb document because the CMS edits one question at a time — a document would make every edit a read-modify-write of the whole survey and give up per-field validation.

Resident and candidate question sets are independent, with no shared question_id. Comparing "residents said X / candidate said Y" will need a hand-maintained mapping; that was a deliberate call, but it's the thing most likely to drift, and the part I'd most want a second opinion on.

The migration is verified, not assumed

The 33 Toronto questions are seeded from JSON generated mechanically from surveyData.ts (not transcribed), committed at db/seeds/elections/toronto_2026_city_priorities.json as the migration record. I then diffed what the database serves against the original definition:

expected: 9 steps / 33 questions
actual:   9 steps / 33 questions

ROUND-TRIP IDENTICAL

Byte-identical, including all 26 ward options. Those aren't frozen into a row: the question stores options_source: "wards" and they resolve at serve time from the election's councillor races, so the list can't drift from the ward map and can't offer a ward with no council race behind it.

Candidate answers have no public write path

election_candidates carries an email, so an endpoint keyed on candidate email would let anyone publish positions in a candidate's name. Staff enter answers in the CMS (Elections → candidate → Enter questionnaire), and source / entered_by keep a published position traceable.

Two deliberate loosenesses there, both worth a look:

  • An answer outside the offered options is kept — staff transcribe what candidates actually say, and refusing "supports with caveats" would mean losing the nuance or the response. Such an answer is offered back in the select as its own selected choice so it round-trips; clearing it stays possible, and stays deliberate, via "— no response —".
  • An answer keyed to a since-deleted question is dropped, not rejected — a stale form shouldn't cost someone their edit. The model still validates unknown keys.

Two bugs found along the way

type is reserved. Naming the column type broke every read: ActiveRecord uses it for single-table inheritance and raised SubclassNotFound: failed to locate the subclass: 'text'. Writes succeeded, so the seed looked fine and only reads failed. Renamed to question_type, serialised back to type for the tracker. Chose the rename over self.inheritance_column = nil because a bare type column is a trap for the next person.

Transcribed answers were silently deleted (caught by Greptile, fixed in fea1144). A stored answer that wasn't among the options rendered as a blank select, posted blank, and the controller — which replaces the answers hash wholesale — dropped the key. So editing one question destroyed an attributed public statement on another, while the help text promised the opposite. The original tests missed it because they only exercised a direct submit, never render-then-save; the new test posts back what the rendered form would actually contain.

Verification

  • 1124 tests, 0 failures — 41 new (20 model, 8 API, 13 admin).
  • Rubocop clean, 704 files.
  • Model guards exercised against real data: cross-election candidate rejected, unknown question_id rejected, question_id locked once published, duplicate option values rejected, publishing stamps published_at.
  • Base merged in (c893cd9). The only conflict was the schema_migrations list in structure.sql; resolved as the union and verified by loading the resolved file into a fresh test database rather than trusting the splice.

Not in this PR

  • The tracker still reads surveyData.ts. Questions are duplicated, not yet switched over, so nothing breaks until that lands separately. It also still needs a decision: API-only with an unavailable state, or permanent fallback to the committed set.
  • No CMS CRUD for authoring questions. The candidate questionnaire is seeded empty and unpublished; questions can be authored as JSON + seed meanwhile, and the entry form renders a notice while it has none.

🤖 Generated with Claude Code

Survey questions lived in the tracker (surveyData.ts). They move here because
candidates now answer surveys too, and an admin-entered candidate questionnaire
has to render the question set as a form in this app — a question set that only
exists in the front end can't do that.

Three tables:

  election_surveys              one per survey per election, told apart by
                                `audience`. Toronto 2026 gets city-priorities
                                (resident) and candidate-questionnaire.
  election_survey_questions     one row per question, carrying its step. Steps
                                are denormalised rather than given a table — a
                                step is a title, an intro and an order with no
                                behaviour, so a table would only guarantee two
                                questions agree on a title, at the cost of
                                another level of CRUD.
  election_candidate_survey_    separate from election_survey_responses, since
  responses                     residents are anonymous subscribers published in
                                aggregate and replaced silently, while a
                                candidate's answers are attributed public
                                statements entered by staff.

The 33 Toronto questions are seeded from JSON generated from surveyData.ts, and
the round-trip was diffed against the original: identical, ward options included.
Those options aren't frozen into a row — the question stores
options_source: "wards" and they resolve at serve time from the election's
councillor races, so the list can't drift from the ward map, and can't offer a
ward with no council race behind it.

Candidate answers have no public write path. election_candidates carries an
`email`, so an endpoint keyed on it would let anyone publish positions in a
candidate's name; staff enter them in the CMS and `source`/`entered_by` keep a
published position traceable. Two deliberate loosenesses there: an answer
outside the offered options is kept, because staff transcribe what candidates
actually say, and an answer keyed to a deleted question is dropped rather than
erroring, so a stale form doesn't cost someone their edit.

The column is `question_type`, not `type`: ActiveRecord reserves `type` for STI
and raised SubclassNotFound on every read while writes still succeeded. It
serialises back to `type` for the tracker.

The tracker still reads surveyData.ts — the questions are duplicated, not yet
switched over, so nothing breaks until that lands separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR moves election survey definitions into database-backed models and adds staff-managed candidate questionnaire responses.

  • Adds survey, question, and candidate-response tables with validation and publication state.
  • Exposes published survey definitions through a read-only API.
  • Adds an admin workflow for entering, publishing, and clearing candidate responses.
  • Seeds the Toronto 2026 resident survey and an empty candidate questionnaire.
  • Preserves transcribed out-of-options answers across unrelated form edits.

Confidence Score: 5/5

The PR appears safe to merge because the previously reported custom-answer data-loss path is fixed and no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
app/views/admin/election_candidate_survey_responses/edit.html.erb Renders candidate questionnaires and now round-trips stored out-of-options answers through a selected custom choice.
app/controllers/admin/election_candidate_survey_responses_controller.rb Implements the admin-only candidate-response lifecycle and filters submitted answer keys against the selected survey.
app/models/warehouse/election_candidate_survey_response.rb Defines response integrity, attribution, publication, and cross-election validation.
app/models/warehouse/election_survey_question.rb Defines validated question types, dynamic ward options, serialization, and published identifier stability.
app/controllers/api/v1/election_surveys_controller.rb Serves published survey definitions and supports draft previews for staff.
db/migrate/20260811000001_create_warehouse_election_surveys.rb Creates normalized survey and question storage with uniqueness, type, and ordering constraints.
db/migrate/20260811000002_create_warehouse_election_candidate_survey_responses.rb Creates attributed candidate-response storage with relational and publication constraints.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Seed[Survey seed or CMS authoring] --> Survey[(Election survey)]
  Survey --> Questions[(Survey questions)]
  Questions --> PublicAPI[Read-only survey API]
  Survey --> AdminForm[Candidate questionnaire form]
  Candidate[Candidate] --> AdminForm
  AdminForm --> Response[(Candidate survey response)]
  Response -->|published| PublicSite[Public presentation]
Loading

Fix All in Greploop

Reviews (4): Last reviewed commit: "candidate survey db storage" | Re-trigger Greptile

Comment thread app/views/admin/election_candidate_survey_responses/edit.html.erb
mikaalnaik and others added 3 commits August 11, 2026 13:14
An answer staff transcribed rather than picked from the options was silently
deleted by an ordinary re-save. The select had no matching option, so it
rendered blank, posted blank, and the controller — which replaces the answers
hash wholesale — dropped the key. The help text promised the opposite. This lost
an attributed public statement on any edit to a different question.

A stored answer that isn't among the options is now added to the select as its
own selected choice, so the form round-trips it. Clearing it stays possible, and
stays deliberate, by picking "no response".

Caught by Greptile on #100. Tests missed it because they only exercised a direct
submit, never render-then-save; the new test posts back what the rendered form
would actually contain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The base picked up main, which renumbered the survey-responses migration from
20260807000001 to 20260811000000 — main's own 20260807000001 is create_api_keys.
Our two migrations already sort after it, so no renumbering is needed here.

The only conflict was the schema_migrations list in db/structure.sql; every table
definition merged cleanly. Resolved as the union, and verified by loading the
resolved structure.sql into a fresh test database rather than trusting the splice:
all four survey tables come back, 1124 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant