From 73f93373f4bfa8bdb1d2d4eec785979bd540bd1e Mon Sep 17 00:00:00 2001 From: Roberta Talmage Date: Fri, 28 Aug 2026 14:41:15 -0400 Subject: [PATCH 01/25] Added mise to AWY --- mise.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 mise.toml diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..2f58cce --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +node = "latest" +ruby = "4.0.5" From 65c8bb04b791d54130d104977de9e7860379205f Mon Sep 17 00:00:00 2001 From: fiona mgharo Date: Fri, 28 Aug 2026 12:26:17 -0700 Subject: [PATCH 02/25] built homepage --- app/controllers/home_controller.rb | 7 +++++++ app/helpers/home_helper.rb | 2 ++ app/views/home/index.html.erb | 2 ++ config/routes.rb | 3 ++- test/controllers/home_controller_test.rb | 7 +++++++ 5 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 app/controllers/home_controller.rb create mode 100644 app/helpers/home_helper.rb create mode 100644 app/views/home/index.html.erb create mode 100644 test/controllers/home_controller_test.rb diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 0000000..6396a5f --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,7 @@ +class HomeController < ApplicationController + def index + end + + def world + end +end diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb new file mode 100644 index 0000000..23de56a --- /dev/null +++ b/app/helpers/home_helper.rb @@ -0,0 +1,2 @@ +module HomeHelper +end diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb new file mode 100644 index 0000000..8d61d42 --- /dev/null +++ b/app/views/home/index.html.erb @@ -0,0 +1,2 @@ +

heading

+

Hi

\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 4368cb6..8410215 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,5 +24,6 @@ # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker # Defines the root path route ("/") - # root "posts#index" + root "home#index" + get "world" => "home#world" end diff --git a/test/controllers/home_controller_test.rb b/test/controllers/home_controller_test.rb new file mode 100644 index 0000000..eac0e33 --- /dev/null +++ b/test/controllers/home_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class HomeControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end From 9da9fe4450d225ec66cf683a7b74bd19a491bab5 Mon Sep 17 00:00:00 2001 From: Johncarlo Cerna Date: Fri, 28 Aug 2026 15:53:13 -0400 Subject: [PATCH 03/25] Add User Auth with Devise --- .rubocop.yml | 3 + Gemfile | 26 +- Gemfile.lock | 19 ++ app/models/user.rb | 6 + config/initializers/devise.rb | 316 ++++++++++++++++++ config/locales/devise.en.yml | 65 ++++ config/routes.rb | 1 + .../20260828193338_devise_create_users.rb | 44 +++ db/schema.rb | 14 +- test/models/user_test.rb | 7 + 10 files changed, 488 insertions(+), 13 deletions(-) create mode 100644 app/models/user.rb create mode 100644 config/initializers/devise.rb create mode 100644 config/locales/devise.en.yml create mode 100644 db/migrate/20260828193338_devise_create_users.rb create mode 100644 test/models/user_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index 68349d6..1d9dbdc 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -12,3 +12,6 @@ AllCops: # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` # Layout/SpaceInsideArrayLiteralBrackets: # Enabled: false + +Bundler/OrderedGems: + Enabled: true \ No newline at end of file diff --git a/Gemfile b/Gemfile index d59d620..4ceebcc 100644 --- a/Gemfile +++ b/Gemfile @@ -2,23 +2,25 @@ source "https://rubygems.org" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 8.1.3" -# The modern asset pipeline for Rails [https://github.com/rails/propshaft] -gem "propshaft" -# Use sqlite3 as the database for Active Record -gem "sqlite3", ">= 2.9.6" -# Use the Puma web server [https://github.com/puma/puma] -gem "puma", ">= 5.0" + + +gem "devise", "~> 5.0" # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] gem "importmap-rails" -# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] -gem "turbo-rails" -# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] -gem "stimulus-rails" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" - +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" # Handle Cross-Origin Resource Sharing so the Expo app can call the API [https://github.com/cyu/rack-cors] gem "rack-cors" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.9.6" +# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] +gem "stimulus-rails" +# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] +gem "turbo-rails" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] # gem "bcrypt", "~> 3.1.7" @@ -27,9 +29,9 @@ gem "rack-cors" gem "tzinfo-data", platforms: %i[ windows jruby ] # Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cable" gem "solid_cache" gem "solid_queue" -gem "solid_cable" # Reduces boot times through caching; required in config/boot.rb gem "bootsnap", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 7ffa82e..d923c23 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -79,6 +79,7 @@ GEM public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) + bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) bigdecimal (4.1.2) bindex (0.8.1) @@ -106,6 +107,12 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + devise (5.0.4) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 7.0) + responders + warden (~> 1.2.3) dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) @@ -201,6 +208,7 @@ GEM racc (~> 1.4) nokogiri (1.19.4-x86_64-linux-musl) racc (~> 1.4) + orm_adapter (0.5.0) ostruct (0.6.3) parallel (2.1.0) parser (3.3.11.1) @@ -274,6 +282,9 @@ GEM regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) + responders (3.2.1) + actionpack (>= 7.0) + railties (>= 7.0) rexml (3.4.4) rubocop (1.88.1) json (~> 2.3) @@ -364,6 +375,8 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) + warden (1.2.9) + rack (>= 2.0.9) web-console (4.3.0) actionview (>= 8.0.0) bindex (>= 0.4.0) @@ -395,6 +408,7 @@ DEPENDENCIES bundler-audit capybara debug + devise (~> 5.0) image_processing (~> 2.0) importmap-rails jbuilder @@ -432,6 +446,7 @@ CHECKSUMS addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e @@ -445,6 +460,7 @@ CHECKSUMS crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + devise (5.0.4) sha256=d605f2b85854e74e56ee789e2d398702bc2d06e6bcd894717a670a3199c74cc1 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 @@ -493,6 +509,7 @@ CHECKSUMS nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9 ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 @@ -519,6 +536,7 @@ CHECKSUMS rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + responders (3.2.1) sha256=c2772970cbda654c0e772d852e5a851e82cd8e96f5fa958650861ba75b8ffa84 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rubocop (1.88.1) sha256=726af773d6bc169ed3ff852f3ca020b7c58d39c34e7a8d879a8e0147cc994f26 rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db @@ -555,6 +573,7 @@ CHECKSUMS unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..4756799 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,6 @@ +class User < ApplicationRecord + # Include default devise modules. Others available are: + # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable + devise :database_authenticatable, :registerable, + :recoverable, :rememberable, :validatable +end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb new file mode 100644 index 0000000..049692b --- /dev/null +++ b/config/initializers/devise.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +# Assuming you have not yet modified this file, each configuration option below +# is set to its default value. Note that some are commented out while others +# are not: uncommented lines are intended to protect your configuration from +# breaking changes in upgrades (i.e., in the event that future versions of +# Devise change the default values for those options). +# +# Use this hook to configure devise mailer, warden hooks and so forth. +# Many of these configuration options can be set straight in your model. +Devise.setup do |config| + # The secret key used by Devise. Devise uses this key to generate + # random tokens. Changing this key will render invalid all existing + # confirmation, reset password and unlock tokens in the database. + # Devise will use the `secret_key_base` as its `secret_key` + # by default. You can change it below and use your own secret key. + # config.secret_key = 'be48cc765be7e2ec33ba15d2a1fbdac62ecd68ea971d1294f8bfaf1dc502921adcbb9a42180741dfe89dccc80c2a1d9d4943cc6c288ce8a6443cb404505e22b3' + + # ==> Controller configuration + # Configure the parent class to the devise controllers. + # config.parent_controller = 'DeviseController' + + # ==> Mailer Configuration + # Configure the e-mail address which will be shown in Devise::Mailer, + # note that it will be overwritten if you use your own mailer class + # with default "from" parameter. + config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com' + + # Configure the class responsible to send e-mails. + # config.mailer = 'Devise::Mailer' + + # Configure the parent class responsible to send e-mails. + # config.parent_mailer = 'ActionMailer::Base' + + # ==> ORM configuration + # Load and configure the ORM. Supports :active_record (default) and + # :mongoid (bson_ext recommended) by default. Other ORMs may be + # available as additional gems. + require 'devise/orm/active_record' + + # ==> Configuration for any authentication mechanism + # Configure which keys are used when authenticating a user. The default is + # just :email. You can configure it to use [:username, :subdomain], so for + # authenticating a user, both parameters are required. Remember that those + # parameters are used only when authenticating and not when retrieving from + # session. If you need permissions, you should implement that in a before filter. + # You can also supply a hash where the value is a boolean determining whether + # or not authentication should be aborted when the value is not present. + # config.authentication_keys = [:email] + + # Configure parameters from the request object used for authentication. Each entry + # given should be a request method and it will automatically be passed to the + # find_for_authentication method and considered in your model lookup. For instance, + # if you set :request_keys to [:subdomain], :subdomain will be used on authentication. + # The same considerations mentioned for authentication_keys also apply to request_keys. + # config.request_keys = [] + + # Configure which authentication keys should be case-insensitive. + # These keys will be downcased upon creating or modifying a user and when used + # to authenticate or find a user. Default is :email. + config.case_insensitive_keys = [:email] + + # Configure which authentication keys should have whitespace stripped. + # These keys will have whitespace before and after removed upon creating or + # modifying a user and when used to authenticate or find a user. Default is :email. + config.strip_whitespace_keys = [:email] + + # Tell if authentication through request.params is enabled. True by default. + # It can be set to an array that will enable params authentication only for the + # given strategies, for example, `config.params_authenticatable = [:database]` will + # enable it only for database (email + password) authentication. + # config.params_authenticatable = true + + # Tell if authentication through HTTP Auth is enabled. False by default. + # It can be set to an array that will enable http authentication only for the + # given strategies, for example, `config.http_authenticatable = [:database]` will + # enable it only for database authentication. + # For API-only applications to support authentication "out-of-the-box", you will likely want to + # enable this with :database unless you are using a custom strategy. + # The supported strategies are: + # :database = Support basic authentication with authentication key + password + # config.http_authenticatable = false + + # If 401 status code should be returned for AJAX requests. True by default. + # config.http_authenticatable_on_xhr = true + + # The realm used in Http Basic Authentication. 'Application' by default. + # config.http_authentication_realm = 'Application' + + # It will change confirmation, password recovery and other workflows + # to behave the same regardless if the e-mail provided was right or wrong. + # Does not affect registerable. + # config.paranoid = true + + # By default Devise will store the user in session. You can skip storage for + # particular strategies by setting this option. + # Notice that if you are skipping storage for all authentication paths, you + # may want to disable generating routes to Devise's sessions controller by + # passing skip: :sessions to `devise_for` in your config/routes.rb + config.skip_session_storage = [:http_auth] + + # By default, Devise cleans up the CSRF token on authentication to + # avoid CSRF token fixation attacks. This means that, when using AJAX + # requests for sign in and sign up, you need to get a new CSRF token + # from the server. You can disable this option at your own risk. + # config.clean_up_csrf_token_on_authentication = true + + # When false, Devise will not attempt to reload routes on eager load. + # This can reduce the time taken to boot the app but if your application + # requires the Devise mappings to be loaded during boot time the application + # won't boot properly. + # config.reload_routes = true + + # ==> Configuration for :database_authenticatable + # For bcrypt, this is the cost for hashing the password and defaults to 12. If + # using other algorithms, it sets how many times you want the password to be hashed. + # The number of stretches used for generating the hashed password are stored + # with the hashed password. This allows you to change the stretches without + # invalidating existing passwords. + # + # Limiting the stretches to just one in testing will increase the performance of + # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use + # a value less than 10 in other environments. Note that, for bcrypt (the default + # algorithm), the cost increases exponentially with the number of stretches (e.g. + # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). + config.stretches = Rails.env.test? ? 1 : 12 + + # Set up a pepper to generate the hashed password. + # config.pepper = '127706bcb62f32c2160abb282491c2810ecf01a418769b330824734bb3a5bbf935ef806c4f977db9681c9aaa31c01e773ee372c40f94b5326690c7d9c7c37c6b' + + # Send a notification to the original email when the user's email is changed. + # config.send_email_changed_notification = false + + # Send a notification email when the user's password is changed. + # config.send_password_change_notification = false + + # ==> Configuration for :confirmable + # A period that the user is allowed to access the website even without + # confirming their account. For instance, if set to 2.days, the user will be + # able to access the website for two days without confirming their account, + # access will be blocked just in the third day. + # You can also set it to nil, which will allow the user to access the website + # without confirming their account. + # Default is 0.days, meaning the user cannot access the website without + # confirming their account. + # config.allow_unconfirmed_access_for = 2.days + + # A period that the user is allowed to confirm their account before their + # token becomes invalid. For example, if set to 3.days, the user can confirm + # their account within 3 days after the mail was sent, but on the fourth day + # their account can't be confirmed with the token any more. + # Default is nil, meaning there is no restriction on how long a user can take + # before confirming their account. + # config.confirm_within = 3.days + + # If true, requires any email changes to be confirmed (exactly the same way as + # initial account confirmation) to be applied. Requires additional unconfirmed_email + # db field (see migrations). Until confirmed, new email is stored in + # unconfirmed_email column, and copied to email column on successful confirmation. + # Also, when used in conjunction with `send_email_changed_notification`, + # the notification is sent to the original email when the change is requested, + # not when the unconfirmed email is confirmed. + config.reconfirmable = true + + # Defines which key will be used when confirming an account + # config.confirmation_keys = [:email] + + # ==> Configuration for :rememberable + # The time the user will be remembered without asking for credentials again. + # config.remember_for = 2.weeks + + # Invalidates all the remember me tokens when the user signs out. + config.expire_all_remember_me_on_sign_out = true + + # If true, extends the user's remember period when remembered via cookie. + # config.extend_remember_period = false + + # Options to be passed to the created cookie. For instance, you can set + # secure: true in order to force SSL only cookies. + # config.rememberable_options = {} + + # ==> Configuration for :validatable + # Range for password length. + config.password_length = 6..128 + + # Email regex used to validate email formats. It simply asserts that + # one (and only one) @ exists in the given string. This is mainly + # to give user feedback and not to assert the e-mail validity. + config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ + + # ==> Configuration for :timeoutable + # The time you want to timeout the user session without activity. After this + # time the user will be asked for credentials again. Default is 30 minutes. + # config.timeout_in = 30.minutes + + # ==> Configuration for :lockable + # Defines which strategy will be used to lock an account. + # :failed_attempts = Locks an account after a number of failed attempts to sign in. + # :none = No lock strategy. You should handle locking by yourself. + # config.lock_strategy = :failed_attempts + + # Defines which key will be used when locking and unlocking an account + # config.unlock_keys = [:email] + + # Defines which strategy will be used to unlock an account. + # :email = Sends an unlock link to the user email + # :time = Re-enables login after a certain amount of time (see :unlock_in below) + # :both = Enables both strategies + # :none = No unlock strategy. You should handle unlocking by yourself. + # config.unlock_strategy = :both + + # Number of authentication tries before locking an account if lock_strategy + # is failed attempts. + # config.maximum_attempts = 20 + + # Time interval to unlock the account if :time is enabled as unlock_strategy. + # config.unlock_in = 1.hour + + # Warn on the last attempt before the account is locked. + # config.last_attempt_warning = true + + # ==> Configuration for :recoverable + # + # Defines which key will be used when recovering the password for an account + # config.reset_password_keys = [:email] + + # Time interval you can reset your password with a reset password key. + # Don't put a too small interval or your users won't have the time to + # change their passwords. + config.reset_password_within = 6.hours + + # When set to false, does not sign a user in automatically after their password is + # reset. Defaults to true, so a user is signed in automatically after a reset. + # config.sign_in_after_reset_password = true + + # ==> Configuration for :encryptable + # Allow you to use another hashing or encryption algorithm besides bcrypt (default). + # You can use :sha1, :sha512 or algorithms from others authentication tools as + # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20 + # for default behavior) and :restful_authentication_sha1 (then you should set + # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper). + # + # Require the `devise-encryptable` gem when using anything other than bcrypt + # config.encryptor = :sha512 + + # ==> Scopes configuration + # Turn scoped views on. Before rendering "sessions/new", it will first check for + # "users/sessions/new". It's turned off by default because it's slower if you + # are using only default views. + # config.scoped_views = false + + # Configure the default scope given to Warden. By default it's the first + # devise role declared in your routes (usually :user). + # config.default_scope = :user + + # Set this configuration to false if you want /users/sign_out to sign out + # only the current scope. By default, Devise signs out all scopes. + # config.sign_out_all_scopes = true + + # ==> Navigation configuration + # Lists the formats that should be treated as navigational. Formats like + # :html should redirect to the sign in page when the user does not have + # access, but formats like :xml or :json, should return 401. + # + # If you have any extra navigational formats, like :iphone or :mobile, you + # should add them to the navigational formats lists. + # + # The "*/*" below is required to match Internet Explorer requests. + # config.navigational_formats = ['*/*', :html, :turbo_stream] + + # The default HTTP method used to sign out a resource. Default is :delete. + config.sign_out_via = :delete + + # ==> OmniAuth + # Add a new OmniAuth provider. Check the wiki for more information on setting + # up on your models and hooks. + # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' + + # ==> Warden configuration + # If you want to use other strategies, that are not supported by Devise, or + # change the failure app, you can configure them inside the config.warden block. + # + # config.warden do |warden_config| + # warden_config.intercept_401 = false + # warden_config.default_strategies(scope: :user).unshift :some_external_strategy + # end + + # ==> Mountable engine configurations + # When using Devise inside an engine, let's call it `MyEngine`, and this engine + # is mountable, there are some extra configurations to be taken into account. + # The following options are available, assuming the engine is mounted as: + # + # mount MyEngine, at: '/my_engine' + # + # The router that invoked `devise_for`, in the example above, would be: + # config.router_name = :my_engine + # + # When using OmniAuth, Devise cannot automatically set OmniAuth path, + # so you need to do it manually. For the users scope, it would be: + # config.omniauth_path_prefix = '/my_engine/users/auth' + + # ==> Hotwire/Turbo configuration + # When using Devise with Hotwire/Turbo, the http status for error responses + # and some redirects must match the following. The default in Devise for existing + # apps is `200 OK` and `302 Found` respectively, but new apps are generated with + # these new defaults that match Hotwire/Turbo behavior. + # Note: These might become the new default in future versions of Devise. + config.responder.error_status = :unprocessable_content + config.responder.redirect_status = :see_other + + # ==> Configuration for :registerable + + # When set to false, does not sign a user in automatically after their password is + # changed. Defaults to true, so a user is signed in automatically after changing a password. + # config.sign_in_after_change_password = true +end diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml new file mode 100644 index 0000000..260e1c4 --- /dev/null +++ b/config/locales/devise.en.yml @@ -0,0 +1,65 @@ +# Additional translations at https://github.com/heartcombo/devise/wiki/I18n + +en: + devise: + confirmations: + confirmed: "Your email address has been successfully confirmed." + send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." + failure: + already_authenticated: "You are already signed in." + inactive: "Your account is not activated yet." + invalid: "Invalid %{authentication_keys} or password." + locked: "Your account is locked." + last_attempt: "You have one more attempt before your account is locked." + not_found_in_database: "Invalid %{authentication_keys} or password." + timeout: "Your session expired. Please sign in again to continue." + unauthenticated: "You need to sign in or sign up before continuing." + unconfirmed: "You have to confirm your email address before continuing." + mailer: + confirmation_instructions: + subject: "Confirmation instructions" + reset_password_instructions: + subject: "Reset password instructions" + unlock_instructions: + subject: "Unlock instructions" + email_changed: + subject: "Email Changed" + password_change: + subject: "Password Changed" + omniauth_callbacks: + failure: "Could not authenticate you from %{kind} because \"%{reason}\"." + success: "Successfully authenticated from %{kind} account." + passwords: + no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." + send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." + updated: "Your password has been changed successfully. You are now signed in." + updated_not_active: "Your password has been changed successfully." + registrations: + destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." + signed_up: "Welcome! You have signed up successfully." + signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." + signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." + signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." + update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." + updated: "Your account has been updated successfully." + updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." + sessions: + signed_in: "Signed in successfully." + signed_out: "Signed out successfully." + already_signed_out: "Signed out successfully." + unlocks: + send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." + send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." + unlocked: "Your account has been unlocked successfully. Please sign in to continue." + errors: + messages: + already_confirmed: "was already confirmed, please try signing in" + confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" + expired: "has expired, please request a new one" + not_found: "not found" + not_locked: "was not locked" + not_saved: + one: "1 error prohibited this %{resource} from being saved:" + other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/routes.rb b/config/routes.rb index 4368cb6..742f9d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,5 @@ Rails.application.routes.draw do + devise_for :users # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # JSON API consumed by the Expo app in mobile/. diff --git a/db/migrate/20260828193338_devise_create_users.rb b/db/migrate/20260828193338_devise_create_users.rb new file mode 100644 index 0000000..1507939 --- /dev/null +++ b/db/migrate/20260828193338_devise_create_users.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class DeviseCreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + ## Database authenticatable + t.string :email, null: false, default: "" + t.string :encrypted_password, null: false, default: "" + + ## Recoverable + t.string :reset_password_token + t.datetime :reset_password_sent_at + + ## Rememberable + t.datetime :remember_created_at + + ## Trackable + # t.integer :sign_in_count, default: 0, null: false + # t.datetime :current_sign_in_at + # t.datetime :last_sign_in_at + # t.string :current_sign_in_ip + # t.string :last_sign_in_ip + + ## Confirmable + # t.string :confirmation_token + # t.datetime :confirmed_at + # t.datetime :confirmation_sent_at + # t.string :unconfirmed_email # Only if using reconfirmable + + ## Lockable + # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts + # t.string :unlock_token # Only if unlock strategy is :email or :both + # t.datetime :locked_at + + + t.timestamps null: false + end + + add_index :users, :email, unique: true + add_index :users, :reset_password_token, unique: true + # add_index :users, :confirmation_token, unique: true + # add_index :users, :unlock_token, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 8e5e9b6..510f41e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,23 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_02_160512) do +ActiveRecord::Schema[8.1].define(version: 2026_08_28_193338) do create_table "tasks", force: :cascade do |t| t.boolean "completed", default: false, null: false t.datetime "created_at", null: false t.string "title", null: false t.datetime "updated_at", null: false end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.datetime "remember_created_at" + t.datetime "reset_password_sent_at" + t.string "reset_password_token" + t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true + end end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..5c07f49 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From 9a7998fa07da02c26af98dcfe758615a2ee9bb27 Mon Sep 17 00:00:00 2001 From: fiona mgharo Date: Fri, 28 Aug 2026 13:19:50 -0700 Subject: [PATCH 04/25] added horses to homepage --- app/assets/images/horse.jpg | Bin 0 -> 56702 bytes app/views/home/index.html.erb | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 app/assets/images/horse.jpg diff --git a/app/assets/images/horse.jpg b/app/assets/images/horse.jpg new file mode 100644 index 0000000000000000000000000000000000000000..201e1bdf0e3b3234221392cb66085c5a1f07a665 GIT binary patch literal 56702 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 Q@ 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 -

Hi

\ No newline at end of file +

Hi

+<%= image_tag "horse.jpg", alt: "Horse", class: "horse-image" %> \ No newline at end of file From 74e90a5bf6b1734eddd96e55d0a5b03508cbf213 Mon Sep 17 00:00:00 2001 From: Johncarlo Cerna Date: Fri, 28 Aug 2026 16:22:50 -0400 Subject: [PATCH 05/25] Fix linting on devise initializer --- config/initializers/devise.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 049692b..b67ddd2 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -24,7 +24,7 @@ # Configure the e-mail address which will be shown in Devise::Mailer, # note that it will be overwritten if you use your own mailer class # with default "from" parameter. - config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com' + config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com" # Configure the class responsible to send e-mails. # config.mailer = 'Devise::Mailer' @@ -36,7 +36,7 @@ # Load and configure the ORM. Supports :active_record (default) and # :mongoid (bson_ext recommended) by default. Other ORMs may be # available as additional gems. - require 'devise/orm/active_record' + require "devise/orm/active_record" # ==> Configuration for any authentication mechanism # Configure which keys are used when authenticating a user. The default is @@ -58,12 +58,12 @@ # Configure which authentication keys should be case-insensitive. # These keys will be downcased upon creating or modifying a user and when used # to authenticate or find a user. Default is :email. - config.case_insensitive_keys = [:email] + config.case_insensitive_keys = [ :email ] # Configure which authentication keys should have whitespace stripped. # These keys will have whitespace before and after removed upon creating or # modifying a user and when used to authenticate or find a user. Default is :email. - config.strip_whitespace_keys = [:email] + config.strip_whitespace_keys = [ :email ] # Tell if authentication through request.params is enabled. True by default. # It can be set to an array that will enable params authentication only for the @@ -97,7 +97,7 @@ # Notice that if you are skipping storage for all authentication paths, you # may want to disable generating routes to Devise's sessions controller by # passing skip: :sessions to `devise_for` in your config/routes.rb - config.skip_session_storage = [:http_auth] + config.skip_session_storage = [ :http_auth ] # By default, Devise cleans up the CSRF token on authentication to # avoid CSRF token fixation attacks. This means that, when using AJAX From 40abeddc3b0f7c4b10551e83d75957fd655fa131 Mon Sep 17 00:00:00 2001 From: Jim Nanney Date: Fri, 28 Aug 2026 14:39:56 -0400 Subject: [PATCH 06/25] basic scaffold --- app/controllers/people_controller.rb | 70 ++++++++++++++++++++++ app/helpers/people_helper.rb | 2 + app/models/person.rb | 3 + app/views/people/_form.html.erb | 47 +++++++++++++++ app/views/people/_person.html.erb | 32 ++++++++++ app/views/people/_person.json.jbuilder | 2 + app/views/people/edit.html.erb | 12 ++++ app/views/people/index.html.erb | 16 +++++ app/views/people/index.json.jbuilder | 1 + app/views/people/new.html.erb | 11 ++++ app/views/people/show.html.erb | 10 ++++ app/views/people/show.json.jbuilder | 1 + config/routes.rb | 1 + db/migrate/20260828183715_create_people.rb | 14 +++++ db/schema.rb | 16 ++++- test/controllers/people_controller_test.rb | 48 +++++++++++++++ test/fixtures/people.yml | 17 ++++++ test/models/person_test.rb | 7 +++ 18 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 app/controllers/people_controller.rb create mode 100644 app/helpers/people_helper.rb create mode 100644 app/models/person.rb create mode 100644 app/views/people/_form.html.erb create mode 100644 app/views/people/_person.html.erb create mode 100644 app/views/people/_person.json.jbuilder create mode 100644 app/views/people/edit.html.erb create mode 100644 app/views/people/index.html.erb create mode 100644 app/views/people/index.json.jbuilder create mode 100644 app/views/people/new.html.erb create mode 100644 app/views/people/show.html.erb create mode 100644 app/views/people/show.json.jbuilder create mode 100644 db/migrate/20260828183715_create_people.rb create mode 100644 test/controllers/people_controller_test.rb create mode 100644 test/fixtures/people.yml create mode 100644 test/models/person_test.rb diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb new file mode 100644 index 0000000..f68035c --- /dev/null +++ b/app/controllers/people_controller.rb @@ -0,0 +1,70 @@ +class PeopleController < ApplicationController + before_action :set_person, only: %i[ show edit update destroy ] + + # GET /people or /people.json + def index + @people = Person.all + end + + # GET /people/1 or /people/1.json + def show + end + + # GET /people/new + def new + @person = Person.new + end + + # GET /people/1/edit + def edit + end + + # POST /people or /people.json + def create + @person = Person.new(person_params) + + respond_to do |format| + if @person.save + format.html { redirect_to @person, notice: "Person was successfully created." } + format.json { render :show, status: :created, location: @person } + else + format.html { render :new, status: :unprocessable_content } + format.json { render json: @person.errors, status: :unprocessable_content } + end + end + end + + # PATCH/PUT /people/1 or /people/1.json + def update + respond_to do |format| + if @person.update(person_params) + format.html { redirect_to @person, notice: "Person was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: @person } + else + format.html { render :edit, status: :unprocessable_content } + format.json { render json: @person.errors, status: :unprocessable_content } + end + end + end + + # DELETE /people/1 or /people/1.json + def destroy + @person.destroy! + + respond_to do |format| + format.html { redirect_to people_path, notice: "Person 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_person + @person = Person.find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def person_params + params.expect(person: [ :first_name, :last_name, :date_of_birth, :social_security_number, :email, :relationship_id ]) + end +end diff --git a/app/helpers/people_helper.rb b/app/helpers/people_helper.rb new file mode 100644 index 0000000..b682fbf --- /dev/null +++ b/app/helpers/people_helper.rb @@ -0,0 +1,2 @@ +module PeopleHelper +end diff --git a/app/models/person.rb b/app/models/person.rb new file mode 100644 index 0000000..9791ac6 --- /dev/null +++ b/app/models/person.rb @@ -0,0 +1,3 @@ +class Person < ApplicationRecord + belongs_to :relationship +end diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb new file mode 100644 index 0000000..0d19245 --- /dev/null +++ b/app/views/people/_form.html.erb @@ -0,0 +1,47 @@ +<%= form_with(model: person) do |form| %> + <% if person.errors.any? %> +
+

<%= pluralize(person.errors.count, "error") %> prohibited this person from being saved:

+ +
    + <% person.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :first_name, style: "display: block" %> + <%= form.text_field :first_name %> +
+ +
+ <%= form.label :last_name, style: "display: block" %> + <%= form.text_field :last_name %> +
+ +
+ <%= form.label :date_of_birth, style: "display: block" %> + <%= form.date_field :date_of_birth %> +
+ +
+ <%= form.label :social_security_number, style: "display: block" %> + <%= form.text_field :social_security_number %> +
+ +
+ <%= form.label :email, style: "display: block" %> + <%= form.text_field :email %> +
+ +
+ <%= form.label :relationship_id, style: "display: block" %> + <%= form.text_field :relationship_id %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/people/_person.html.erb b/app/views/people/_person.html.erb new file mode 100644 index 0000000..36f544d --- /dev/null +++ b/app/views/people/_person.html.erb @@ -0,0 +1,32 @@ +
+
+ First name: + <%= person.first_name %> +
+ +
+ Last name: + <%= person.last_name %> +
+ +
+ Date of birth: + <%= person.date_of_birth %> +
+ +
+ Social security number: + <%= person.social_security_number %> +
+ +
+ Email: + <%= person.email %> +
+ +
+ Relationship: + <%= person.relationship_id %> +
+ +
diff --git a/app/views/people/_person.json.jbuilder b/app/views/people/_person.json.jbuilder new file mode 100644 index 0000000..95ae139 --- /dev/null +++ b/app/views/people/_person.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! person, :id, :first_name, :last_name, :date_of_birth, :social_security_number, :email, :relationship_id, :created_at, :updated_at +json.url person_url(person, format: :json) diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb new file mode 100644 index 0000000..82fbae5 --- /dev/null +++ b/app/views/people/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing person" %> + +

Editing person

+ +<%= render "form", person: @person %> + +
+ +
+ <%= link_to "Show this person", @person %> | + <%= link_to "Back to people", people_path %> +
diff --git a/app/views/people/index.html.erb b/app/views/people/index.html.erb new file mode 100644 index 0000000..c4611fb --- /dev/null +++ b/app/views/people/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "People" %> + +

People

+ +
+ <% @people.each do |person| %> + <%= render person %> +

+ <%= link_to "Show this person", person %> +

+ <% end %> +
+ +<%= link_to "New person", new_person_path %> diff --git a/app/views/people/index.json.jbuilder b/app/views/people/index.json.jbuilder new file mode 100644 index 0000000..14d7899 --- /dev/null +++ b/app/views/people/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @people, partial: "people/person", as: :person diff --git a/app/views/people/new.html.erb b/app/views/people/new.html.erb new file mode 100644 index 0000000..d26f879 --- /dev/null +++ b/app/views/people/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New person" %> + +

New person

+ +<%= render "form", person: @person %> + +
+ +
+ <%= link_to "Back to people", people_path %> +
diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb new file mode 100644 index 0000000..5fa7251 --- /dev/null +++ b/app/views/people/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @person %> + +
+ <%= link_to "Edit this person", edit_person_path(@person) %> | + <%= link_to "Back to people", people_path %> + + <%= button_to "Destroy this person", @person, method: :delete %> +
diff --git a/app/views/people/show.json.jbuilder b/app/views/people/show.json.jbuilder new file mode 100644 index 0000000..cf0f817 --- /dev/null +++ b/app/views/people/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "people/person", person: @person diff --git a/config/routes.rb b/config/routes.rb index 8410215..fc2af80 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,5 @@ Rails.application.routes.draw do + resources :people # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # JSON API consumed by the Expo app in mobile/. diff --git a/db/migrate/20260828183715_create_people.rb b/db/migrate/20260828183715_create_people.rb new file mode 100644 index 0000000..159c2c8 --- /dev/null +++ b/db/migrate/20260828183715_create_people.rb @@ -0,0 +1,14 @@ +class CreatePeople < ActiveRecord::Migration[8.1] + def change + create_table :people do |t| + t.string :first_name + t.string :last_name + t.date :date_of_birth + t.string :social_security_number + t.string :email + t.belongs_to :relationship, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 8e5e9b6..f16e994 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,25 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_02_160512) do +ActiveRecord::Schema[8.1].define(version: 2026_08_28_183715) do + create_table "people", force: :cascade do |t| + t.datetime "created_at", null: false + t.date "date_of_birth" + t.string "email" + t.string "first_name" + t.string "last_name" + t.integer "relationship_id", null: false + t.string "social_security_number" + t.datetime "updated_at", null: false + t.index ["relationship_id"], name: "index_people_on_relationship_id" + end + create_table "tasks", force: :cascade do |t| t.boolean "completed", default: false, null: false t.datetime "created_at", null: false t.string "title", null: false t.datetime "updated_at", null: false end + + add_foreign_key "people", "relationships" end diff --git a/test/controllers/people_controller_test.rb b/test/controllers/people_controller_test.rb new file mode 100644 index 0000000..3dfc530 --- /dev/null +++ b/test/controllers/people_controller_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class PeopleControllerTest < ActionDispatch::IntegrationTest + setup do + @person = people(:one) + end + + test "should get index" do + get people_url + assert_response :success + end + + test "should get new" do + get new_person_url + assert_response :success + end + + test "should create person" do + assert_difference("Person.count") do + post people_url, params: { person: { date_of_birth: @person.date_of_birth, email: @person.email, first_name: @person.first_name, last_name: @person.last_name, relationship_id: @person.relationship_id, social_security_number: @person.social_security_number } } + end + + assert_redirected_to person_url(Person.last) + end + + test "should show person" do + get person_url(@person) + assert_response :success + end + + test "should get edit" do + get edit_person_url(@person) + assert_response :success + end + + test "should update person" do + patch person_url(@person), params: { person: { date_of_birth: @person.date_of_birth, email: @person.email, first_name: @person.first_name, last_name: @person.last_name, relationship_id: @person.relationship_id, social_security_number: @person.social_security_number } } + assert_redirected_to person_url(@person) + end + + test "should destroy person" do + assert_difference("Person.count", -1) do + delete person_url(@person) + end + + assert_redirected_to people_url + end +end diff --git a/test/fixtures/people.yml b/test/fixtures/people.yml new file mode 100644 index 0000000..a6957bc --- /dev/null +++ b/test/fixtures/people.yml @@ -0,0 +1,17 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + first_name: MyString + last_name: MyString + date_of_birth: 2026-08-28 + social_security_number: MyString + email: MyString + relationship: one + +two: + first_name: MyString + last_name: MyString + date_of_birth: 2026-08-28 + social_security_number: MyString + email: MyString + relationship: two diff --git a/test/models/person_test.rb b/test/models/person_test.rb new file mode 100644 index 0000000..357c9af --- /dev/null +++ b/test/models/person_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class PersonTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From 24c412a0ef0ac0641847a131598e09b9e9d58c91 Mon Sep 17 00:00:00 2001 From: Jim Nanney Date: Fri, 28 Aug 2026 14:47:10 -0400 Subject: [PATCH 07/25] medication type, medication models --- .../medication_types_controller.rb | 70 +++++++++++++++++ app/controllers/medications_controller.rb | 70 +++++++++++++++++ app/helpers/medication_types_helper.rb | 2 + app/helpers/medications_helper.rb | 2 + app/models/medication.rb | 3 + app/models/medication_type.rb | 2 + app/models/relationship.rb | 2 + app/views/medication_types/_form.html.erb | 22 ++++++ .../_medication_type.html.erb | 7 ++ .../_medication_type.json.jbuilder | 2 + app/views/medication_types/edit.html.erb | 12 +++ app/views/medication_types/index.html.erb | 16 ++++ .../medication_types/index.json.jbuilder | 1 + app/views/medication_types/new.html.erb | 11 +++ app/views/medication_types/show.html.erb | 10 +++ app/views/medication_types/show.json.jbuilder | 1 + app/views/medications/_form.html.erb | 77 +++++++++++++++++++ app/views/medications/_medication.html.erb | 62 +++++++++++++++ .../medications/_medication.json.jbuilder | 2 + app/views/medications/edit.html.erb | 12 +++ app/views/medications/index.html.erb | 16 ++++ app/views/medications/index.json.jbuilder | 1 + app/views/medications/new.html.erb | 11 +++ app/views/medications/show.html.erb | 10 +++ app/views/medications/show.json.jbuilder | 1 + config/routes.rb | 2 + .../20260828184216_create_medication_types.rb | 9 +++ .../20260829113658_create_medications.rb | 20 +++++ .../20260829113836_create_relationships.rb | 9 +++ db/schema.rb | 33 +++++++- .../medication_types_controller_test.rb | 48 ++++++++++++ .../medications_controller_test.rb | 48 ++++++++++++ test/fixtures/medication_types.yml | 7 ++ test/fixtures/medications.yml | 29 +++++++ test/fixtures/relationships.yml | 7 ++ test/models/medication_test.rb | 7 ++ test/models/medication_type_test.rb | 7 ++ test/models/relationship_test.rb | 7 ++ 38 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 app/controllers/medication_types_controller.rb create mode 100644 app/controllers/medications_controller.rb create mode 100644 app/helpers/medication_types_helper.rb create mode 100644 app/helpers/medications_helper.rb create mode 100644 app/models/medication.rb create mode 100644 app/models/medication_type.rb create mode 100644 app/models/relationship.rb create mode 100644 app/views/medication_types/_form.html.erb create mode 100644 app/views/medication_types/_medication_type.html.erb create mode 100644 app/views/medication_types/_medication_type.json.jbuilder create mode 100644 app/views/medication_types/edit.html.erb create mode 100644 app/views/medication_types/index.html.erb create mode 100644 app/views/medication_types/index.json.jbuilder create mode 100644 app/views/medication_types/new.html.erb create mode 100644 app/views/medication_types/show.html.erb create mode 100644 app/views/medication_types/show.json.jbuilder create mode 100644 app/views/medications/_form.html.erb create mode 100644 app/views/medications/_medication.html.erb create mode 100644 app/views/medications/_medication.json.jbuilder create mode 100644 app/views/medications/edit.html.erb create mode 100644 app/views/medications/index.html.erb create mode 100644 app/views/medications/index.json.jbuilder create mode 100644 app/views/medications/new.html.erb create mode 100644 app/views/medications/show.html.erb create mode 100644 app/views/medications/show.json.jbuilder create mode 100644 db/migrate/20260828184216_create_medication_types.rb create mode 100644 db/migrate/20260829113658_create_medications.rb create mode 100644 db/migrate/20260829113836_create_relationships.rb create mode 100644 test/controllers/medication_types_controller_test.rb create mode 100644 test/controllers/medications_controller_test.rb create mode 100644 test/fixtures/medication_types.yml create mode 100644 test/fixtures/medications.yml create mode 100644 test/fixtures/relationships.yml create mode 100644 test/models/medication_test.rb create mode 100644 test/models/medication_type_test.rb create mode 100644 test/models/relationship_test.rb diff --git a/app/controllers/medication_types_controller.rb b/app/controllers/medication_types_controller.rb new file mode 100644 index 0000000..7bae334 --- /dev/null +++ b/app/controllers/medication_types_controller.rb @@ -0,0 +1,70 @@ +class MedicationTypesController < ApplicationController + before_action :set_medication_type, only: %i[ show edit update destroy ] + + # GET /medication_types or /medication_types.json + def index + @medication_types = MedicationType.all + end + + # GET /medication_types/1 or /medication_types/1.json + def show + end + + # GET /medication_types/new + def new + @medication_type = MedicationType.new + end + + # GET /medication_types/1/edit + def edit + end + + # POST /medication_types or /medication_types.json + def create + @medication_type = MedicationType.new(medication_type_params) + + respond_to do |format| + if @medication_type.save + format.html { redirect_to @medication_type, notice: "Medication type was successfully created." } + format.json { render :show, status: :created, location: @medication_type } + else + format.html { render :new, status: :unprocessable_content } + format.json { render json: @medication_type.errors, status: :unprocessable_content } + end + end + end + + # PATCH/PUT /medication_types/1 or /medication_types/1.json + def update + respond_to do |format| + if @medication_type.update(medication_type_params) + format.html { redirect_to @medication_type, notice: "Medication type was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: @medication_type } + else + format.html { render :edit, status: :unprocessable_content } + format.json { render json: @medication_type.errors, status: :unprocessable_content } + end + end + end + + # DELETE /medication_types/1 or /medication_types/1.json + def destroy + @medication_type.destroy! + + respond_to do |format| + format.html { redirect_to medication_types_path, notice: "Medication 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_medication_type + @medication_type = MedicationType.find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def medication_type_params + params.expect(medication_type: [ :name ]) + end +end diff --git a/app/controllers/medications_controller.rb b/app/controllers/medications_controller.rb new file mode 100644 index 0000000..804f765 --- /dev/null +++ b/app/controllers/medications_controller.rb @@ -0,0 +1,70 @@ +class MedicationsController < ApplicationController + before_action :set_medication, only: %i[ show edit update destroy ] + + # GET /medications or /medications.json + def index + @medications = Medication.all + end + + # GET /medications/1 or /medications/1.json + def show + end + + # GET /medications/new + def new + @medication = Medication.new + end + + # GET /medications/1/edit + def edit + end + + # POST /medications or /medications.json + def create + @medication = Medication.new(medication_params) + + respond_to do |format| + if @medication.save + format.html { redirect_to @medication, notice: "Medication was successfully created." } + format.json { render :show, status: :created, location: @medication } + else + format.html { render :new, status: :unprocessable_content } + format.json { render json: @medication.errors, status: :unprocessable_content } + end + end + end + + # PATCH/PUT /medications/1 or /medications/1.json + def update + respond_to do |format| + if @medication.update(medication_params) + format.html { redirect_to @medication, notice: "Medication was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: @medication } + else + format.html { render :edit, status: :unprocessable_content } + format.json { render json: @medication.errors, status: :unprocessable_content } + end + end + end + + # DELETE /medications/1 or /medications/1.json + def destroy + @medication.destroy! + + respond_to do |format| + format.html { redirect_to medications_path, notice: "Medication 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_medication + @medication = Medication.find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def medication_params + params.expect(medication: [ :name, :medication_type_id, :current, :dosage, :frequency, :time_of_day, :form, :purpose, :start_date, :stop_date, :refill, :notes ]) + end +end diff --git a/app/helpers/medication_types_helper.rb b/app/helpers/medication_types_helper.rb new file mode 100644 index 0000000..84a67f0 --- /dev/null +++ b/app/helpers/medication_types_helper.rb @@ -0,0 +1,2 @@ +module MedicationTypesHelper +end diff --git a/app/helpers/medications_helper.rb b/app/helpers/medications_helper.rb new file mode 100644 index 0000000..1cca854 --- /dev/null +++ b/app/helpers/medications_helper.rb @@ -0,0 +1,2 @@ +module MedicationsHelper +end diff --git a/app/models/medication.rb b/app/models/medication.rb new file mode 100644 index 0000000..3527fc1 --- /dev/null +++ b/app/models/medication.rb @@ -0,0 +1,3 @@ +class Medication < ApplicationRecord + belongs_to :medication_type +end diff --git a/app/models/medication_type.rb b/app/models/medication_type.rb new file mode 100644 index 0000000..548d639 --- /dev/null +++ b/app/models/medication_type.rb @@ -0,0 +1,2 @@ +class MedicationType < ApplicationRecord +end diff --git a/app/models/relationship.rb b/app/models/relationship.rb new file mode 100644 index 0000000..3cabc44 --- /dev/null +++ b/app/models/relationship.rb @@ -0,0 +1,2 @@ +class Relationship < ApplicationRecord +end diff --git a/app/views/medication_types/_form.html.erb b/app/views/medication_types/_form.html.erb new file mode 100644 index 0000000..3fd2f7b --- /dev/null +++ b/app/views/medication_types/_form.html.erb @@ -0,0 +1,22 @@ +<%= form_with(model: medication_type) do |form| %> + <% if medication_type.errors.any? %> +
+

<%= pluralize(medication_type.errors.count, "error") %> prohibited this medication_type from being saved:

+ +
    + <% medication_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/medication_types/_medication_type.html.erb b/app/views/medication_types/_medication_type.html.erb new file mode 100644 index 0000000..6b97951 --- /dev/null +++ b/app/views/medication_types/_medication_type.html.erb @@ -0,0 +1,7 @@ +
+
+ Name: + <%= medication_type.name %> +
+ +
diff --git a/app/views/medication_types/_medication_type.json.jbuilder b/app/views/medication_types/_medication_type.json.jbuilder new file mode 100644 index 0000000..b8273fa --- /dev/null +++ b/app/views/medication_types/_medication_type.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! medication_type, :id, :name, :created_at, :updated_at +json.url medication_type_url(medication_type, format: :json) diff --git a/app/views/medication_types/edit.html.erb b/app/views/medication_types/edit.html.erb new file mode 100644 index 0000000..de94f6c --- /dev/null +++ b/app/views/medication_types/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing medication type" %> + +

Editing medication type

+ +<%= render "form", medication_type: @medication_type %> + +
+ +
+ <%= link_to "Show this medication type", @medication_type %> | + <%= link_to "Back to medication types", medication_types_path %> +
diff --git a/app/views/medication_types/index.html.erb b/app/views/medication_types/index.html.erb new file mode 100644 index 0000000..ef7c687 --- /dev/null +++ b/app/views/medication_types/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Medication types" %> + +

Medication types

+ +
+ <% @medication_types.each do |medication_type| %> + <%= render medication_type %> +

+ <%= link_to "Show this medication type", medication_type %> +

+ <% end %> +
+ +<%= link_to "New medication type", new_medication_type_path %> diff --git a/app/views/medication_types/index.json.jbuilder b/app/views/medication_types/index.json.jbuilder new file mode 100644 index 0000000..699339e --- /dev/null +++ b/app/views/medication_types/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @medication_types, partial: "medication_types/medication_type", as: :medication_type diff --git a/app/views/medication_types/new.html.erb b/app/views/medication_types/new.html.erb new file mode 100644 index 0000000..14c0467 --- /dev/null +++ b/app/views/medication_types/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New medication type" %> + +

New medication type

+ +<%= render "form", medication_type: @medication_type %> + +
+ +
+ <%= link_to "Back to medication types", medication_types_path %> +
diff --git a/app/views/medication_types/show.html.erb b/app/views/medication_types/show.html.erb new file mode 100644 index 0000000..7ec776e --- /dev/null +++ b/app/views/medication_types/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @medication_type %> + +
+ <%= link_to "Edit this medication type", edit_medication_type_path(@medication_type) %> | + <%= link_to "Back to medication types", medication_types_path %> + + <%= button_to "Destroy this medication type", @medication_type, method: :delete %> +
diff --git a/app/views/medication_types/show.json.jbuilder b/app/views/medication_types/show.json.jbuilder new file mode 100644 index 0000000..b175477 --- /dev/null +++ b/app/views/medication_types/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "medication_types/medication_type", medication_type: @medication_type diff --git a/app/views/medications/_form.html.erb b/app/views/medications/_form.html.erb new file mode 100644 index 0000000..678d8b6 --- /dev/null +++ b/app/views/medications/_form.html.erb @@ -0,0 +1,77 @@ +<%= form_with(model: medication) do |form| %> + <% if medication.errors.any? %> +
+

<%= pluralize(medication.errors.count, "error") %> prohibited this medication from being saved:

+ +
    + <% medication.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :name, style: "display: block" %> + <%= form.text_field :name %> +
+ +
+ <%= form.label :medication_type_id, style: "display: block" %> + <%= form.text_field :medication_type_id %> +
+ +
+ <%= form.label :current, style: "display: block" %> + <%= form.checkbox :current %> +
+ +
+ <%= form.label :dosage, style: "display: block" %> + <%= form.text_field :dosage %> +
+ +
+ <%= form.label :frequency, style: "display: block" %> + <%= form.text_field :frequency %> +
+ +
+ <%= form.label :time_of_day, style: "display: block" %> + <%= form.text_field :time_of_day %> +
+ +
+ <%= form.label :form, style: "display: block" %> + <%= form.text_field :form %> +
+ +
+ <%= form.label :purpose, style: "display: block" %> + <%= form.textarea :purpose %> +
+ +
+ <%= form.label :start_date, style: "display: block" %> + <%= form.date_field :start_date %> +
+ +
+ <%= form.label :stop_date, style: "display: block" %> + <%= form.date_field :stop_date %> +
+ +
+ <%= form.label :refill, style: "display: block" %> + <%= form.text_field :refill %> +
+ +
+ <%= form.label :notes, style: "display: block" %> + <%= form.textarea :notes %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/medications/_medication.html.erb b/app/views/medications/_medication.html.erb new file mode 100644 index 0000000..9d4de65 --- /dev/null +++ b/app/views/medications/_medication.html.erb @@ -0,0 +1,62 @@ +
+
+ Name: + <%= medication.name %> +
+ +
+ Medication type: + <%= medication.medication_type_id %> +
+ +
+ Current: + <%= medication.current %> +
+ +
+ Dosage: + <%= medication.dosage %> +
+ +
+ Frequency: + <%= medication.frequency %> +
+ +
+ Time of day: + <%= medication.time_of_day %> +
+ +
+ Form: + <%= medication.form %> +
+ +
+ Purpose: + <%= medication.purpose %> +
+ +
+ Start date: + <%= medication.start_date %> +
+ +
+ Stop date: + <%= medication.stop_date %> +
+ +
+ Refill: + <%= medication.refill %> +
+ +
+ Notes: + <%= medication.notes %> +
+ +
diff --git a/app/views/medications/_medication.json.jbuilder b/app/views/medications/_medication.json.jbuilder new file mode 100644 index 0000000..7035a68 --- /dev/null +++ b/app/views/medications/_medication.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! medication, :id, :name, :medication_type_id, :current, :dosage, :frequency, :time_of_day, :form, :purpose, :start_date, :stop_date, :refill, :notes, :created_at, :updated_at +json.url medication_url(medication, format: :json) diff --git a/app/views/medications/edit.html.erb b/app/views/medications/edit.html.erb new file mode 100644 index 0000000..501cf0d --- /dev/null +++ b/app/views/medications/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing medication" %> + +

Editing medication

+ +<%= render "form", medication: @medication %> + +
+ +
+ <%= link_to "Show this medication", @medication %> | + <%= link_to "Back to medications", medications_path %> +
diff --git a/app/views/medications/index.html.erb b/app/views/medications/index.html.erb new file mode 100644 index 0000000..af091f8 --- /dev/null +++ b/app/views/medications/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Medications" %> + +

Medications

+ +
+ <% @medications.each do |medication| %> + <%= render medication %> +

+ <%= link_to "Show this medication", medication %> +

+ <% end %> +
+ +<%= link_to "New medication", new_medication_path %> diff --git a/app/views/medications/index.json.jbuilder b/app/views/medications/index.json.jbuilder new file mode 100644 index 0000000..fa6c129 --- /dev/null +++ b/app/views/medications/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @medications, partial: "medications/medication", as: :medication diff --git a/app/views/medications/new.html.erb b/app/views/medications/new.html.erb new file mode 100644 index 0000000..64c6d1c --- /dev/null +++ b/app/views/medications/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New medication" %> + +

New medication

+ +<%= render "form", medication: @medication %> + +
+ +
+ <%= link_to "Back to medications", medications_path %> +
diff --git a/app/views/medications/show.html.erb b/app/views/medications/show.html.erb new file mode 100644 index 0000000..603914f --- /dev/null +++ b/app/views/medications/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @medication %> + +
+ <%= link_to "Edit this medication", edit_medication_path(@medication) %> | + <%= link_to "Back to medications", medications_path %> + + <%= button_to "Destroy this medication", @medication, method: :delete %> +
diff --git a/app/views/medications/show.json.jbuilder b/app/views/medications/show.json.jbuilder new file mode 100644 index 0000000..b2ae681 --- /dev/null +++ b/app/views/medications/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "medications/medication", medication: @medication diff --git a/config/routes.rb b/config/routes.rb index fc2af80..c44d5cf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + resources :medications + resources :medication_types resources :people # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html diff --git a/db/migrate/20260828184216_create_medication_types.rb b/db/migrate/20260828184216_create_medication_types.rb new file mode 100644 index 0000000..9a71d5d --- /dev/null +++ b/db/migrate/20260828184216_create_medication_types.rb @@ -0,0 +1,9 @@ +class CreateMedicationTypes < ActiveRecord::Migration[8.1] + def change + create_table :medication_types do |t| + t.string :name + + t.timestamps + end + end +end diff --git a/db/migrate/20260829113658_create_medications.rb b/db/migrate/20260829113658_create_medications.rb new file mode 100644 index 0000000..613848b --- /dev/null +++ b/db/migrate/20260829113658_create_medications.rb @@ -0,0 +1,20 @@ +class CreateMedications < ActiveRecord::Migration[8.1] + def change + create_table :medications do |t| + t.string :name + t.belongs_to :medication_type, null: false, foreign_key: true + t.boolean :current + t.string :dosage + t.string :frequency + t.string :time_of_day + t.string :form + t.text :purpose + t.date :start_date + t.date :stop_date + t.string :refill + t.text :notes + + t.timestamps + end + end +end diff --git a/db/migrate/20260829113836_create_relationships.rb b/db/migrate/20260829113836_create_relationships.rb new file mode 100644 index 0000000..e81702b --- /dev/null +++ b/db/migrate/20260829113836_create_relationships.rb @@ -0,0 +1,9 @@ +class CreateRelationships < ActiveRecord::Migration[8.1] + def change + create_table :relationships do |t| + t.string :name + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index f16e994..59f3b10 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,31 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_28_183715) do +ActiveRecord::Schema[8.1].define(version: 2026_08_29_113836) do + create_table "medication_types", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name" + t.datetime "updated_at", null: false + end + + create_table "medications", force: :cascade do |t| + t.datetime "created_at", null: false + t.boolean "current" + t.string "dosage" + t.string "form" + t.string "frequency" + t.integer "medication_type_id", null: false + t.string "name" + t.text "notes" + t.text "purpose" + t.string "refill" + t.date "start_date" + t.date "stop_date" + t.string "time_of_day" + t.datetime "updated_at", null: false + t.index ["medication_type_id"], name: "index_medications_on_medication_type_id" + end + create_table "people", force: :cascade do |t| t.datetime "created_at", null: false t.date "date_of_birth" @@ -23,6 +47,12 @@ t.index ["relationship_id"], name: "index_people_on_relationship_id" end + create_table "relationships", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name" + t.datetime "updated_at", null: false + end + create_table "tasks", force: :cascade do |t| t.boolean "completed", default: false, null: false t.datetime "created_at", null: false @@ -30,5 +60,6 @@ t.datetime "updated_at", null: false end + add_foreign_key "medications", "medication_types" add_foreign_key "people", "relationships" end diff --git a/test/controllers/medication_types_controller_test.rb b/test/controllers/medication_types_controller_test.rb new file mode 100644 index 0000000..684c1a4 --- /dev/null +++ b/test/controllers/medication_types_controller_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class MedicationTypesControllerTest < ActionDispatch::IntegrationTest + setup do + @medication_type = medication_types(:one) + end + + test "should get index" do + get medication_types_url + assert_response :success + end + + test "should get new" do + get new_medication_type_url + assert_response :success + end + + test "should create medication_type" do + assert_difference("MedicationType.count") do + post medication_types_url, params: { medication_type: { name: @medication_type.name } } + end + + assert_redirected_to medication_type_url(MedicationType.last) + end + + test "should show medication_type" do + get medication_type_url(@medication_type) + assert_response :success + end + + test "should get edit" do + get edit_medication_type_url(@medication_type) + assert_response :success + end + + test "should update medication_type" do + patch medication_type_url(@medication_type), params: { medication_type: { name: @medication_type.name } } + assert_redirected_to medication_type_url(@medication_type) + end + + test "should destroy medication_type" do + assert_difference("MedicationType.count", -1) do + delete medication_type_url(@medication_type) + end + + assert_redirected_to medication_types_url + end +end diff --git a/test/controllers/medications_controller_test.rb b/test/controllers/medications_controller_test.rb new file mode 100644 index 0000000..58f7f29 --- /dev/null +++ b/test/controllers/medications_controller_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class MedicationsControllerTest < ActionDispatch::IntegrationTest + setup do + @medication = medications(:one) + end + + test "should get index" do + get medications_url + assert_response :success + end + + test "should get new" do + get new_medication_url + assert_response :success + end + + test "should create medication" do + assert_difference("Medication.count") do + post medications_url, params: { medication: { current: @medication.current, dosage: @medication.dosage, form: @medication.form, frequency: @medication.frequency, medication_type_id: @medication.medication_type_id, name: @medication.name, notes: @medication.notes, purpose: @medication.purpose, refill: @medication.refill, start_date: @medication.start_date, stop_date: @medication.stop_date, time_of_day: @medication.time_of_day } } + end + + assert_redirected_to medication_url(Medication.last) + end + + test "should show medication" do + get medication_url(@medication) + assert_response :success + end + + test "should get edit" do + get edit_medication_url(@medication) + assert_response :success + end + + test "should update medication" do + patch medication_url(@medication), params: { medication: { current: @medication.current, dosage: @medication.dosage, form: @medication.form, frequency: @medication.frequency, medication_type_id: @medication.medication_type_id, name: @medication.name, notes: @medication.notes, purpose: @medication.purpose, refill: @medication.refill, start_date: @medication.start_date, stop_date: @medication.stop_date, time_of_day: @medication.time_of_day } } + assert_redirected_to medication_url(@medication) + end + + test "should destroy medication" do + assert_difference("Medication.count", -1) do + delete medication_url(@medication) + end + + assert_redirected_to medications_url + end +end diff --git a/test/fixtures/medication_types.yml b/test/fixtures/medication_types.yml new file mode 100644 index 0000000..7d41224 --- /dev/null +++ b/test/fixtures/medication_types.yml @@ -0,0 +1,7 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + +two: + name: MyString diff --git a/test/fixtures/medications.yml b/test/fixtures/medications.yml new file mode 100644 index 0000000..2487777 --- /dev/null +++ b/test/fixtures/medications.yml @@ -0,0 +1,29 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + medication_type: two + current: false + dosage: MyString + frequency: MyString + time_of_day: MyString + form: MyString + purpose: MyText + start_date: 2026-08-29 + stop_date: 2026-08-29 + refill: MyString + notes: MyText + +two: + name: MyString + medication_type: two + current: false + dosage: MyString + frequency: MyString + time_of_day: MyString + form: MyString + purpose: MyText + start_date: 2026-08-29 + stop_date: 2026-08-29 + refill: MyString + notes: MyText diff --git a/test/fixtures/relationships.yml b/test/fixtures/relationships.yml new file mode 100644 index 0000000..7d41224 --- /dev/null +++ b/test/fixtures/relationships.yml @@ -0,0 +1,7 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + +two: + name: MyString diff --git a/test/models/medication_test.rb b/test/models/medication_test.rb new file mode 100644 index 0000000..8fcd105 --- /dev/null +++ b/test/models/medication_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MedicationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/medication_type_test.rb b/test/models/medication_type_test.rb new file mode 100644 index 0000000..6417e1a --- /dev/null +++ b/test/models/medication_type_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MedicationTypeTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/relationship_test.rb b/test/models/relationship_test.rb new file mode 100644 index 0000000..6b1491a --- /dev/null +++ b/test/models/relationship_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class RelationshipTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From b1d8648ec3e5bd7a339f10be23bcf06b57b77976 Mon Sep 17 00:00:00 2001 From: Johncarlo Cerna Date: Sat, 29 Aug 2026 10:29:50 -0400 Subject: [PATCH 08/25] Add other Devise modules to User model --- .rubocop.yml | 2 +- app/models/user.rb | 5 +-- .../20260828193338_devise_create_users.rb | 32 +++++++++---------- db/schema.rb | 18 +++++++++-- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 1d9dbdc..267d958 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -14,4 +14,4 @@ AllCops: # Enabled: false Bundler/OrderedGems: - Enabled: true \ No newline at end of file + Enabled: true diff --git a/app/models/user.rb b/app/models/user.rb index 4756799..4f14a8d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,7 @@ class User < ApplicationRecord # Include default devise modules. Others available are: - # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable + # :omniauthable devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :validatable + :recoverable, :rememberable, :validatable, + :lockable, :trackable, :confirmable end diff --git a/db/migrate/20260828193338_devise_create_users.rb b/db/migrate/20260828193338_devise_create_users.rb index 1507939..b237e7a 100644 --- a/db/migrate/20260828193338_devise_create_users.rb +++ b/db/migrate/20260828193338_devise_create_users.rb @@ -4,8 +4,8 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.1] def change create_table :users do |t| ## Database authenticatable - t.string :email, null: false, default: "" - t.string :encrypted_password, null: false, default: "" + t.string :email, null: false + t.string :encrypted_password, null: false ## Recoverable t.string :reset_password_token @@ -15,22 +15,22 @@ def change t.datetime :remember_created_at ## Trackable - # t.integer :sign_in_count, default: 0, null: false - # t.datetime :current_sign_in_at - # t.datetime :last_sign_in_at - # t.string :current_sign_in_ip - # t.string :last_sign_in_ip + t.integer :sign_in_count, default: 0, null: false + t.datetime :current_sign_in_at + t.datetime :last_sign_in_at + t.string :current_sign_in_ip + t.string :last_sign_in_ip ## Confirmable - # t.string :confirmation_token - # t.datetime :confirmed_at - # t.datetime :confirmation_sent_at - # t.string :unconfirmed_email # Only if using reconfirmable + t.string :confirmation_token + t.datetime :confirmed_at + t.datetime :confirmation_sent_at + t.string :unconfirmed_email # Only if using reconfirmable ## Lockable - # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts - # t.string :unlock_token # Only if unlock strategy is :email or :both - # t.datetime :locked_at + t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts + t.string :unlock_token # Only if unlock strategy is :email or :both + t.datetime :locked_at t.timestamps null: false @@ -38,7 +38,7 @@ def change add_index :users, :email, unique: true add_index :users, :reset_password_token, unique: true - # add_index :users, :confirmation_token, unique: true - # add_index :users, :unlock_token, unique: true + add_index :users, :confirmation_token, unique: true + add_index :users, :unlock_token, unique: true end end diff --git a/db/schema.rb b/db/schema.rb index 510f41e..4929882 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -19,14 +19,28 @@ end create_table "users", force: :cascade do |t| + t.datetime "confirmation_sent_at" + t.string "confirmation_token" + t.datetime "confirmed_at" t.datetime "created_at", null: false - t.string "email", default: "", null: false - t.string "encrypted_password", default: "", null: false + t.datetime "current_sign_in_at" + t.string "current_sign_in_ip" + t.string "email", null: false + t.string "encrypted_password", null: false + t.integer "failed_attempts", default: 0, null: false + t.datetime "last_sign_in_at" + t.string "last_sign_in_ip" + t.datetime "locked_at" t.datetime "remember_created_at" t.datetime "reset_password_sent_at" t.string "reset_password_token" + t.integer "sign_in_count", default: 0, null: false + t.string "unconfirmed_email" + t.string "unlock_token" t.datetime "updated_at", null: false + t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true + t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true end end From d7a6dbc46d78613d10f3ae943c6bfdf71701d9ab Mon Sep 17 00:00:00 2001 From: Johncarlo Cerna Date: Sat, 29 Aug 2026 10:56:40 -0400 Subject: [PATCH 09/25] Add space in schema --- db/schema.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/db/schema.rb b/db/schema.rb index 0d2b4e0..c2e519c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -85,6 +85,7 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true end + add_foreign_key "medications", "medication_types" add_foreign_key "people", "relationships" end From 0c5dd162a9d8d8b5f795a0c8ebe8d1909150dcf8 Mon Sep 17 00:00:00 2001 From: Domenick Powers Date: Sat, 29 Aug 2026 13:39:45 -0400 Subject: [PATCH 10/25] Upgrade ruby-version and mise version --- .ruby-version | 2 +- mise.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.ruby-version b/.ruby-version index ea897ac..1cf76f5 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-4.0.5 +ruby-4.0.6 diff --git a/mise.toml b/mise.toml index 2f58cce..dce2b74 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,3 @@ [tools] node = "latest" -ruby = "4.0.5" +ruby = "4.0.6" From 28b2a19a6d346c2a5e64a3131338ffcc525699de Mon Sep 17 00:00:00 2001 From: Johncarlo Cerna Date: Sat, 29 Aug 2026 13:44:14 -0400 Subject: [PATCH 11/25] Add user creation and login workflows --- app/views/devise/confirmations/new.html.erb | 16 +++++++ .../mailer/confirmation_instructions.html.erb | 5 +++ .../devise/mailer/email_changed.html.erb | 7 ++++ .../devise/mailer/password_change.html.erb | 3 ++ .../reset_password_instructions.html.erb | 8 ++++ .../mailer/unlock_instructions.html.erb | 7 ++++ app/views/devise/passwords/edit.html.erb | 25 +++++++++++ app/views/devise/passwords/new.html.erb | 16 +++++++ app/views/devise/registrations/edit.html.erb | 42 +++++++++++++++++++ app/views/devise/registrations/new.html.erb | 29 +++++++++++++ app/views/devise/sessions/new.html.erb | 26 ++++++++++++ .../devise/shared/_error_messages.html.erb | 15 +++++++ app/views/devise/shared/_links.html.erb | 25 +++++++++++ app/views/devise/unlocks/new.html.erb | 16 +++++++ app/views/home/index.html.erb | 9 +++- config/initializers/devise.rb | 6 +-- 16 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 app/views/devise/confirmations/new.html.erb create mode 100644 app/views/devise/mailer/confirmation_instructions.html.erb create mode 100644 app/views/devise/mailer/email_changed.html.erb create mode 100644 app/views/devise/mailer/password_change.html.erb create mode 100644 app/views/devise/mailer/reset_password_instructions.html.erb create mode 100644 app/views/devise/mailer/unlock_instructions.html.erb create mode 100644 app/views/devise/passwords/edit.html.erb create mode 100644 app/views/devise/passwords/new.html.erb create mode 100644 app/views/devise/registrations/edit.html.erb create mode 100644 app/views/devise/registrations/new.html.erb create mode 100644 app/views/devise/sessions/new.html.erb create mode 100644 app/views/devise/shared/_error_messages.html.erb create mode 100644 app/views/devise/shared/_links.html.erb create mode 100644 app/views/devise/unlocks/new.html.erb diff --git a/app/views/devise/confirmations/new.html.erb b/app/views/devise/confirmations/new.html.erb new file mode 100644 index 0000000..3c21122 --- /dev/null +++ b/app/views/devise/confirmations/new.html.erb @@ -0,0 +1,16 @@ +

Resend confirmation instructions

+ +<%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %>

+
+ +
+ <%= f.submit "Resend confirmation instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb new file mode 100644 index 0000000..dc55f64 --- /dev/null +++ b/app/views/devise/mailer/confirmation_instructions.html.erb @@ -0,0 +1,5 @@ +

Welcome <%= @email %>!

+ +

You can confirm your account email through the link below:

+ +

<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>

diff --git a/app/views/devise/mailer/email_changed.html.erb b/app/views/devise/mailer/email_changed.html.erb new file mode 100644 index 0000000..32f4ba8 --- /dev/null +++ b/app/views/devise/mailer/email_changed.html.erb @@ -0,0 +1,7 @@ +

Hello <%= @email %>!

+ +<% if @resource.try(:unconfirmed_email?) %> +

We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.

+<% else %> +

We're contacting you to notify you that your email has been changed to <%= @resource.email %>.

+<% end %> diff --git a/app/views/devise/mailer/password_change.html.erb b/app/views/devise/mailer/password_change.html.erb new file mode 100644 index 0000000..b41daf4 --- /dev/null +++ b/app/views/devise/mailer/password_change.html.erb @@ -0,0 +1,3 @@ +

Hello <%= @resource.email %>!

+ +

We're contacting you to notify you that your password has been changed.

diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb new file mode 100644 index 0000000..f667dc1 --- /dev/null +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -0,0 +1,8 @@ +

Hello <%= @resource.email %>!

+ +

Someone has requested a link to change your password. You can do this through the link below.

+ +

<%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>

+ +

If you didn't request this, please ignore this email.

+

Your password won't change until you access the link above and create a new one.

diff --git a/app/views/devise/mailer/unlock_instructions.html.erb b/app/views/devise/mailer/unlock_instructions.html.erb new file mode 100644 index 0000000..41e148b --- /dev/null +++ b/app/views/devise/mailer/unlock_instructions.html.erb @@ -0,0 +1,7 @@ +

Hello <%= @resource.email %>!

+ +

Your account has been locked due to an excessive number of unsuccessful sign in attempts.

+ +

Click the link below to unlock your account:

+ +

<%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>

diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb new file mode 100644 index 0000000..3f1dbc7 --- /dev/null +++ b/app/views/devise/passwords/edit.html.erb @@ -0,0 +1,25 @@ +

Change your password

+ +<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + <%= f.hidden_field :reset_password_token %> + +
+

<%= f.label :password, "New password" %>

+ <% if @minimum_password_length %> +

(<%= @minimum_password_length %> characters minimum)

+ <% end %> +

<%= f.password_field :password, autofocus: true, autocomplete: "new-password" %>

+
+ +
+

<%= f.label :password_confirmation, "Confirm new password" %>

+

<%= f.password_field :password_confirmation, autocomplete: "new-password" %>

+
+ +
+ <%= f.submit "Change my password" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb new file mode 100644 index 0000000..bea7ad1 --- /dev/null +++ b/app/views/devise/passwords/new.html.erb @@ -0,0 +1,16 @@ +

Forgot your password?

+ +<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

+
+ +
+ <%= f.submit "Send me password reset instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb new file mode 100644 index 0000000..19bb019 --- /dev/null +++ b/app/views/devise/registrations/edit.html.erb @@ -0,0 +1,42 @@ +

Edit <%= resource_name.to_s.humanize %>

+ +<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

+
+ + <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %> +
Currently waiting confirmation for: <%= resource.unconfirmed_email %>
+ <% end %> + +
+

<%= f.label :password %> (leave blank if you don't want to change it)

+

<%= f.password_field :password, autocomplete: "new-password" %>

+ <% if @minimum_password_length %> +

<%= @minimum_password_length %> characters minimum

+ <% end %> +
+ +
+

<%= f.label :password_confirmation %>

+

<%= f.password_field :password_confirmation, autocomplete: "new-password" %>

+
+ +
+

<%= f.label :current_password %> (we need your current password to confirm your changes)

+

<%= f.password_field :current_password, autocomplete: "current-password" %>

+
+ +
+ <%= f.submit "Update" %> +
+<% end %> + +

Cancel my account

+ +
Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" }, method: :delete %>
+ +<%= link_to "Back", :back %> diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb new file mode 100644 index 0000000..03f48fb --- /dev/null +++ b/app/views/devise/registrations/new.html.erb @@ -0,0 +1,29 @@ +

Sign up

+ +<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

+
+ +
+

<%= f.label :password %>

+ <% if @minimum_password_length %> +

(<%= @minimum_password_length %> characters minimum)

+ <% end %> +

<%= f.password_field :password, autocomplete: "new-password" %>

+
+ +
+

<%= f.label :password_confirmation %>

+

<%= f.password_field :password_confirmation, autocomplete: "new-password" %>

+
+ +
+ <%= f.submit "Sign up" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb new file mode 100644 index 0000000..6eeb9fc --- /dev/null +++ b/app/views/devise/sessions/new.html.erb @@ -0,0 +1,26 @@ +

Log in

+ +<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %> +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

+
+ +
+

<%= f.label :password %>

+

<%= f.password_field :password, autocomplete: "current-password" %>

+
+ + <% if devise_mapping.rememberable? %> +
+

<%= f.check_box :remember_me %>

+

<%= f.label :remember_me %>

+
+ <% end %> + +
+ <%= f.submit "Log in" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/shared/_error_messages.html.erb b/app/views/devise/shared/_error_messages.html.erb new file mode 100644 index 0000000..8c9c171 --- /dev/null +++ b/app/views/devise/shared/_error_messages.html.erb @@ -0,0 +1,15 @@ +<% if resource.errors.any? %> +
+

+ <%= I18n.t("errors.messages.not_saved", + count: resource.errors.count, + resource: resource.class.model_name.human.downcase) + %> +

+
    + <% resource.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+<% end %> diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb new file mode 100644 index 0000000..21cf422 --- /dev/null +++ b/app/views/devise/shared/_links.html.erb @@ -0,0 +1,25 @@ +<%- if controller_name != 'sessions' %> +

<%= link_to "Log in", new_session_path(resource_name) %>

+<% end %> + +<%- if devise_mapping.registerable? && controller_name != 'registrations' %> +

<%= link_to "Sign up", new_registration_path(resource_name) %>

+<% end %> + +<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> +

<%= link_to "Forgot your password?", new_password_path(resource_name) %>

+<% end %> + +<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> +

<%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>

+<% end %> + +<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> +

<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>

+<% end %> + +<%- if devise_mapping.omniauthable? %> + <%- resource_class.omniauth_providers.each do |provider| %> +

<%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } %>

+ <% end %> +<% end %> diff --git a/app/views/devise/unlocks/new.html.erb b/app/views/devise/unlocks/new.html.erb new file mode 100644 index 0000000..6b68d72 --- /dev/null +++ b/app/views/devise/unlocks/new.html.erb @@ -0,0 +1,16 @@ +

Resend unlock instructions

+ +<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

+
+ +
+ <%= f.submit "Resend unlock instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index f76fc29..5f429f1 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -1,3 +1,10 @@

heading

Hi

-<%= image_tag "horse.jpg", alt: "Horse", class: "horse-image" %> \ No newline at end of file +<%= image_tag "horse.jpg", alt: "Horse", class: "horse-image" %> + +<% if user_signed_in? %> +

<%= link_to 'Sign out', destroy_user_session_path, data: { turbo_method: :delete } %>

+<% else %> +

<%= link_to 'Sign in', new_user_session_path %>

+<% end %> + diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index b67ddd2..1c2def7 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -143,7 +143,7 @@ # without confirming their account. # Default is 0.days, meaning the user cannot access the website without # confirming their account. - # config.allow_unconfirmed_access_for = 2.days + config.allow_unconfirmed_access_for = nil # A period that the user is allowed to confirm their account before their # token becomes invalid. For example, if set to 3.days, the user can confirm @@ -222,7 +222,7 @@ # ==> Configuration for :recoverable # # Defines which key will be used when recovering the password for an account - # config.reset_password_keys = [:email] + config.reset_password_keys = [:email] # Time interval you can reset your password with a reset password key. # Don't put a too small interval or your users won't have the time to @@ -312,5 +312,5 @@ # When set to false, does not sign a user in automatically after their password is # changed. Defaults to true, so a user is signed in automatically after changing a password. - # config.sign_in_after_change_password = true + config.sign_in_after_change_password = true end From dbaac71d65a0aaa4328a61dd39b2185a3b8795de Mon Sep 17 00:00:00 2001 From: Johncarlo Cerna Date: Sat, 29 Aug 2026 13:47:50 -0400 Subject: [PATCH 12/25] Add Sign Up page to homepage when not logged in --- app/views/home/index.html.erb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 5f429f1..3485ac1 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -6,5 +6,6 @@

<%= link_to 'Sign out', destroy_user_session_path, data: { turbo_method: :delete } %>

<% else %>

<%= link_to 'Sign in', new_user_session_path %>

+

<%= link_to 'Sign up', new_user_registration_path %>

