Skip to content

Commit 0599cb0

Browse files
rubysclaude
andcommitted
Add Question and Answer models for option questions
Phase 1: Data Model Implementation - Created Question model with billable association, ordered scope - Created Answer model with person/question associations - Added validations for question types (radio/textarea) - Implemented choice removal handling (nullifies affected answers) - Prevented question type changes when answers exist - Added nested attributes support to Billable and Person models - Updated controllers to handle questions and answers - Added applicable_questions method to Person model - All existing tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ec468d9 commit 0599cb0

File tree

9 files changed

+160
-7
lines changed

9 files changed

+160
-7
lines changed

app/controllers/billables_controller.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ def set_billable
217217

218218
# Only allow a list of trusted parameters through.
219219
def billable_params
220-
params.expect(billable: [:type, :name, :price, :order, :couples, :table_size, { options: {} }, { packages: {} }])
220+
params.expect(billable: [:type, :name, :price, :order, :couples, :table_size, { options: {} }, { packages: {} },
221+
{ questions_attributes: [:id, :question_text, :question_type, :choices, :order, :_destroy] }])
221222
end
222223

223224
def update_includes

app/controllers/people_controller.rb

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -646,10 +646,11 @@ def update
646646
respond_to do |format|
647647
if @person.update(update)
648648
update_options
649+
update_answers
649650

650-
format.html {
651+
format.html {
651652
redirect_url = params[:return_to].presence || person_url(@person)
652-
redirect_to redirect_url, notice: "#{@person.display_name} was successfully updated."
653+
redirect_to redirect_url, notice: "#{@person.display_name} was successfully updated."
653654
}
654655
format.json { render :show, status: :ok, location: @person }
655656
else
@@ -881,7 +882,8 @@ def set_person
881882

882883
# Only allow a list of trusted parameters through.
883884
def person_params
884-
params.expect(person: [:name, :studio_id, :type, :back, :level_id, :age_id, :category, :role, :exclude_id, :package_id, :independent, :invoice_to_id, :available, :table_id, { options: {} }])
885+
params.expect(person: [:name, :studio_id, :type, :back, :level_id, :age_id, :category, :role, :exclude_id, :package_id, :independent, :invoice_to_id, :available, :table_id, { options: {} },
886+
{ answers_attributes: [:id, :question_id, :answer_value] }])
885887
end
886888

887889
def filtered_params(person)
@@ -1113,7 +1115,7 @@ def update_options
11131115
desired_options = person_params[:options] || {}
11141116
option_tables = params[:person][:option_tables] || {}
11151117
current_options = @person.options.group_by(&:option_id)
1116-
1118+
11171119
Billable.where(type: 'Option').each do |option|
11181120
got = current_options[option.id]&.length || 0
11191121
want = desired_options[option.id.to_s].to_i
@@ -1130,7 +1132,7 @@ def update_options
11301132
current_options[option.id].pop.destroy
11311133
got -= 1
11321134
end
1133-
1135+
11341136
# Update table assignment for existing PersonOption records
11351137
if got > 0 && current_options[option.id]
11361138
current_options[option.id].each do |person_option|
@@ -1142,6 +1144,27 @@ def update_options
11421144
end
11431145
end
11441146

1147+
def update_answers
1148+
answers_data = person_params[:answers_attributes] || []
1149+
1150+
answers_data.each do |answer_attrs|
1151+
question_id = answer_attrs[:question_id]
1152+
answer_value = answer_attrs[:answer_value]
1153+
1154+
next unless question_id
1155+
1156+
answer = @person.answers.find_or_initialize_by(question_id: question_id)
1157+
1158+
if answer_value.present?
1159+
answer.answer_value = answer_value
1160+
answer.save
1161+
elsif answer.persisted?
1162+
# If value is empty and answer exists, update to nil
1163+
answer.update(answer_value: nil)
1164+
end
1165+
end
1166+
end
1167+
11451168
def list_heats
11461169
Heat.joins(:entry).where(entry: {follow_id: @person.id}).
11471170
or(Heat.joins(:entry).where(entry: {lead_id: @person.id}))

app/models/answer.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class Answer < ApplicationRecord
2+
belongs_to :person
3+
belongs_to :question
4+
5+
validates :person_id, uniqueness: { scope: :question_id }
6+
end

app/models/billable.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ class Billable < ApplicationRecord
2020
has_many :people_option_link, class_name: 'PersonOption', dependent: :destroy, foreign_key: :option_id
2121
has_many :people_options, through: :people_option_link, source: :person
2222
has_many :tables, foreign_key: :option_id, dependent: :destroy
23+
has_many :questions, dependent: :destroy
24+
25+
accepts_nested_attributes_for :questions, allow_destroy: true
2326

2427
def people
2528
if type == 'Option'

app/models/person.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ class Person < ApplicationRecord
4242
has_many :formations, dependent: :destroy
4343
has_many :options, class_name: 'PersonOption', foreign_key: :person_id,
4444
dependent: :destroy
45+
has_many :answers, dependent: :destroy
46+
47+
accepts_nested_attributes_for :answers
4548

4649
has_many :scores, dependent: :destroy, foreign_key: :judge_id
4750
has_many :payments, dependent: :destroy
@@ -177,6 +180,25 @@ def eligible_heats(start_times)
177180
end
178181
end
179182

183+
# Get all questions for this person based on their package and selected options
184+
def applicable_questions
185+
question_ids = Set.new
186+
187+
# Questions from package
188+
if package
189+
package.package_includes.each do |pi|
190+
question_ids.merge(pi.option.questions.pluck(:id))
191+
end
192+
end
193+
194+
# Questions from directly selected options
195+
options.each do |person_option|
196+
question_ids.merge(person_option.option.questions.pluck(:id))
197+
end
198+
199+
Question.where(id: question_ids.to_a).ordered
200+
end
201+
180202
def self.nobody
181203
person = Person.find_or_create_by(id: 0)
182204

app/models/question.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
class Question < ApplicationRecord
2+
belongs_to :billable
3+
has_many :answers, dependent: :destroy
4+
5+
# Rails 8.0 compatible ordering scope
6+
scope :ordered, -> { order(arel_table[:order]) }
7+
8+
validates :question_text, presence: true
9+
validates :question_type, presence: true, inclusion: { in: %w[radio textarea] }
10+
validate :choices_present_for_radio
11+
validate :prevent_type_change_with_answers
12+
13+
# Serialize choices as JSON
14+
serialize :choices, coder: JSON
15+
16+
before_save :handle_choice_removal
17+
18+
private
19+
20+
def choices_present_for_radio
21+
if question_type == 'radio' && (choices.nil? || choices.empty?)
22+
errors.add(:choices, "must be present for radio questions")
23+
end
24+
end
25+
26+
def prevent_type_change_with_answers
27+
if question_type_changed? && persisted? && answers.exists?
28+
errors.add(:question_type, "cannot be changed when answers exist")
29+
end
30+
end
31+
32+
def handle_choice_removal
33+
return unless question_type == 'radio' && choices_changed? && persisted?
34+
35+
old_choices = choices_was || []
36+
new_choices = choices || []
37+
removed_choices = old_choices - new_choices
38+
39+
return if removed_choices.empty?
40+
41+
# Nullify answers that match removed choices
42+
answers.where(answer_value: removed_choices).update_all(answer_value: nil)
43+
end
44+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
class CreateQuestions < ActiveRecord::Migration[8.0]
2+
def change
3+
create_table :questions do |t|
4+
t.references :billable, null: false, foreign_key: true
5+
t.text :question_text, null: false
6+
t.string :question_type, null: false
7+
t.text :choices
8+
t.integer :order
9+
10+
t.timestamps
11+
end
12+
13+
add_index :questions, [:billable_id, :order]
14+
end
15+
end
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class CreateAnswers < ActiveRecord::Migration[8.0]
2+
def change
3+
create_table :answers do |t|
4+
t.references :person, null: false, foreign_key: true
5+
t.references :question, null: false, foreign_key: true
6+
t.text :answer_value
7+
8+
t.timestamps
9+
end
10+
11+
add_index :answers, [:person_id, :question_id], unique: true
12+
end
13+
end

db/schema.rb

Lines changed: 27 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)