diff --git a/app/controllers/doctor_question_types_controller.rb b/app/controllers/doctor_question_types_controller.rb
new file mode 100644
index 0000000..b65d278
--- /dev/null
+++ b/app/controllers/doctor_question_types_controller.rb
@@ -0,0 +1,70 @@
+class DoctorQuestionTypesController < ApplicationController
+ before_action :set_doctor_question_type, only: %i[ show edit update destroy ]
+
+ # GET /doctor_question_types or /doctor_question_types.json
+ def index
+ @doctor_question_types = DoctorQuestionType.includes(:doctor_questions).all
+ end
+
+ # GET /doctor_question_types/1 or /doctor_question_types/1.json
+ def show
+ end
+
+ # GET /doctor_question_types/new
+ def new
+ @doctor_question_type = DoctorQuestionType.new
+ end
+
+ # GET /doctor_question_types/1/edit
+ def edit
+ end
+
+ # POST /doctor_question_types or /doctor_question_types.json
+ def create
+ @doctor_question_type = DoctorQuestionType.new(doctor_question_type_params)
+
+ respond_to do |format|
+ if @doctor_question_type.save
+ format.html { redirect_to @doctor_question_type, notice: "Doctor question type was successfully created." }
+ format.json { render :show, status: :created, location: @doctor_question_type }
+ else
+ format.html { render :new, status: :unprocessable_content }
+ format.json { render json: @doctor_question_type.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # PATCH/PUT /doctor_question_types/1 or /doctor_question_types/1.json
+ def update
+ respond_to do |format|
+ if @doctor_question_type.update(doctor_question_type_params)
+ format.html { redirect_to @doctor_question_type, notice: "Doctor question type was successfully updated.", status: :see_other }
+ format.json { render :show, status: :ok, location: @doctor_question_type }
+ else
+ format.html { render :edit, status: :unprocessable_content }
+ format.json { render json: @doctor_question_type.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # DELETE /doctor_question_types/1 or /doctor_question_types/1.json
+ def destroy
+ @doctor_question_type.destroy!
+
+ respond_to do |format|
+ format.html { redirect_to doctor_question_types_path, notice: "Doctor question type was successfully destroyed.", status: :see_other }
+ format.json { head :no_content }
+ end
+ end
+
+ private
+ # Use callbacks to share common setup or constraints between actions.
+ def set_doctor_question_type
+ @doctor_question_type = DoctorQuestionType.find(params.expect(:id))
+ end
+
+ # Only allow a list of trusted parameters through.
+ def doctor_question_type_params
+ params.expect(doctor_question_type: [ :name ])
+ end
+end
diff --git a/app/controllers/doctor_questions_controller.rb b/app/controllers/doctor_questions_controller.rb
new file mode 100644
index 0000000..f622738
--- /dev/null
+++ b/app/controllers/doctor_questions_controller.rb
@@ -0,0 +1,70 @@
+class DoctorQuestionsController < ApplicationController
+ before_action :set_doctor_question, only: %i[ show edit update destroy ]
+
+ # GET /doctor_questions or /doctor_questions.json
+ def index
+ @doctor_questions = DoctorQuestion.sorted_by(params[:sort], params[:direction])
+ end
+
+ # GET /doctor_questions/1 or /doctor_questions/1.json
+ def show
+ end
+
+ # GET /doctor_questions/new
+ def new
+ @doctor_question = DoctorQuestion.new
+ end
+
+ # GET /doctor_questions/1/edit
+ def edit
+ end
+
+ # POST /doctor_questions or /doctor_questions.json
+ def create
+ @doctor_question = DoctorQuestion.new(doctor_question_params)
+
+ respond_to do |format|
+ if @doctor_question.save
+ format.html { redirect_to @doctor_question, notice: "Doctor question was successfully created." }
+ format.json { render :show, status: :created, location: @doctor_question }
+ else
+ format.html { render :new, status: :unprocessable_content }
+ format.json { render json: @doctor_question.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # PATCH/PUT /doctor_questions/1 or /doctor_questions/1.json
+ def update
+ respond_to do |format|
+ if @doctor_question.update(doctor_question_params)
+ format.html { redirect_to @doctor_question, notice: "Doctor question was successfully updated.", status: :see_other }
+ format.json { render :show, status: :ok, location: @doctor_question }
+ else
+ format.html { render :edit, status: :unprocessable_content }
+ format.json { render json: @doctor_question.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # DELETE /doctor_questions/1 or /doctor_questions/1.json
+ def destroy
+ @doctor_question.destroy!
+
+ respond_to do |format|
+ format.html { redirect_to doctor_questions_path, notice: "Doctor question was successfully destroyed.", status: :see_other }
+ format.json { head :no_content }
+ end
+ end
+
+ private
+ # Use callbacks to share common setup or constraints between actions.
+ def set_doctor_question
+ @doctor_question = DoctorQuestion.find(params.expect(:id))
+ end
+
+ # Only allow a list of trusted parameters through.
+ def doctor_question_params
+ params.expect(doctor_question: [ :question, :doctor_question_type_id ])
+ end
+end
diff --git a/app/helpers/doctor_questions_helper.rb b/app/helpers/doctor_questions_helper.rb
new file mode 100644
index 0000000..b1c46ed
--- /dev/null
+++ b/app/helpers/doctor_questions_helper.rb
@@ -0,0 +1,14 @@
+module DoctorQuestionsHelper
+ # A column heading that links back to the index with the sort it should apply.
+ # Clicking the column already sorted flips the direction; clicking any other
+ # starts it ascending.
+ def doctor_question_sort_link(column, label)
+ sorting_by_this = params[:sort].to_s == column
+ descending = params[:direction].to_s == "desc"
+
+ direction = sorting_by_this && !descending ? "desc" : "asc"
+ arrow = sorting_by_this ? (descending ? " ▼" : " ▲") : ""
+
+ link_to "#{label}#{arrow}", doctor_questions_path(sort: column, direction: direction)
+ end
+end
diff --git a/app/models/doctor_question.rb b/app/models/doctor_question.rb
new file mode 100644
index 0000000..0b574ca
--- /dev/null
+++ b/app/models/doctor_question.rb
@@ -0,0 +1,27 @@
+class DoctorQuestion < ApplicationRecord
+ belongs_to :doctor_question_type
+
+ validates :question, presence: true, length: { maximum: 1000 }
+
+ # A whitelist, not a lookup table: params never reach the Arel.sql below.
+ SORT_COLUMNS = {
+ "type" => "doctor_question_types.name",
+ "question" => "doctor_questions.question"
+ }.freeze
+
+ DEFAULT_SORT = "type"
+
+ scope :sorted_by, ->(column, direction) {
+ column = SORT_COLUMNS.fetch(column) { SORT_COLUMNS.fetch(DEFAULT_SORT) }
+ direction = direction == "desc" ? "desc" : "asc"
+
+ # references forces the LEFT JOIN that ordering on the types table needs;
+ # includes on its own would load them in a second query with nothing to sort
+ # on. created_at settles rows the chosen column ties on, so questions stay
+ # in the order they were added within a type rather than an arbitrary one.
+ includes(:doctor_question_type)
+ .references(:doctor_question_types)
+ .order(Arel.sql("#{column} #{direction}"))
+ .order(:created_at)
+ }
+end
diff --git a/app/models/doctor_question_type.rb b/app/models/doctor_question_type.rb
new file mode 100644
index 0000000..4bc09f5
--- /dev/null
+++ b/app/models/doctor_question_type.rb
@@ -0,0 +1,5 @@
+class DoctorQuestionType < ApplicationRecord
+ has_many :doctor_questions, dependent: :restrict_with_error
+
+ validates :name, presence: true, uniqueness: { case_sensitive: false }
+end
diff --git a/app/views/doctor_question_types/_doctor_question_type.html.erb b/app/views/doctor_question_types/_doctor_question_type.html.erb
new file mode 100644
index 0000000..5be591f
--- /dev/null
+++ b/app/views/doctor_question_types/_doctor_question_type.html.erb
@@ -0,0 +1,3 @@
+
+ <%= link_to doctor_question_type.name, doctor_question_type %>
+
diff --git a/app/views/doctor_question_types/_doctor_question_type.json.jbuilder b/app/views/doctor_question_types/_doctor_question_type.json.jbuilder
new file mode 100644
index 0000000..0e6a538
--- /dev/null
+++ b/app/views/doctor_question_types/_doctor_question_type.json.jbuilder
@@ -0,0 +1,2 @@
+json.extract! doctor_question_type, :id, :name, :created_at, :updated_at
+json.url doctor_question_type_url(doctor_question_type, format: :json)
diff --git a/app/views/doctor_question_types/_form.html.erb b/app/views/doctor_question_types/_form.html.erb
new file mode 100644
index 0000000..9d0d8ea
--- /dev/null
+++ b/app/views/doctor_question_types/_form.html.erb
@@ -0,0 +1,22 @@
+<%= form_with(model: doctor_question_type) do |form| %>
+ <% if doctor_question_type.errors.any? %>
+
+
<%= pluralize(doctor_question_type.errors.count, "error") %> prohibited this doctor_question_type from being saved:
+
+
+ <% doctor_question_type.errors.each do |error| %>
+ - <%= error.full_message %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form.label :name, style: "display: block" %>
+ <%= form.text_field :name %>
+
+
+
+ <%= form.submit %>
+
+<% end %>
diff --git a/app/views/doctor_question_types/edit.html.erb b/app/views/doctor_question_types/edit.html.erb
new file mode 100644
index 0000000..a4c163f
--- /dev/null
+++ b/app/views/doctor_question_types/edit.html.erb
@@ -0,0 +1,12 @@
+<% content_for :title, "Editing doctor question type" %>
+
+Editing doctor question type
+
+<%= render "form", doctor_question_type: @doctor_question_type %>
+
+
+
+
+ <%= link_to "Show this doctor question type", @doctor_question_type %> |
+ <%= link_to "Back to doctor question types", doctor_question_types_path %>
+
diff --git a/app/views/doctor_question_types/index.html.erb b/app/views/doctor_question_types/index.html.erb
new file mode 100644
index 0000000..7adc5ae
--- /dev/null
+++ b/app/views/doctor_question_types/index.html.erb
@@ -0,0 +1,14 @@
+<%= notice %>
+
+<% content_for :title, "Doctor question types" %>
+
+Doctor question types
+
+
+ <% @doctor_question_types.each do |doctor_question_type| %>
+ <%= render doctor_question_type %>
+
+ <%= render doctor_question_type.doctor_questions%>
+
+ <% end %>
+
diff --git a/app/views/doctor_question_types/index.json.jbuilder b/app/views/doctor_question_types/index.json.jbuilder
new file mode 100644
index 0000000..bb75649
--- /dev/null
+++ b/app/views/doctor_question_types/index.json.jbuilder
@@ -0,0 +1 @@
+json.array! @doctor_question_types, partial: "doctor_question_types/doctor_question_type", as: :doctor_question_type
diff --git a/app/views/doctor_question_types/new.html.erb b/app/views/doctor_question_types/new.html.erb
new file mode 100644
index 0000000..3511167
--- /dev/null
+++ b/app/views/doctor_question_types/new.html.erb
@@ -0,0 +1,11 @@
+<% content_for :title, "New doctor question type" %>
+
+New doctor question type
+
+<%= render "form", doctor_question_type: @doctor_question_type %>
+
+
+
+
+ <%= link_to "Back to doctor question types", doctor_question_types_path %>
+
diff --git a/app/views/doctor_question_types/show.html.erb b/app/views/doctor_question_types/show.html.erb
new file mode 100644
index 0000000..ca5e599
--- /dev/null
+++ b/app/views/doctor_question_types/show.html.erb
@@ -0,0 +1,10 @@
+<%= notice %>
+
+<%= render @doctor_question_type %>
+
+
+ <%= link_to "Edit this doctor question type", edit_doctor_question_type_path(@doctor_question_type) %> |
+ <%= link_to "Back to doctor question types", doctor_question_types_path %>
+
+ <%= button_to "Destroy this doctor question type", @doctor_question_type, method: :delete %>
+
diff --git a/app/views/doctor_question_types/show.json.jbuilder b/app/views/doctor_question_types/show.json.jbuilder
new file mode 100644
index 0000000..134f619
--- /dev/null
+++ b/app/views/doctor_question_types/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial! "doctor_question_types/doctor_question_type", doctor_question_type: @doctor_question_type
diff --git a/app/views/doctor_questions/_doctor_question.html.erb b/app/views/doctor_questions/_doctor_question.html.erb
new file mode 100644
index 0000000..159f4f0
--- /dev/null
+++ b/app/views/doctor_questions/_doctor_question.html.erb
@@ -0,0 +1,3 @@
+
+ <%= link_to doctor_question.question, doctor_question %>
+
diff --git a/app/views/doctor_questions/_doctor_question.json.jbuilder b/app/views/doctor_questions/_doctor_question.json.jbuilder
new file mode 100644
index 0000000..20e05fa
--- /dev/null
+++ b/app/views/doctor_questions/_doctor_question.json.jbuilder
@@ -0,0 +1,2 @@
+json.extract! doctor_question, :id, :question, :doctor_question_type_id, :created_at, :updated_at
+json.url doctor_question_url(doctor_question, format: :json)
diff --git a/app/views/doctor_questions/_form.html.erb b/app/views/doctor_questions/_form.html.erb
new file mode 100644
index 0000000..0f56e37
--- /dev/null
+++ b/app/views/doctor_questions/_form.html.erb
@@ -0,0 +1,32 @@
+<%= form_with(model: doctor_question) do |form| %>
+ <% if doctor_question.errors.any? %>
+
+
<%= pluralize(doctor_question.errors.count, "error") %> prohibited this doctor_question from being saved:
+
+
+ <% doctor_question.errors.each do |error| %>
+ - <%= error.full_message %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form.label :question, style: "display: block" %>
+ <%= form.textarea :question %>
+
+
+
+ <%= form.label :doctor_question_type_id, "Type", style: "display: block" %>
+ <%= form.collection_select :doctor_question_type_id,
+ DoctorQuestionType.order(:name),
+ :id,
+ :name,
+ { prompt: "Choose a type" } %>
+
+
+
+
+ <%= form.submit %>
+
+<% end %>
diff --git a/app/views/doctor_questions/edit.html.erb b/app/views/doctor_questions/edit.html.erb
new file mode 100644
index 0000000..d98c1e8
--- /dev/null
+++ b/app/views/doctor_questions/edit.html.erb
@@ -0,0 +1,12 @@
+<% content_for :title, "Editing doctor question" %>
+
+Editing doctor question
+
+<%= render "form", doctor_question: @doctor_question %>
+
+
+
+
+ <%= link_to "Show this doctor question", @doctor_question %> |
+ <%= link_to "Back to doctor questions", doctor_questions_path %>
+
diff --git a/app/views/doctor_questions/index.html.erb b/app/views/doctor_questions/index.html.erb
new file mode 100644
index 0000000..d7a9d2a
--- /dev/null
+++ b/app/views/doctor_questions/index.html.erb
@@ -0,0 +1,27 @@
+<%= notice %>
+
+<% content_for :title, "Questions to be asked" %>
+
+Questions to be asked
+
+
+
+
+ | <%= doctor_question_sort_link "type", "Type" %> |
+ <%= doctor_question_sort_link "question", "Question" %> |
+ |
+
+
+
+
+ <% @doctor_questions.each do |doctor_question| %>
+
+ | <%= doctor_question.doctor_question_type.name %> |
+ <%= doctor_question.question %> |
+ <%= link_to "Show this doctor question", doctor_question %> |
+
+ <% end %>
+
+
+
+<%= link_to "New doctor question", new_doctor_question_path %>
diff --git a/app/views/doctor_questions/index.json.jbuilder b/app/views/doctor_questions/index.json.jbuilder
new file mode 100644
index 0000000..0e4e6c0
--- /dev/null
+++ b/app/views/doctor_questions/index.json.jbuilder
@@ -0,0 +1 @@
+json.array! @doctor_questions, partial: "doctor_questions/doctor_question", as: :doctor_question
diff --git a/app/views/doctor_questions/new.html.erb b/app/views/doctor_questions/new.html.erb
new file mode 100644
index 0000000..8004bd0
--- /dev/null
+++ b/app/views/doctor_questions/new.html.erb
@@ -0,0 +1,11 @@
+<% content_for :title, "New doctor question" %>
+
+New doctor question
+
+<%= render "form", doctor_question: @doctor_question %>
+
+
+
+
+ <%= link_to "Back to doctor questions", doctor_questions_path %>
+
diff --git a/app/views/doctor_questions/show.html.erb b/app/views/doctor_questions/show.html.erb
new file mode 100644
index 0000000..09fe8c4
--- /dev/null
+++ b/app/views/doctor_questions/show.html.erb
@@ -0,0 +1,10 @@
+<%= notice %>
+
+<%= render @doctor_question %>
+
+
+ <%= link_to "Edit this doctor question", edit_doctor_question_path(@doctor_question) %> |
+ <%= link_to "Back to doctor questions", doctor_questions_path %>
+
+ <%= button_to "Destroy this doctor question", @doctor_question, method: :delete %>
+
diff --git a/app/views/doctor_questions/show.json.jbuilder b/app/views/doctor_questions/show.json.jbuilder
new file mode 100644
index 0000000..89a12e5
--- /dev/null
+++ b/app/views/doctor_questions/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial! "doctor_questions/doctor_question", doctor_question: @doctor_question
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index ec70fcb..912c55e 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -63,7 +63,8 @@
equal billing with People and Prescriptions. %>
<%= link_to "Admin", admin_path,
aria: { current: ("page" if admin_area?) } %>
-
+ <%= link_to "Questions", doctor_question_types_path,
+ aria: { current: aria_current_section(doctor_question_types_path) } %>
<%# Devise signs out via DELETE, so this needs Turbo to issue the
verb. button_to would work without it, but it would land in the
nav as a filled button - and, being a delete, painted in the
diff --git a/config/routes.rb b/config/routes.rb
index c305b40..51533f2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,5 +1,7 @@
Rails.application.routes.draw do
resources :appointment_requirements
+ resources :doctor_questions
+ resources :doctor_question_types
devise_for :users
resources :medications
resources :medication_forms
diff --git a/db/migrate/20260829160124_create_doctor_question_types.rb b/db/migrate/20260829160124_create_doctor_question_types.rb
new file mode 100644
index 0000000..2031e59
--- /dev/null
+++ b/db/migrate/20260829160124_create_doctor_question_types.rb
@@ -0,0 +1,9 @@
+class CreateDoctorQuestionTypes < ActiveRecord::Migration[8.1]
+ def change
+ create_table :doctor_question_types do |t|
+ t.string :name, null: false, index: { unique: true }
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260829173203_create_doctor_questions.rb b/db/migrate/20260829173203_create_doctor_questions.rb
new file mode 100644
index 0000000..7702c9c
--- /dev/null
+++ b/db/migrate/20260829173203_create_doctor_questions.rb
@@ -0,0 +1,10 @@
+class CreateDoctorQuestions < ActiveRecord::Migration[8.1]
+ def change
+ create_table :doctor_questions do |t|
+ t.text :question, null: false
+ t.belongs_to :doctor_question_type, null: false, foreign_key: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 4727c87..192fddf 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_29_203900) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_29_210653) do
create_table "addresses", force: :cascade do |t|
t.string "city"
t.datetime "created_at", null: false
@@ -26,6 +26,21 @@
t.datetime "updated_at", null: false
end
+ create_table "doctor_question_types", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "name", null: false
+ t.datetime "updated_at", null: false
+ t.index ["name"], name: "index_doctor_question_types_on_name", unique: true
+ end
+
+ create_table "doctor_questions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.integer "doctor_question_type_id", null: false
+ t.text "question", null: false
+ t.datetime "updated_at", null: false
+ t.index ["doctor_question_type_id"], name: "index_doctor_questions_on_doctor_question_type_id"
+ end
+
create_table "medication_forms", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "name"
@@ -117,6 +132,7 @@
end
add_foreign_key "addresses", "people"
+ add_foreign_key "doctor_questions", "doctor_question_types"
add_foreign_key "medications", "medication_types"
add_foreign_key "people", "relationships"
add_foreign_key "prescriptions", "medication_forms"
diff --git a/db/seeds.rb b/db/seeds.rb
index f7802de..d35cace 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -1,60 +1,12 @@
# This file should ensure the existence of records required to run the application in every environment (production,
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
-
-if Task.none?
- Task.create!([
- { title: "Open mobile/src/app/tasks.tsx and edit this list", completed: false },
- { title: "Point EXPO_PUBLIC_API_URL at the Rails server", completed: true },
- { title: "Replace Task with a real model", completed: false }
- ])
-end
-
-# The medication catalog from issue #164. Looked up by name so re-seeding an
-# existing database tops it up instead of duplicating it.
-[
- "Prescription",
- "Over-the-counter",
- "Vitamin or supplement",
- "Herbal or natural"
-].each { |name| MedicationType.find_or_create_by!(name: name) }
-
-[
- "Capsule",
- "Liquid",
- "Suspension",
- "Syrup",
- "Chewable tablet",
- "Dissolvable tablet",
- "Powder",
- "Injection",
- "Inhaler",
- "Nebulizer solution",
- "Patch",
- "Cream",
- "Ointment",
- "Gel",
- "Drops",
- "Suppository",
- "Spray"
-].each { |name| MedicationForm.find_or_create_by!(name: name) }
-
-{
- "Lisinopril" => "Dry cough, dizziness, headache",
- "Atorvastatin" => "Muscle aches, nausea, joint pain",
- "Metformin" => "Nausea, diarrhea, stomach upset",
- "Levothyroxine" => "Weight changes, insomnia, tremor",
- "Amlodipine" => "Swollen ankles, flushing, fatigue",
- "Metoprolol" => "Fatigue, dizziness, slow heartbeat",
- "Omeprazole" => "Headache, gas, constipation",
- "Albuterol" => "Jitteriness, rapid heartbeat, headache",
- "Gabapentin" => "Drowsiness, dizziness, coordination problems",
- "Sertraline" => "Nausea, insomnia, dry mouth",
- "Ibuprofen" => "Stomach upset, heartburn, dizziness",
- "Acetaminophen" => "Nausea, rash",
- "Amoxicillin" => "Diarrhea, nausea, rash",
- "Prednisone" => "Increased appetite, insomnia, mood changes",
- "Warfarin" => "Easy bruising, bleeding, nausea"
-}.each do |name, side_effects|
- Medication.find_or_create_by!(name: name) { |medication| medication.side_effects = side_effects }
+#
+# The data itself lives in db/seeds/, one file per concern, loaded in filename
+# order - the numeric prefixes are there for when one file depends on another
+# having run first.
+
+Dir[Rails.root.join("db/seeds/*.rb")].sort.each do |seed_file|
+ puts "Seeding #{File.basename(seed_file)}"
+ load seed_file
end
diff --git a/db/seeds/01_tasks.rb b/db/seeds/01_tasks.rb
new file mode 100644
index 0000000..665962a
--- /dev/null
+++ b/db/seeds/01_tasks.rb
@@ -0,0 +1,7 @@
+if Task.none?
+ Task.create!([
+ { title: "Open mobile/src/app/tasks.tsx and edit this list", completed: false },
+ { title: "Point EXPO_PUBLIC_API_URL at the Rails server", completed: true },
+ { title: "Replace Task with a real model", completed: false }
+ ])
+end
diff --git a/db/seeds/02_doctor_questions.rb b/db/seeds/02_doctor_questions.rb
new file mode 100644
index 0000000..42a65ff
--- /dev/null
+++ b/db/seeds/02_doctor_questions.rb
@@ -0,0 +1,69 @@
+# Questions a patient might want to take into an appointment, grouped by the
+# type they belong to. The hash defines the types too - there is no separate
+# list to keep in step with this one.
+#
+# Order matters: questions are created in the order written here, and the index
+# falls back to created_at within a type, so this is the order a patient sees.
+questions_by_type = {
+ "Health concern" => [
+ "What is my diagnosis?",
+ "Do I need any more tests to confirm the diagnosis?",
+ "What is changing?",
+ "What are my treatment options? What are the benefits of each option? What are the side effects or complications of each option?",
+ "What if I choose to do nothing?",
+ "How soon do I need to make a decision?",
+ "What will the medicine you are prescribing do? How do I take it? Are there any side effects? How long will I need to take the medicine?",
+ "Why do I need surgery? Are there other ways to treat my condition? How often do you perform this surgery?",
+ "What are the costs involved in the treatment?",
+ "Do I need to change my daily routine? Do I need special help at home? Can I drive?",
+ "What is the outlook for my future?"
+ ],
+
+ "Diagnostic tests" => [
+ "What is the test for?",
+ "How is the test done?",
+ "How accurate is the test?",
+ "What are the possible complications?",
+ "Is the test the only way to find out that information?",
+ "What do I need to do to prepare for the test?",
+ "Will I be able to drive myself home after the test?",
+ "When will I get the results?",
+ "What will the results tell me?",
+ "What is the next step after the tests?"
+ ],
+
+ "Surgery" => [
+ "Why do I need the surgery?",
+ "What does the surgery involve?",
+ "What are the potential complications?",
+ "Is there some other way to treat my condition?",
+ "Who will do the surgery? How many times have they done this surgery?",
+ "Which hospital is best for the surgery?",
+ "Will I need anesthesia?",
+ "How long will it take me to recover?",
+ "How long will I be in the hospital?",
+ "What will happen after the surgery? Will I need special help at home? Will I be able to drive?",
+ "What will happen if I wait or do not have the surgery?"
+ ],
+
+ "Insurance" => [
+ "Is this visit/procedure/test covered by my insurance plan? Do you anticipate any problems with my insurance?",
+ "Is this provider in-network for both the health care provider and the facility/hospital?",
+ "Do I need a referral or prior authorization before this visit/test/procedure? If authorization is needed, will your office handle it, or do I need to contact my insurer first?"
+ ],
+
+ "Costs" => [
+ "What is the estimated total cost for the visit/procedure/test?",
+ "What portion is typically deductible, copay or coinsurance?",
+ "What is my estimated out-of-pocket cost specifically?",
+ "Is there anything else about cost that patients are usually surprised by in this situation?"
+ ]
+}
+
+questions_by_type.each do |type_name, questions|
+ type = DoctorQuestionType.find_or_create_by!(name: type_name)
+
+ questions.each do |question|
+ type.doctor_questions.find_or_create_by!(question: question)
+ end
+end
diff --git a/db/seeds/02_medications.rb b/db/seeds/02_medications.rb
new file mode 100644
index 0000000..61e9640
--- /dev/null
+++ b/db/seeds/02_medications.rb
@@ -0,0 +1,48 @@
+# The medication catalog from issue #164. Looked up by name so re-seeding an
+# existing database tops it up instead of duplicating it.
+[
+ "Prescription",
+ "Over-the-counter",
+ "Vitamin or supplement",
+ "Herbal or natural"
+].each { |name| MedicationType.find_or_create_by!(name: name) }
+
+[
+ "Capsule",
+ "Liquid",
+ "Suspension",
+ "Syrup",
+ "Chewable tablet",
+ "Dissolvable tablet",
+ "Powder",
+ "Injection",
+ "Inhaler",
+ "Nebulizer solution",
+ "Patch",
+ "Cream",
+ "Ointment",
+ "Gel",
+ "Drops",
+ "Suppository",
+ "Spray"
+].each { |name| MedicationForm.find_or_create_by!(name: name) }
+
+{
+ "Lisinopril" => "Dry cough, dizziness, headache",
+ "Atorvastatin" => "Muscle aches, nausea, joint pain",
+ "Metformin" => "Nausea, diarrhea, stomach upset",
+ "Levothyroxine" => "Weight changes, insomnia, tremor",
+ "Amlodipine" => "Swollen ankles, flushing, fatigue",
+ "Metoprolol" => "Fatigue, dizziness, slow heartbeat",
+ "Omeprazole" => "Headache, gas, constipation",
+ "Albuterol" => "Jitteriness, rapid heartbeat, headache",
+ "Gabapentin" => "Drowsiness, dizziness, coordination problems",
+ "Sertraline" => "Nausea, insomnia, dry mouth",
+ "Ibuprofen" => "Stomach upset, heartburn, dizziness",
+ "Acetaminophen" => "Nausea, rash",
+ "Amoxicillin" => "Diarrhea, nausea, rash",
+ "Prednisone" => "Increased appetite, insomnia, mood changes",
+ "Warfarin" => "Easy bruising, bleeding, nausea"
+}.each do |name, side_effects|
+ Medication.find_or_create_by!(name: name) { |medication| medication.side_effects = side_effects }
+end
diff --git a/db/seeds/03_doctor_questions.rb b/db/seeds/03_doctor_questions.rb
new file mode 100644
index 0000000..42a65ff
--- /dev/null
+++ b/db/seeds/03_doctor_questions.rb
@@ -0,0 +1,69 @@
+# Questions a patient might want to take into an appointment, grouped by the
+# type they belong to. The hash defines the types too - there is no separate
+# list to keep in step with this one.
+#
+# Order matters: questions are created in the order written here, and the index
+# falls back to created_at within a type, so this is the order a patient sees.
+questions_by_type = {
+ "Health concern" => [
+ "What is my diagnosis?",
+ "Do I need any more tests to confirm the diagnosis?",
+ "What is changing?",
+ "What are my treatment options? What are the benefits of each option? What are the side effects or complications of each option?",
+ "What if I choose to do nothing?",
+ "How soon do I need to make a decision?",
+ "What will the medicine you are prescribing do? How do I take it? Are there any side effects? How long will I need to take the medicine?",
+ "Why do I need surgery? Are there other ways to treat my condition? How often do you perform this surgery?",
+ "What are the costs involved in the treatment?",
+ "Do I need to change my daily routine? Do I need special help at home? Can I drive?",
+ "What is the outlook for my future?"
+ ],
+
+ "Diagnostic tests" => [
+ "What is the test for?",
+ "How is the test done?",
+ "How accurate is the test?",
+ "What are the possible complications?",
+ "Is the test the only way to find out that information?",
+ "What do I need to do to prepare for the test?",
+ "Will I be able to drive myself home after the test?",
+ "When will I get the results?",
+ "What will the results tell me?",
+ "What is the next step after the tests?"
+ ],
+
+ "Surgery" => [
+ "Why do I need the surgery?",
+ "What does the surgery involve?",
+ "What are the potential complications?",
+ "Is there some other way to treat my condition?",
+ "Who will do the surgery? How many times have they done this surgery?",
+ "Which hospital is best for the surgery?",
+ "Will I need anesthesia?",
+ "How long will it take me to recover?",
+ "How long will I be in the hospital?",
+ "What will happen after the surgery? Will I need special help at home? Will I be able to drive?",
+ "What will happen if I wait or do not have the surgery?"
+ ],
+
+ "Insurance" => [
+ "Is this visit/procedure/test covered by my insurance plan? Do you anticipate any problems with my insurance?",
+ "Is this provider in-network for both the health care provider and the facility/hospital?",
+ "Do I need a referral or prior authorization before this visit/test/procedure? If authorization is needed, will your office handle it, or do I need to contact my insurer first?"
+ ],
+
+ "Costs" => [
+ "What is the estimated total cost for the visit/procedure/test?",
+ "What portion is typically deductible, copay or coinsurance?",
+ "What is my estimated out-of-pocket cost specifically?",
+ "Is there anything else about cost that patients are usually surprised by in this situation?"
+ ]
+}
+
+questions_by_type.each do |type_name, questions|
+ type = DoctorQuestionType.find_or_create_by!(name: type_name)
+
+ questions.each do |question|
+ type.doctor_questions.find_or_create_by!(question: question)
+ end
+end
diff --git a/test/controllers/doctor_question_types_controller_test.rb b/test/controllers/doctor_question_types_controller_test.rb
new file mode 100644
index 0000000..4abcac2
--- /dev/null
+++ b/test/controllers/doctor_question_types_controller_test.rb
@@ -0,0 +1,48 @@
+require "test_helper"
+
+class DoctorQuestionTypesControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @doctor_question_type = doctor_question_types(:health_concern)
+ end
+
+ test "should get index" do
+ get doctor_question_types_url
+ assert_response :success
+ end
+
+ test "should get new" do
+ get new_doctor_question_type_url
+ assert_response :success
+ end
+
+ test "should create doctor_question_type" do
+ assert_difference("DoctorQuestionType.count") do
+ post doctor_question_types_url, params: { doctor_question_type: { name: "Medication" } }
+ end
+
+ assert_redirected_to doctor_question_type_url(DoctorQuestionType.last)
+ end
+
+ test "should show doctor_question_type" do
+ get doctor_question_type_url(@doctor_question_type)
+ assert_response :success
+ end
+
+ test "should get edit" do
+ get edit_doctor_question_type_url(@doctor_question_type)
+ assert_response :success
+ end
+
+ test "should update doctor_question_type" do
+ patch doctor_question_type_url(@doctor_question_type), params: { doctor_question_type: { name: @doctor_question_type.name } }
+ assert_redirected_to doctor_question_type_url(@doctor_question_type)
+ end
+
+ test "should destroy doctor_question_type" do
+ assert_difference("DoctorQuestionType.count", -1) do
+ delete doctor_question_type_url(doctor_question_types(:unused))
+ end
+
+ assert_redirected_to doctor_question_types_url
+ end
+end
diff --git a/test/controllers/doctor_questions_controller_test.rb b/test/controllers/doctor_questions_controller_test.rb
new file mode 100644
index 0000000..5738cc2
--- /dev/null
+++ b/test/controllers/doctor_questions_controller_test.rb
@@ -0,0 +1,48 @@
+require "test_helper"
+
+class DoctorQuestionsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @doctor_question = doctor_questions(:diagnosis)
+ end
+
+ test "should get index" do
+ get doctor_questions_url
+ assert_response :success
+ end
+
+ test "should get new" do
+ get new_doctor_question_url
+ assert_response :success
+ end
+
+ test "should create doctor_question" do
+ assert_difference("DoctorQuestion.count") do
+ post doctor_questions_url, params: { doctor_question: { doctor_question_type_id: @doctor_question.doctor_question_type_id, question: @doctor_question.question } }
+ end
+
+ assert_redirected_to doctor_question_url(DoctorQuestion.last)
+ end
+
+ test "should show doctor_question" do
+ get doctor_question_url(@doctor_question)
+ assert_response :success
+ end
+
+ test "should get edit" do
+ get edit_doctor_question_url(@doctor_question)
+ assert_response :success
+ end
+
+ test "should update doctor_question" do
+ patch doctor_question_url(@doctor_question), params: { doctor_question: { doctor_question_type_id: @doctor_question.doctor_question_type_id, question: @doctor_question.question } }
+ assert_redirected_to doctor_question_url(@doctor_question)
+ end
+
+ test "should destroy doctor_question" do
+ assert_difference("DoctorQuestion.count", -1) do
+ delete doctor_question_url(@doctor_question)
+ end
+
+ assert_redirected_to doctor_questions_url
+ end
+end
diff --git a/test/controllers/doctor_questions_index_test.rb b/test/controllers/doctor_questions_index_test.rb
new file mode 100644
index 0000000..28de17f
--- /dev/null
+++ b/test/controllers/doctor_questions_index_test.rb
@@ -0,0 +1,55 @@
+require "test_helper"
+
+class DoctorQuestionsIndexTest < ActionDispatch::IntegrationTest
+ test "index lists questions in a table under one heading" do
+ get doctor_questions_url
+
+ assert_response :success
+ assert_select "h1", "Questions to be asked"
+ assert_select "table tbody tr", count: DoctorQuestion.count
+ assert_select "td", text: "Health Concern"
+ end
+
+ test "index sorts by type name by default" do
+ get doctor_questions_url
+
+ types = css_select("tbody tr td:first-child").map(&:text)
+ assert_equal types.sort, types
+ end
+
+ test "sort links reverse the order" do
+ get doctor_questions_url(sort: "type", direction: "desc")
+
+ types = css_select("tbody tr td:first-child").map(&:text)
+ assert_equal types.sort.reverse, types
+ end
+
+ # The sort parameter reaches Arel.sql, so it must never reach it as SQL: an
+ # unknown column has to fall back to the default rather than be interpolated.
+ test "an unknown sort column falls back to the default" do
+ get doctor_questions_url(sort: "question; drop table doctor_questions", direction: "asc")
+
+ assert_response :success
+ types = css_select("tbody tr td:first-child").map(&:text)
+ assert_equal types.sort, types
+ end
+
+ test "sorts by the question column" do
+ get doctor_questions_url(sort: "question", direction: "asc")
+
+ questions = css_select("tbody tr td:nth-child(2)").map(&:text)
+ assert_equal questions.sort, questions
+
+ # The fixtures sort differently by question than by type, so this also
+ # proves the question sort is not quietly falling back to the default.
+ types = css_select("tbody tr td:first-child").map(&:text)
+ assert_not_equal types.sort, types
+ end
+
+ test "reverses the question column" do
+ get doctor_questions_url(sort: "question", direction: "desc")
+
+ questions = css_select("tbody tr td:nth-child(2)").map(&:text)
+ assert_equal questions.sort.reverse, questions
+ end
+end
diff --git a/test/fixtures/doctor_question_types.yml b/test/fixtures/doctor_question_types.yml
new file mode 100644
index 0000000..0bb2441
--- /dev/null
+++ b/test/fixtures/doctor_question_types.yml
@@ -0,0 +1,18 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+# Names have to differ from each other: fixtures are inserted straight into the
+# database, so they skip the model's validations but not its unique index.
+
+health_concern:
+ name: Health Concern
+
+surgery:
+ name: Surgery
+
+insurance:
+ name: Insurance
+
+# Deliberately referenced by no question, so a test has something it is allowed
+# to destroy. dependent: :restrict_with_error blocks deleting the others.
+unused:
+ name: Costs
diff --git a/test/fixtures/doctor_questions.yml b/test/fixtures/doctor_questions.yml
new file mode 100644
index 0000000..0c403b3
--- /dev/null
+++ b/test/fixtures/doctor_questions.yml
@@ -0,0 +1,24 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+# Three questions under three types. Three rather than two on purpose: with two
+# rows a sort assertion passes even if the sort merely reverses, and the middle
+# element is what catches an ordering that is subtly wrong.
+#
+# The question text deliberately sorts into a different order than the type
+# names do (Does/Should/What against Health Concern/Insurance/Surgery), so a
+# test sorting by one column cannot pass while reading the other.
+#
+# doctor_question_type names a label in doctor_question_types.yml, which Rails
+# resolves to that row's id.
+
+diagnosis:
+ question: Should I take this with food?
+ doctor_question_type: health_concern
+
+recovery:
+ question: What did the scan show?
+ doctor_question_type: surgery
+
+coverage:
+ question: Does my plan cover this?
+ doctor_question_type: insurance
diff --git a/test/helpers/doctor_questions_helper_test.rb b/test/helpers/doctor_questions_helper_test.rb
new file mode 100644
index 0000000..61e26e8
--- /dev/null
+++ b/test/helpers/doctor_questions_helper_test.rb
@@ -0,0 +1,42 @@
+require "test_helper"
+
+class DoctorQuestionsHelperTest < ActionView::TestCase
+ test "a column that is not the current sort links to it ascending, with no arrow" do
+ link = doctor_question_sort_link("type", "Type")
+
+ assert_includes link, "sort=type"
+ assert_includes link, "direction=asc"
+ assert_includes link, ">Type"
+ end
+
+ test "the column sorted ascending offers descending, and shows an up arrow" do
+ params[:sort] = "type"
+ params[:direction] = "asc"
+
+ link = doctor_question_sort_link("type", "Type")
+
+ assert_includes link, "direction=desc"
+ assert_includes link, "Type ▲"
+ end
+
+ test "the column sorted descending offers ascending again, and shows a down arrow" do
+ params[:sort] = "type"
+ params[:direction] = "desc"
+
+ link = doctor_question_sort_link("type", "Type")
+
+ assert_includes link, "direction=asc"
+ assert_includes link, "Type ▼"
+ end
+
+ # The arrow marks which column is sorted, so it must not appear on the others.
+ test "other columns show no arrow while one column is sorted" do
+ params[:sort] = "type"
+ params[:direction] = "desc"
+
+ link = doctor_question_sort_link("question", "Question")
+
+ assert_includes link, ">Question"
+ assert_includes link, "direction=asc"
+ end
+end
diff --git a/test/models/doctor_question_test.rb b/test/models/doctor_question_test.rb
new file mode 100644
index 0000000..4c86a20
--- /dev/null
+++ b/test/models/doctor_question_test.rb
@@ -0,0 +1,38 @@
+require "test_helper"
+
+class DoctorQuestionTest < ActiveSupport::TestCase
+ test "requires a question" do
+ question = DoctorQuestion.new(doctor_question_type: doctor_question_types(:health_concern))
+
+ assert_not question.valid?
+ assert_includes question.errors[:question], "can't be blank"
+ end
+
+ # belongs_to is required by default, so this passes with no validation of our
+ # own - and matches the null: false on the column.
+ test "requires a type" do
+ question = DoctorQuestion.new(question: "Anything I should watch for?")
+
+ assert_not question.valid?
+ assert_includes question.errors[:doctor_question_type], "must exist"
+ end
+
+ test "rejects a question longer than 1000 characters" do
+ question = DoctorQuestion.new(
+ question: "a" * 1001,
+ doctor_question_type: doctor_question_types(:health_concern)
+ )
+
+ assert_not question.valid?
+ assert_includes question.errors[:question], "is too long (maximum is 1000 characters)"
+ end
+
+ test "accepts a question of exactly 1000 characters" do
+ question = DoctorQuestion.new(
+ question: "a" * 1000,
+ doctor_question_type: doctor_question_types(:health_concern)
+ )
+
+ assert question.valid?
+ end
+end
diff --git a/test/models/doctor_question_type_test.rb b/test/models/doctor_question_type_test.rb
new file mode 100644
index 0000000..4860ff9
--- /dev/null
+++ b/test/models/doctor_question_type_test.rb
@@ -0,0 +1,25 @@
+require "test_helper"
+
+class DoctorQuestionTypeTest < ActiveSupport::TestCase
+ # Lowercase on purpose: this is what proves case_sensitive: false is doing
+ # something. "Health Concern" alone would pass with or without it.
+ test "requires a unique name regardless of case" do
+ duplicate = DoctorQuestionType.new(name: "health concern")
+
+ assert_not duplicate.valid?
+ assert_includes duplicate.errors[:name], "has already been taken"
+ end
+
+ test "refuses to be destroyed while questions still use it" do
+ type = doctor_question_types(:health_concern)
+
+ assert_not type.destroy
+ assert_includes type.errors[:base], "Cannot delete record because dependent doctor questions exist"
+ end
+
+ test "can be destroyed when nothing uses it" do
+ assert_difference("DoctorQuestionType.count", -1) do
+ doctor_question_types(:unused).destroy
+ end
+ end
+end