<% end %> From 6cede4657a4df21eb83ca725c407774629cceee6 Mon Sep 17 00:00:00 2001 From: Johncarlo Cerna Date: Sat, 29 Aug 2026 13:51:33 -0400 Subject: [PATCH 13/25] rubocop fixes --- config/initializers/devise.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 1c2def7..75117b6 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -222,7 +222,7 @@ # ==> Configuration for :recoverable # # Defines which key will be used when recovering the password for an account - config.reset_password_keys = [:email] + config.reset_password_keys = [ :email ] # Time interval you can reset your password with a reset password key. # Don't put a too small interval or your users won't have the time to From 708f8643cdeabdc9d1927b64534e9db0cda733e3 Mon Sep 17 00:00:00 2001 From: Danny Collier <294724+dcollie2@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:53:10 -0400 Subject: [PATCH 14/25] Add basic design without touching scaffolded views --- app/assets/stylesheets/application.css | 404 ++++++++++++++++++++++++- app/helpers/application_helper.rb | 14 + app/views/layouts/application.html.erb | 61 +++- 3 files changed, 469 insertions(+), 10 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333..0776e9f 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1,10 +1,402 @@ /* - * This is a manifest file that'll be compiled into application.css. + * The web app's stylesheet. One file on purpose: Propshaft serves + * app/assets/stylesheets without a build step, and a single file means no + * questions about which sheet loads first. * - * With Propshaft, assets are served efficiently without preprocessing steps. You can still include - * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard - * cascading order, meaning styles declared later in the document or manifest will override earlier ones, - * depending on specificity. + * THE PALETTE IS NOT ORIGINAL TO THIS FILE. It is copied from + * mobile/src/global.css, which is the source of truth and carries the + * reasoning - including why the light-mode primary is a deeper teal than the + * brand teal (white text on the brand teal is 2.8:1, and WCAG AA wants 4.5:1). + * Values are `H S% L%` triplets rather than colours, same as over there, so the + * two files diff cleanly. Change one, change both. * - * Consider organizing styles into separate files for maintainability. + * The mobile app switches schemes with a `.dark` class because it has a + * toggle. This side has no toggle, so it follows the operating system via + * prefers-color-scheme instead. That is the only intentional difference. + * + * Everything below styles plain HTML elements. Nothing here needs a class on a + * view, which is what lets the Rails scaffolds pick it up without being edited. + */ + +:root { + /* Light. Mirrors :root in mobile/src/global.css. */ + --background: 180 30% 99%; /* #FCFDFD */ + --foreground: 196 28% 16%; /* #1D2E34 */ + --card: 0 0% 100%; /* #FFFFFF */ + --card-foreground: 196 28% 16%; /* #1D2E34 */ + --primary: 181 66% 29%; /* #19797B */ + --primary-foreground: 0 0% 100%; /* #FFFFFF */ + --secondary: 180 30% 95%; /* #EEF6F6 */ + --secondary-foreground: 194 30% 22%; /* #274149 */ + --muted: 180 28% 96%; /* #F2F8F8 */ + --muted-foreground: 196 14% 40%; /* #586D74 */ + --accent: 181 40% 92%; /* #E2F2F3 */ + --accent-foreground: 193 45% 22%; /* #1F4651 */ + --destructive: 352 58% 45%; /* #B53042 */ + --destructive-foreground: 0 0% 100%; /* #FFFFFF */ + --success: 168 45% 38%; /* #358D7B - chart-3 over in global.css */ + --border: 185 22% 88%; /* #DAE6E7 */ + --input: 185 22% 85%; /* #D0E0E1 */ + --ring: 181 66% 29%; /* #19797B */ + + --radius: 0.875rem; + + /* Spacing, mirroring Spacing in mobile/src/constants/theme.ts. */ + --space-1: 4px; + --space-2: 8px; + --space-3: 16px; + --space-4: 24px; + --space-5: 32px; + --space-6: 64px; + + /* MaxContentWidth in mobile/src/constants/theme.ts. */ + --content-width: 800px; + + /* The same stacks as :root in mobile/src/global.css. Spline Sans and Inter + are not loaded - no webfont request, no new dependency - so in practice + this resolves to the system UI font, which is what the mobile web build + falls back to as well. */ + --font-display: Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, + Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + Liberation Mono, Courier New, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + /* Mirrors .dark:root in mobile/src/global.css. */ + --background: 196 30% 8%; /* #0E171B */ + --foreground: 180 18% 94%; /* #EDF2F2 */ + --card: 196 26% 11%; /* #151F23 */ + --card-foreground: 180 18% 94%; /* #EDF2F2 */ + --primary: 181 66% 41%; /* #24ABAE - the brand teal */ + --primary-foreground: 192 50% 8%; /* #0A1B1F */ + --secondary: 196 22% 17%; /* #223035 */ + --secondary-foreground: 180 16% 92%; /* #E7EEEE */ + --muted: 196 22% 16%; /* #202D32 */ + --muted-foreground: 188 14% 68%; /* #A2B6B9 */ + --accent: 194 26% 22%; /* #2A4047 */ + --accent-foreground: 180 16% 92%; /* #E7EEEE */ + --destructive: 352 55% 48%; /* #BE3749 */ + --destructive-foreground: 0 0% 100%; /* #FFFFFF */ + --success: 168 45% 52%; /* #4EBCA6 */ + --border: 196 20% 20%; /* #29383D */ + --input: 196 20% 24%; /* #314349 */ + --ring: 181 66% 41%; /* #24ABAE */ + } +} + +/* ---------------------------------------------------------------- base --- */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: var(--font-display); + /* 17px. The people using this are often tired, often on a phone in bad + light, often older. Default 16px is the floor, not the target. */ + font-size: 1.0625rem; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3 { + line-height: 1.25; + text-wrap: balance; + margin: 0 0 var(--space-3); +} + +h1 { font-size: 1.875rem; letter-spacing: -0.02em; } +h2 { font-size: 1.375rem; } +h3 { font-size: 1.125rem; } + +p { margin: 0 0 var(--space-3); } + +a { + color: hsl(var(--primary)); + text-decoration-color: hsl(var(--primary) / 0.4); + text-underline-offset: 3px; +} + +a:hover { text-decoration-color: hsl(var(--primary)); } + +/* One focus ring for everything. Visible in both schemes at 3:1 or better. */ +:where(a, button, input, select, textarea, summary):focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; + border-radius: calc(var(--radius) - 6px); +} + +hr { + border: 0; + border-top: 1px solid hsl(var(--border)); + margin: var(--space-4) 0; +} + +img { max-width: 100%; height: auto; } + +code, pre { font-family: var(--font-mono); font-size: 0.9375em; } + +/* -------------------------------------------------------------- layout --- */ + +.skip-link { + position: absolute; + left: var(--space-2); + top: -3rem; + z-index: 10; + padding: var(--space-2) var(--space-3); + border-radius: calc(var(--radius) - 4px); + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + text-decoration: none; + transition: top 120ms ease; +} + +.skip-link:focus { top: var(--space-2); } + +.site-header { + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--card)); +} + +.site-header__inner, +.site-main, +.site-footer__inner { + max-width: var(--content-width); + margin-inline: auto; + padding-inline: var(--space-3); +} + +.site-header__inner { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-2) var(--space-4); + padding-block: var(--space-3); +} + +.site-title { + font-size: 1.0625rem; + font-weight: 650; + letter-spacing: -0.01em; + color: hsl(var(--foreground)); + text-decoration: none; +} + +.site-nav { + /* Filling the row is what lets the account group's auto margin push it to + the far edge, away from the section links. */ + flex: 1; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-1) var(--space-3); + font-size: 0.9375rem; +} + +.site-nav__account { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-1) var(--space-3); + margin-inline-start: auto; +} + +/* Whose account this is - context, not a place you can go. */ +.site-nav__user { + color: hsl(var(--muted-foreground)); + overflow-wrap: anywhere; +} + +.site-nav a { + color: hsl(var(--muted-foreground)); + text-decoration: none; + padding-block: var(--space-1); +} + +.site-nav a:hover { color: hsl(var(--foreground)); } + +.site-nav a[aria-current="page"] { + color: hsl(var(--primary)); + font-weight: 600; +} + +.site-main { padding-block: var(--space-5) var(--space-6); } + +.site-footer { + border-top: 1px solid hsl(var(--border)); + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; +} + +.site-footer__inner { padding-block: var(--space-4); } +.site-footer p { margin: 0; } + +/* ------------------------------------------------------------- controls --- */ + +label { + display: block; + margin-bottom: var(--space-1); + font-size: 0.9375rem; + font-weight: 550; +} + +input:not([type="submit"]):not([type="checkbox"]):not([type="radio"]), +select, +textarea { + width: 100%; + max-width: 26rem; + padding: 10px var(--space-2); + border: 1px solid hsl(var(--input)); + border-radius: calc(var(--radius) - 4px); + background: hsl(var(--card)); + color: hsl(var(--foreground)); + font: inherit; + font-size: 1rem; /* Below 16px, iOS Safari zooms on focus. */ +} + +input::placeholder, +textarea::placeholder { color: hsl(var(--muted-foreground)); } + +/* A hand-written <%= f.check_box %><%= f.label %> pair puts the two next to + each other, where the block label above would drop the caption onto its own + line under a bare box. Beside a checkbox or radio, a label stays inline. */ +input[type="checkbox"] + label, +input[type="radio"] + label { + display: inline; + margin-bottom: 0; + font-weight: 400; +} + +/* The scaffold forms wrap each field in a bare
. This is what gives them + breathing room without touching _form.html.erb. */ +form > div { margin-bottom: var(--space-3); } + +input[type="submit"], +button { + padding: 10px var(--space-4); + border: 1px solid transparent; + border-radius: calc(var(--radius) - 4px); + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + font: inherit; + font-size: 1rem; + font-weight: 550; + cursor: pointer; +} + +input[type="submit"]:hover, +button:hover { background: hsl(var(--primary) / 0.9); } + +/* button_to ... method: :delete posts a form carrying a hidden _method=delete, + which is the only thing distinguishing "Destroy this person" from any other + button. Without this it renders in the brand teal, making delete the loudest + control on the show page. :has is safe here - ApplicationController already + gates on allow_browser versions: :modern. */ +form:has(input[name="_method"][value="delete"]) button { + background: hsl(var(--destructive)); + color: hsl(var(--destructive-foreground)); +} + +form:has(input[name="_method"][value="delete"]) button:hover { + background: hsl(var(--destructive) / 0.9); +} + +/* ------------------------------------------------- scaffold accommodation --- */ +/* + * Everything below exists so the generator's output looks deliberate without + * anyone editing app/views. If a view is ever rewritten by hand, the matching + * rule here can go with it. */ + +/* The partials render
via dom_id. Carding them turns the + generator's field dumps into something readable, on both index and show. + The div is load-bearing: form_with names its fields person_first_name, + medication_name and so on, so an unanchored [id^="person_"] would card every + input on every form as well. */ +div[id^="person_"], +div[id^="medication_"] { + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-3); + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--card)); +} + +/* Inside those cards the pattern is Field name: value. + Demoting the label and leading the value reads as a definition list. */ +div[id^="person_"] strong, +div[id^="medication_"] strong { + display: block; + color: hsl(var(--muted-foreground)); + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +div[id^="person_"] > div, +div[id^="medication_"] > div { margin-bottom: var(--space-2); } + +div[id^="person_"] > div:last-child, +div[id^="medication_"] > div:last-child { margin-bottom: 0; } + +/* The scaffolds emit `

` for the flash and + `

` for the error summary. The inline colour wins on + specificity, so these rules have to override it. Ugly, and deliberately so: + it is cheaper than editing nine views, and it disappears the moment those + views get written properly. */ +p[style*="green"] { + padding: var(--space-2) var(--space-3); + border-radius: calc(var(--radius) - 4px); + border: 1px solid hsl(var(--success) / 0.35); + background: hsl(var(--success) / 0.12); + color: hsl(var(--foreground)) !important; +} + +/* With no flash to show, that

still renders - empty. Hide it. */ +p[style*="green"]:empty { display: none; } + +div[style*="red"] { + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + border-radius: var(--radius); + border: 1px solid hsl(var(--destructive) / 0.35); + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--foreground)) !important; +} + +div[style*="red"] h2 { + font-size: 1rem; + color: hsl(var(--destructive)); +} + +div[style*="red"] ul { margin: 0; padding-left: 1.25rem; } + +/* Devise ships its own views, and sessions/new wraps the "remember me" + checkbox and its label in two consecutive

elements - so the caption + lands a line below a box with nothing beside it, whatever the label rule + above says. Rowing that one field puts them back together. Same bargain as + the scaffolds: cheaper than running the generator and owning the views. */ +.field:has(> p > input[type="checkbox"]) { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.field:has(> p > input[type="checkbox"]) p { margin: 0; } + +.field:has(> p > input[type="checkbox"]) label { + margin: 0; + font-weight: 400; +} diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..1bc4396 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,16 @@ module ApplicationHelper + # True when the request is anywhere inside a section, not only on its index. + # current_page? matches the whole path, so it drops the active state the + # moment you open /people/1 or /people/new - which is exactly when knowing + # where you are matters most. The trailing slash keeps /medications from + # claiming /medication_types. + def section_current?(path) + request.path == path || request.path.start_with?("#{path}/") + end + + # The value aria-current wants, or nil to leave the attribute off entirely. + # link_to drops an aria value of nil rather than rendering aria-current="". + def aria_current_section(path) + "page" if section_current?(path) + end end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 58c8657..591b25b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,11 +1,17 @@ - + - <%= content_for(:title) || "Alongwithyou" %> + <%= content_for(:title) || "Along With You" %> - + + + <%# Tells the browser to paint its own furniture - scrollbars, date pickers, + checkboxes - in whichever scheme the OS is set to, so they match the + palette in application.css rather than staying stubbornly light. %> + + <%= csrf_meta_tags %> <%= csp_meta_tag %> @@ -24,6 +30,53 @@ - <%= yield %> + <%# First thing in the tab order, hidden until focused. Without it, a + keyboard user tabs the whole nav on every page before reaching content. %> + + +

+ +
+ <%= yield %> +
+ +
+ +
From 55b59c4d7b7f3f8d05619b9f76103c7e9b61c054 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Fri, 28 Aug 2026 15:39:42 -0400 Subject: [PATCH 15/25] added Addresses --- app/controllers/addresses_controller.rb | 70 +++++++++++++++++++ app/models/address.rb | 2 + app/views/addresses/_address.html.erb | 12 ++++ app/views/addresses/_address.json.jbuilder | 2 + app/views/addresses/_form.html.erb | 27 +++++++ app/views/addresses/edit.html.erb | 12 ++++ app/views/addresses/index.html.erb | 16 +++++ app/views/addresses/index.json.jbuilder | 1 + app/views/addresses/new.html.erb | 11 +++ app/views/addresses/show.html.erb | 10 +++ app/views/addresses/show.json.jbuilder | 1 + config/routes.rb | 1 + db/migrate/20260828192329_create_addresses.rb | 10 +++ db/schema.rb | 7 ++ test/controllers/addresses_controller_test.rb | 48 +++++++++++++ test/fixtures/addresses.yml | 9 +++ 16 files changed, 239 insertions(+) create mode 100644 app/controllers/addresses_controller.rb create mode 100644 app/models/address.rb create mode 100644 app/views/addresses/_address.html.erb create mode 100644 app/views/addresses/_address.json.jbuilder create mode 100644 app/views/addresses/_form.html.erb create mode 100644 app/views/addresses/edit.html.erb create mode 100644 app/views/addresses/index.html.erb create mode 100644 app/views/addresses/index.json.jbuilder create mode 100644 app/views/addresses/new.html.erb create mode 100644 app/views/addresses/show.html.erb create mode 100644 app/views/addresses/show.json.jbuilder create mode 100644 db/migrate/20260828192329_create_addresses.rb create mode 100644 test/controllers/addresses_controller_test.rb create mode 100644 test/fixtures/addresses.yml diff --git a/app/controllers/addresses_controller.rb b/app/controllers/addresses_controller.rb new file mode 100644 index 0000000..4bc245b --- /dev/null +++ b/app/controllers/addresses_controller.rb @@ -0,0 +1,70 @@ +class AddressesController < ApplicationController + before_action :set_address, only: %i[ show edit update destroy ] + + # GET /addresses or /addresses.json + def index + @addresses = Address.all + end + + # GET /addresses/1 or /addresses/1.json + def show + end + + # GET /addresses/new + def new + @address = Address.new + end + + # GET /addresses/1/edit + def edit + end + + # POST /addresses or /addresses.json + def create + @address = Address.new(address_params) + + respond_to do |format| + if @address.save + format.html { redirect_to @address, notice: "Address was successfully created." } + format.json { render :show, status: :created, location: @address } + else + format.html { render :new, status: :unprocessable_content } + format.json { render json: @address.errors, status: :unprocessable_content } + end + end + end + + # PATCH/PUT /addresses/1 or /addresses/1.json + def update + respond_to do |format| + if @address.update(address_params) + format.html { redirect_to @address, notice: "Address was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: @address } + else + format.html { render :edit, status: :unprocessable_content } + format.json { render json: @address.errors, status: :unprocessable_content } + end + end + end + + # DELETE /addresses/1 or /addresses/1.json + def destroy + @address.destroy! + + respond_to do |format| + format.html { redirect_to addresses_path, notice: "Address 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_address + @address = Address.find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def address_params + params.expect(address: [ :city, :state ]) + end +end diff --git a/app/models/address.rb b/app/models/address.rb new file mode 100644 index 0000000..a19addc --- /dev/null +++ b/app/models/address.rb @@ -0,0 +1,2 @@ +class Address < ApplicationRecord +end diff --git a/app/views/addresses/_address.html.erb b/app/views/addresses/_address.html.erb new file mode 100644 index 0000000..1f0a08b --- /dev/null +++ b/app/views/addresses/_address.html.erb @@ -0,0 +1,12 @@ +
+
+ City: + <%= address.city %> +
+ +
+ State: + <%= address.state %> +
+ +
diff --git a/app/views/addresses/_address.json.jbuilder b/app/views/addresses/_address.json.jbuilder new file mode 100644 index 0000000..95169b4 --- /dev/null +++ b/app/views/addresses/_address.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! address, :id, :city, :state, :created_at, :updated_at +json.url address_url(address, format: :json) diff --git a/app/views/addresses/_form.html.erb b/app/views/addresses/_form.html.erb new file mode 100644 index 0000000..a43421a --- /dev/null +++ b/app/views/addresses/_form.html.erb @@ -0,0 +1,27 @@ +<%= form_with(model: address) do |form| %> + <% if address.errors.any? %> +
+

<%= pluralize(address.errors.count, "error") %> prohibited this address from being saved:

+ +
    + <% address.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :city, style: "display: block" %> + <%= form.text_field :city %> +
+ +
+ <%= form.label :state, style: "display: block" %> + <%= form.text_field :state %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/addresses/edit.html.erb b/app/views/addresses/edit.html.erb new file mode 100644 index 0000000..172a14d --- /dev/null +++ b/app/views/addresses/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing address" %> + +

Editing address

+ +<%= render "form", address: @address %> + +
+ +
+ <%= link_to "Show this address", @address %> | + <%= link_to "Back to addresses", addresses_path %> +
diff --git a/app/views/addresses/index.html.erb b/app/views/addresses/index.html.erb new file mode 100644 index 0000000..a7f798a --- /dev/null +++ b/app/views/addresses/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Addresses" %> + +

Addresses

+ +
+ <% @addresses.each do |address| %> + <%= render address %> +

+ <%= link_to "Show this address", address %> +

+ <% end %> +
+ +<%= link_to "New address", new_address_path %> diff --git a/app/views/addresses/index.json.jbuilder b/app/views/addresses/index.json.jbuilder new file mode 100644 index 0000000..475be0e --- /dev/null +++ b/app/views/addresses/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @addresses, partial: "addresses/address", as: :address diff --git a/app/views/addresses/new.html.erb b/app/views/addresses/new.html.erb new file mode 100644 index 0000000..442443a --- /dev/null +++ b/app/views/addresses/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New address" %> + +

New address

+ +<%= render "form", address: @address %> + +
+ +
+ <%= link_to "Back to addresses", addresses_path %> +
diff --git a/app/views/addresses/show.html.erb b/app/views/addresses/show.html.erb new file mode 100644 index 0000000..3b6ec63 --- /dev/null +++ b/app/views/addresses/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @address %> + +
+ <%= link_to "Edit this address", edit_address_path(@address) %> | + <%= link_to "Back to addresses", addresses_path %> + + <%= button_to "Destroy this address", @address, method: :delete %> +
diff --git a/app/views/addresses/show.json.jbuilder b/app/views/addresses/show.json.jbuilder new file mode 100644 index 0000000..8d0edad --- /dev/null +++ b/app/views/addresses/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "addresses/address", address: @address diff --git a/config/routes.rb b/config/routes.rb index 499ad5c..793dd7e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ resources :medications resources :medication_types resources :people + resources :addresses # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # JSON API consumed by the Expo app in mobile/. diff --git a/db/migrate/20260828192329_create_addresses.rb b/db/migrate/20260828192329_create_addresses.rb new file mode 100644 index 0000000..0b35579 --- /dev/null +++ b/db/migrate/20260828192329_create_addresses.rb @@ -0,0 +1,10 @@ +class CreateAddresses < ActiveRecord::Migration[8.1] + def change + create_table :addresses do |t| + t.string :city + t.string :state + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c2e519c..9a40b67 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,6 +11,13 @@ # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema[8.1].define(version: 2026_08_29_113836) do + create_table "addresses", force: :cascade do |t| + t.string "city" + t.datetime "created_at", null: false + t.string "state" + t.datetime "updated_at", null: false + end + create_table "medication_types", force: :cascade do |t| t.datetime "created_at", null: false t.string "name" diff --git a/test/controllers/addresses_controller_test.rb b/test/controllers/addresses_controller_test.rb new file mode 100644 index 0000000..402d6b9 --- /dev/null +++ b/test/controllers/addresses_controller_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class AddressesControllerTest < ActionDispatch::IntegrationTest + setup do + @address = addresses(:one) + end + + test "should get index" do + get addresses_url + assert_response :success + end + + test "should get new" do + get new_address_url + assert_response :success + end + + test "should create address" do + assert_difference("Address.count") do + post addresses_url, params: { address: { city: @address.city, state: @address.state } } + end + + assert_redirected_to address_url(Address.last) + end + + test "should show address" do + get address_url(@address) + assert_response :success + end + + test "should get edit" do + get edit_address_url(@address) + assert_response :success + end + + test "should update address" do + patch address_url(@address), params: { address: { city: @address.city, state: @address.state } } + assert_redirected_to address_url(@address) + end + + test "should destroy address" do + assert_difference("Address.count", -1) do + delete address_url(@address) + end + + assert_redirected_to addresses_url + end +end diff --git a/test/fixtures/addresses.yml b/test/fixtures/addresses.yml new file mode 100644 index 0000000..9e29e86 --- /dev/null +++ b/test/fixtures/addresses.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + city: MyString + state: MyString + +two: + city: MyString + state: MyString From 126a8466e761fb6c274224f8aebeb3c95f24c2c4 Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Sat, 29 Aug 2026 10:05:56 -0400 Subject: [PATCH 16/25] added reference to parent class Person --- app/models/address.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/address.rb b/app/models/address.rb index a19addc..0848b5a 100644 --- a/app/models/address.rb +++ b/app/models/address.rb @@ -1,2 +1,3 @@ class Address < ApplicationRecord + belongs_to:person end From 3dd08364d87be840509124c10e396af59e4e889d Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Sat, 29 Aug 2026 11:38:11 -0400 Subject: [PATCH 17/25] WIP --- Gemfile | 2 +- Gemfile.lock | 10 ++++++++++ app/models/address.rb | 2 +- app/models/person.rb | 2 ++ db/migrate/20260829145325_add_person_ref_to_address.rb | 5 +++++ db/schema.rb | 5 ++++- test/controllers/addresses_controller_test.rb | 5 ++++- test/fixtures/addresses.yml | 2 ++ 8 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20260829145325_add_person_ref_to_address.rb diff --git a/Gemfile b/Gemfile index 4ceebcc..a8bcce6 100644 --- a/Gemfile +++ b/Gemfile @@ -53,7 +53,7 @@ gem "ruby-vips", "~> 2.0", require: false group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" - + gem "pry" # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) gem "bundler-audit", require: false diff --git a/Gemfile.lock b/Gemfile.lock index d923c23..a786622 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,6 +100,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + coderay (1.1.3) concurrent-ruby (1.3.8) connection_pool (3.0.2) crass (1.0.7) @@ -174,6 +175,7 @@ GEM net-smtp marcel (1.2.1) matrix (0.4.3) + method_source (1.1.0) mini_mime (1.1.5) minitest (6.0.6) drb (~> 2.0) @@ -222,6 +224,10 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack + pry (0.16.0) + coderay (~> 1.1) + method_source (~> 1.0) + reline (>= 0.6.0) public_suffix (7.0.5) puma (8.0.2) nio4r (~> 2.0) @@ -414,6 +420,7 @@ DEPENDENCIES jbuilder kamal propshaft + pry puma (>= 5.0) rack-cors rails (~> 8.1.3) @@ -455,6 +462,7 @@ CHECKSUMS builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 @@ -491,6 +499,7 @@ CHECKSUMS mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 msgpack (1.8.3) sha256=8bda4a6428d3244e50d6bd55854d354edbada88a4e1f4f5731a39a0f86bee6a1 @@ -517,6 +526,7 @@ CHECKSUMS prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a diff --git a/app/models/address.rb b/app/models/address.rb index 0848b5a..d7153de 100644 --- a/app/models/address.rb +++ b/app/models/address.rb @@ -1,3 +1,3 @@ class Address < ApplicationRecord - belongs_to:person + belongs_to :person end diff --git a/app/models/person.rb b/app/models/person.rb index 9791ac6..e8c2489 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -1,3 +1,5 @@ class Person < ApplicationRecord belongs_to :relationship + + has_one :address, dependent: :destroy end diff --git a/db/migrate/20260829145325_add_person_ref_to_address.rb b/db/migrate/20260829145325_add_person_ref_to_address.rb new file mode 100644 index 0000000..2367ad6 --- /dev/null +++ b/db/migrate/20260829145325_add_person_ref_to_address.rb @@ -0,0 +1,5 @@ +class AddPersonRefToAddress < ActiveRecord::Migration[8.1] + def change + add_reference :addresses, :person, null: false, foreign_key: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 9a40b67..36dd645 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,12 +10,14 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_29_113836) do +ActiveRecord::Schema[8.1].define(version: 2026_08_29_145325) do create_table "addresses", force: :cascade do |t| t.string "city" t.datetime "created_at", null: false + t.integer "person_id", null: false t.string "state" t.datetime "updated_at", null: false + t.index ["person_id"], name: "index_addresses_on_person_id" end create_table "medication_types", force: :cascade do |t| @@ -93,6 +95,7 @@ t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true end + add_foreign_key "addresses", "people" add_foreign_key "medications", "medication_types" add_foreign_key "people", "relationships" end diff --git a/test/controllers/addresses_controller_test.rb b/test/controllers/addresses_controller_test.rb index 402d6b9..992c7c8 100644 --- a/test/controllers/addresses_controller_test.rb +++ b/test/controllers/addresses_controller_test.rb @@ -1,4 +1,5 @@ require "test_helper" +require "pry" class AddressesControllerTest < ActionDispatch::IntegrationTest setup do @@ -16,8 +17,9 @@ class AddressesControllerTest < ActionDispatch::IntegrationTest end test "should create address" do + binding.pry assert_difference("Address.count") do - post addresses_url, params: { address: { city: @address.city, state: @address.state } } + post addresses_url, params: { address: { city: @address.city, state: @address.state, person_id: @address.person_id } } end assert_redirected_to address_url(Address.last) @@ -30,6 +32,7 @@ class AddressesControllerTest < ActionDispatch::IntegrationTest test "should get edit" do get edit_address_url(@address) + assert_response :success end diff --git a/test/fixtures/addresses.yml b/test/fixtures/addresses.yml index 9e29e86..3a1770c 100644 --- a/test/fixtures/addresses.yml +++ b/test/fixtures/addresses.yml @@ -3,7 +3,9 @@ one: city: MyString state: MyString + person: one two: city: MyString state: MyString + person: two From 4a5f0b3dfe7cc4754a0f6b149ad0642f16f7404c Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Sat, 29 Aug 2026 11:45:35 -0400 Subject: [PATCH 18/25] added person id to parameter allow list --- app/controllers/addresses_controller.rb | 2 +- test/controllers/addresses_controller_test.rb | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/controllers/addresses_controller.rb b/app/controllers/addresses_controller.rb index 4bc245b..56b8f4d 100644 --- a/app/controllers/addresses_controller.rb +++ b/app/controllers/addresses_controller.rb @@ -65,6 +65,6 @@ def set_address # Only allow a list of trusted parameters through. def address_params - params.expect(address: [ :city, :state ]) + params.expect(address: [ :city, :state, :person_id ]) end end diff --git a/test/controllers/addresses_controller_test.rb b/test/controllers/addresses_controller_test.rb index 992c7c8..d2a026b 100644 --- a/test/controllers/addresses_controller_test.rb +++ b/test/controllers/addresses_controller_test.rb @@ -17,7 +17,6 @@ class AddressesControllerTest < ActionDispatch::IntegrationTest end test "should create address" do - binding.pry assert_difference("Address.count") do post addresses_url, params: { address: { city: @address.city, state: @address.state, person_id: @address.person_id } } end @@ -32,7 +31,7 @@ class AddressesControllerTest < ActionDispatch::IntegrationTest test "should get edit" do get edit_address_url(@address) - + assert_response :success end From 536904cdc624d578de0d4df58abb7bffd7cc535e Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Sat, 29 Aug 2026 14:03:16 -0400 Subject: [PATCH 19/25] removed redundant files, and added new revisions to routes --- app/views/addresses/_form.html.erb | 27 --------------------------- app/views/addresses/edit.html.erb | 12 ------------ app/views/addresses/new.html.erb | 11 ----------- config/routes.rb | 5 +++-- 4 files changed, 3 insertions(+), 52 deletions(-) delete mode 100644 app/views/addresses/_form.html.erb delete mode 100644 app/views/addresses/edit.html.erb delete mode 100644 app/views/addresses/new.html.erb diff --git a/app/views/addresses/_form.html.erb b/app/views/addresses/_form.html.erb deleted file mode 100644 index a43421a..0000000 --- a/app/views/addresses/_form.html.erb +++ /dev/null @@ -1,27 +0,0 @@ -<%= form_with(model: address) do |form| %> - <% if address.errors.any? %> -
-

<%= pluralize(address.errors.count, "error") %> prohibited this address from being saved:

- -
    - <% address.errors.each do |error| %> -
  • <%= error.full_message %>
  • - <% end %> -
-
- <% end %> - -
- <%= form.label :city, style: "display: block" %> - <%= form.text_field :city %> -
- -
- <%= form.label :state, style: "display: block" %> - <%= form.text_field :state %> -
- -
- <%= form.submit %> -
-<% end %> diff --git a/app/views/addresses/edit.html.erb b/app/views/addresses/edit.html.erb deleted file mode 100644 index 172a14d..0000000 --- a/app/views/addresses/edit.html.erb +++ /dev/null @@ -1,12 +0,0 @@ -<% content_for :title, "Editing address" %> - -

Editing address

- -<%= render "form", address: @address %> - -
- -
- <%= link_to "Show this address", @address %> | - <%= link_to "Back to addresses", addresses_path %> -
diff --git a/app/views/addresses/new.html.erb b/app/views/addresses/new.html.erb deleted file mode 100644 index 442443a..0000000 --- a/app/views/addresses/new.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<% content_for :title, "New address" %> - -

New address

- -<%= render "form", address: @address %> - -
- -
- <%= link_to "Back to addresses", addresses_path %> -
diff --git a/config/routes.rb b/config/routes.rb index 793dd7e..c173960 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,8 +2,9 @@ devise_for :users resources :medications resources :medication_types - resources :people - resources :addresses + resources :people do + resource :address, only: [:show, :create, :update, :destroy] + end # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # JSON API consumed by the Expo app in mobile/. From 4266f5982ee56ae2b889125f5076d099e8eb24ac Mon Sep 17 00:00:00 2001 From: Matthew Thomas Date: Sat, 29 Aug 2026 14:06:47 -0400 Subject: [PATCH 20/25] Police are scary --- config/routes.rb | 2 +- test/controllers/addresses_controller_test.rb | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index c173960..bce874b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,7 +3,7 @@ resources :medications resources :medication_types resources :people do - resource :address, only: [:show, :create, :update, :destroy] + resource :address, only: [ :show, :create, :update, :destroy ] end # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html diff --git a/test/controllers/addresses_controller_test.rb b/test/controllers/addresses_controller_test.rb index d2a026b..cfe2015 100644 --- a/test/controllers/addresses_controller_test.rb +++ b/test/controllers/addresses_controller_test.rb @@ -35,11 +35,6 @@ class AddressesControllerTest < ActionDispatch::IntegrationTest assert_response :success end - test "should update address" do - patch address_url(@address), params: { address: { city: @address.city, state: @address.state } } - assert_redirected_to address_url(@address) - end - test "should destroy address" do assert_difference("Address.count", -1) do delete address_url(@address) From 5f5594b24cd630a57f44dbbfc9fdc411916187e7 Mon Sep 17 00:00:00 2001 From: Domenick Powers Date: Sat, 29 Aug 2026 14:14:22 -0400 Subject: [PATCH 21/25] Fix failing tests --- Gemfile.lock | 3 ++ app/controllers/addresses_controller.rb | 52 +++++++------------ app/views/addresses/show.html.erb | 5 +- test/controllers/addresses_controller_test.rb | 33 +++--------- 4 files changed, 32 insertions(+), 61 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a786622..7009a24 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,6 +81,7 @@ GEM base64 (0.3.0) bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) + bcrypt_pbkdf (1.1.2-arm64-darwin) bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.24.6) @@ -402,6 +403,7 @@ PLATFORMS aarch64-linux-musl arm-linux-gnu arm-linux-musl + arm64-darwin-22 arm64-darwin-23 arm64-darwin-25 x86_64-linux @@ -455,6 +457,7 @@ CHECKSUMS base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + bcrypt_pbkdf (1.1.2-arm64-darwin) bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.24.6) sha256=c60bab88c70332290f0a2636a288f675299eb4f804a02a3c085b42eca9da164a diff --git a/app/controllers/addresses_controller.rb b/app/controllers/addresses_controller.rb index 56b8f4d..ddfba73 100644 --- a/app/controllers/addresses_controller.rb +++ b/app/controllers/addresses_controller.rb @@ -1,32 +1,17 @@ class AddressesController < ApplicationController - before_action :set_address, only: %i[ show edit update destroy ] + before_action :set_person + before_action :set_address, only: %i[ show update destroy ] - # GET /addresses or /addresses.json - def index - @addresses = Address.all - end - - # GET /addresses/1 or /addresses/1.json def show end - # GET /addresses/new - def new - @address = Address.new - end - - # GET /addresses/1/edit - def edit - end - - # POST /addresses or /addresses.json def create - @address = Address.new(address_params) + @address = @person.build_address(address_params) respond_to do |format| if @address.save - format.html { redirect_to @address, notice: "Address was successfully created." } - format.json { render :show, status: :created, location: @address } + format.html { redirect_to person_address_url(@person), notice: "Address was successfully created." } + format.json { render :show, status: :created, location: person_address_url(@person) } else format.html { render :new, status: :unprocessable_content } format.json { render json: @address.errors, status: :unprocessable_content } @@ -34,12 +19,11 @@ def create end end - # PATCH/PUT /addresses/1 or /addresses/1.json def update respond_to do |format| if @address.update(address_params) - format.html { redirect_to @address, notice: "Address was successfully updated.", status: :see_other } - format.json { render :show, status: :ok, location: @address } + format.html { redirect_to person_address_url(@person), notice: "Address was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: person_address_url(@person) } else format.html { render :edit, status: :unprocessable_content } format.json { render json: @address.errors, status: :unprocessable_content } @@ -47,24 +31,26 @@ def update end end - # DELETE /addresses/1 or /addresses/1.json def destroy @address.destroy! respond_to do |format| - format.html { redirect_to addresses_path, notice: "Address was successfully destroyed.", status: :see_other } + format.html { redirect_to person_url(@person), notice: "Address 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_address - @address = Address.find(params.expect(:id)) - end - # Only allow a list of trusted parameters through. - def address_params - params.expect(address: [ :city, :state, :person_id ]) - end + def set_person + @person = Person.find(params[:person_id]) + end + + def set_address + @address = @person.address + end + + def address_params + params.expect(address: [ :city, :state ]) + end end diff --git a/app/views/addresses/show.html.erb b/app/views/addresses/show.html.erb index 3b6ec63..dd36790 100644 --- a/app/views/addresses/show.html.erb +++ b/app/views/addresses/show.html.erb @@ -3,8 +3,7 @@ <%= render @address %>
- <%= link_to "Edit this address", edit_address_path(@address) %> | - <%= link_to "Back to addresses", addresses_path %> + <%= link_to "Back to person", @person %> - <%= button_to "Destroy this address", @address, method: :delete %> + <%= button_to "Destroy this address", person_address_path(@person), method: :delete %>
diff --git a/test/controllers/addresses_controller_test.rb b/test/controllers/addresses_controller_test.rb index cfe2015..93dfabf 100644 --- a/test/controllers/addresses_controller_test.rb +++ b/test/controllers/addresses_controller_test.rb @@ -1,45 +1,28 @@ require "test_helper" -require "pry" class AddressesControllerTest < ActionDispatch::IntegrationTest setup do @address = addresses(:one) + @person = @address.person end - test "should get index" do - get addresses_url - assert_response :success - end - - test "should get new" do - get new_address_url + test "should show address" do + get person_address_url(@person) assert_response :success end test "should create address" do + @person.address.destroy assert_difference("Address.count") do - post addresses_url, params: { address: { city: @address.city, state: @address.state, person_id: @address.person_id } } + post person_address_url(@person), params: { address: { city: @address.city, state: @address.state } } end - - assert_redirected_to address_url(Address.last) - end - - test "should show address" do - get address_url(@address) - assert_response :success - end - - test "should get edit" do - get edit_address_url(@address) - - assert_response :success + assert_redirected_to person_address_url(@person) end test "should destroy address" do assert_difference("Address.count", -1) do - delete address_url(@address) + delete person_address_url(@person) end - - assert_redirected_to addresses_url + assert_redirected_to person_url(@person) end end From c03cec60112d2611d0a78bb742c6cfe6516903c0 Mon Sep 17 00:00:00 2001 From: Domenick Powers Date: Sat, 29 Aug 2026 14:15:39 -0400 Subject: [PATCH 22/25] lock --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7009a24..402d23f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -457,7 +457,7 @@ CHECKSUMS base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 - bcrypt_pbkdf (1.1.2-arm64-darwin) + bcrypt_pbkdf (1.1.2-arm64-darwin) sha256=afdd6feb6ed5a97b8e44caacb3f2d641b98af78e6a516d4a3520b69af5cf9fea bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.24.6) sha256=c60bab88c70332290f0a2636a288f675299eb4f804a02a3c085b42eca9da164a From cb21ea06db161ec5cb656088874f8ab85b507739 Mon Sep 17 00:00:00 2001 From: Danny Collier <294724+dcollie2@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:15:45 -0400 Subject: [PATCH 23/25] Add prescription model and rewire to specs --- app/assets/stylesheets/application.css | 117 +++++++++++++++++- .../medication_forms_controller.rb | 70 +++++++++++ app/controllers/medications_controller.rb | 15 ++- app/controllers/prescriptions_controller.rb | 74 +++++++++++ app/helpers/application_helper.rb | 6 + app/helpers/medication_forms_helper.rb | 2 + app/helpers/prescriptions_helper.rb | 34 +++++ app/models/medication.rb | 6 +- app/models/medication_form.rb | 5 + app/models/medication_type.rb | 3 + app/models/prescription.rb | 4 + app/views/layouts/application.html.erb | 4 + app/views/medication_forms/_form.html.erb | 22 ++++ .../_medication_form.html.erb | 7 ++ .../_medication_form.json.jbuilder | 2 + app/views/medication_forms/edit.html.erb | 12 ++ app/views/medication_forms/index.html.erb | 16 +++ .../medication_forms/index.json.jbuilder | 1 + app/views/medication_forms/new.html.erb | 11 ++ app/views/medication_forms/show.html.erb | 10 ++ app/views/medication_forms/show.json.jbuilder | 1 + app/views/medications/_form.html.erb | 53 +------- app/views/medications/_medication.html.erb | 51 +------- app/views/medications/show.html.erb | 1 + app/views/prescriptions/_form.html.erb | 72 +++++++++++ .../prescriptions/_prescription.html.erb | 57 +++++++++ .../prescriptions/_prescription.json.jbuilder | 2 + app/views/prescriptions/edit.html.erb | 12 ++ app/views/prescriptions/index.html.erb | 54 ++++++++ app/views/prescriptions/index.json.jbuilder | 1 + app/views/prescriptions/new.html.erb | 11 ++ app/views/prescriptions/show.html.erb | 10 ++ app/views/prescriptions/show.json.jbuilder | 1 + config/routes.rb | 2 + .../20260829203813_create_medication_forms.rb | 9 ++ .../20260829203818_create_prescriptions.rb | 19 +++ ...ove_prescription_fields_off_medications.rb | 22 ++++ db/schema.rb | 41 ++++-- db/seeds.rb | 49 ++++++++ .../medication_forms_controller_test.rb | 48 +++++++ .../medications_controller_test.rb | 24 +++- .../prescriptions_controller_test.rb | 69 +++++++++++ test/fixtures/medication_forms.yml | 7 ++ test/fixtures/medication_types.yml | 4 +- test/fixtures/medications.yml | 35 ++---- test/fixtures/prescriptions.yml | 27 ++++ test/models/medication_form_test.rb | 15 +++ test/models/medication_test.rb | 17 ++- test/models/medication_type_test.rb | 14 ++- test/models/prescription_test.rb | 11 ++ 50 files changed, 1007 insertions(+), 153 deletions(-) create mode 100644 app/controllers/medication_forms_controller.rb create mode 100644 app/controllers/prescriptions_controller.rb create mode 100644 app/helpers/medication_forms_helper.rb create mode 100644 app/helpers/prescriptions_helper.rb create mode 100644 app/models/medication_form.rb create mode 100644 app/models/prescription.rb create mode 100644 app/views/medication_forms/_form.html.erb create mode 100644 app/views/medication_forms/_medication_form.html.erb create mode 100644 app/views/medication_forms/_medication_form.json.jbuilder create mode 100644 app/views/medication_forms/edit.html.erb create mode 100644 app/views/medication_forms/index.html.erb create mode 100644 app/views/medication_forms/index.json.jbuilder create mode 100644 app/views/medication_forms/new.html.erb create mode 100644 app/views/medication_forms/show.html.erb create mode 100644 app/views/medication_forms/show.json.jbuilder create mode 100644 app/views/prescriptions/_form.html.erb create mode 100644 app/views/prescriptions/_prescription.html.erb create mode 100644 app/views/prescriptions/_prescription.json.jbuilder create mode 100644 app/views/prescriptions/edit.html.erb create mode 100644 app/views/prescriptions/index.html.erb create mode 100644 app/views/prescriptions/index.json.jbuilder create mode 100644 app/views/prescriptions/new.html.erb create mode 100644 app/views/prescriptions/show.html.erb create mode 100644 app/views/prescriptions/show.json.jbuilder create mode 100644 db/migrate/20260829203813_create_medication_forms.rb create mode 100644 db/migrate/20260829203818_create_prescriptions.rb create mode 100644 db/migrate/20260829203900_move_prescription_fields_off_medications.rb create mode 100644 test/controllers/medication_forms_controller_test.rb create mode 100644 test/controllers/prescriptions_controller_test.rb create mode 100644 test/fixtures/medication_forms.yml create mode 100644 test/fixtures/prescriptions.yml create mode 100644 test/models/medication_form_test.rb create mode 100644 test/models/prescription_test.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 0776e9f..6353bab 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -312,6 +312,111 @@ form:has(input[name="_method"][value="delete"]) button:hover { background: hsl(var(--destructive) / 0.9); } +/* --------------------------------------------------------------- tables --- */ +/* + * The prescriptions index is a log, so it is a real table. Seven columns will + * not fit a phone, and neither hiding columns nor collapsing each row into a + * card keeps a log scannable - so the table keeps its shape and the wrapper + * around it scrolls sideways instead. + */ + +.table-scroll { + overflow-x: auto; + margin-bottom: var(--space-4); + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--card)); + /* The rounded corners would otherwise be sliced off by the header band. */ + overflow-y: hidden; +} + +/* --content-width is a comfortable measure for reading sentences, and a log is + not sentences: seven columns do not fit in it, and scrolling sideways on a + wide monitor to reach the last two is silly. So once the window is wide + enough to spare it, the table steps out of the reading column and centres + itself on the page. Percentage margins resolve against the containing block, + so `50% - half the width` is what centres a box wider than its parent. */ +@media (min-width: 64rem) { + .table-scroll { + --table-width: min(1120px, 100vw - var(--space-6)); + + width: var(--table-width); + margin-inline: calc(50% - var(--table-width) / 2); + } +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9375rem; +} + +th, +td { + padding: var(--space-2) var(--space-3); + text-align: left; + vertical-align: top; +} + +/* Same treatment as the field captions inside the scaffold cards below, so a + column heading and a field label read as the same kind of thing. */ +thead th { + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + white-space: nowrap; +} + +tbody tr + tr th, +tbody tr + tr td { border-top: 1px solid hsl(var(--border)); } + +tbody tr:hover { background: hsl(var(--accent) / 0.5); } + +/* The row header - which medication this row is about. Everything else in the + row is a detail of it, so only this column is allowed to wrap. */ +tbody th[scope="row"] { + font-weight: 550; + min-width: 11rem; +} + +tbody td { white-space: nowrap; } + +/* A second line under the row header: the form the medication comes in. */ +.table-note { + display: block; + color: hsl(var(--muted-foreground)); + font-size: 0.8125rem; + font-weight: 400; +} + +/* Whether a prescription is current. The word says it; the colour only agrees + with the word, so this still reads with colour vision differences or in + print. */ +.status { + display: inline-block; + padding: 1px var(--space-2); + border: 1px solid; + border-radius: 999px; + font-size: 0.8125rem; + font-weight: 600; +} + +.status--active { + border-color: hsl(var(--success) / 0.4); + background: hsl(var(--success) / 0.14); + color: hsl(var(--foreground)); +} + +.status--stopped { + border-color: hsl(var(--border)); + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); +} + /* ------------------------------------------------- scaffold accommodation --- */ /* * Everything below exists so the generator's output looks deliberate without @@ -325,7 +430,8 @@ form:has(input[name="_method"][value="delete"]) button:hover { medication_name and so on, so an unanchored [id^="person_"] would card every input on every form as well. */ div[id^="person_"], -div[id^="medication_"] { +div[id^="medication_"], +div[id^="prescription_"] { padding: var(--space-3) var(--space-4); margin-bottom: var(--space-3); border: 1px solid hsl(var(--border)); @@ -336,7 +442,8 @@ div[id^="medication_"] { /* Inside those cards the pattern is Field name: value. Demoting the label and leading the value reads as a definition list. */ div[id^="person_"] strong, -div[id^="medication_"] strong { +div[id^="medication_"] strong, +div[id^="prescription_"] strong { display: block; color: hsl(var(--muted-foreground)); font-size: 0.8125rem; @@ -346,10 +453,12 @@ div[id^="medication_"] strong { } div[id^="person_"] > div, -div[id^="medication_"] > div { margin-bottom: var(--space-2); } +div[id^="medication_"] > div, +div[id^="prescription_"] > div { margin-bottom: var(--space-2); } div[id^="person_"] > div:last-child, -div[id^="medication_"] > div:last-child { margin-bottom: 0; } +div[id^="medication_"] > div:last-child, +div[id^="prescription_"] > div:last-child { margin-bottom: 0; } /* The scaffolds emit `

` for the flash and `

` for the error summary. The inline colour wins on diff --git a/app/controllers/medication_forms_controller.rb b/app/controllers/medication_forms_controller.rb new file mode 100644 index 0000000..d5f3098 --- /dev/null +++ b/app/controllers/medication_forms_controller.rb @@ -0,0 +1,70 @@ +class MedicationFormsController < ApplicationController + before_action :set_medication_form, only: %i[ show edit update destroy ] + + # GET /medication_forms or /medication_forms.json + def index + @medication_forms = MedicationForm.all + end + + # GET /medication_forms/1 or /medication_forms/1.json + def show + end + + # GET /medication_forms/new + def new + @medication_form = MedicationForm.new + end + + # GET /medication_forms/1/edit + def edit + end + + # POST /medication_forms or /medication_forms.json + def create + @medication_form = MedicationForm.new(medication_form_params) + + respond_to do |format| + if @medication_form.save + format.html { redirect_to @medication_form, notice: "Medication form was successfully created." } + format.json { render :show, status: :created, location: @medication_form } + else + format.html { render :new, status: :unprocessable_content } + format.json { render json: @medication_form.errors, status: :unprocessable_content } + end + end + end + + # PATCH/PUT /medication_forms/1 or /medication_forms/1.json + def update + respond_to do |format| + if @medication_form.update(medication_form_params) + format.html { redirect_to @medication_form, notice: "Medication form was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: @medication_form } + else + format.html { render :edit, status: :unprocessable_content } + format.json { render json: @medication_form.errors, status: :unprocessable_content } + end + end + end + + # DELETE /medication_forms/1 or /medication_forms/1.json + def destroy + @medication_form.destroy! + + respond_to do |format| + format.html { redirect_to medication_forms_path, notice: "Medication form 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_medication_form + @medication_form = MedicationForm.find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def medication_form_params + params.expect(medication_form: [ :name ]) + end +end diff --git a/app/controllers/medications_controller.rb b/app/controllers/medications_controller.rb index 804f765..4c7c4d7 100644 --- a/app/controllers/medications_controller.rb +++ b/app/controllers/medications_controller.rb @@ -49,11 +49,16 @@ def update # DELETE /medications/1 or /medications/1.json def destroy - @medication.destroy! - + # A medication in use by a prescription refuses to be destroyed, so this + # reports the refusal rather than raising. respond_to do |format| - format.html { redirect_to medications_path, notice: "Medication was successfully destroyed.", status: :see_other } - format.json { head :no_content } + if @medication.destroy + format.html { redirect_to medications_path, notice: "Medication was successfully destroyed.", status: :see_other } + format.json { head :no_content } + else + format.html { redirect_to @medication, alert: @medication.errors.full_messages.to_sentence, status: :see_other } + format.json { render json: @medication.errors, status: :unprocessable_content } + end end end @@ -65,6 +70,6 @@ def set_medication # Only allow a list of trusted parameters through. def medication_params - params.expect(medication: [ :name, :medication_type_id, :current, :dosage, :frequency, :time_of_day, :form, :purpose, :start_date, :stop_date, :refill, :notes ]) + params.expect(medication: [ :name, :medication_type_id, :side_effects ]) end end diff --git a/app/controllers/prescriptions_controller.rb b/app/controllers/prescriptions_controller.rb new file mode 100644 index 0000000..2cfbf09 --- /dev/null +++ b/app/controllers/prescriptions_controller.rb @@ -0,0 +1,74 @@ +class PrescriptionsController < ApplicationController + before_action :set_prescription, only: %i[ show edit update destroy ] + + # GET /prescriptions or /prescriptions.json + def index + # The index reads as a log: what is being taken now, newest first, with the + # stopped ones below it as history. includes avoids a medication and a form + # query per row. + @prescriptions = Prescription.includes(:medication, :medication_form) + .order(active: :desc, start_date: :desc, created_at: :desc) + end + + # GET /prescriptions/1 or /prescriptions/1.json + def show + end + + # GET /prescriptions/new + def new + @prescription = Prescription.new + end + + # GET /prescriptions/1/edit + def edit + end + + # POST /prescriptions or /prescriptions.json + def create + @prescription = Prescription.new(prescription_params) + + respond_to do |format| + if @prescription.save + format.html { redirect_to @prescription, notice: "Prescription was successfully created." } + format.json { render :show, status: :created, location: @prescription } + else + format.html { render :new, status: :unprocessable_content } + format.json { render json: @prescription.errors, status: :unprocessable_content } + end + end + end + + # PATCH/PUT /prescriptions/1 or /prescriptions/1.json + def update + respond_to do |format| + if @prescription.update(prescription_params) + format.html { redirect_to @prescription, notice: "Prescription was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: @prescription } + else + format.html { render :edit, status: :unprocessable_content } + format.json { render json: @prescription.errors, status: :unprocessable_content } + end + end + end + + # DELETE /prescriptions/1 or /prescriptions/1.json + def destroy + @prescription.destroy! + + respond_to do |format| + format.html { redirect_to prescriptions_path, notice: "Prescription 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_prescription + @prescription = Prescription.find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def prescription_params + params.expect(prescription: [ :medication_id, :medication_form_id, :dosage, :frequency, :time_of_day, :prescribing_doctor, :purpose, :active, :start_date, :stop_date, :notes ]) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1bc4396..8b7bc9c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -8,6 +8,12 @@ def section_current?(path) request.path == path || request.path.start_with?("#{path}/") end + # An empty table cell reads as a rendering fault. An em dash reads as "nobody + # recorded this", which is what a blank optional field actually means. + def or_dash(value) + value.presence || "—" + end + # The value aria-current wants, or nil to leave the attribute off entirely. # link_to drops an aria value of nil rather than rendering aria-current="". def aria_current_section(path) diff --git a/app/helpers/medication_forms_helper.rb b/app/helpers/medication_forms_helper.rb new file mode 100644 index 0000000..a8169b4 --- /dev/null +++ b/app/helpers/medication_forms_helper.rb @@ -0,0 +1,2 @@ +module MedicationFormsHelper +end diff --git a/app/helpers/prescriptions_helper.rb b/app/helpers/prescriptions_helper.rb new file mode 100644 index 0000000..f9f8c09 --- /dev/null +++ b/app/helpers/prescriptions_helper.rb @@ -0,0 +1,34 @@ +module PrescriptionsHelper + # Two lines in one column rather than two columns. Most prescriptions have a + # start and no stop, so a Stopped column would be a mostly empty stripe down + # the log. + def prescription_started(prescription) + prescription_date(prescription.start_date) || "—" + end + + # What has happened since it started, or nil when there is nothing to say. + def prescription_stopped(prescription) + stopped = prescription_date(prescription.stop_date) + + return "stopped #{stopped}" if stopped + + "ongoing" if prescription.active? + end + + # The word carries the meaning; the colour only agrees with it. A boolean + # printed raw ("true") is the one thing a caregiver scanning this cannot read + # at a glance. + def prescription_status(prescription) + if prescription.active? + tag.span "Active", class: "status status--active" + else + tag.span "Stopped", class: "status status--stopped" + end + end + + # "Aug 1, 2026". Short enough to keep a log column narrow, spelled enough to + # sidestep the 8/1 vs 1/8 question. + def prescription_date(date) + date&.strftime("%b %-d, %Y") + end +end diff --git a/app/models/medication.rb b/app/models/medication.rb index 3527fc1..71f13dc 100644 --- a/app/models/medication.rb +++ b/app/models/medication.rb @@ -1,3 +1,7 @@ class Medication < ApplicationRecord - belongs_to :medication_type + belongs_to :medication_type, optional: true + + has_many :prescriptions, dependent: :restrict_with_error + + validates :name, presence: true end diff --git a/app/models/medication_form.rb b/app/models/medication_form.rb new file mode 100644 index 0000000..1a57e97 --- /dev/null +++ b/app/models/medication_form.rb @@ -0,0 +1,5 @@ +class MedicationForm < ApplicationRecord + has_many :prescriptions, dependent: :nullify + + validates :name, presence: true +end diff --git a/app/models/medication_type.rb b/app/models/medication_type.rb index 548d639..2ccd64f 100644 --- a/app/models/medication_type.rb +++ b/app/models/medication_type.rb @@ -1,2 +1,5 @@ class MedicationType < ApplicationRecord + has_many :medications, dependent: :nullify + + validates :name, presence: true end diff --git a/app/models/prescription.rb b/app/models/prescription.rb new file mode 100644 index 0000000..5f0f0fd --- /dev/null +++ b/app/models/prescription.rb @@ -0,0 +1,4 @@ +class Prescription < ApplicationRecord + belongs_to :medication + belongs_to :medication_form, optional: true +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 591b25b..5d87a3d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -46,10 +46,14 @@ current_page?. %> <%= link_to "People", people_path, aria: { current: aria_current_section(people_path) } %> + <%= link_to "Prescriptions", prescriptions_path, + aria: { current: aria_current_section(prescriptions_path) } %> <%= link_to "Medications", medications_path, aria: { current: aria_current_section(medications_path) } %> <%= link_to "Medication types", medication_types_path, aria: { current: aria_current_section(medication_types_path) } %> + <%= link_to "Medication forms", medication_forms_path, + aria: { current: aria_current_section(medication_forms_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 diff --git a/app/views/medication_forms/_form.html.erb b/app/views/medication_forms/_form.html.erb new file mode 100644 index 0000000..7030d94 --- /dev/null +++ b/app/views/medication_forms/_form.html.erb @@ -0,0 +1,22 @@ +<%= form_with(model: medication_form) do |form| %> + <% if medication_form.errors.any? %> +
+

<%= pluralize(medication_form.errors.count, "error") %> prohibited this medication_form from being saved:

+ +
    + <% medication_form.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/medication_forms/_medication_form.html.erb b/app/views/medication_forms/_medication_form.html.erb new file mode 100644 index 0000000..d3709da --- /dev/null +++ b/app/views/medication_forms/_medication_form.html.erb @@ -0,0 +1,7 @@ +
+
+ Name: + <%= medication_form.name %> +
+ +
diff --git a/app/views/medication_forms/_medication_form.json.jbuilder b/app/views/medication_forms/_medication_form.json.jbuilder new file mode 100644 index 0000000..fafc114 --- /dev/null +++ b/app/views/medication_forms/_medication_form.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! medication_form, :id, :name, :created_at, :updated_at +json.url medication_form_url(medication_form, format: :json) diff --git a/app/views/medication_forms/edit.html.erb b/app/views/medication_forms/edit.html.erb new file mode 100644 index 0000000..3619855 --- /dev/null +++ b/app/views/medication_forms/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing medication form" %> + +

Editing medication form

+ +<%= render "form", medication_form: @medication_form %> + +
+ +
+ <%= link_to "Show this medication form", @medication_form %> | + <%= link_to "Back to medication forms", medication_forms_path %> +
diff --git a/app/views/medication_forms/index.html.erb b/app/views/medication_forms/index.html.erb new file mode 100644 index 0000000..053bf29 --- /dev/null +++ b/app/views/medication_forms/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Medication forms" %> + +

Medication forms

+ +
+ <% @medication_forms.each do |medication_form| %> + <%= render medication_form %> +

+ <%= link_to "Show this medication form", medication_form %> +

+ <% end %> +
+ +<%= link_to "New medication form", new_medication_form_path %> diff --git a/app/views/medication_forms/index.json.jbuilder b/app/views/medication_forms/index.json.jbuilder new file mode 100644 index 0000000..1b23c5c --- /dev/null +++ b/app/views/medication_forms/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @medication_forms, partial: "medication_forms/medication_form", as: :medication_form diff --git a/app/views/medication_forms/new.html.erb b/app/views/medication_forms/new.html.erb new file mode 100644 index 0000000..91be4d9 --- /dev/null +++ b/app/views/medication_forms/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New medication form" %> + +

New medication form

+ +<%= render "form", medication_form: @medication_form %> + +
+ +
+ <%= link_to "Back to medication forms", medication_forms_path %> +
diff --git a/app/views/medication_forms/show.html.erb b/app/views/medication_forms/show.html.erb new file mode 100644 index 0000000..0258580 --- /dev/null +++ b/app/views/medication_forms/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @medication_form %> + +
+ <%= link_to "Edit this medication form", edit_medication_form_path(@medication_form) %> | + <%= link_to "Back to medication forms", medication_forms_path %> + + <%= button_to "Destroy this medication form", @medication_form, method: :delete %> +
diff --git a/app/views/medication_forms/show.json.jbuilder b/app/views/medication_forms/show.json.jbuilder new file mode 100644 index 0000000..8ecbaa9 --- /dev/null +++ b/app/views/medication_forms/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "medication_forms/medication_form", medication_form: @medication_form diff --git a/app/views/medications/_form.html.erb b/app/views/medications/_form.html.erb index 678d8b6..a8597bf 100644 --- a/app/views/medications/_form.html.erb +++ b/app/views/medications/_form.html.erb @@ -17,58 +17,13 @@
- <%= form.label :medication_type_id, style: "display: block" %> - <%= form.text_field :medication_type_id %> + <%= form.label :medication_type_id, "Medication type", style: "display: block" %> + <%= form.collection_select :medication_type_id, MedicationType.order(:name), :id, :name, include_blank: true %>
- <%= form.label :current, style: "display: block" %> - <%= form.checkbox :current %> -
- -
- <%= form.label :dosage, style: "display: block" %> - <%= form.text_field :dosage %> -
- -
- <%= form.label :frequency, style: "display: block" %> - <%= form.text_field :frequency %> -
- -
- <%= form.label :time_of_day, style: "display: block" %> - <%= form.text_field :time_of_day %> -
- -
- <%= form.label :form, style: "display: block" %> - <%= form.text_field :form %> -
- -
- <%= form.label :purpose, style: "display: block" %> - <%= form.textarea :purpose %> -
- -
- <%= form.label :start_date, style: "display: block" %> - <%= form.date_field :start_date %> -
- -
- <%= form.label :stop_date, style: "display: block" %> - <%= form.date_field :stop_date %> -
- -
- <%= form.label :refill, style: "display: block" %> - <%= form.text_field :refill %> -
- -
- <%= form.label :notes, style: "display: block" %> - <%= form.textarea :notes %> + <%= form.label :side_effects, style: "display: block" %> + <%= form.textarea :side_effects %>
diff --git a/app/views/medications/_medication.html.erb b/app/views/medications/_medication.html.erb index 9d4de65..fbe227b 100644 --- a/app/views/medications/_medication.html.erb +++ b/app/views/medications/_medication.html.erb @@ -6,57 +6,12 @@
Medication type: - <%= medication.medication_type_id %> + <%= medication.medication_type&.name %>
- Current: - <%= medication.current %> -
- -
- Dosage: - <%= medication.dosage %> -
- -
- Frequency: - <%= medication.frequency %> -
- -
- Time of day: - <%= medication.time_of_day %> -
- -
- Form: - <%= medication.form %> -
- -
- Purpose: - <%= medication.purpose %> -
- -
- Start date: - <%= medication.start_date %> -
- -
- Stop date: - <%= medication.stop_date %> -
- -
- Refill: - <%= medication.refill %> -
- -
- Notes: - <%= medication.notes %> + Side effects: + <%= medication.side_effects %>
diff --git a/app/views/medications/show.html.erb b/app/views/medications/show.html.erb index 603914f..522d5c7 100644 --- a/app/views/medications/show.html.erb +++ b/app/views/medications/show.html.erb @@ -1,4 +1,5 @@

<%= notice %>

+

<%= alert %>

<%= render @medication %> diff --git a/app/views/prescriptions/_form.html.erb b/app/views/prescriptions/_form.html.erb new file mode 100644 index 0000000..742a49d --- /dev/null +++ b/app/views/prescriptions/_form.html.erb @@ -0,0 +1,72 @@ +<%= form_with(model: prescription) do |form| %> + <% if prescription.errors.any? %> +
+

<%= pluralize(prescription.errors.count, "error") %> prohibited this prescription from being saved:

+ +
    + <% prescription.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :medication_id, "Medication", style: "display: block" %> + <%= form.collection_select :medication_id, Medication.order(:name), :id, :name, include_blank: true %> +
+ +
+ <%= form.label :medication_form_id, "Form", style: "display: block" %> + <%= form.collection_select :medication_form_id, MedicationForm.order(:name), :id, :name, include_blank: true %> +
+ +
+ <%= form.label :dosage, style: "display: block" %> + <%= form.text_field :dosage %> +
+ +
+ <%= form.label :frequency, style: "display: block" %> + <%= form.text_field :frequency %> +
+ +
+ <%= form.label :time_of_day, style: "display: block" %> + <%= form.text_field :time_of_day %> +
+ +
+ <%= form.label :prescribing_doctor, style: "display: block" %> + <%= form.text_field :prescribing_doctor %> +
+ +
+ <%= form.label :purpose, style: "display: block" %> + <%= form.textarea :purpose %> +
+ +
+ <%= form.label :active, style: "display: block" %> + <%= form.checkbox :active %> +
+ +
+ <%= form.label :start_date, style: "display: block" %> + <%= form.date_field :start_date %> +
+ +
+ <%= form.label :stop_date, style: "display: block" %> + <%= form.date_field :stop_date %> +
+ +
+ <%= form.label :notes, style: "display: block" %> + <%= form.textarea :notes %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/prescriptions/_prescription.html.erb b/app/views/prescriptions/_prescription.html.erb new file mode 100644 index 0000000..e130fa1 --- /dev/null +++ b/app/views/prescriptions/_prescription.html.erb @@ -0,0 +1,57 @@ +
+
+ Medication: + <%= prescription.medication.name %> +
+ +
+ Form: + <%= prescription.medication_form&.name %> +
+ +
+ Dosage: + <%= prescription.dosage %> +
+ +
+ Frequency: + <%= prescription.frequency %> +
+ +
+ Time of day: + <%= prescription.time_of_day %> +
+ +
+ Prescribing doctor: + <%= prescription.prescribing_doctor %> +
+ +
+ Purpose: + <%= prescription.purpose %> +
+ +
+ Active: + <%= prescription_status prescription %> +
+ +
+ Start date: + <%= or_dash prescription_date(prescription.start_date) %> +
+ +
+ Stop date: + <%= or_dash prescription_date(prescription.stop_date) %> +
+ +
+ Notes: + <%= prescription.notes %> +
+ +
diff --git a/app/views/prescriptions/_prescription.json.jbuilder b/app/views/prescriptions/_prescription.json.jbuilder new file mode 100644 index 0000000..ac80f17 --- /dev/null +++ b/app/views/prescriptions/_prescription.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! prescription, :id, :medication_id, :medication_form_id, :dosage, :frequency, :time_of_day, :prescribing_doctor, :purpose, :active, :start_date, :stop_date, :notes, :created_at, :updated_at +json.url prescription_url(prescription, format: :json) diff --git a/app/views/prescriptions/edit.html.erb b/app/views/prescriptions/edit.html.erb new file mode 100644 index 0000000..06c9f86 --- /dev/null +++ b/app/views/prescriptions/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing prescription" %> + +

Editing prescription

+ +<%= render "form", prescription: @prescription %> + +
+ +
+ <%= link_to "Show this prescription", @prescription %> | + <%= link_to "Back to prescriptions", prescriptions_path %> +
diff --git a/app/views/prescriptions/index.html.erb b/app/views/prescriptions/index.html.erb new file mode 100644 index 0000000..3cca715 --- /dev/null +++ b/app/views/prescriptions/index.html.erb @@ -0,0 +1,54 @@ +

<%= notice %>

+ +<% content_for :title, "Prescriptions" %> + +

Prescriptions

+ +<% if @prescriptions.any? %> + <%# The wrapper, not the table, is what scrolls on a narrow screen. tabindex + makes that scroll reachable without a mouse, and the role/label keep a + focusable div from being announced as an unnamed group. %> +
+ + + + + + + + + + + + + + + <% @prescriptions.each do |prescription| %> + + + + + + + + + + <% end %> + +
MedicationDosageFrequencyTime of dayPrescribing doctorStartedStatus
+ <%= link_to prescription.medication.name, prescription %> + <% if prescription.medication_form %> + <%= prescription.medication_form.name %> + <% end %> + <%= or_dash prescription.dosage %><%= or_dash prescription.frequency %><%= or_dash prescription.time_of_day %><%= or_dash prescription.prescribing_doctor %> + <%= prescription_started prescription %> + <% if (since = prescription_stopped(prescription)) %> + <%= since %> + <% end %> + <%= prescription_status prescription %>
+
+<% else %> +

No prescriptions yet.

+<% end %> + +<%= link_to "New prescription", new_prescription_path %> diff --git a/app/views/prescriptions/index.json.jbuilder b/app/views/prescriptions/index.json.jbuilder new file mode 100644 index 0000000..8751726 --- /dev/null +++ b/app/views/prescriptions/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @prescriptions, partial: "prescriptions/prescription", as: :prescription diff --git a/app/views/prescriptions/new.html.erb b/app/views/prescriptions/new.html.erb new file mode 100644 index 0000000..e0b3c07 --- /dev/null +++ b/app/views/prescriptions/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New prescription" %> + +

New prescription

+ +<%= render "form", prescription: @prescription %> + +
+ +
+ <%= link_to "Back to prescriptions", prescriptions_path %> +
diff --git a/app/views/prescriptions/show.html.erb b/app/views/prescriptions/show.html.erb new file mode 100644 index 0000000..efa49f9 --- /dev/null +++ b/app/views/prescriptions/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @prescription %> + +
+ <%= link_to "Edit this prescription", edit_prescription_path(@prescription) %> | + <%= link_to "Back to prescriptions", prescriptions_path %> + + <%= button_to "Destroy this prescription", @prescription, method: :delete %> +
diff --git a/app/views/prescriptions/show.json.jbuilder b/app/views/prescriptions/show.json.jbuilder new file mode 100644 index 0000000..d70e156 --- /dev/null +++ b/app/views/prescriptions/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "prescriptions/prescription", prescription: @prescription diff --git a/config/routes.rb b/config/routes.rb index bce874b..48205eb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,9 @@ Rails.application.routes.draw do devise_for :users resources :medications + resources :medication_forms resources :medication_types + resources :prescriptions resources :people do resource :address, only: [ :show, :create, :update, :destroy ] end diff --git a/db/migrate/20260829203813_create_medication_forms.rb b/db/migrate/20260829203813_create_medication_forms.rb new file mode 100644 index 0000000..92b31dc --- /dev/null +++ b/db/migrate/20260829203813_create_medication_forms.rb @@ -0,0 +1,9 @@ +class CreateMedicationForms < ActiveRecord::Migration[8.1] + def change + create_table :medication_forms do |t| + t.string :name + + t.timestamps + end + end +end diff --git a/db/migrate/20260829203818_create_prescriptions.rb b/db/migrate/20260829203818_create_prescriptions.rb new file mode 100644 index 0000000..f1e041c --- /dev/null +++ b/db/migrate/20260829203818_create_prescriptions.rb @@ -0,0 +1,19 @@ +class CreatePrescriptions < ActiveRecord::Migration[8.1] + def change + create_table :prescriptions do |t| + t.belongs_to :medication, null: false, foreign_key: true + t.belongs_to :medication_form, foreign_key: true + t.string :dosage + t.string :frequency + t.string :time_of_day + t.string :prescribing_doctor + t.text :purpose + t.boolean :active + t.date :start_date + t.date :stop_date + t.text :notes + + t.timestamps + end + end +end diff --git a/db/migrate/20260829203900_move_prescription_fields_off_medications.rb b/db/migrate/20260829203900_move_prescription_fields_off_medications.rb new file mode 100644 index 0000000..f5a2062 --- /dev/null +++ b/db/migrate/20260829203900_move_prescription_fields_off_medications.rb @@ -0,0 +1,22 @@ +class MovePrescriptionFieldsOffMedications < ActiveRecord::Migration[8.1] + def change + # A medication is now the catalog entry (what the drug is); how a patient + # takes it lives on prescriptions. See issue #164. + remove_column :medications, :current, :boolean + remove_column :medications, :dosage, :string + remove_column :medications, :frequency, :string + remove_column :medications, :time_of_day, :string + remove_column :medications, :form, :string + remove_column :medications, :purpose, :text + remove_column :medications, :start_date, :date + remove_column :medications, :stop_date, :date + remove_column :medications, :refill, :string + remove_column :medications, :notes, :text + + add_column :medications, :side_effects, :text + + # The seed catalog in #164 lists no type for each medication, so a + # medication can exist before anyone classifies it. + change_column_null :medications, :medication_type_id, true + end +end diff --git a/db/schema.rb b/db/schema.rb index 36dd645..aa7f165 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_145325) do +ActiveRecord::Schema[8.1].define(version: 2026_08_29_203900) do create_table "addresses", force: :cascade do |t| t.string "city" t.datetime "created_at", null: false @@ -20,6 +20,12 @@ t.index ["person_id"], name: "index_addresses_on_person_id" end + create_table "medication_forms", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name" + t.datetime "updated_at", null: false + end + create_table "medication_types", force: :cascade do |t| t.datetime "created_at", null: false t.string "name" @@ -28,18 +34,9 @@ create_table "medications", force: :cascade do |t| t.datetime "created_at", null: false - t.boolean "current" - t.string "dosage" - t.string "form" - t.string "frequency" - t.integer "medication_type_id", null: false + t.integer "medication_type_id" t.string "name" - t.text "notes" - t.text "purpose" - t.string "refill" - t.date "start_date" - t.date "stop_date" - t.string "time_of_day" + t.text "side_effects" t.datetime "updated_at", null: false t.index ["medication_type_id"], name: "index_medications_on_medication_type_id" end @@ -56,6 +53,24 @@ t.index ["relationship_id"], name: "index_people_on_relationship_id" end + create_table "prescriptions", force: :cascade do |t| + t.boolean "active" + t.datetime "created_at", null: false + t.string "dosage" + t.string "frequency" + t.integer "medication_form_id" + t.integer "medication_id", null: false + t.text "notes" + t.string "prescribing_doctor" + t.text "purpose" + t.date "start_date" + t.date "stop_date" + t.string "time_of_day" + t.datetime "updated_at", null: false + t.index ["medication_form_id"], name: "index_prescriptions_on_medication_form_id" + t.index ["medication_id"], name: "index_prescriptions_on_medication_id" + end + create_table "relationships", force: :cascade do |t| t.datetime "created_at", null: false t.string "name" @@ -98,4 +113,6 @@ add_foreign_key "addresses", "people" add_foreign_key "medications", "medication_types" add_foreign_key "people", "relationships" + add_foreign_key "prescriptions", "medication_forms" + add_foreign_key "prescriptions", "medications" end diff --git a/db/seeds.rb b/db/seeds.rb index 5fb2308..f7802de 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -9,3 +9,52 @@ { 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 } +end diff --git a/test/controllers/medication_forms_controller_test.rb b/test/controllers/medication_forms_controller_test.rb new file mode 100644 index 0000000..3d693f2 --- /dev/null +++ b/test/controllers/medication_forms_controller_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class MedicationFormsControllerTest < ActionDispatch::IntegrationTest + setup do + @medication_form = medication_forms(:one) + end + + test "should get index" do + get medication_forms_url + assert_response :success + end + + test "should get new" do + get new_medication_form_url + assert_response :success + end + + test "should create medication_form" do + assert_difference("MedicationForm.count") do + post medication_forms_url, params: { medication_form: { name: @medication_form.name } } + end + + assert_redirected_to medication_form_url(MedicationForm.last) + end + + test "should show medication_form" do + get medication_form_url(@medication_form) + assert_response :success + end + + test "should get edit" do + get edit_medication_form_url(@medication_form) + assert_response :success + end + + test "should update medication_form" do + patch medication_form_url(@medication_form), params: { medication_form: { name: @medication_form.name } } + assert_redirected_to medication_form_url(@medication_form) + end + + test "should destroy medication_form" do + assert_difference("MedicationForm.count", -1) do + delete medication_form_url(@medication_form) + end + + assert_redirected_to medication_forms_url + end +end diff --git a/test/controllers/medications_controller_test.rb b/test/controllers/medications_controller_test.rb index 58f7f29..2b8834d 100644 --- a/test/controllers/medications_controller_test.rb +++ b/test/controllers/medications_controller_test.rb @@ -17,12 +17,20 @@ class MedicationsControllerTest < ActionDispatch::IntegrationTest test "should create medication" do assert_difference("Medication.count") do - post medications_url, params: { medication: { current: @medication.current, dosage: @medication.dosage, form: @medication.form, frequency: @medication.frequency, medication_type_id: @medication.medication_type_id, name: @medication.name, notes: @medication.notes, purpose: @medication.purpose, refill: @medication.refill, start_date: @medication.start_date, stop_date: @medication.stop_date, time_of_day: @medication.time_of_day } } + post medications_url, params: { medication: { name: "Metformin", medication_type_id: @medication.medication_type_id, side_effects: "Nausea, diarrhea, stomach upset" } } end assert_redirected_to medication_url(Medication.last) end + test "should not create medication without a name" do + assert_no_difference("Medication.count") do + post medications_url, params: { medication: { name: "", side_effects: "Nausea" } } + end + + assert_response :unprocessable_content + end + test "should show medication" do get medication_url(@medication) assert_response :success @@ -34,15 +42,25 @@ class MedicationsControllerTest < ActionDispatch::IntegrationTest end test "should update medication" do - patch medication_url(@medication), params: { medication: { current: @medication.current, dosage: @medication.dosage, form: @medication.form, frequency: @medication.frequency, medication_type_id: @medication.medication_type_id, name: @medication.name, notes: @medication.notes, purpose: @medication.purpose, refill: @medication.refill, start_date: @medication.start_date, stop_date: @medication.stop_date, time_of_day: @medication.time_of_day } } + patch medication_url(@medication), params: { medication: { name: @medication.name, medication_type_id: @medication.medication_type_id, side_effects: @medication.side_effects } } assert_redirected_to medication_url(@medication) end test "should destroy medication" do + unprescribed = medications(:three) + assert_difference("Medication.count", -1) do - delete medication_url(@medication) + delete medication_url(unprescribed) end assert_redirected_to medications_url end + + test "should not destroy medication that a prescription depends on" do + assert_no_difference("Medication.count") do + delete medication_url(@medication) + end + + assert_redirected_to medication_url(@medication) + end end diff --git a/test/controllers/prescriptions_controller_test.rb b/test/controllers/prescriptions_controller_test.rb new file mode 100644 index 0000000..b24ff30 --- /dev/null +++ b/test/controllers/prescriptions_controller_test.rb @@ -0,0 +1,69 @@ +require "test_helper" + +class PrescriptionsControllerTest < ActionDispatch::IntegrationTest + setup do + @prescription = prescriptions(:one) + end + + test "should get index" do + get prescriptions_url + + assert_response :success + assert_select "table#prescriptions thead th", 7 + assert_select "table#prescriptions tbody tr", Prescription.count + assert_select "##{dom_id(@prescription)} th[scope=row]", text: /#{@prescription.medication.name}/ + end + + test "index lists active prescriptions before stopped ones" do + get prescriptions_url + + statuses = css_select("table#prescriptions tbody .status").map(&:text) + assert_equal statuses.sort_by { |status| status == "Active" ? 0 : 1 }, statuses + end + + test "index shows an empty state when there is nothing to log" do + Prescription.delete_all + + get prescriptions_url + + assert_response :success + assert_select "table#prescriptions", false + assert_select "p", text: "No prescriptions yet." + end + + test "should get new" do + get new_prescription_url + assert_response :success + end + + test "should create prescription" do + assert_difference("Prescription.count") do + post prescriptions_url, params: { prescription: { active: @prescription.active, dosage: @prescription.dosage, frequency: @prescription.frequency, medication_form_id: @prescription.medication_form_id, medication_id: @prescription.medication_id, notes: @prescription.notes, prescribing_doctor: @prescription.prescribing_doctor, purpose: @prescription.purpose, start_date: @prescription.start_date, stop_date: @prescription.stop_date, time_of_day: @prescription.time_of_day } } + end + + assert_redirected_to prescription_url(Prescription.last) + end + + test "should show prescription" do + get prescription_url(@prescription) + assert_response :success + end + + test "should get edit" do + get edit_prescription_url(@prescription) + assert_response :success + end + + test "should update prescription" do + patch prescription_url(@prescription), params: { prescription: { active: @prescription.active, dosage: @prescription.dosage, frequency: @prescription.frequency, medication_form_id: @prescription.medication_form_id, medication_id: @prescription.medication_id, notes: @prescription.notes, prescribing_doctor: @prescription.prescribing_doctor, purpose: @prescription.purpose, start_date: @prescription.start_date, stop_date: @prescription.stop_date, time_of_day: @prescription.time_of_day } } + assert_redirected_to prescription_url(@prescription) + end + + test "should destroy prescription" do + assert_difference("Prescription.count", -1) do + delete prescription_url(@prescription) + end + + assert_redirected_to prescriptions_url + end +end diff --git a/test/fixtures/medication_forms.yml b/test/fixtures/medication_forms.yml new file mode 100644 index 0000000..cd3ef68 --- /dev/null +++ b/test/fixtures/medication_forms.yml @@ -0,0 +1,7 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: Capsule + +two: + name: Liquid diff --git a/test/fixtures/medication_types.yml b/test/fixtures/medication_types.yml index 7d41224..83794d7 100644 --- a/test/fixtures/medication_types.yml +++ b/test/fixtures/medication_types.yml @@ -1,7 +1,7 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - name: MyString + name: Prescription two: - name: MyString + name: Over-the-counter diff --git a/test/fixtures/medications.yml b/test/fixtures/medications.yml index 2487777..d5513cc 100644 --- a/test/fixtures/medications.yml +++ b/test/fixtures/medications.yml @@ -1,29 +1,18 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - name: MyString - medication_type: two - current: false - dosage: MyString - frequency: MyString - time_of_day: MyString - form: MyString - purpose: MyText - start_date: 2026-08-29 - stop_date: 2026-08-29 - refill: MyString - notes: MyText + name: Lisinopril + medication_type: one + side_effects: Dry cough, dizziness, headache two: - name: MyString + name: Ibuprofen medication_type: two - current: false - dosage: MyString - frequency: MyString - time_of_day: MyString - form: MyString - purpose: MyText - start_date: 2026-08-29 - stop_date: 2026-08-29 - refill: MyString - notes: MyText + side_effects: Stomach upset, heartburn, dizziness + +# No prescription points at this one. A medication that is in use refuses to be +# destroyed, so the destroy tests need a medication nothing depends on. +three: + name: Warfarin + medication_type: one + side_effects: Easy bruising, bleeding, nausea diff --git a/test/fixtures/prescriptions.yml b/test/fixtures/prescriptions.yml new file mode 100644 index 0000000..7629f98 --- /dev/null +++ b/test/fixtures/prescriptions.yml @@ -0,0 +1,27 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + medication: one + medication_form: one + dosage: 10 mg + frequency: Once daily + time_of_day: Morning + prescribing_doctor: Dr. Reyes + purpose: Blood pressure + active: true + start_date: 2026-08-01 + stop_date: 2026-12-31 + notes: Take with food + +two: + medication: two + medication_form: two + dosage: 200 mg + frequency: As needed + time_of_day: Evening + prescribing_doctor: Dr. Okafor + purpose: Joint pain + active: false + start_date: 2026-07-01 + stop_date: 2026-07-31 + notes: Stopped after the flare settled diff --git a/test/models/medication_form_test.rb b/test/models/medication_form_test.rb new file mode 100644 index 0000000..63981d1 --- /dev/null +++ b/test/models/medication_form_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class MedicationFormTest < ActiveSupport::TestCase + test "requires a name" do + assert_not MedicationForm.new.valid? + end + + test "leaves its prescriptions behind when destroyed" do + prescription = prescriptions(:one) + + prescription.medication_form.destroy + + assert_nil prescription.reload.medication_form_id + end +end diff --git a/test/models/medication_test.rb b/test/models/medication_test.rb index 8fcd105..15fb9c4 100644 --- a/test/models/medication_test.rb +++ b/test/models/medication_test.rb @@ -1,7 +1,18 @@ require "test_helper" class MedicationTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "requires a name" do + assert_not Medication.new(side_effects: "Nausea").valid? + end + + test "does not require a medication type" do + assert Medication.new(name: "Aspirin").valid? + end + + test "refuses to be destroyed while a prescription points at it" do + medication = medications(:one) + + assert_not medication.destroy + assert medication.errors.any? + end end diff --git a/test/models/medication_type_test.rb b/test/models/medication_type_test.rb index 6417e1a..6813a2b 100644 --- a/test/models/medication_type_test.rb +++ b/test/models/medication_type_test.rb @@ -1,7 +1,15 @@ require "test_helper" class MedicationTypeTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "requires a name" do + assert_not MedicationType.new.valid? + end + + test "leaves its medications behind when destroyed" do + medication = medications(:one) + + medication.medication_type.destroy + + assert_nil medication.reload.medication_type_id + end end diff --git a/test/models/prescription_test.rb b/test/models/prescription_test.rb new file mode 100644 index 0000000..69bb205 --- /dev/null +++ b/test/models/prescription_test.rb @@ -0,0 +1,11 @@ +require "test_helper" + +class PrescriptionTest < ActiveSupport::TestCase + test "requires a medication" do + assert_not Prescription.new(dosage: "10 mg").valid? + end + + test "does not require a form" do + assert Prescription.new(medication: medications(:one)).valid? + end +end From 6f236d1c05e57313e88b65563d9a82516e8f45db Mon Sep 17 00:00:00 2001 From: Danny Collier <294724+dcollie2@users.noreply.github.com> Date: Sun, 30 Aug 2026 06:26:36 -0400 Subject: [PATCH 24/25] Address feedback from dpowers1 --- app/helpers/medication_forms_helper.rb | 2 - app/helpers/prescriptions_helper.rb | 12 ++++-- app/views/prescriptions/index.html.erb | 4 +- .../20260829203818_create_prescriptions.rb | 2 +- db/schema.rb | 2 +- test/helpers/prescriptions_helper_test.rb | 39 +++++++++++++++++++ test/models/prescription_test.rb | 4 ++ 7 files changed, 54 insertions(+), 11 deletions(-) delete mode 100644 app/helpers/medication_forms_helper.rb create mode 100644 test/helpers/prescriptions_helper_test.rb diff --git a/app/helpers/medication_forms_helper.rb b/app/helpers/medication_forms_helper.rb deleted file mode 100644 index a8169b4..0000000 --- a/app/helpers/medication_forms_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module MedicationFormsHelper -end diff --git a/app/helpers/prescriptions_helper.rb b/app/helpers/prescriptions_helper.rb index f9f8c09..262d2d1 100644 --- a/app/helpers/prescriptions_helper.rb +++ b/app/helpers/prescriptions_helper.rb @@ -6,13 +6,17 @@ def prescription_started(prescription) prescription_date(prescription.start_date) || "—" end - # What has happened since it started, or nil when there is nothing to say. + # What has happened since it started. Every combination of active and + # stop_date says something, and all four say it out loud: a blank line under + # a Stopped badge is indistinguishable from a column that failed to render. def prescription_stopped(prescription) stopped = prescription_date(prescription.stop_date) - return "stopped #{stopped}" if stopped - - "ongoing" if prescription.active? + if prescription.active? + stopped ? "until #{stopped}" : "ongoing" + else + stopped ? "stopped #{stopped}" : "no stop date recorded" + end end # The word carries the meaning; the colour only agrees with it. A boolean diff --git a/app/views/prescriptions/index.html.erb b/app/views/prescriptions/index.html.erb index 3cca715..32ca691 100644 --- a/app/views/prescriptions/index.html.erb +++ b/app/views/prescriptions/index.html.erb @@ -37,9 +37,7 @@ <%= or_dash prescription.prescribing_doctor %> <%= prescription_started prescription %> - <% if (since = prescription_stopped(prescription)) %> - <%= since %> - <% end %> + <%= prescription_stopped prescription %> <%= prescription_status prescription %> diff --git a/db/migrate/20260829203818_create_prescriptions.rb b/db/migrate/20260829203818_create_prescriptions.rb index f1e041c..46398a6 100644 --- a/db/migrate/20260829203818_create_prescriptions.rb +++ b/db/migrate/20260829203818_create_prescriptions.rb @@ -8,7 +8,7 @@ def change t.string :time_of_day t.string :prescribing_doctor t.text :purpose - t.boolean :active + t.boolean :active, default: true, null: false t.date :start_date t.date :stop_date t.text :notes diff --git a/db/schema.rb b/db/schema.rb index aa7f165..84a1e9a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -54,7 +54,7 @@ end create_table "prescriptions", force: :cascade do |t| - t.boolean "active" + t.boolean "active", default: true, null: false t.datetime "created_at", null: false t.string "dosage" t.string "frequency" diff --git a/test/helpers/prescriptions_helper_test.rb b/test/helpers/prescriptions_helper_test.rb new file mode 100644 index 0000000..adfd173 --- /dev/null +++ b/test/helpers/prescriptions_helper_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class PrescriptionsHelperTest < ActionView::TestCase + # The four states the sub-note under the Started column has to cover. The + # last one is the one that used to render blank, which read as a broken cell + # rather than as a missing stop date. + test "still being taken, with no end in sight" do + assert_equal "ongoing", prescription_stopped(prescription(active: true, stop_date: nil)) + end + + test "still being taken, with an end already planned" do + assert_equal "until Jan 19, 2026", + prescription_stopped(prescription(active: true, stop_date: Date.new(2026, 1, 19))) + end + + test "no longer being taken, and we know when it ended" do + assert_equal "stopped Jan 19, 2026", + prescription_stopped(prescription(active: false, stop_date: Date.new(2026, 1, 19))) + end + + test "no longer being taken, and nobody wrote down when" do + assert_equal "no stop date recorded", + prescription_stopped(prescription(active: false, stop_date: nil)) + end + + test "a missing start date is dashed rather than left blank" do + assert_equal "—", prescription_started(prescription(start_date: nil)) + end + + test "status says the word, not the boolean" do + assert_match "Active", prescription_status(prescription(active: true)) + assert_match "Stopped", prescription_status(prescription(active: false)) + end + + private + def prescription(**attributes) + Prescription.new(attributes.reverse_merge(medication: medications(:one))) + end +end diff --git a/test/models/prescription_test.rb b/test/models/prescription_test.rb index 69bb205..88bfb6e 100644 --- a/test/models/prescription_test.rb +++ b/test/models/prescription_test.rb @@ -5,6 +5,10 @@ class PrescriptionTest < ActiveSupport::TestCase assert_not Prescription.new(dosage: "10 mg").valid? end + test "starts out active" do + assert Prescription.new(medication: medications(:one)).active? + end + test "does not require a form" do assert Prescription.new(medication: medications(:one)).valid? end From 029dd7b429b9576ecfbab0f2b440ead1d6cc3e0d Mon Sep 17 00:00:00 2001 From: Danny Collier <294724+dcollie2@users.noreply.github.com> Date: Sun, 30 Aug 2026 06:52:11 -0400 Subject: [PATCH 25/25] Group administrative sections behind an Admin menu The header listed People, Prescriptions, Medications, Medication types and Medication forms as five equal links. The last three are reference data - nobody using the app day to day adds a medication or invents a dosage form - so giving them equal billing with the work the app exists for was misleading. They now sit behind one Admin link leading to /admin, which lists them, and a sub-nav band follows you into each one so they read as part of an area rather than as top-level sections. The resources keep their own top-level paths and controllers; this is grouping in the navigation, not an Admin:: namespace. ApplicationHelper#admin_sections is the single list of what counts as admin, so the header link, the sub-nav and the landing page cannot disagree. When these pages stop being open to everyone, AdminController carries a note on where the gate goes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01T1LkcbAaEZigyffSKycUNy --- app/assets/stylesheets/application.css | 113 ++++++++++++++++++++++ app/controllers/admin_controller.rb | 12 +++ app/helpers/application_helper.rb | 32 ++++++ app/views/admin/index.html.erb | 23 +++++ app/views/layouts/application.html.erb | 16 +-- app/views/shared/_admin_nav.html.erb | 20 ++++ config/routes.rb | 6 ++ test/controllers/admin_controller_test.rb | 43 ++++++++ 8 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 app/controllers/admin_controller.rb create mode 100644 app/views/admin/index.html.erb create mode 100644 app/views/shared/_admin_nav.html.erb create mode 100644 test/controllers/admin_controller_test.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 6353bab..a42eb9d 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -233,6 +233,119 @@ code, pre { font-family: var(--font-mono); font-size: 0.9375em; } .site-main { padding-block: var(--space-5) var(--space-6); } +/* ---------------------------------------------------------------- admin --- */ +/* + * The administrative area - the lists of medications, types and forms that + * are set up once rather than used daily. It is not a separate app: same + * header, same palette. The one signal that you have stepped sideways into + * setup is this second nav band, on the muted grey the sheet already uses for + * table headings and field captions. + * + * These sections still live at /medications and friends, so the band is what + * ties them together. ApplicationHelper#admin_area? decides when it shows. + */ + +.admin-nav { + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted)); + font-size: 0.9375rem; +} + +.admin-nav__inner { + max-width: var(--content-width); + margin-inline: auto; + padding-inline: var(--space-3); + padding-block: var(--space-2); + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-1) var(--space-3); +} + +/* Names the band rather than labelling any one link, so the row still reads + as "Admin: Overview, Medications, ..." when the header is scrolled away. + The nav's aria-label says the same thing for a screen reader. */ +.admin-nav__label { + color: hsl(var(--muted-foreground)); + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.admin-nav a { + color: hsl(var(--muted-foreground)); + text-decoration: none; + padding-block: var(--space-1); +} + +.admin-nav a:hover { color: hsl(var(--foreground)); } + +.admin-nav a[aria-current="page"] { + color: hsl(var(--primary)); + font-weight: 600; +} + +/* The opening sentence on /admin. Wider than body text would allow it to be + emphatic, so it stays the same size and only lightens. */ +.lede { + max-width: 46ch; + color: hsl(var(--muted-foreground)); + margin-bottom: var(--space-4); +} + +.admin-cards { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-3); + /* One column on a phone, two once there is room for two readable ones. */ + grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); +} + +.admin-card { + position: relative; + padding: var(--space-3) var(--space-4); + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--card)); +} + +.admin-card:hover { border-color: hsl(var(--primary) / 0.5); } + +.admin-card__title { + font-size: 1.0625rem; + margin-bottom: var(--space-1); +} + +/* Stretching the one link over the whole card makes the card a click target + without adding a second link to the same place - which a screen reader would + read out twice and a keyboard user would have to tab through twice. */ +.admin-card__link { text-decoration: none; } + +.admin-card__link::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; +} + +/* With the pseudo-element covering the card, the focus ring would otherwise + draw around the words alone while the whole card is what activates. */ +.admin-card__link:focus-visible { outline: none; } + +.admin-card:has(.admin-card__link:focus-visible) { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; +} + +.admin-card__description { + margin: 0; + color: hsl(var(--muted-foreground)); + font-size: 0.9375rem; +} + .site-footer { border-top: 1px solid hsl(var(--border)); color: hsl(var(--muted-foreground)); diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb new file mode 100644 index 0000000..8e0354b --- /dev/null +++ b/app/controllers/admin_controller.rb @@ -0,0 +1,12 @@ +# The landing page for the administrative area. It has no model of its own: the +# sections it lists live in ApplicationHelper#admin_sections, and each is still +# served by its own top-level controller. +# +# When these pages stop being open to everyone, this is the seam - the +# before_action that gates the area goes here and in the three reference-data +# controllers, or they all move under an Admin:: namespace inheriting from a +# base controller that carries it. +class AdminController < ApplicationController + def index + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 8b7bc9c..ec88b20 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -19,4 +19,36 @@ def or_dash(value) def aria_current_section(path) "page" if section_current?(path) end + + # The sections that maintain reference data rather than a person's own + # records. Nobody using the app day to day adds a medication or invents a new + # dosage form; someone setting the app up does, once. + # + # This is the only list of them. The header's Admin link, the sub-nav under + # it and the cards on /admin all read from here, so a fourth lookup table + # gets added in one place and cannot appear in two of the three. + def admin_sections + [ + { name: "Medications", + path: medications_path, + description: "The catalogue of drugs a prescription can point at, " \ + "with the side effects to warn about." }, + { name: "Medication types", + path: medication_types_path, + description: "How medications are grouped - the categories offered " \ + "when adding one." }, + { name: "Medication forms", + path: medication_forms_path, + description: "How a medication is taken: tablet, capsule, liquid, " \ + "patch, and the rest." } + ] + end + + # True anywhere in the administrative area: its landing page, or inside any + # section the landing page lists. Those sections keep their own top-level + # paths, so there is no /admin prefix to match on - the list is the test. + def admin_area? + section_current?(admin_path) || + admin_sections.any? { |section| section_current?(section[:path]) } + end end diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb new file mode 100644 index 0000000..17ac541 --- /dev/null +++ b/app/views/admin/index.html.erb @@ -0,0 +1,23 @@ +<% content_for :title, "Admin" %> + +

Admin

+ +

+ The reference data behind prescriptions. These lists are set up once and + edited rarely - the people using the app day to day pick from them rather + than add to them. +

+ +
    + <% admin_sections.each do |section| %> +
  • + <%# The heading carries the only link, and it stretches over the whole + card - so the card is clickable without a second link to the same + place for a screen reader to read out twice. %> +

    + <%= link_to section[:name], section[:path], class: "admin-card__link" %> +

    +

    <%= section[:description] %>

    +
  • + <% end %> +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 5d87a3d..b6cad65 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -48,12 +48,14 @@ aria: { current: aria_current_section(people_path) } %> <%= link_to "Prescriptions", prescriptions_path, aria: { current: aria_current_section(prescriptions_path) } %> - <%= link_to "Medications", medications_path, - aria: { current: aria_current_section(medications_path) } %> - <%= link_to "Medication types", medication_types_path, - aria: { current: aria_current_section(medication_types_path) } %> - <%= link_to "Medication forms", medication_forms_path, - aria: { current: aria_current_section(medication_forms_path) } %> + + <%# One link for the whole administrative area. The lookup tables it + covers - medications, types, forms - are listed on /admin and in + the sub-nav below, which is where they belong: they are the app's + setup, not the work it exists for, and five flat links gave them + equal billing with People and Prescriptions. %> + <%= link_to "Admin", admin_path, + aria: { current: ("page" if admin_area?) } %> <%# 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 @@ -73,6 +75,8 @@
+ <%= render "shared/admin_nav" if admin_area? %> +
<%= yield %>
diff --git a/app/views/shared/_admin_nav.html.erb b/app/views/shared/_admin_nav.html.erb new file mode 100644 index 0000000..7024434 --- /dev/null +++ b/app/views/shared/_admin_nav.html.erb @@ -0,0 +1,20 @@ +<%# The second nav band, shown by the layout on every page of the + administrative area. It is what makes /medications feel like part of a + place rather than another top-level section: the header's Admin link stays + lit, and this row says which admin section you are in. + + "Overview" is here rather than as a heading link because it is one of the + destinations - from a medication form, /admin is somewhere you go back to. %> + diff --git a/config/routes.rb b/config/routes.rb index 48205eb..683683e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,6 +31,12 @@ # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + # The administrative area. The reference-data resources it covers keep their + # own top-level paths - this is only the landing page that gathers them, so + # the header can carry one Admin link instead of one per lookup table. See + # ApplicationHelper#admin_sections for the list it shows. + get "admin" => "admin#index", as: :admin + # Defines the root path route ("/") root "home#index" get "world" => "home#world" diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb new file mode 100644 index 0000000..6b075db --- /dev/null +++ b/test/controllers/admin_controller_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class AdminControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get admin_url + assert_response :success + end + + # The landing page is generated from ApplicationHelper#admin_sections, so + # this fails if a section is added to the list without a working path helper. + test "index links to every admin section" do + get admin_url + + assert_select "a[href=?]", medications_path + assert_select "a[href=?]", medication_types_path + assert_select "a[href=?]", medication_forms_path + end + + # The point of the exercise: the reference-data sections are behind the one + # Admin link, not sitting in the main nav beside People and Prescriptions. + test "main nav carries a single admin link" do + get root_url + + assert_select "nav.site-nav a[href=?]", admin_path + assert_select "nav.site-nav a[href=?]", medications_path, count: 0 + assert_select "nav.admin-nav", count: 0 + end + + # The sub-nav is what makes /medications read as part of the admin area + # rather than another top-level section, so it has to follow you into one. + test "admin sub-nav shows inside an admin section" do + get medications_url + + assert_select "nav.admin-nav a[href=?][aria-current=page]", medications_path + assert_select "nav.site-nav a[href=?][aria-current=page]", admin_path + end + + test "admin sub-nav stays hidden outside the admin area" do + get people_url + + assert_select "nav.admin-nav", count: 0 + end +end