diff --git a/CHANGELOG.md b/CHANGELOG.md index 961e2504fe..01808126a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ - Add pdf handling in `render_respond_to_format_with_error_message` [#3482](https://github.com/DMPRoadmap/roadmap/pull/3482) - Lower PostgreSQL GitHub Action Chrome Version to Address Breaking Changes Between Latest Chrome Version (134) and `/features` Tests [#3491](https://github.com/DMPRoadmap/roadmap/pull/3491) - Bumped dependencies via `bundle update && yarn upgrade` [#3483](https://github.com/DMPRoadmap/roadmap/pull/3483) +- Fixed issues with Conditional Question serialization offered by @briri from PR https://github.com/CDLUC3/dmptool/pull/667 for DMPTool. There is a migration file with code for MySQL and Postgres to update the Conditions table to convert JSON Arrays in string format records in the conditions table so that they are JSON Arrays. +- Refactor `org_admin/conditions/_form.html.erb` [#3502](https://github.com/DMPRoadmap/roadmap/pull/3502) +- Refactor `Question.save_condition` [#3501](https://github.com/DMPRoadmap/roadmap/pull/3501) ## v4.2.0 diff --git a/app/controllers/org_admin/questions_controller.rb b/app/controllers/org_admin/questions_controller.rb index 4e789df405..77fed52609 100644 --- a/app/controllers/org_admin/questions_controller.rb +++ b/app/controllers/org_admin/questions_controller.rb @@ -211,30 +211,32 @@ def destroy private - # param_conditions looks like: - # [ - # { - # "conditions_N" => { - # name: ... - # subject ... - # ... - # } - # ... - # } - # ] + # param_conditions is a hash of a hash like this (example where + # action_types is "remove" and "add_webhook" respectively): + # Parameters: + # {"0"=>{"question_option"=>["212159"], + # "action_type"=>"remove", + # "remove_question_id"=>["191471 191472"], + # "number"=>"0"}, + # "1"=>{"question_option"=>["212160"], + # "action_type"=>"add_webhook", + # "number"=>"1", + # "webhook-name"=>"DMP Admin", + # "webhook-email"=>"dmp-admin@example.com", + # "webhook-subject"=>"Woodcote cillum quis elit consectetur epsom", + # "webhook-message"=>"Labore ut epsom downs exercitation ...."} + # } def sanitize_hash(param_conditions) return {} if param_conditions.nil? return {} if param_conditions.empty? res = {} - hash_of_hashes = param_conditions[0] - hash_of_hashes.each do |cond_name, cond_hash| + param_conditions.each do |cond_id, cond_hash| sanitized_hash = {} cond_hash.each do |k, v| - v = ActionController::Base.helpers.sanitize(v) if k.start_with?('webhook') - sanitized_hash[k] = v + sanitized_hash[k] = k.start_with?('webhook') ? ActionController::Base.helpers.sanitize(v) : v end - res[cond_name] = sanitized_hash + res[cond_id] = sanitized_hash end res end diff --git a/app/helpers/conditions_helper.rb b/app/helpers/conditions_helper.rb index 9ea082a326..326e8d32f8 100644 --- a/app/helpers/conditions_helper.rb +++ b/app/helpers/conditions_helper.rb @@ -10,7 +10,7 @@ def remove_list(object) id_list = [] plan_answers = object.answers if object.is_a?(Plan) plan_answers = object[:answers] if object.is_a?(Hash) - return [] unless plan_answers.present? + return [] if plan_answers.blank? plan_answers.each { |answer| id_list += answer_remove_list(answer) } id_list @@ -32,7 +32,7 @@ def answer_remove_list(answer, user = nil) rems = cond.remove_data.map(&:to_i) id_list += rems elsif !user.nil? - UserMailer.question_answered(JSON.parse(cond.webhook_data), user, answer, + UserMailer.question_answered(cond.webhook_data, user, answer, chosen.join(' and ')).deliver_now end end @@ -57,7 +57,7 @@ def email_trigger_list(answer) chosen = answer.question_option_ids.sort next unless chosen == opts - email_list << JSON.parse(cond.webhook_data)['email'] if action == 'add_webhook' + email_list << cond.webhook_data['email'] if action == 'add_webhook' end # uniq because could get same remove id from diff conds email_list.uniq.join(',') @@ -70,7 +70,7 @@ def num_section_answers(plan, section) plan_remove_list = remove_list(plan) plan.answers.each do |answer| next unless answer.question.section_id == section.id && - !plan_remove_list.include?(answer.question_id) && + plan_remove_list.exclude?(answer.question_id) && section.question_ids.include?(answer.question_id) && answer.answered? @@ -107,10 +107,9 @@ def num_section_questions(plan, section, phase = nil) def sections_info(plan) return [] if plan.nil? - info = plan.sections.map do |section| + plan.sections.map do |section| section_info(plan, section) end - info || [] end def section_info(plan, section) @@ -190,7 +189,7 @@ def condition_to_text(conditions) return_string += "
#{_('Answering')} " return_string += opts.join(' and ') if cond.action_type == 'add_webhook' - subject_string = text_formatted(JSON.parse(cond.webhook_data)['subject']) + subject_string = text_formatted(cond.webhook_data['subject']) return_string += format(_(' will send an email with subject %{subject_name}'), subject_name: subject_string) else @@ -209,7 +208,7 @@ def condition_to_text(conditions) def text_formatted(object) text = Question.find(object).text if object.is_a?(Integer) text = object if object.is_a?(String) - return 'type error' unless text.present? + return 'type error' if text.blank? cleaned_text = text text = ActionController::Base.helpers.truncate(cleaned_text, length: DISPLAY_LENGTH, @@ -231,7 +230,7 @@ def conditions_to_param_form(conditions) webhook_data: condition.webhook_data } } if param_conditions.key?(title) param_conditions[title].merge!(condition_hash[title]) do |_key, val1, val2| - if val1.is_a?(Array) && !val1.include?(val2[0]) + if val1.is_a?(Array) && val1.exclude?(val2[0]) val1 + val2 else val1 diff --git a/app/javascript/src/orgAdmin/conditions/updateConditions.js b/app/javascript/src/orgAdmin/conditions/updateConditions.js index 2e65989cfb..7daa01eff5 100644 --- a/app/javascript/src/orgAdmin/conditions/updateConditions.js +++ b/app/javascript/src/orgAdmin/conditions/updateConditions.js @@ -15,11 +15,6 @@ export default function updateConditions(id) { addLogicButton.get(0).click(); } } - // set up form-select select boxes for condition options - const setSelectPicker = () => { - // $('.form-select.narrow').selectpicker({ width: 120 }); - // $('.form-select.regular').selectpicker({ width: 150 }); - }; // test if a webhook is selected and set up if so const allowWebhook = (selectObject, webhook = false) => { // webhook false => new condition @@ -30,8 +25,10 @@ export default function updateConditions(id) { // Retreive 'data-bs-target' for modal and create Jquery element const associatedModal = $(condition.find('.pseudo-webhook-btn').attr('data-bs-target')); associatedModal.modal('show'); + condition.find('.display-if-action-remove').hide(); } else { // condition type is remove condition.find('.remove-dropdown').show(); + condition.find('.display-if-action-remove').show(); condition.find('.webhook-replacement').hide(); } } else { // loading already saved conditions @@ -97,11 +94,10 @@ export default function updateConditions(id) { addLogicButton.attr('data-loaded', 'true'); addLogicButton.addClass('disabled'); addLogicButton.blur(); - addLogicButton.text('Conditions'); + addLogicButton.text('Edit Conditions'); if (isObject(content)) { content.html(e.detail[0].container); } - setSelectPicker(); webhookForm(e.detail[0].webhooks, undefined); }); @@ -114,7 +110,6 @@ export default function updateConditions(id) { conditionList.append(e.detail[0].attachment_partial); addDiv.html(e.detail[0].add_link); conditionList.attr('data-loaded', 'false'); - setSelectPicker(); const selectObject = conditionList.find('.form-select.action-type').last(); webhookForm(undefined, selectObject); } diff --git a/app/models/condition.rb b/app/models/condition.rb index fd13793009..4887de31df 100644 --- a/app/models/condition.rb +++ b/app/models/condition.rb @@ -27,9 +27,9 @@ # Object that represents a condition of a conditional question class Condition < ApplicationRecord belongs_to :question - enum action_type: %i[remove add_webhook] - serialize :option_list, type: Array - serialize :remove_data, type: Array + enum :action_type, { remove: 0, add_webhook: 1 } + serialize :option_list, type: Array, coder: JSON + serialize :remove_data, type: Array, coder: JSON serialize :webhook_data, coder: JSON # Sort order: Number ASC diff --git a/app/models/question.rb b/app/models/question.rb index 0afb049bda..6f7d006750 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -145,7 +145,7 @@ def guidance_for_org(org) guidances = {} if theme_ids.any? GuidanceGroup.includes(guidances: :themes) - .where(org_id: org.id).each do |group| + .where(org_id: org.id).find_each do |group| group.guidances.each do |g| g.themes.each do |theme| guidances["#{group.name} " + _('guidance on') + " #{theme.title}"] = g if theme_ids.include? theme.id @@ -196,8 +196,8 @@ def annotations_per_org(org_id) type: Annotation.types[:example_answer]) guidance = annotations.find_by(org_id: org_id, type: Annotation.types[:guidance]) - example_answer = annotations.build(type: :example_answer, text: '', org_id: org_id) unless example_answer.present? - guidance = annotations.build(type: :guidance, text: '', org_id: org_id) unless guidance.present? + example_answer = annotations.build(type: :example_answer, text: '', org_id: org_id) if example_answer.blank? + guidance = annotations.build(type: :guidance, text: '', org_id: org_id) if guidance.blank? [example_answer, guidance] end @@ -206,48 +206,45 @@ def annotations_per_org(org_id) # after versioning def update_conditions(param_conditions, old_to_new_opts, question_id_map) conditions.destroy_all - return unless param_conditions.present? + return if param_conditions.blank? param_conditions.each_value do |value| save_condition(value, old_to_new_opts, question_id_map) end end - # rubocop:disable Metrics/MethodLength, Metrics/AbcSize - # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def save_condition(value, opt_map, question_id_map) c = conditions.build c.action_type = value['action_type'] c.number = value['number'] + # question options may have changed so rewrite them - c.option_list = value['question_option'] - unless opt_map.blank? - new_question_options = c.option_list.map do |qopt| - opt_map[qopt] - end - c.option_list = new_question_options || [] + c.option_list = handle_option_list(value, opt_map) + # Do not save the condition if the option_list is empty + if c.option_list.empty? + c.destroy + return end if value['action_type'] == 'remove' - c.remove_data = value['remove_question_id'] - unless question_id_map.blank? - new_question_ids = c.remove_data.each do |qid| - question_id_map[qid] - end - c.remove_data = new_question_ids || [] + c.remove_data = handle_remove_data(value, question_id_map) + # Do not save the condition if remove_data is empty + if c.remove_data.empty? + c.destroy + return end else - c.webhook_data = { - name: value['webhook-name'], - email: value['webhook-email'], - subject: value['webhook-subject'], - message: value['webhook-message'] - }.to_json + c.webhook_data = handle_webhook_data(value) + # Do not save the condition if webhook_data is nil + if c.webhook_data.nil? + c.destroy + return + end end c.save end - # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity - # rubocop:enable Metrics/MethodLength, Metrics/AbcSize + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength private @@ -274,4 +271,33 @@ def check_remove_conditions end end # rubocop:enable Metrics/AbcSize + + def handle_option_list(value, opt_map) + if opt_map.present? + value['question_option'].map { |qopt| opt_map[qopt] } + else + value['question_option'] + end + end + + def handle_remove_data(value, question_id_map) + if question_id_map.present? + value['remove_question_id'].map { |qid| question_id_map[qid] } + else + value['remove_question_id'] + end + end + + def handle_webhook_data(value) + # return nil if any of the webhook fields are blank + return if %w[webhook-name webhook-email webhook-subject webhook-message].any? { |key| value[key].blank? } + + # else return the constructed webhook_data hash + { + name: value['webhook-name'], + email: value['webhook-email'], + subject: value['webhook-subject'], + message: value['webhook-message'] + } + end end diff --git a/app/views/org_admin/conditions/_add.html.erb b/app/views/org_admin/conditions/_add.html.erb index 85126c6dd6..f2ff72d72a 100644 --- a/app/views/org_admin/conditions/_add.html.erb +++ b/app/views/org_admin/conditions/_add.html.erb @@ -1,5 +1,13 @@
<%= link_to _('Add condition'), new_org_admin_question_condition_path(question_id: question.id, condition_no: condition_no), remote: true, class: "add-condition" %> +

+ <%= _('To add a condition you must have selected an Option and Action together with') %> +

    +
  • <%= _("if Action is 'remove', you need to select one or more choices in Target.") %>
  • +
  • <%= _("if Action is 'add notification', you need to fill in all the fields in the 'Send email' popup.") %>
  • +
+ <%= _('Otherwise, the condition will not be saved.') %> +

diff --git a/app/views/org_admin/conditions/_container.html.erb b/app/views/org_admin/conditions/_container.html.erb index 03e4b96af6..0fccf1692c 100644 --- a/app/views/org_admin/conditions/_container.html.erb +++ b/app/views/org_admin/conditions/_container.html.erb @@ -4,13 +4,14 @@
<%= label(:text, _('Option'), class: "control-label")%>
-
+
<%= label(:text, _('Action'), class: "control-label") %>
- <%= _('Remove')%> + <%= label(:text, _('Target'), class: "control-label") %>
-
+
+ <%= label(:text, _('Remove'), class: "control-label") %>
<% conditions_params = conditions_to_param_form(conditions).sort_by { |key| key }.to_h %> diff --git a/app/views/org_admin/conditions/_existing_condition_display.erb b/app/views/org_admin/conditions/_existing_condition_display.erb new file mode 100644 index 0000000000..85ad7efdac --- /dev/null +++ b/app/views/org_admin/conditions/_existing_condition_display.erb @@ -0,0 +1,41 @@ + <% + qopt = condition[:question_option_id].any? ? QuestionOption.find_by(id: condition[:question_option_id].first): nil + rquesArray = condition[:remove_question_id].any? ? Question.where(id: condition[:remove_question_id]) : nil + view_email_content_info = _("Hover over the email address to view email content. To change email details you need to remove and add the condition again.") + %> +
+ <%= qopt[:text]&.slice(0, 25) %> + <%= hidden_field_tag(name_start + "[question_option][]", condition[:question_option_id]) %> +
+
+ <%= condition[:action_type] == 'remove' ? _('Remove') : _('Email') %> + <%= hidden_field_tag(name_start + "[action_type]", condition[:action_type]) %> +
+
+ <% if !rquesArray.nil? %> + <% rquesArray.each do |rques| %> + Question <%= rques[:number] %>: <%= rques.text.gsub(%r{}, '').slice(0, 50) %> + <%= '...' if rques.text.gsub(%r{}, '').length > 50 %> +
+ <% end %> + <%= hidden_field_tag(name_start + "[remove_question_id][]", condition[:remove_question_id]) %> + <% else %> + <% + hook_tip = "#{_('Name')}: #{condition[:webhook_data]['name']}\n" + hook_tip += "#{_('Email')}: #{condition[:webhook_data]['email']}\n" + hook_tip += "#{_('Subject')}: #{condition[:webhook_data]['subject']}\n" + hook_tip += "#{_('Message')}: #{condition[:webhook_data]['message']}" + %> + <%= condition[:webhook_data]['email'] %> +
(<%= view_email_content_info %>) + + <%= hidden_field_tag(name_start + "[webhook-email]", condition[:webhook_data]['email']) %> + <%= hidden_field_tag(name_start + "[webhook-name]", condition[:webhook_data]['name']) %> + <%= hidden_field_tag(name_start + "[webhook-subject]", condition[:webhook_data]['subject']) %> + <%= hidden_field_tag(name_start + "[webhook-message]", condition[:webhook_data]['message']) %> + <% end %> + <%= hidden_field_tag(name_start + "[number]", condition_no) %> +
+ diff --git a/app/views/org_admin/conditions/_form.html.erb b/app/views/org_admin/conditions/_form.html.erb index 8a48a3fc5c..0fe405972b 100644 --- a/app/views/org_admin/conditions/_form.html.erb +++ b/app/views/org_admin/conditions/_form.html.erb @@ -1,35 +1,31 @@ +<%# This partial is called from the following files: + - app/controllers/org_admin/conditions_controller.rb + - app/views/org_admin/conditions/_container.html.erb + %> +
<% - action_type_arr = [["removes", :remove], ["adds notification", :add_webhook]] - name_start = "conditions[]condition_" + condition_no.to_s - remove_question_collection = later_question_list(question) - condition_exists = local_assigns.has_key? :condition - type_default = condition_exists ? (condition[:action_type] == "remove" ? :remove : :add_webhook) : :remove - remove_question_group = condition_exists ? - grouped_options_for_select(remove_question_collection, condition[:remove_question_id]) : - grouped_options_for_select(remove_question_collection) - multiple = (question.question_format.multiselectbox? || question.question_format.checkbox?) + condition ||= nil + name_start = "conditions[#{condition_no.to_s}]" %> -
- <%= select_tag(:question_option, options_from_collection_for_select(question.question_options.sort_by(&:number), "id", "text", - condition_exists ? condition[:question_option_id] : question.question_options.sort_by(&:number)[0]), {class: 'form-select regular', 'data-bs-style': 'dropdown-toggle bg-white px-4 py-3', name: name_start + "[question_option][]"}) %> -
-
- <%= select_tag(:action_type, options_for_select(action_type_arr, type_default), {name: name_start + "[action_type]", class: 'action-type form-select narrow', 'data-bs-style': 'dropdown-toggle bg-white px-4 py-3'}) %> -
-
-
- <%= select_tag(:remove_question_id, remove_question_group, {name: name_start + "[remove_question_id][]", class: 'form-select regular', multiple: true, 'data-bs-style': 'dropdown-toggle bg-white px-4 py-3'}) %> -
-
- <%= link_to _('Edit email'), '#' %> -
-
- <%= hidden_field_tag(name_start + "[number]", condition_no) %> + + <%# If this is a new condition then display the interactive controls. otherwise just display the logic %> + <% if condition.nil? %> + <%= render partial: 'org_admin/conditions/new_condition_form', + locals: { condition_no: condition_no, + name_start: name_start, + question: question + } + %> - + <% else %> + <%= render partial: 'org_admin/conditions/existing_condition_display', + locals: { condition: condition, + condition_no: condition_no, + name_start: name_start, + question: question + } + %> - <%= render partial: 'org_admin/conditions/webhook_form', locals: {name_start: name_start, condition_no: condition_no} %> + <% end %>
diff --git a/app/views/org_admin/conditions/_new_condition_form.erb b/app/views/org_admin/conditions/_new_condition_form.erb new file mode 100644 index 0000000000..7b42c99f10 --- /dev/null +++ b/app/views/org_admin/conditions/_new_condition_form.erb @@ -0,0 +1,34 @@ + <% + action_type_arr = [["removes", :remove], ["adds notification", :add_webhook]] + remove_question_collection = later_question_list(question) + remove_question_group = grouped_options_for_select(remove_question_collection) + %> + +
<%= _('Add condition') %>
+
+
+
<%= _('Option') %>
+ <%= select_tag(:question_option, options_from_collection_for_select(question.question_options.sort_by(&:number), "id", "text", + question.question_options.sort_by(&:number)[0]), {class: 'form-select regular', 'data-bs-style': 'dropdown-toggle bg-white px-4 py-3', name: name_start + "[question_option][]"}) %> +
+
+
<%= _('Action') %>
+ <%= select_tag(:action_type, options_for_select(action_type_arr, :remove), {name: name_start + "[action_type]", class: 'action-type form-select narrow', 'data-bs-style': 'dropdown-toggle bg-white px-4 py-3'}) %> +
+
+
+
+
<%= _('Target') %>
+
+ <%= select_tag(:remove_question_id, remove_question_group, {name: name_start + "[remove_question_id][]", class: 'form-select regular', multiple: true, 'data-bs-style': 'dropdown-toggle bg-white px-4 py-3'}) %> +
+
+ <%= link_to _('Edit email'), '#' %> +
+ <%= hidden_field_tag(name_start + "[number]", condition_no) %> +
+ + <%= render partial: 'org_admin/conditions/webhook_form', locals: {name_start: name_start, condition_no: condition_no} %> +
diff --git a/app/views/org_admin/conditions/_webhook_form.html.erb b/app/views/org_admin/conditions/_webhook_form.html.erb index 8871af47c4..9b80a56743 100644 --- a/app/views/org_admin/conditions/_webhook_form.html.erb +++ b/app/views/org_admin/conditions/_webhook_form.html.erb @@ -9,25 +9,25 @@ diff --git a/app/views/org_admin/questions/_form.html.erb b/app/views/org_admin/questions/_form.html.erb index f966845301..5d715390d1 100644 --- a/app/views/org_admin/questions/_form.html.erb +++ b/app/views/org_admin/questions/_form.html.erb @@ -45,8 +45,10 @@ <%= render "/org_admin/question_options/option_fields", f: f, q: question %>
+ <% if question.id != nil && question.question_options[0].text != nil %> - <%= link_to _('Add Conditions'), org_admin_question_open_conditions_path(question_id: question.id, conditions: conditions), class: "add-logic btn btn-secondary", 'data-loaded': (conditions.size > 0).to_s, remote: true %> + <% cond_lbl = conditions&.any? ? 'Edit Conditions' : 'Add Conditions' %> + <%= link_to cond_lbl, org_admin_question_open_conditions_path(question_id: question.id, conditions: conditions), class: "add-logic btn btn-secondary", 'data-loaded': (conditions.size > 0).to_s, remote: true %>

<%= render partial: 'org_admin/conditions/container', locals: { f: f, question: question, conditions: conditions } %> @@ -54,7 +56,7 @@

<% else %>
- <%= link_to _('Add Conditions'), '#', class: "add-logic btn btn-secondary disabled" %> + <%= link_to _('Edit Conditions'), '#', class: "add-logic btn btn-secondary disabled" %>
<% end %> diff --git a/db/migrate/20250115102816_update_conditions_json_columns_data.rb b/db/migrate/20250115102816_update_conditions_json_columns_data.rb new file mode 100644 index 0000000000..21605b7212 --- /dev/null +++ b/db/migrate/20250115102816_update_conditions_json_columns_data.rb @@ -0,0 +1,91 @@ +# This migration runs SQL for MySQL databases or POSTGRESQL database (default). +class UpdateConditionsJsonColumnsData < ActiveRecord::Migration[7.1] + # rubocop:disable Metrics/MethodLength + def change + if ActiveRecord::Base.connection.adapter_name.downcase.include?('mysql') + # MySQL sql + execute <<-SQL + UPDATE conditions + SET + option_list = CONCAT( + '[', + REPLACE( + REPLACE( + REPLACE(option_list, '---\r\n-', ''), + '\r\n-', + ',' + ), + '\r\n', + '' + ), + ']' + ), + remove_data = CONCAT( + '[', + REPLACE( + REPLACE( + REPLACE(remove_data, '---\r\n-', ''), + '\r\n-', + ',' + ), + '\r\n', + '' + ), + ']' + ) + WHERE option_list LIKE '---%'; + SQL + else + # POSTGRES SQL + execute <<-SQL + UPDATE conditions +SET + option_list = concat ( + '[', + regexp_replace ( + regexp_replace ( + regexp_replace ( + regexp_replace (option_list, '---(\r|\n)-', '', 'g'), + '(\r|\n)-', + ',', + 'g' + ), + '\r|\n', + '', + 'g' + ), + '''', + '"', + 'g'), + ']' + ), + remove_data = concat ( + '[', + regexp_replace ( + regexp_replace ( + regexp_replace ( + regexp_replace (remove_data, '---(\r|\n)-', '', 'g'), + '(\r|\n)-', + ',', + 'g' + ), + '\r|\n', + '', + 'g' + ), + '''', + '"', + 'g'), + ']' + ) +WHERE + option_list LIKE '---%'; + SQL + end + end + # rubocop:enable Metrics/MethodLength + + def down + # Add rollback logic if needed + end +end diff --git a/db/schema.rb b/db/schema.rb index ac3ee9619c..3b6036f2bf 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[7.1].define(version: 2024_06_13_141451) do +ActiveRecord::Schema[7.1].define(version: 2025_01_15_102816) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql"