Survey questions in the database, and candidate responses - #100
Open
mikaalnaik wants to merge 4 commits into
Open
Survey questions in the database, and candidate responses#100mikaalnaik wants to merge 4 commits into
mikaalnaik wants to merge 4 commits into
Conversation
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 SummaryThis PR moves election survey definitions into database-backed models and adds staff-managed candidate questionnaire responses.
Confidence Score: 5/5The 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.
|
| 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]
Reviews (4): Last reviewed commit: "candidate survey db storage" | Re-trigger Greptile
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #93 — base is
mikaal/voter-survey, notmain, becausemainhas no survey code and both branches touchelection.rb,routes.rbandstructure.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
election_surveysaudience. Toronto 2026 getscity-priorities(resident) andcandidate-questionnaire.election_survey_questionselection_candidate_survey_responseselection_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 atdb/seeds/elections/toronto_2026_city_priorities.jsonas the migration record. I then diffed what the database serves against the original definition: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_candidatescarries anemail, 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), andsource/entered_bykeep a published position traceable.Two deliberate loosenesses there, both worth a look:
Two bugs found along the way
typeis reserved. Naming the columntypebroke every read: ActiveRecord uses it for single-table inheritance and raisedSubclassNotFound: failed to locate the subclass: 'text'. Writes succeeded, so the seed looked fine and only reads failed. Renamed toquestion_type, serialised back totypefor the tracker. Chose the rename overself.inheritance_column = nilbecause a baretypecolumn 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
question_idrejected,question_idlocked once published, duplicate option values rejected, publishing stampspublished_at.schema_migrationslist instructure.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
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.🤖 Generated with Claude Code