diff --git a/Gemfile b/Gemfile index ac3afced3..fb3dbdaa9 100644 --- a/Gemfile +++ b/Gemfile @@ -76,6 +76,7 @@ end group :development do gem "listen" + gem "rubocop" gem "rubocop-performance" gem "rubocop-rails-omakase", require: false gem "rubocop-rspec", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 3ea4a5192..394e22013 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -317,7 +317,7 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - language_server-protocol (3.17.0.3) + language_server-protocol (3.17.0.4) launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) @@ -478,17 +478,17 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.2) - rubocop (1.70.0) + rubocop (1.71.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.36.2, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.37.0) + rubocop-ast (1.38.0) parser (>= 3.3.1.0) rubocop-minitest (0.36.0) rubocop (>= 1.61, < 2.0) @@ -641,6 +641,7 @@ DEPENDENCIES responders rspec-rails rspec_api_documentation! + rubocop rubocop-performance rubocop-rails-omakase rubocop-rspec diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index 79568dd81..666ff13aa 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -46,6 +46,15 @@ def validate_request_schema(with:, **options, &block) end end + def apply_filters(scope, with:) + validate_request_schema( + with: ApplicationFilter.build_filter_schema(with), + input_params: request.query_parameters + ) do |permitted_params| + FilterScopeQuery.new(scope, permitted_params).apply + end + end + def respond_with_errors(object, **) respond_with(object, responder: InvalidRequestSchemaResponder, **) end diff --git a/app/controllers/api/v1/beneficiaries_controller.rb b/app/controllers/api/v1/beneficiaries_controller.rb index ce8644296..c15351591 100644 --- a/app/controllers/api/v1/beneficiaries_controller.rb +++ b/app/controllers/api/v1/beneficiaries_controller.rb @@ -2,12 +2,7 @@ module API module V1 class BeneficiariesController < BaseController def index - validate_request_schema( - with: BeneficiaryFilter, - input_params: request.query_parameters - ) do |permitted_params| - FilterScopeQuery.new(beneficiaries_scope, permitted_params).apply - end + apply_filters(beneficiaries_scope, with: BeneficiaryFilter) end def show diff --git a/app/controllers/api/v1/broadcasts_controller.rb b/app/controllers/api/v1/broadcasts_controller.rb new file mode 100644 index 000000000..39873c051 --- /dev/null +++ b/app/controllers/api/v1/broadcasts_controller.rb @@ -0,0 +1,48 @@ +module API + module V1 + class BroadcastsController < BaseController + def index + broadcasts = broadcasts_scope + respond_with_resource(broadcasts) + end + + def show + broadcast = broadcasts_scope.find(params[:id]) + respond_with_resource(broadcast) + end + + def create + validate_request_schema( + with: ::V1::BroadcastRequestSchema, + # TODO: can remove this once after we rename the model to broadcast + location: ->(resource) { api_v1_broadcast_path(resource) } + ) do |permitted_params| + broadcasts_scope.create!(permitted_params) + end + end + + def update + broadcast = broadcasts_scope.find(params[:id]) + + validate_request_schema( + with: ::V1::UpdateBroadcastRequestSchema, + schema_options: { resource: broadcast }, + ) do |permitted_params| + broadcast.update!(permitted_params) + + if broadcast.queued? + ExecuteWorkflowJob.perform_later(PopulateBroadcastBeneficiaries.to_s, broadcast) + end + + broadcast + end + end + + private + + def broadcasts_scope + current_account.broadcasts + end + end + end +end diff --git a/app/filters/application_filter.rb b/app/filters/application_filter.rb index 4915e7968..8625e2e47 100644 --- a/app/filters/application_filter.rb +++ b/app/filters/application_filter.rb @@ -1,6 +1,20 @@ class ApplicationFilter < ApplicationRequestSchema - def output - output_data = super - output_data.fetch(:filter, {}) + class_attribute :__filter_class__ + + def self.build_filter_schema(filter_class) + Class.new(ApplicationFilter) do + self.__filter_class__ = filter_class + + params do + optional(:filter).schema(filter_class.schema) + end + + rule(:filter).validate(contract: filter_class) + + def output + result = super.fetch(:filter, {}) + __filter_class__.new(input_params: result).output + end + end end end diff --git a/app/filters/beneficiary_filter.rb b/app/filters/beneficiary_filter.rb index cdbc68921..bd1fdac37 100644 --- a/app/filters/beneficiary_filter.rb +++ b/app/filters/beneficiary_filter.rb @@ -1,20 +1,18 @@ class BeneficiaryFilter < ApplicationFilter params do - optional(:filter).value(:hash).schema do - optional(:status).filled(included_in?: Contact.status.values) - optional(:disability_status).maybe(included_in?: Contact.disability_status.values) - optional(:gender).filled(Types::UpcaseString, included_in?: Contact.gender.values) - optional(:date_of_birth).filled(:date) - optional(:iso_country_code).filled(Types::UpcaseString, included_in?: Contact.iso_country_code.values) - optional(:language_code).maybe(:string) - optional(:"address.iso_region_code").filled(:string) - optional(:"address.administrative_division_level_2_code").filled(:string) - optional(:"address.administrative_division_level_2_name").filled(:string) - optional(:"address.administrative_division_level_3_code").filled(:string) - optional(:"address.administrative_division_level_3_name").filled(:string) - optional(:"address.administrative_division_level_4_code").filled(:string) - optional(:"address.administrative_division_level_4_name").filled(:string) - end + optional(:status).filled(included_in?: Contact.status.values) + optional(:disability_status).maybe(included_in?: Contact.disability_status.values) + optional(:gender).filled(Types::UpcaseString, included_in?: Contact.gender.values) + optional(:date_of_birth).filled(:date) + optional(:iso_country_code).filled(Types::UpcaseString, included_in?: Contact.iso_country_code.values) + optional(:language_code).maybe(:string) + optional(:"address.iso_region_code").filled(:string) + optional(:"address.administrative_division_level_2_code").filled(:string) + optional(:"address.administrative_division_level_2_name").filled(:string) + optional(:"address.administrative_division_level_3_code").filled(:string) + optional(:"address.administrative_division_level_3_name").filled(:string) + optional(:"address.administrative_division_level_4_code").filled(:string) + optional(:"address.administrative_division_level_4_name").filled(:string) end def output diff --git a/app/models/account.rb b/app/models/account.rb index e9a712e09..f78d648e1 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -30,8 +30,8 @@ class Account < ApplicationRecord has_many :beneficiaries, class_name: "Contact", dependent: :restrict_with_error - has_many :callouts, - dependent: :restrict_with_error + has_many :callouts, dependent: :restrict_with_error + has_many :broadcasts, class_name: "Callout", dependent: :restrict_with_error has_many :batch_operations, class_name: "BatchOperation::Base", diff --git a/app/models/batch_operation/callout_population.rb b/app/models/batch_operation/callout_population.rb index 27a3531fd..004fdd0c1 100644 --- a/app/models/batch_operation/callout_population.rb +++ b/app/models/batch_operation/callout_population.rb @@ -19,6 +19,10 @@ class CalloutPopulation < Base validates :contact_filter_params, contact_filter_params: true + def self.jsonapi_serializer_class + BroadcastPopulationSerializer + end + def run! transaction do create_callout_participations diff --git a/app/models/callout.rb b/app/models/callout.rb index 515c7f7ff..8a6d45d82 100644 --- a/app/models/callout.rb +++ b/app/models/callout.rb @@ -1,5 +1,8 @@ class Callout < ApplicationRecord + extend Enumerize + AUDIO_CONTENT_TYPES = %w[audio/mpeg audio/mp3 audio/wav audio/x-wav].freeze + CHANNELS = %i[voice].freeze module ActiveStorageDirty attr_reader :audio_file_blob_was, :audio_file_will_change @@ -26,10 +29,15 @@ def audio_file_blob_changed? store_accessor :settings accepts_nested_key_value_fields_for :settings + # TODO: Remove the default after we removed the old API + enumerize :channel, in: CHANNELS, default: :voice + belongs_to :account belongs_to :created_by, class_name: "User", optional: true has_many :callout_participations, dependent: :restrict_with_error + has_many :broadcast_beneficiaries, class_name: "CalloutParticipation", dependent: :restrict_with_error + has_many :beneficiaries, class_name: "Contact", through: :broadcast_beneficiaries, source: :contact has_many :batch_operations, class_name: "BatchOperation::Base", @@ -37,6 +45,8 @@ def audio_file_blob_changed? has_many :callout_populations, class_name: "BatchOperation::CalloutPopulation" + has_many :populations, + class_name: "BatchOperation::CalloutPopulation" has_many :phone_calls @@ -48,6 +58,7 @@ def audio_file_blob_changed? has_one_attached :audio_file + validates :channel, :status, presence: true validates :call_flow_logic, :status, presence: true validates :audio_file, @@ -68,40 +79,65 @@ def audio_file_blob_changed? after_commit :process_audio_file aasm column: :status, whiny_transitions: false do - state :initialized, initial: true + state :pending, initial: true + state :queued state :running - state :paused state :stopped + state :completed + # TODO: Remove state transition from pending after we removed the old API event :start do transitions( - from: :initialized, + from: [ :pending, :queued ], to: :running ) end + event :stop do + transitions( + from: [ :running, :queued ], + to: :stopped + ) + end + + # TODO: Remove the pause event after we removed the old API event :pause do transitions( - from: :running, - to: :paused + from: [ :running, :queued ], + to: :stopped ) end event :resume do transitions( - from: %i[paused stopped], + from: :stopped, to: :running ) end - event :stop do + event :complete do transitions( - from: %i[running paused], - to: :stopped + from: :running, + to: :completed ) end end + def self.jsonapi_serializer_class + BroadcastSerializer + end + + # TODO: Remove this after we removed the old API + def as_json(*) + result = super(except: [ "channel", "beneficiary_filter" ]) + result["status"] = "initialized" if result["status"] == "pending" + result + end + + def updatable? + status == "pending" + end + private def set_call_flow_logic diff --git a/app/models/phone_call.rb b/app/models/phone_call.rb index 0071dc1f2..365ee4501 100644 --- a/app/models/phone_call.rb +++ b/app/models/phone_call.rb @@ -21,6 +21,7 @@ class PhoneCall < ApplicationRecord belongs_to :callout_participation, optional: true, counter_cache: true belongs_to :contact, validate: true + belongs_to :beneficiary, class_name: "Contact", foreign_key: "contact_id" belongs_to :account belongs_to :callout, optional: true has_many :remote_phone_call_events, dependent: :restrict_with_error diff --git a/app/request_schemas/application_request_schema.rb b/app/request_schemas/application_request_schema.rb index 9a419939e..e8d23e42b 100644 --- a/app/request_schemas/application_request_schema.rb +++ b/app/request_schemas/application_request_schema.rb @@ -10,6 +10,36 @@ class ApplicationRequestSchema < Dry::Validation::Contract key.failure(text: "is invalid") if key? && !Phony.plausible?(value) end + register_macro(:url_format) do + next unless key? + + uri = URI.parse(value) + isValid = (uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)) && uri.host.present? + + key.failure(text: "is invalid") unless isValid + rescue URI::InvalidURIError + key.failure(text: "is invalid") + end + + # NOTE: composable contracts + # + # params do + # required(:a).hash(OtherContract.schema) + # end + # + # rule(:a).validate(contract: OtherContract) + # + register_macro(:contract) do |macro:| + contract_instance = macro.args[0] + contract_result = contract_instance.new(input_params: value) + unless contract_result.success? + errors = contract_result.errors + errors.each do |error| + key(key.path.to_a + error.path).failure(error.text) + end + end + end + module Types include Dry.Types() diff --git a/app/request_schemas/v1/beneficiary_stats_request_schema.rb b/app/request_schemas/v1/beneficiary_stats_request_schema.rb index eeaa3c0fc..521d51568 100644 --- a/app/request_schemas/v1/beneficiary_stats_request_schema.rb +++ b/app/request_schemas/v1/beneficiary_stats_request_schema.rb @@ -14,10 +14,13 @@ class BeneficiaryStatsRequestSchema < ApplicationRequestSchema "address.administrative_division_level_4_name" ].freeze - params(BeneficiaryFilter.schema) do + params do + optional(:filter).schema(BeneficiaryFilter.schema) required(:group_by).value(array[:string]) end + rule(:filter).validate(contract: BeneficiaryFilter) + rule(:group_by) do next key.failure("is invalid") unless value.all? { |group| group.in?(GROUPS) } @@ -37,7 +40,7 @@ class BeneficiaryStatsRequestSchema < ApplicationRequestSchema def output result = super - result[:filter_fields] = BeneficiaryFilter.new(input_params: result).output + result[:filter_fields] = BeneficiaryFilter.new(input_params: result[:filter]).output if result[:filter] result[:group_by_fields] = result[:group_by].map do |group| BeneficiaryField.find(group) diff --git a/app/request_schemas/v1/broadcast_request_schema.rb b/app/request_schemas/v1/broadcast_request_schema.rb new file mode 100644 index 000000000..91b70f54c --- /dev/null +++ b/app/request_schemas/v1/broadcast_request_schema.rb @@ -0,0 +1,25 @@ +module V1 + class BroadcastRequestSchema < JSONAPIRequestSchema + params do + required(:data).value(:hash).schema do + required(:type).filled(:str?, eql?: "broadcast") + required(:attributes).value(:hash).schema do + required(:channel).filled(:str?, included_in?: Callout.channel.values) + required(:audio_url).filled(:string) + required(:beneficiary_filter).filled(:hash).schema(BeneficiaryFilter.schema) + optional(:metadata).value(:hash) + end + end + end + + attribute_rule(:beneficiary_filter).validate(contract: BeneficiaryFilter) + attribute_rule(:audio_url).validate(:url_format) + + def output + result = super + # TODO: remove this after we removed call_flow from callouts + result[:call_flow_logic] = CallFlowLogic::PlayMessage.name + result + end + end +end diff --git a/app/request_schemas/v1/update_broadcast_request_schema.rb b/app/request_schemas/v1/update_broadcast_request_schema.rb new file mode 100644 index 000000000..5b3c43d3e --- /dev/null +++ b/app/request_schemas/v1/update_broadcast_request_schema.rb @@ -0,0 +1,42 @@ +module V1 + class UpdateBroadcastRequestSchema < JSONAPIRequestSchema + STATES = Callout.aasm.states.map { _1.name.to_s } - [ "queued" ] + + params do + required(:data).value(:hash).schema do + required(:id).filled(:integer) + required(:type).filled(:str?, eql?: "broadcast") + required(:attributes).value(:hash).schema do + optional(:audio_url).filled(:string) + optional(:beneficiary_filter).filled(:hash).schema(BeneficiaryFilter.schema) + optional(:status).filled(included_in?: STATES) + optional(:metadata).value(:hash) + end + end + end + + attribute_rule(:beneficiary_filter).validate(contract: BeneficiaryFilter) + attribute_rule(:audio_url).validate(:url_format) + + attribute_rule(:status) do + next unless key? + + next if resource.status == value + next if value == "running" && (resource.may_start? || resource.may_resume?) + next if value == "stopped" && resource.may_stop? + next if value == "completed" && resource.may_complete? + + key.failure("does not allow to transition from #{resource.status} to #{value}") + end + + def output + result = super + + if result[:status] == "running" && resource.pending? + result[:status] = "queued" + end + + result + end + end +end diff --git a/app/serailizers/broadcast_population_serializer.rb b/app/serailizers/broadcast_population_serializer.rb new file mode 100644 index 000000000..13911e278 --- /dev/null +++ b/app/serailizers/broadcast_population_serializer.rb @@ -0,0 +1,5 @@ +class BroadcastPopulationSerializer < ResourceSerializer + attributes :parameters, :status, :metadata + + belongs_to :callout, key: :broadcast, serializer: BroadcastSerializer +end diff --git a/app/serailizers/broadcast_serializer.rb b/app/serailizers/broadcast_serializer.rb new file mode 100644 index 000000000..ee06f74cf --- /dev/null +++ b/app/serailizers/broadcast_serializer.rb @@ -0,0 +1,3 @@ +class BroadcastSerializer < ResourceSerializer + attributes :channel, :audio_url, :metadata, :beneficiary_filter, :status +end diff --git a/app/views/dashboard/callouts/_form.html.haml b/app/views/dashboard/callouts/_form.html.haml index 943ddcd31..6860b6dfa 100644 --- a/app/views/dashboard/callouts/_form.html.haml +++ b/app/views/dashboard/callouts/_form.html.haml @@ -1,5 +1,6 @@ .card-body = simple_form_for([:dashboard, resource]) do |f| + = f.input :channel = f.input :audio_file, as: :file, input_html: { direct_upload: true, accept: Callout::AUDIO_CONTENT_TYPES.join(", ") } = f.input :audio_url = f.input :call_flow_logic, collection: CallFlowLogic::Base.registered.map(&:to_sym), as: :radio_buttons diff --git a/app/views/dashboard/callouts/show.html.erb b/app/views/dashboard/callouts/show.html.erb index d1f0ae323..039d58c3c 100644 --- a/app/views/dashboard/callouts/show.html.erb +++ b/app/views/dashboard/callouts/show.html.erb @@ -1,6 +1,12 @@ <%= page_title(title: title) do %> - <%= render("shared/edit_resource_page_action", path: edit_dashboard_callout_path(resource)) %> - <%= render("shared/destroy_resource_page_action", path: dashboard_callout_path(resource)) %> + <%= render( + "shared/edit_resource_page_action", + path: edit_dashboard_callout_path(resource), + ) %> + <%= render( + "shared/destroy_resource_page_action", + path: dashboard_callout_path(resource), + ) %> <% if resource.may_start? %> <%= button_to(dashboard_callout_callout_events_path(resource, event: :start), class: "btn btn-outline-success", form_class: "d-inline", form: { data: { turbo_confim: translate(:"titles.actions.data_confirm")}}) do %> @@ -19,9 +25,12 @@ <% end %> <%= render "shared/resource_related_links" do %> - <%= related_link_to t(:"titles.batch_operations.index"), dashboard_callout_batch_operations_path(resource) %> - <%= related_link_to t(:"titles.callout_participations.index"), dashboard_callout_callout_participations_path(resource) %> - <%= related_link_to t(:"titles.phone_calls.index"), dashboard_callout_phone_calls_path(resource) %> + <%= related_link_to t(:"titles.batch_operations.index"), + dashboard_callout_batch_operations_path(resource) %> + <%= related_link_to t(:"titles.callout_participations.index"), + dashboard_callout_callout_participations_path(resource) %> + <%= related_link_to t(:"titles.phone_calls.index"), + dashboard_callout_phone_calls_path(resource) %> <% end %> <% end %> @@ -39,7 +48,11 @@ <%= link_to(resource.audio_url, resource.audio_url) %> <% end %> <% end %> - <%= f.attribute :call_flow_logic, value: translate("simple_form.options.defaults.call_flow_logic.#{resource.call_flow_logic}") %> + <%= f.attribute :call_flow_logic, + value: + translate( + "simple_form.options.defaults.call_flow_logic.#{resource.call_flow_logic}", + ) %> <%= f.attribute :created_at, value: local_time(resource.created_at) %> <%= f.attribute :created_by do %> <% if resource.created_by_id.present? %> diff --git a/app/workflows/populate_broadcast_beneficiaries.rb b/app/workflows/populate_broadcast_beneficiaries.rb new file mode 100644 index 000000000..8244662f5 --- /dev/null +++ b/app/workflows/populate_broadcast_beneficiaries.rb @@ -0,0 +1,60 @@ +class PopulateBroadcastBeneficiaries < ApplicationWorkflow + attr_reader :broadcast + + delegate :account, :beneficiary_filter, to: :broadcast, private: true + + def initialize(broadcast) + @broadcast = broadcast + end + + def call + ApplicationRecord.transaction do + create_broadcast_beneficiaries + create_phone_calls + + broadcast.start! + end + end + + private + + def create_broadcast_beneficiaries + broadcast_beneficiaries = beneficiaries_scope.find_each.map do |beneficiary| + { + callout_id: broadcast.id, + contact_id: beneficiary.id, + phone_number: beneficiary.phone_number, + call_flow_logic: broadcast.call_flow_logic, + phone_calls_count: 1 + } + end + + CalloutParticipation.upsert_all(broadcast_beneficiaries) if broadcast_beneficiaries.any? + end + + def create_phone_calls + phone_calls = broadcast.broadcast_beneficiaries.find_each.map do |broadcast_beneficiary| + { + account_id: account.id, + callout_id: broadcast.id, + contact_id: broadcast_beneficiary.contact_id, + call_flow_logic: broadcast_beneficiary.call_flow_logic, + callout_participation_id: broadcast_beneficiary.id, + phone_number: broadcast_beneficiary.phone_number, + status: :created + } + end + + PhoneCall.upsert_all(phone_calls) if phone_calls.any? + end + + def beneficiaries_scope + @beneficiaries_scope ||= begin + filter_fields = beneficiary_filter.each_with_object({}) do |(filter, value), filters| + filters[BeneficiaryField.find(filter.to_s)] = value + end + + FilterScopeQuery.new(account.beneficiaries.active, filter_fields).apply + end + end +end diff --git a/config/locales/titles.en.yml b/config/locales/titles.en.yml index 1f3fbbb1e..0f010559c 100644 --- a/config/locales/titles.en.yml +++ b/config/locales/titles.en.yml @@ -36,6 +36,7 @@ en: edit: Edit Callout index: Callouts new: New Callout + pause_callout: Pause resume_callout: Resume show: Callout %{id} start_callout: Start diff --git a/config/locales/titles.km.yml b/config/locales/titles.km.yml index b96ae7b48..336f63e20 100644 --- a/config/locales/titles.km.yml +++ b/config/locales/titles.km.yml @@ -36,6 +36,7 @@ km: edit: កែប្រែ សេចក្ដីប្រកាស index: សេចក្ដីប្រកាស new: បង្កើត សេចក្ដីប្រកាស + pause_callout: ផ្អាក resume_callout: បន្តរដំណើរការ show: សេចក្ដីប្រកាស %{id} start_callout: ចាប់ផ្ដើម diff --git a/config/routes.rb b/config/routes.rb index d7deb9122..3a2ed445c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -63,6 +63,8 @@ end namespace :v1, module: "api/v1", as: "api_v1", defaults: { format: "json" } do + resources :broadcasts, only: [ :index, :show, :create, :update ] + resources :beneficiaries, only: [ :index, :create, :show, :update, :destroy ] do get "stats" => "beneficiaries/stats#index", on: :collection resources :addresses, only: [ :index, :create, :show, :destroy ] diff --git a/db/migrate/20250127074839_add_beneficiary_filter_and_channel_to_callouts.rb b/db/migrate/20250127074839_add_beneficiary_filter_and_channel_to_callouts.rb new file mode 100644 index 000000000..02f8bfcf4 --- /dev/null +++ b/db/migrate/20250127074839_add_beneficiary_filter_and_channel_to_callouts.rb @@ -0,0 +1,18 @@ +class AddBeneficiaryFilterAndChannelToCallouts < ActiveRecord::Migration[8.0] + def change + add_column :callouts, :channel, :string + add_column :callouts, :beneficiary_filter, :jsonb, null: false, default: {} + + reversible do |dir| + dir.up do + execute <<-SQL + UPDATE callouts SET channel = 'voice'; + UPDATE callouts SET status = 'stopped' WHERE status = 'paused'; + UPDATE callouts SET status = 'pending' WHERE status = 'initialized'; + SQL + end + end + + change_column_null(:callouts, :channel, false) + end +end diff --git a/db/schema.rb b/db/schema.rb index 529b37b1e..40e4bd17d 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.0].define(version: 2025_01_23_085352) do +ActiveRecord::Schema[8.0].define(version: 2025_01_27_074839) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_catalog.plpgsql" @@ -121,6 +121,8 @@ t.string "audio_url" t.jsonb "settings", default: {}, null: false t.bigint "created_by_id" + t.string "channel", null: false + t.jsonb "beneficiary_filter", default: {}, null: false t.index ["account_id"], name: "index_callouts_on_account_id" t.index ["created_by_id"], name: "index_callouts_on_created_by_id" t.index ["status"], name: "index_callouts_on_status" diff --git a/spec/factories.rb b/spec/factories.rb index f4e81c27e..0488d4d3f 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -37,8 +37,9 @@ SecureRandom.uuid end - factory :callout do + factory :callout, aliases: [ :broadcast ] do account + channel { "voice" } transient do audio_file { nil } @@ -53,7 +54,8 @@ end end - trait :initialized do + trait :pending do + status { Callout::STATE_PENDING } end trait :can_start do @@ -91,7 +93,7 @@ traits_for_enum :status, %i[preview queued running finished] - factory :callout_population, aliases: [ :batch_operation ], + factory :callout_population, aliases: [ :batch_operation, :broadcast_population ], class: "BatchOperation::CalloutPopulation" do after(:build) do |callout_population| callout_population.callout ||= build(:callout, account: callout_population.account) diff --git a/spec/models/callout_spec.rb b/spec/models/callout_spec.rb index 05304dbfc..5e8f29530 100644 --- a/spec/models/callout_spec.rb +++ b/spec/models/callout_spec.rb @@ -81,43 +81,35 @@ def assert_transitions! end describe "#start!" do - let(:current_status) { :initialized } + let(:current_status) { :pending } let(:asserted_new_status) { :running } let(:event) { :start } it { assert_transitions! } end - describe "#pause!" do + describe "#stop!" do let(:current_status) { :running } - let(:asserted_new_status) { :paused } - let(:event) { :pause } + let(:asserted_new_status) { :stopped } + let(:event) { :stop } it { assert_transitions! } end describe "#resume!" do + let(:current_status) { :stopped } let(:asserted_new_status) { :running } let(:event) { :resume } - %i[paused stopped].each do |current_status| - context "status: '#{current_status}'" do - let(:current_status) { current_status } - it { assert_transitions! } - end - end + it { assert_transitions! } end - describe "#stop!" do - let(:asserted_new_status) { :stopped } - let(:event) { :stop } + describe "#complete!" do + let(:current_status) { :running } + let(:asserted_new_status) { :completed } + let(:event) { :complete } - %i[running paused].each do |current_status| - context "status: '#{current_status}'" do - let(:current_status) { current_status } - it { assert_transitions! } - end - end + it { assert_transitions! } end end end diff --git a/spec/models/event/callout_spec.rb b/spec/models/event/callout_spec.rb index 6b06e3bbd..20cd047fb 100644 --- a/spec/models/event/callout_spec.rb +++ b/spec/models/event/callout_spec.rb @@ -5,7 +5,7 @@ it_behaves_like("resource_event") do let(:event) { "start" } - let(:asserted_current_status) { Callout::STATE_INITIALIZED } + let(:asserted_current_status) { Callout::STATE_PENDING } let(:asserted_new_status) { Callout::STATE_RUNNING } end end diff --git a/spec/request_schemas/v1/update_broadcast_request_schema_spec.rb b/spec/request_schemas/v1/update_broadcast_request_schema_spec.rb new file mode 100644 index 000000000..8576c56e0 --- /dev/null +++ b/spec/request_schemas/v1/update_broadcast_request_schema_spec.rb @@ -0,0 +1,101 @@ +require "rails_helper" + +module V1 + RSpec.describe UpdateBroadcastRequestSchema, type: :request_schema do + it "validates the audio_url" do + broadcast = create(:broadcast) + + expect( + validate_schema(input_params: { data: { attributes: {} } }, options: { resource: broadcast }) + ).to have_valid_field(:data, :attributes, :audio_url) + + expect( + validate_schema(input_params: { data: { attributes: { audio_url: "invalid-url" } } }, options: { resource: broadcast }) + ).not_to have_valid_field(:data, :attributes, :audio_url) + + expect( + validate_schema(input_params: { data: { attributes: { audio_url: "http://example.com/sample.mp3" } } }, options: { resource: broadcast }) + ).to have_valid_field(:data, :attributes, :audio_url) + end + + it "validates the status" do + pending_broadcast = create(:broadcast, status: :pending) + running_broadcast = create(:broadcast, status: :running) + stopped_broadcast = create(:broadcast, status: :stopped) + completed_broadcast = create(:broadcast, status: :completed) + + expect( + validate_schema(input_params: { data: { attributes: { status: "foobar" } } }, options: { resource: pending_broadcast }) + ).not_to have_valid_field(:data, :attributes, :status) + + expect( + validate_schema(input_params: { data: { attributes: {} } }, options: { resource: pending_broadcast }) + ).to have_valid_field(:data, :attributes, :status) + + expect( + validate_schema(input_params: { data: { attributes: { status: "pending" } } }, options: { resource: pending_broadcast }) + ).to have_valid_field(:data, :attributes, :status) + + expect( + validate_schema(input_params: { data: { attributes: { status: "pending" } } }, options: { resource: running_broadcast }) + ).not_to have_valid_field(:data, :attributes, :status) + + expect( + validate_schema(input_params: { data: { attributes: { status: "stopped" } } }, options: { resource: running_broadcast }) + ).to have_valid_field(:data, :attributes, :status) + + expect( + validate_schema(input_params: { data: { attributes: { status: "completed" } } }, options: { resource: running_broadcast }) + ).to have_valid_field(:data, :attributes, :status) + + expect( + validate_schema(input_params: { data: { attributes: { status: "running" } } }, options: { resource: stopped_broadcast }) + ).to have_valid_field(:data, :attributes, :status) + + expect( + validate_schema(input_params: { data: { attributes: { status: "completed" } } }, options: { resource: stopped_broadcast }) + ).not_to have_valid_field(:data, :attributes, :status) + + expect( + validate_schema(input_params: { data: { attributes: { status: "running" } } }, options: { resource: completed_broadcast }) + ).not_to have_valid_field(:data, :attributes, :status) + + expect( + validate_schema(input_params: { data: { attributes: { status: "stopped" } } }, options: { resource: completed_broadcast }) + ).not_to have_valid_field(:data, :attributes, :status) + + expect( + validate_schema(input_params: { data: { attributes: { status: "queued" } } }, options: { resource: pending_broadcast }) + ).not_to have_valid_field(:data, :attributes, :status) + end + + it "handles post processing" do + broadcast = create(:broadcast, status: :pending) + + result = validate_schema( + input_params: { + data: { + id: broadcast.id, + attributes: { + status: "running", + audio_url: "http://example.com/sample.mp3" + } + } + }, + options: { resource: broadcast } + ).output + + expect(result).to include( + status: "queued", + audio_url: "http://example.com/sample.mp3" + ) + end + + def validate_schema(input_params:, options: {}) + UpdateBroadcastRequestSchema.new( + input_params:, + options: options.reverse_merge(account: build_stubbed(:account)) + ) + end + end +end diff --git a/spec/requests/open_ews_api/v1/beneficiaries_spec.rb b/spec/requests/open_ews_api/v1/beneficiaries_spec.rb index 2d0b83e2e..7e845d8d4 100644 --- a/spec/requests/open_ews_api/v1/beneficiaries_spec.rb +++ b/spec/requests/open_ews_api/v1/beneficiaries_spec.rb @@ -340,7 +340,7 @@ set_authorization_header_for(account) do_request( - filter: { "gender": "M" }, + filter: { "gender": "M", "address.iso_region_code": "KH-12" }, group_by: [ "iso_country_code", "address.iso_region_code", diff --git a/spec/requests/open_ews_api/v1/broadcasts_spec.rb b/spec/requests/open_ews_api/v1/broadcasts_spec.rb new file mode 100644 index 000000000..3bbf0e982 --- /dev/null +++ b/spec/requests/open_ews_api/v1/broadcasts_spec.rb @@ -0,0 +1,163 @@ +require "rails_helper" + +RSpec.resource "Broadcasts" do + get "/v1/broadcasts" do + example "List all broadcasts" do + account = create(:account) + account_broadcast = create(:broadcast, account:) + _other_account_broadcast = create(:broadcast) + + set_authorization_header_for(account) + do_request + + expect(response_status).to eq(200) + expect(response_body).to match_jsonapi_resource_collection_schema("broadcast") + expect(json_response.fetch("data").pluck("id")).to contain_exactly( + account_broadcast.id.to_s + ) + end + end + + get "/v1/broadcasts/:id" do + example "Get a broadcasts" do + account = create(:account) + broadcast = create(:broadcast, account:) + + set_authorization_header_for(account) + do_request(id: broadcast.id) + + expect(response_status).to eq(200) + expect(response_body).to match_jsonapi_resource_schema("broadcast") + expect(json_response.dig("data", "id")).to eq(broadcast.id.to_s) + end + end + + post "/v1/broadcasts" do + example "Create a broadcasts" do + account = create(:account) + + set_authorization_header_for(account) + do_request( + data: { + type: :broadcast, + attributes: { + channel: "voice", + audio_url: "https://www.example.com/sample.mp3", + beneficiary_filter: { + gender: "M", + "address.iso_region_code" => "KH-1" + } + } + } + ) + + expect(response_status).to eq(201) + expect(response_body).to match_jsonapi_resource_schema("broadcast") + expect(json_response.dig("data", "attributes")).to include( + "channel" => "voice", + "status" => "pending", + "audio_url" => "https://www.example.com/sample.mp3", + "beneficiary_filter" => { + "gender" => "M", + "address.iso_region_code" => "KH-1" + } + ) + end + + example "Failed to create a broadcast", document: false do + account = create(:account) + + set_authorization_header_for(account) + do_request( + data: { + type: :broadcast, + attributes: { + channel: "voice", + audio_url: nil, + beneficiary_filter: {} + } + } + ) + + expect(response_status).to eq(422) + expect(response_body).to match_api_response_schema("jsonapi_error") + expect(json_response.dig("errors", 0, "source", "pointer")).to eq("/data/attributes/audio_url") + expect(json_response.dig("errors", 1, "source", "pointer")).to eq("/data/attributes/beneficiary_filter") + end + end + + patch "/v1/broadcasts/:id" do + example "Update a broadcasts" do + account = create(:account) + _male_beneficiary = create(:beneficiary, account:, gender: "M") + female_beneficiary = create(:beneficiary, account:, gender: "F") + broadcast = create( + :broadcast, + status: :pending, + account:, + audio_url: "https://www.example.com/old-sample.mp3", + beneficiary_filter: { + gender: "M" + } + ) + + set_authorization_header_for(account) + perform_enqueued_jobs do + do_request( + id: broadcast.id, + data: { + id: broadcast.id, + type: :broadcast, + attributes: { + status: "running", + audio_url: "https://www.example.com/sample.mp3", + beneficiary_filter: { + gender: "F" + } + } + } + ) + end + + expect(response_status).to eq(200) + expect(response_body).to match_jsonapi_resource_schema("broadcast") + expect(json_response.dig("data", "attributes")).to include( + "status" => "queued", + "audio_url" => "https://www.example.com/sample.mp3", + "beneficiary_filter" => { + "gender" => "F" + } + ) + expect(broadcast.reload.status).to eq("running") + expect(broadcast.beneficiaries).to match_array([ female_beneficiary ]) + expect(broadcast.phone_calls.count).to eq(1) + expect(broadcast.phone_calls.first.beneficiary).to eq(female_beneficiary) + end + + example "Failed to update a broadcast", document: false do + account = create(:account) + broadcast = create( + :broadcast, + account:, + status: :running + ) + + set_authorization_header_for(account) + do_request( + id: broadcast.id, + data: { + id: broadcast.id, + type: :broadcast, + attributes: { + status: "pending", + audio_url: "https://www.example.com/sample.mp3" + } + } + ) + + expect(response_status).to eq(422) + expect(response_body).to match_api_response_schema("jsonapi_error") + expect(json_response.dig("errors", 0, "source", "pointer")).to eq("/data/attributes/status") + end + end +end diff --git a/spec/requests/scfm_api/callouts_spec.rb b/spec/requests/scfm_api/callouts_spec.rb index c699d1ed6..ea9185e61 100644 --- a/spec/requests/scfm_api/callouts_spec.rb +++ b/spec/requests/scfm_api/callouts_spec.rb @@ -42,7 +42,7 @@ parameter( :settings, - "Additionoal settings which are needed byt the call flow logic." + "Additional settings which are needed by the call flow logic." ) example "Create a Callout" do @@ -69,6 +69,7 @@ expect(created_callout.settings).to eq(request_body.fetch(:settings)) expect(created_callout.call_flow_logic).to eq(request_body.fetch(:call_flow_logic)) expect(created_callout.audio_url).to eq(request_body.fetch(:audio_url)) + expect(parsed_response.fetch("status")).to eq("initialized") end end @@ -143,7 +144,7 @@ callout = create( :callout, account: account, - status: Callout::STATE_INITIALIZED + status: Callout::STATE_PENDING ) set_authorization_header_for(account) diff --git a/spec/requests/twilio_webhooks/phone_call_events_spec.rb b/spec/requests/twilio_webhooks/phone_call_events_spec.rb index ca39fb23a..79d0d521b 100644 --- a/spec/requests/twilio_webhooks/phone_call_events_spec.rb +++ b/spec/requests/twilio_webhooks/phone_call_events_spec.rb @@ -67,7 +67,6 @@ expect(response.code).to eq("201") created_event = RemotePhoneCallEvent.last! expect(created_event).to have_attributes( - phone_call:, call_duration: 87, phone_call: have_attributes( status: "completed", diff --git a/spec/support/api_response_schemas/broadcast_schema.rb b/spec/support/api_response_schemas/broadcast_schema.rb new file mode 100644 index 000000000..76c824788 --- /dev/null +++ b/spec/support/api_response_schemas/broadcast_schema.rb @@ -0,0 +1,16 @@ +module APIResponseSchema + BroadcastSchema = Dry::Schema.JSON do + required(:id).filled(:str?) + required(:type).filled(eql?: "broadcast") + + required(:attributes).schema do + required(:channel).maybe(:str?) + required(:audio_url).maybe(:str?) + required(:beneficiary_filter).maybe(:hash?) + required(:metadata).maybe(:hash?) + required(:status).filled(:str?) + required(:created_at).filled(:str?) + required(:updated_at).filled(:str?) + end + end +end diff --git a/spec/system/dashboard/callout_participations_spec.rb b/spec/system/dashboard/callout_participations_spec.rb index 86b98fcba..172d6de0e 100644 --- a/spec/system/dashboard/callout_participations_spec.rb +++ b/spec/system/dashboard/callout_participations_spec.rb @@ -12,7 +12,7 @@ sign_in(user) visit( dashboard_callout_participations_path( - q: { callout_filter_params: { status: :initialized } } + q: { callout_filter_params: { status: :pending } } ) ) diff --git a/spec/system/dashboard/callouts_spec.rb b/spec/system/dashboard/callouts_spec.rb index 47af46dd9..96733348a 100644 --- a/spec/system/dashboard/callouts_spec.rb +++ b/spec/system/dashboard/callouts_spec.rb @@ -5,7 +5,7 @@ user = create(:user) callout = create( :callout, - :initialized, + :pending, call_flow_logic: CallFlowLogic::HelloWorld, account: user.account ) @@ -128,7 +128,7 @@ user = create(:user) callout = create( :callout, - :initialized, + :pending, account: user.account, call_flow_logic: CallFlowLogic::HelloWorld, created_by: user, @@ -187,11 +187,7 @@ it "can perform actions on callouts", :js do user = create(:user) - callout = create( - :callout, - :initialized, - account: user.account - ) + callout = create(:callout, :pending, account: user.account) sign_in(user) visit dashboard_callout_path(callout) @@ -200,10 +196,13 @@ expect(page).to have_content("Event was successfully created.") expect(page).not_to have_selector(:link_or_button, "Start") + expect(page).to have_selector(:link_or_button, "Stop") click_on("Stop") + expect(page).to have_content("Event was successfully created.") expect(page).not_to have_selector(:link_or_button, "Stop") + expect(page).to have_selector(:link_or_button, "Resume") click_on("Resume") diff --git a/spec/workflows/populate_broadcast_beneficiaries_spec.rb b/spec/workflows/populate_broadcast_beneficiaries_spec.rb new file mode 100644 index 000000000..06f0438a5 --- /dev/null +++ b/spec/workflows/populate_broadcast_beneficiaries_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe PopulateBroadcastBeneficiaries do + it "populates broadcast beneficiaries" do + account = create(:account) + _male_beneficiary = create(:beneficiary, account:, gender: "M") + female_beneficiary = create(:beneficiary, account:, gender: "F") + create(:beneficiary_address, beneficiary: female_beneficiary, iso_region_code: "KH-12") + other_female_beneficiary = create(:beneficiary, account:, gender: "F") + create(:beneficiary_address, beneficiary: other_female_beneficiary, iso_region_code: "KH-11") + + broadcast = create( + :broadcast, + status: :pending, + account:, + beneficiary_filter: { + gender: "F", + "address.iso_region_code": "KH-12" + } + ) + + PopulateBroadcastBeneficiaries.new(broadcast).call + + expect(broadcast.status).to eq("running") + expect(broadcast.beneficiaries.count).to eq(1) + expect(broadcast.broadcast_beneficiaries.first).to have_attributes( + contact: female_beneficiary, + phone_number: female_beneficiary.phone_number, + phone_calls_count: 1 + ) + expect(broadcast.phone_calls.count).to eq(1) + expect(broadcast.phone_calls.first).to have_attributes( + callout_participation: broadcast.broadcast_beneficiaries.first, + phone_number: female_beneficiary.phone_number, + status: "created" + ) + end +end