diff --git a/app/controllers/mailings_controller.rb b/app/controllers/mailings_controller.rb index 15b92d9..ea48adf 100644 --- a/app/controllers/mailings_controller.rb +++ b/app/controllers/mailings_controller.rb @@ -1,6 +1,6 @@ class MailingsController < ApplicationController before_action :require_current_project! - before_action :set_mailing, only: %i[show edit update destroy queue cancel test_send test_preview refresh_recipients update_recipients preview] + before_action :set_mailing, only: %i[show edit update destroy queue cancel test_send test_preview refresh_recipients update_recipients preview audience update_audience] before_action :load_form_collections, only: %i[new create edit update] def index @@ -26,6 +26,7 @@ class MailingsController < ApplicationController mail_template: template, mail_identity: identity, audience: "to_send", + audience_filters: Mailings::AudienceFilters.default("to_send").to_h, interval_seconds: 0, send_window_start_minutes: 10 * 60, send_window_end_minutes: 18 * 60, @@ -41,8 +42,7 @@ class MailingsController < ApplicationController @mailing.project = current_project apply_template_if_needed if @mailing.save - @mailing.rebuild_recipients! - redirect_to @mailing, notice: "Bozza creata. Controlla i destinatari e poi invia." + redirect_to audience_mailing_path(@mailing), notice: "Bozza creata. Ora raffina i destinatari, poi genera la lista." else @page_title = "Nuovo invio" render :new, status: :unprocessable_entity @@ -95,6 +95,45 @@ class MailingsController < ApplicationController redirect_to dashboard_mailings_path, notice: "Invio eliminato." end + def audience + unless @mailing.editable? + redirect_to @mailing, alert: "I destinatari sono bloccati: l'invio è già partito." + return + end + + @page_title = "Destinatari · #{@mailing.name}" + load_audience_options + assign_audience_preview + end + + def update_audience + unless @mailing.editable? + redirect_to @mailing, alert: "I destinatari sono bloccati: l'invio è già partito." + return + end + + filters = Mailings::AudienceFilters.from_params(audience_filter_params) + @mailing.assign_attributes(audience_filters: filters.to_h, audience: filters.preset) + + unless @mailing.save + @page_title = "Destinatari · #{@mailing.name}" + load_audience_options + assign_audience_preview + render :audience, status: :unprocessable_entity + return + end + + if Array(params[:intent]).include?("apply") + @mailing.rebuild_recipients! + redirect_to @mailing, notice: "Lista generata: #{@mailing.pending_count} da inviare, #{@mailing.skipped_count} esclusi." + else + @page_title = "Destinatari · #{@mailing.name}" + load_audience_options + assign_audience_preview + render :audience + end + end + def refresh_recipients unless @mailing.editable? redirect_to @mailing, alert: "Destinatari bloccati: l'invio è già partito." @@ -224,11 +263,37 @@ class MailingsController < ApplicationController def rebuild_recipients_if_needed return unless @mailing.editable? - return unless @mailing.saved_change_to_audience? || @mailing.saved_change_to_ab_test? || @mailing.saved_change_to_ab_assignment? + return unless @mailing.saved_change_to_ab_test? || @mailing.saved_change_to_ab_assignment? @mailing.rebuild_recipients! end + def load_audience_options + orgs = Organization.for_project(current_project) + @sport_options = orgs.where.not(sport: [nil, ""]).distinct.order(:sport).pluck(:sport) + @region_options = orgs.where.not(region: [nil, ""]).distinct.order(:region).pluck(:region) + @province_options = orgs.where.not(province: [nil, ""]).distinct.order(:province).pluck(:province) + @history_mailings = Mailing.for_project(current_project).where.not(id: @mailing.id).recent + end + + def assign_audience_preview + @filters = @mailing.parsed_audience_filters + query = @mailing.audience_query + @audience_counts = query.counts + @audience_preview = query.preview + @open_sections = Array(params[:open_sections]).map(&:to_s) + end + + def audience_filter_params + params.fetch(:filters, ActionController::Parameters.new).permit( + :preset, :list_min, :list_max, :estimated_value, :history_kind, :history_mailing_id, :history_days, + :exclude_customers, :exclude_mailed_within_days, + sports: [], team_genders: [], regions: [], provinces: [], streaming_statuses: [], statuses: [], + send_statuses: [], ab_variants: [], pipeline_stages: [], + exclude_received_mailing_ids: [], exclude_opened_mailing_ids: [] + ) + end + def preview_recipient if params[:recipient_id].present? @mailing.mailing_recipients.find_by(id: params[:recipient_id]) diff --git a/app/javascript/controllers/audience_preview_controller.js b/app/javascript/controllers/audience_preview_controller.js new file mode 100644 index 0000000..fb57895 --- /dev/null +++ b/app/javascript/controllers/audience_preview_controller.js @@ -0,0 +1,34 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["intent"] + + refresh() { + this.persistOpenSections() + this.setIntent("preview") + this.element.requestSubmit() + } + + apply() { + this.persistOpenSections() + this.setIntent("apply") + } + + persistOpenSections() { + this.element.querySelectorAll("input[data-open-section]").forEach((input) => input.remove()) + this.element.querySelectorAll("details[data-section]").forEach((details) => { + if (!details.open) return + + const input = document.createElement("input") + input.type = "hidden" + input.name = "open_sections[]" + input.value = details.dataset.section + input.setAttribute("data-open-section", "") + this.element.appendChild(input) + }) + } + + setIntent(value) { + if (this.hasIntentTarget) this.intentTarget.value = value + } +} diff --git a/app/models/mailing.rb b/app/models/mailing.rb index 23846c8..a08e0c9 100644 --- a/app/models/mailing.rb +++ b/app/models/mailing.rb @@ -22,6 +22,7 @@ class Mailing < ApplicationRecord validates :send_window_start_minutes, :send_window_end_minutes, presence: true, if: :send_window_enabled? validate :send_window_order clears_blank_html :body_html, :body_html_b + before_validation :sync_audience_filters scope :recent, -> { order(created_at: :desc) } scope :for_project, ->(project) { where(project_id: project.id) } @@ -33,7 +34,15 @@ class Mailing < ApplicationRecord end def audience_label - Catalog.label_for(Catalog::MAILING_AUDIENCES, audience) + parsed_audience_filters.summary_parts.join(" · ") + end + + def parsed_audience_filters + Mailings::AudienceFilters.new(audience_filters.presence || { "preset" => audience.presence || "to_send" }) + end + + def audience_query + Mailings::AudienceQuery.new(project, parsed_audience_filters, mailing: self) end def ab_assignment_label @@ -219,6 +228,25 @@ class Mailing < ApplicationRecord private + def sync_audience_filters + source = stringify_audience_filters(audience_filters) + if will_save_change_to_audience? && !will_save_change_to_audience_filters? + source["preset"] = audience + elsif source["preset"].blank? + source["preset"] = audience.presence || "to_send" + end + + filters = Mailings::AudienceFilters.new(source) + self.audience_filters = filters.to_h + self.audience = filters.preset + end + + def stringify_audience_filters(value) + return {} if value.blank? + + value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h.deep_stringify_keys : value.to_h.deep_stringify_keys + end + def send_window_order return unless send_window_enabled? return if send_window_start_minutes.blank? || send_window_end_minutes.blank? diff --git a/app/services/mailings/audience_filters.rb b/app/services/mailings/audience_filters.rb new file mode 100644 index 0000000..bdd4574 --- /dev/null +++ b/app/services/mailings/audience_filters.rb @@ -0,0 +1,153 @@ +module Mailings + class AudienceFilters + PRESETS = Catalog::MAILING_AUDIENCES.keys.freeze + HISTORY_KINDS = %w[never received not_received opened not_opened recent].freeze + VALUE_MODES = %w[any present blank].freeze + + ARRAY_KEYS = %w[ + sports team_genders regions provinces streaming_statuses statuses + send_statuses ab_variants pipeline_stages + exclude_received_mailing_ids exclude_opened_mailing_ids + ].freeze + + def self.default(preset = "to_send") + new("preset" => preset.presence_in(PRESETS) || "to_send") + end + + def self.from_params(raw) + hash = if raw.respond_to?(:to_unsafe_h) + raw.to_unsafe_h + else + raw.to_h + end + new(hash) + end + + def initialize(hash = {}) + @data = normalize(hash) + end + + def to_h + @data.deep_dup + end + + def preset + @data["preset"] + end + + def [](key) + @data[key.to_s] + end + + def customized? + @data.except("preset").any? { |_key, value| value.present? && value != false && value != "any" } + end + + def summary_parts + parts = [Catalog.label_for(Catalog::MAILING_AUDIENCES, preset)] + parts << "sport: #{Array(@data["sports"]).join(", ")}" if @data["sports"].present? + parts << "M/F: #{Array(@data["team_genders"]).map { |g| Catalog.label_for(Catalog::TEAM_GENDERS, g) }.join(", ")}" if @data["team_genders"].present? + parts << "regione: #{Array(@data["regions"]).join(", ")}" if @data["regions"].present? + parts << "provincia: #{Array(@data["provinces"]).join(", ")}" if @data["provinces"].present? + parts << "n. lista #{[@data["list_min"], @data["list_max"]].compact.join("–")}" if @data["list_min"].present? || @data["list_max"].present? + parts << "stage: #{Array(@data["pipeline_stages"]).map { |s| Catalog.label_for(Catalog::PIPELINE_STAGES, s) }.join(", ")}" if @data["pipeline_stages"].present? + parts << history_summary if history_kind.present? + parts << "escludi clienti" if @data["exclude_customers"] + parts << "escludi già ricevute" if @data["exclude_received_mailing_ids"].present? + parts << "escludi aperte" if @data["exclude_opened_mailing_ids"].present? + parts << "escludi inviate da #{@data["exclude_mailed_within_days"]}g" if @data["exclude_mailed_within_days"].present? + parts + end + + def history_kind + @data["history_kind"] + end + + def history_mailing_id + @data["history_mailing_id"] + end + + def history_days + @data["history_days"] + end + + def organization_extra_count + count_present(%w[team_genders provinces streaming_statuses statuses]) + + ((@data["list_min"].present? || @data["list_max"].present?) ? 1 : 0) + end + + def campaign_extra_count + count_present(%w[send_statuses ab_variants pipeline_stages]) + + ((@data["estimated_value"].present? && @data["estimated_value"] != "any") ? 1 : 0) + end + + def exclusions_count + n = 0 + n += 1 if @data["exclude_customers"] + n += 1 if @data["exclude_received_mailing_ids"].present? + n += 1 if @data["exclude_opened_mailing_ids"].present? + n += 1 if @data["exclude_mailed_within_days"].present? + n + end + + def history_active? + history_kind.present? + end + + def organization_extra_active? + organization_extra_count.positive? + end + + def campaign_extra_active? + campaign_extra_count.positive? + end + + def exclusions_active? + exclusions_count.positive? + end + + private + + def count_present(keys) + keys.count { |key| @data[key].present? } + end + + + def history_summary + case history_kind + when "never" then "mai ricevuta una campagna" + when "received" then "già ricevuta campagna ##{history_mailing_id}" + when "not_received" then "non ricevuta campagna ##{history_mailing_id}" + when "opened" then "aperta campagna ##{history_mailing_id}" + when "not_opened" then "non aperta campagna ##{history_mailing_id}" + when "recent" then "inviata negli ultimi #{history_days} giorni" + end + end + + def normalize(hash) + source = hash.to_h.deep_stringify_keys + data = { "preset" => source["preset"].presence_in(PRESETS) || "to_send" } + + ARRAY_KEYS.each do |key| + data[key] = Array(source[key]).flatten.map { |value| value.to_s.strip }.reject(&:blank?).uniq + data[key] = data[key].map(&:to_i) if key.end_with?("_ids") + end + + data["list_min"] = integer_or_nil(source["list_min"]) + data["list_max"] = integer_or_nil(source["list_max"]) + data["estimated_value"] = source["estimated_value"].presence_in(VALUE_MODES) || "any" + data["history_kind"] = source["history_kind"].presence_in(HISTORY_KINDS) + data["history_mailing_id"] = integer_or_nil(source["history_mailing_id"]) + data["history_days"] = integer_or_nil(source["history_days"]) + data["exclude_customers"] = ActiveModel::Type::Boolean.new.cast(source["exclude_customers"]) || false + data["exclude_mailed_within_days"] = integer_or_nil(source["exclude_mailed_within_days"]) + data + end + + def integer_or_nil(value) + return if value.blank? + + Integer(value, exception: false) + end + end +end diff --git a/app/services/mailings/audience_query.rb b/app/services/mailings/audience_query.rb new file mode 100644 index 0000000..473d4f1 --- /dev/null +++ b/app/services/mailings/audience_query.rb @@ -0,0 +1,153 @@ +module Mailings + class AudienceQuery + def initialize(project, filters = {}, mailing: nil) + @project = project + @filters = filters.is_a?(AudienceFilters) ? filters : AudienceFilters.new(filters) + @mailing = mailing + end + + def relation + scope = Organization.for_project(@project) + scope = apply_preset(scope) + scope = apply_organization_filters(scope) + scope = apply_opportunity_filters(scope) + scope = apply_history_filters(scope) + scope = apply_exclusions(scope) + scope.distinct + end + + def counts + { + total: relation.unscope(:order).distinct.count("organizations.id"), + with_email: with_email_scope(relation).unscope(:order).distinct.count("organizations.id") + } + end + + def preview(limit = 15) + relation.includes(:primary_contact).reorder(Arel.sql("organizations.list_position ASC NULLS LAST"), "organizations.name ASC").limit(limit) + end + + private + + def apply_preset(scope) + case @filters.preset + when "to_send" + scope.where(id: opportunity_scope.where(send_status: "to_send").select(:organization_id)) + when "test_a" + scope.where(id: opportunity_scope.where(ab_variant: "A").select(:organization_id)) + when "test_b" + scope.where(id: opportunity_scope.where(ab_variant: "B").select(:organization_id)) + else + scope + end + end + + def apply_organization_filters(scope) + scope = scope.where(sport: @filters["sports"]) if @filters["sports"].present? + scope = scope.where(team_gender: @filters["team_genders"]) if @filters["team_genders"].present? + scope = scope.where(region: @filters["regions"]) if @filters["regions"].present? + scope = scope.where(province: @filters["provinces"]) if @filters["provinces"].present? + scope = scope.where(streaming_status: @filters["streaming_statuses"]) if @filters["streaming_statuses"].present? + scope = scope.where(status: @filters["statuses"]) if @filters["statuses"].present? + scope = scope.where("organizations.list_position >= ?", @filters["list_min"]) if @filters["list_min"].present? + scope = scope.where("organizations.list_position <= ?", @filters["list_max"]) if @filters["list_max"].present? + scope + end + + def apply_opportunity_filters(scope) + extra = opportunity_scope + filtered = false + if @filters["send_statuses"].present? + extra = extra.where(send_status: @filters["send_statuses"]) + filtered = true + end + if @filters["ab_variants"].present? + extra = extra.where(ab_variant: @filters["ab_variants"]) + filtered = true + end + if @filters["pipeline_stages"].present? + extra = extra.where(pipeline_stage: @filters["pipeline_stages"]) + filtered = true + end + case @filters["estimated_value"] + when "present" + extra = extra.where("opportunities.estimated_value IS NOT NULL AND opportunities.estimated_value > 0") + filtered = true + when "blank" + extra = extra.where("opportunities.estimated_value IS NULL OR opportunities.estimated_value = 0") + filtered = true + end + + filtered ? scope.where(id: extra.select(:organization_id)) : scope + end + + def apply_history_filters(scope) + case @filters.history_kind + when "never" + scope.where.not(id: sent_org_ids) + when "received" + scope.where(id: sent_org_ids(mailing_id: @filters.history_mailing_id)) + when "not_received" + scope.where.not(id: sent_org_ids(mailing_id: @filters.history_mailing_id)) + when "opened" + scope.where(id: opened_org_ids(mailing_id: @filters.history_mailing_id)) + when "not_opened" + sent = sent_org_ids(mailing_id: @filters.history_mailing_id) + opened = opened_org_ids(mailing_id: @filters.history_mailing_id) + scope.where(id: sent).where.not(id: opened) + when "recent" + days = @filters.history_days.presence || 30 + scope.where(id: sent_org_ids(since: days.days.ago)) + else + scope + end + end + + def apply_exclusions(scope) + scope = scope.where.not(status: %w[active_customer inactive_customer]) if @filters["exclude_customers"] + if @filters["exclude_received_mailing_ids"].present? + scope = scope.where.not(id: sent_org_ids(mailing_id: project_mailing_ids(@filters["exclude_received_mailing_ids"]))) + end + if @filters["exclude_opened_mailing_ids"].present? + scope = scope.where.not(id: opened_org_ids(mailing_id: project_mailing_ids(@filters["exclude_opened_mailing_ids"]))) + end + if @filters["exclude_mailed_within_days"].present? + scope = scope.where.not(id: sent_org_ids(since: @filters["exclude_mailed_within_days"].days.ago)) + end + scope + end + + def with_email_scope(scope) + scope.left_joins(:contacts).where( + "NULLIF(BTRIM(organizations.email), '') IS NOT NULL OR NULLIF(BTRIM(contacts.email), '') IS NOT NULL" + ) + end + + def opportunity_scope + Opportunity.where(project_id: @project.id) + end + + def sent_org_ids(mailing_id: nil, since: nil) + recips = history_recipients.where(status: "sent") + recips = recips.where(mailing_id: mailing_id) if mailing_id.present? + recips = recips.where("mailing_recipients.sent_at >= ?", since) if since.present? + recips.select(:organization_id) + end + + def opened_org_ids(mailing_id: nil) + recips = history_recipients.where.not(opened_at: nil) + recips = recips.where(mailing_id: mailing_id) if mailing_id.present? + recips.select(:organization_id) + end + + def history_recipients + recips = MailingRecipient.joins(:mailing).where(mailings: { project_id: @project.id }) + recips = recips.where.not(mailing_id: @mailing.id) if @mailing&.persisted? + recips + end + + def project_mailing_ids(ids) + Mailing.for_project(@project).where(id: ids).select(:id) + end + end +end diff --git a/app/services/mailings/recipient_builder.rb b/app/services/mailings/recipient_builder.rb index b201e33..ffbfced 100644 --- a/app/services/mailings/recipient_builder.rb +++ b/app/services/mailings/recipient_builder.rb @@ -36,19 +36,9 @@ class Mailings::RecipientBuilder end def scope - orgs = Organization.for_project(@mailing.project).includes(:contacts, :opportunities) - org_ids = - case @mailing.audience - when "to_send" - Opportunity.where(project_id: @mailing.project_id, send_status: "to_send").select(:organization_id) - when "test_a" - Opportunity.where(project_id: @mailing.project_id, ab_variant: "A").select(:organization_id) - when "test_b" - Opportunity.where(project_id: @mailing.project_id, ab_variant: "B").select(:organization_id) - else - orgs.select(:id) - end - orgs.where(id: org_ids) + Mailings::AudienceQuery.new(@mailing.project, @mailing.parsed_audience_filters, mailing: @mailing) + .relation + .includes(:contacts, :opportunities) end def assign_variant!(attrs, org, split_index) diff --git a/app/views/mailings/_audience_disclosure.html.erb b/app/views/mailings/_audience_disclosure.html.erb new file mode 100644 index 0000000..6f09766 --- /dev/null +++ b/app/views/mailings/_audience_disclosure.html.erb @@ -0,0 +1,22 @@ +<%# locals: name:, title:, hint:, open:, count: 0 %> +
> + +
+
<%= title %>
+ <% if hint.present? %> +

<%= hint %>

+ <% end %> +
+
+ <% if count.to_i.positive? %> + <%= count %> + <% end %> + +
+
+
+ <%= yield %> +
+
diff --git a/app/views/mailings/_form.html.erb b/app/views/mailings/_form.html.erb index 5cbb9b1..4e54148 100644 --- a/app/views/mailings/_form.html.erb +++ b/app/views/mailings/_form.html.erb @@ -16,20 +16,15 @@ <%= f.text_field :name, required: true, class: input_class %> -
-
- - <%= f.collection_select :mail_identity_id, @mail_identities, :id, :name, { prompt: "Scegli SMTP" }, { class: input_class, required: true } %> -
-
- - <%= f.select :audience, Catalog::MAILING_AUDIENCES.map { |k, v| [v, k] }, {}, class: input_class %> -
-
- - <%= f.number_field :interval_seconds, min: 0, max: 86_400, class: input_class %> -

0 = in coda una dopo l’altra, senza attesa. Es. 120 = una email ogni 2 minuti.

-
+
+ + <%= f.collection_select :mail_identity_id, @mail_identities, :id, :name, { prompt: "Scegli SMTP" }, { class: input_class, required: true } %> +
+ +
+ + <%= f.number_field :interval_seconds, min: 0, max: 86_400, class: input_class %> +

0 = in coda una dopo l’altra, senza attesa. Es. 120 = una email ogni 2 minuti.

@@ -120,7 +115,7 @@
- <%= f.submit mailing.new_record? ? "Crea bozza" : "Salva", class: "#{btn_primary} w-full sm:w-auto" %> + <%= f.submit mailing.new_record? ? "Continua: destinatari" : "Salva messaggio", class: "#{btn_primary} w-full sm:w-auto" %> <%= link_to "Annulla", mailing.new_record? ? dashboard_mailings_path : mailing_path(mailing), class: "#{btn_secondary} w-full sm:w-auto" %>
diff --git a/app/views/mailings/_wizard.html.erb b/app/views/mailings/_wizard.html.erb new file mode 100644 index 0000000..3094aa0 --- /dev/null +++ b/app/views/mailings/_wizard.html.erb @@ -0,0 +1,21 @@ +<% + steps = [ + ["Messaggio", mailing.persisted? ? edit_mailing_path(mailing) : new_mailing_path, current == :message], + ["Destinatari", mailing.persisted? && mailing.editable? ? audience_mailing_path(mailing) : nil, current == :audience], + ["Revisione", mailing.persisted? ? mailing_path(mailing) : nil, current == :review] + ] +%> + diff --git a/app/views/mailings/audience.html.erb b/app/views/mailings/audience.html.erb new file mode 100644 index 0000000..8a11097 --- /dev/null +++ b/app/views/mailings/audience.html.erb @@ -0,0 +1,261 @@ +<% + preset_hints = { + "to_send" => "Chi in campagna è ancora da contattare. È la scelta giusta quasi sempre.", + "all" => "Tutte le società del progetto, anche già inviate o clienti.", + "test_a" => "Solo chi ha già Test A in scheda.", + "test_b" => "Solo chi ha già Test B in scheda." + } + extra_parts = @filters.summary_parts.drop(1) + compact_select = "#{input_class} h-28 py-1" + org_open = @filters.organization_extra_active? || @open_sections.include?("organization") + campaign_open = @filters.campaign_extra_active? || @open_sections.include?("campaign") + history_open = @filters.history_active? || @open_sections.include?("history") + exclude_open = @filters.exclusions_active? || @open_sections.include?("exclusions") +%> + +
+ <%= render "mailings/wizard", mailing: @mailing, current: :audience %> + +
+

Destinatari

+

+ Scegli da dove parti, eventualmente raffina, poi genera la lista. I filtri avanzati stanno nei box sotto. +

+
+ + <%= form_with url: audience_mailing_path(@mailing), method: :patch, class: "grid gap-6 xl:grid-cols-[1fr_20rem]", + data: { controller: "audience-preview", action: "change->audience-preview#refresh" } do %> + <%= hidden_field_tag :intent, "preview", data: { audience_preview_target: "intent" } %> +
+
+
+

Chi deve ricevere questa email?

+

Tre cose bastano: lista di partenza, sport, regione. Il resto è opzionale.

+
+ +
+ + <%= select_tag "filters[preset]", options_for_select(Catalog::MAILING_AUDIENCES.map { |k, v| [v, k] }, @filters.preset), class: input_class %> +

<%= preset_hints[@filters.preset] %>

+
+ +
+
+ + <%= hidden_field_tag "filters[sports][]", "" %> + <%= select_tag "filters[sports][]", options_for_select(@sport_options, @filters["sports"]), + multiple: true, size: 5, class: compact_select %> +

Vuoto = tutti gli sport. Tieni premuto Ctrl o Cmd per più valori.

+
+
+ + <%= hidden_field_tag "filters[regions][]", "" %> + <%= select_tag "filters[regions][]", options_for_select(@region_options, @filters["regions"]), + multiple: true, size: 5, class: compact_select %> +
+
+ + <% if extra_parts.any? %> +
+ <% extra_parts.each do |part| %> + <%= part %> + <% end %> +
+ <% end %> +
+ + <%= render layout: "mailings/audience_disclosure", locals: { + name: "organization", + title: "Altri dati società", + hint: "M/F, provincia, streaming, stato, numero in lista", + open: org_open, + count: @filters.organization_extra_count + } do %> +
+
+ + <%= hidden_field_tag "filters[team_genders][]", "" %> + <%= select_tag "filters[team_genders][]", options_for_select(Catalog::TEAM_GENDERS.map { |k, v| [v, k] }, @filters["team_genders"]), + multiple: true, size: 3, class: compact_select %> +
+
+ + <%= hidden_field_tag "filters[provinces][]", "" %> + <%= select_tag "filters[provinces][]", options_for_select(@province_options, @filters["provinces"]), + multiple: true, size: 4, class: compact_select %> +
+
+ + <%= hidden_field_tag "filters[streaming_statuses][]", "" %> + <%= select_tag "filters[streaming_statuses][]", options_for_select(Catalog::STREAMING_STATUSES.map { |k, v| [v, k] }, @filters["streaming_statuses"]), + multiple: true, size: 4, class: compact_select %> +
+
+ + <%= hidden_field_tag "filters[statuses][]", "" %> + <%= select_tag "filters[statuses][]", options_for_select(Catalog::ORGANIZATION_STATUSES.map { |k, v| [v, k] }, @filters["statuses"]), + multiple: true, size: 4, class: compact_select %> +
+
+ + <%= number_field_tag "filters[list_min]", @filters["list_min"], min: 1, class: input_class %> +
+
+ + <%= number_field_tag "filters[list_max]", @filters["list_max"], min: 1, class: input_class %> +
+
+ <% end %> + + <%= render layout: "mailings/audience_disclosure", locals: { + name: "campaign", + title: "Campagna e trattativa", + hint: "Stato invio, test A/B, stage, valore stimato", + open: campaign_open, + count: @filters.campaign_extra_count + } do %> +
+
+ + <%= hidden_field_tag "filters[send_statuses][]", "" %> + <%= select_tag "filters[send_statuses][]", options_for_select(Catalog::SEND_STATUSES.map { |k, v| [v, k] }, @filters["send_statuses"]), + multiple: true, size: 3, class: compact_select %> +

Serve soprattutto se la lista di partenza è «tutte».

+
+
+ + <%= hidden_field_tag "filters[ab_variants][]", "" %> + <%= select_tag "filters[ab_variants][]", options_for_select(Catalog::AB_VARIANTS.map { |k, v| [v, k] }, @filters["ab_variants"]), + multiple: true, size: 2, class: compact_select %> +
+
+ + <%= hidden_field_tag "filters[pipeline_stages][]", "" %> + <%= select_tag "filters[pipeline_stages][]", options_for_select(Catalog::PIPELINE_STAGES.map { |k, v| [v, k] }, @filters["pipeline_stages"]), + multiple: true, size: 5, class: compact_select %> +
+
+ + <%= select_tag "filters[estimated_value]", options_for_select([["Qualsiasi", "any"], ["Presente", "present"], ["Assente", "blank"]], @filters["estimated_value"]), class: input_class %> +
+
+ <% end %> + + <%= render layout: "mailings/audience_disclosure", locals: { + name: "history", + title: "Storico email", + hint: "Chi ha già ricevuto o aperto una campagna", + open: history_open, + count: @filters.history_active? ? 1 : 0 + } do %> +
+
+ + <%= select_tag "filters[history_kind]", options_for_select([ + ["Nessun filtro storico", ""], + ["Mai ricevuta una campagna", "never"], + ["Ha ricevuto questa campagna", "received"], + ["Non ha ricevuto questa campagna", "not_received"], + ["Ha aperto questa campagna", "opened"], + ["Ha ricevuto ma non aperto questa campagna", "not_opened"], + ["Inviata negli ultimi N giorni", "recent"] + ], @filters.history_kind), class: input_class %> +
+
+ + <%= select_tag "filters[history_mailing_id]", + options_from_collection_for_select(@history_mailings, :id, :name, @filters.history_mailing_id), + include_blank: "Scegli campagna", class: input_class %> +
+
+ + <%= number_field_tag "filters[history_days]", @filters.history_days, min: 1, placeholder: "30", class: input_class %> +
+
+ <% end %> + + <%= render layout: "mailings/audience_disclosure", locals: { + name: "exclusions", + title: "Escludi dalla lista", + hint: "Clienti, già ricevute, già aperte, invii recenti", + open: exclude_open, + count: @filters.exclusions_count + } do %> + +
+ + <%= hidden_field_tag "filters[exclude_received_mailing_ids][]", "" %> + <%= select_tag "filters[exclude_received_mailing_ids][]", + options_from_collection_for_select(@history_mailings, :id, :name, @filters["exclude_received_mailing_ids"]), + multiple: true, size: 4, class: compact_select %> +
+
+ + <%= hidden_field_tag "filters[exclude_opened_mailing_ids][]", "" %> + <%= select_tag "filters[exclude_opened_mailing_ids][]", + options_from_collection_for_select(@history_mailings, :id, :name, @filters["exclude_opened_mailing_ids"]), + multiple: true, size: 4, class: compact_select %> +
+
+ + <%= number_field_tag "filters[exclude_mailed_within_days]", @filters["exclude_mailed_within_days"], min: 1, class: input_class %> +
+ <% end %> + +
+ <%= button_tag "Genera lista e rivedi", type: "submit", class: "#{btn_primary} w-full sm:w-auto", + data: { action: "audience-preview#apply" } %> + <%= link_to "Vai alla revisione senza rigenerare", @mailing, class: "#{btn_secondary} w-full sm:w-auto" %> +
+
+ + + + <% end %> +
diff --git a/app/views/mailings/edit.html.erb b/app/views/mailings/edit.html.erb index 4a1e7ad..6f965e6 100644 --- a/app/views/mailings/edit.html.erb +++ b/app/views/mailings/edit.html.erb @@ -1,4 +1,5 @@
+ <%= render "mailings/wizard", mailing: @mailing, current: :message %>

Modifica invio

<%= @mailing.name %>

diff --git a/app/views/mailings/new.html.erb b/app/views/mailings/new.html.erb index 8547433..a68565a 100644 --- a/app/views/mailings/new.html.erb +++ b/app/views/mailings/new.html.erb @@ -1,7 +1,8 @@
+ <%= render "mailings/wizard", mailing: @mailing, current: :message %>

Nuovo invio

-

Scegli mittente, lista e contenuto. Dopo il salvataggio fai il check dei destinatari.

+

Prima il messaggio, poi i filtri sui destinatari, poi la revisione della lista.

<%= render "form", mailing: @mailing %> diff --git a/app/views/mailings/show.html.erb b/app/views/mailings/show.html.erb index b3935ca..846d642 100644 --- a/app/views/mailings/show.html.erb +++ b/app/views/mailings/show.html.erb @@ -1,5 +1,6 @@ <%= turbo_frame_tag "mailing_live", target: "_top", refresh: "morph" do %> <%= tag.div class: "space-y-6", data: (@mailing.sending? ? { controller: "auto-reload", auto_reload_interval_value: 10_000 } : {}) do %> + <%= render "mailings/wizard", mailing: @mailing, current: :review %>

<%= @mailing.name %>

@@ -30,7 +31,8 @@ <%= link_to "Cruscotto invii", dashboard_mailings_path, class: "#{btn_secondary} flex-1 sm:flex-none" %> <% if @mailing.editable? %> <%= link_to "Modifica contenuto", edit_mailing_path(@mailing), class: btn_secondary %> - <%= button_to "Aggiorna lista", refresh_recipients_mailing_path(@mailing), method: :post, class: btn_secondary %> + <%= link_to "Raffina destinatari", audience_mailing_path(@mailing), class: btn_secondary %> + <%= button_to "Rigenera lista", refresh_recipients_mailing_path(@mailing), method: :post, class: btn_secondary %> <% elsif @mailing.sending? %> <%= button_to "Ferma invio", cancel_mailing_path(@mailing), method: :post, class: btn_danger, @@ -126,6 +128,13 @@ <% end %>
+ <% if @mailing.editable? && @recipients.empty? %> +
+ Nessuna lista generata. Raffina i filtri e genera la lista congelata. + <%= link_to "Scegli destinatari", audience_mailing_path(@mailing), class: "font-medium underline" %> +
+ <% end %> + <%= form_with url: update_recipients_mailing_path(@mailing), method: :patch do %>
<% if @mailing.editable? %> diff --git a/config/routes.rb b/config/routes.rb index 74b5511..1febf05 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -62,6 +62,8 @@ Rails.application.routes.draw do post :refresh_recipients patch :update_recipients get :preview + get :audience + patch :audience, action: :update_audience end end end diff --git a/db/migrate/20260824190000_add_audience_filters_to_mailings.rb b/db/migrate/20260824190000_add_audience_filters_to_mailings.rb new file mode 100644 index 0000000..e0d4853 --- /dev/null +++ b/db/migrate/20260824190000_add_audience_filters_to_mailings.rb @@ -0,0 +1,6 @@ +class AddAudienceFiltersToMailings < ActiveRecord::Migration[8.1] + def change + add_column :mailings, :audience_filters, :jsonb, null: false, default: {} + add_index :mailings, :audience_filters, using: :gin + end +end diff --git a/db/schema.rb b/db/schema.rb index a7a595d..d500298 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_20_110000) do +ActiveRecord::Schema[8.1].define(version: 2026_08_24_190000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -149,6 +149,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_20_110000) do t.string "ab_assignment", default: "from_record", null: false t.boolean "ab_test", default: false, null: false t.string "audience", default: "to_send", null: false + t.jsonb "audience_filters", default: {}, null: false t.datetime "authorized_at" t.text "body_html", null: false t.text "body_html_b" @@ -174,6 +175,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_20_110000) do t.datetime "updated_at", null: false t.bigint "updated_by_id" t.index ["ab_test"], name: "index_mailings_on_ab_test" + t.index ["audience_filters"], name: "index_mailings_on_audience_filters", using: :gin t.index ["mail_identity_id"], name: "index_mailings_on_mail_identity_id" t.index ["project_id"], name: "index_mailings_on_project_id" t.index ["status", "next_send_at"], name: "index_mailings_on_status_and_next_send_at" diff --git a/test/controllers/mailings_controller_test.rb b/test/controllers/mailings_controller_test.rb index 8eb710f..07a54fe 100644 --- a/test/controllers/mailings_controller_test.rb +++ b/test/controllers/mailings_controller_test.rb @@ -16,11 +16,11 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest assert_match(/Invii email/, response.body) end - test "creates a draft and builds recipients" do + test "creates a draft and opens audience filters" do login_as users(:admin) follow_redirect! if response.redirect? - assert_difference -> { Mailing.count } => 1, -> { MailingRecipient.count } => 1 do + assert_difference -> { Mailing.count } => 1, -> { MailingRecipient.count } => 0 do post mailings_path(project_code: @project.code), params: { mailing: { name: "Lancio", @@ -33,10 +33,59 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest } end mailing = Mailing.last + assert_redirected_to audience_mailing_path(mailing, project_code: @project.code) + assert mailing.mailing_recipients.none? + end + + test "audience apply builds frozen recipient list" do + login_as users(:admin) + follow_redirect! if response.redirect? + mailing = create_mailing(identity: @identity, audience: "to_send") + + patch audience_mailing_path(mailing, project_code: @project.code), params: { + intent: "apply", + filters: { preset: "to_send" } + } + assert_redirected_to mailing_path(mailing, project_code: @project.code) + assert mailing.reload.mailing_recipients.any? assert_equal "pending", mailing.mailing_recipients.first.status end + test "audience page shows live filter counts" do + login_as users(:admin) + follow_redirect! if response.redirect? + mailing = create_mailing(identity: @identity, audience: "to_send") + + get audience_mailing_path(mailing, project_code: @project.code) + assert_response :success + assert_match(/Destinatari/, response.body) + assert_match(/Corrispondenze/, response.body) + assert_match(/Genera lista e rivedi/, response.body) + assert_match(/Chi deve ricevere questa email/, response.body) + assert_select "details[data-section=organization]" + assert_select "details[data-section=campaign]" + assert_select "details[data-section=history]" + assert_select "details[data-section=exclusions]" + end + + test "audience preview saves filters without building recipients" do + login_as users(:admin) + follow_redirect! if response.redirect? + mailing = create_mailing(identity: @identity, audience: "to_send") + + patch audience_mailing_path(mailing, project_code: @project.code), params: { + intent: "preview", + filters: { preset: "all", sports: [ "Calcio" ] } + } + + assert_response :success + assert_match(/Destinatari/, response.body) + assert mailing.reload.mailing_recipients.none? + assert_equal "all", mailing.audience + assert_equal [ "Calcio" ], mailing.parsed_audience_filters["sports"] + end + test "check recipients then queue send" do login_as users(:admin) follow_redirect! if response.redirect? @@ -125,7 +174,11 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest } mailing = Mailing.last assert mailing.ab_test? - assert_equal "A", mailing.mailing_recipients.first.ab_variant + patch audience_mailing_path(mailing, project_code: @project.code), params: { + intent: "apply", + filters: { preset: "to_send" } + } + assert_equal "A", mailing.reload.mailing_recipients.first.ab_variant assert_emails 1 do post test_send_mailing_path(mailing, project_code: @project.code), params: { @@ -184,7 +237,7 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest } } mailing = Mailing.last - assert_redirected_to mailing_path(mailing, project_code: @project.code) + assert_redirected_to audience_mailing_path(mailing, project_code: @project.code) assert_equal "B {{societa}}", mailing.subject_b assert_includes mailing.body_html_b, "Versione B dal template" end diff --git a/test/services/mailings_audience_query_test.rb b/test/services/mailings_audience_query_test.rb new file mode 100644 index 0000000..7d6c844 --- /dev/null +++ b/test/services/mailings_audience_query_test.rb @@ -0,0 +1,68 @@ +require "test_helper" + +class MailingsAudienceQueryTest < ActiveSupport::TestCase + setup do + opportunities(:deal).update!(send_status: "to_send", ab_variant: "A") + @project = projects(:matchlivetv) + @acme = organizations(:acme) + @volley = create_campaign_org( + name: "Volley Rosa", + email: "volley@example.com", + ab_variant: "B", + send_status: "to_send", + list_position: 20, + sport: "Pallavolo", + region: "Lazio", + team_gender: "female" + ) + @acme.update!(sport: "Calcio", region: "Lombardia", list_position: 1, team_gender: "male") + end + + test "preset to_send keeps campaign pending orgs" do + ids = query({ "preset" => "to_send" }).relation.pluck(:id) + assert_includes ids, @acme.id + assert_includes ids, @volley.id + end + + test "filters by sport" do + ids = query({ "preset" => "all", "sports" => ["Calcio"] }).relation.pluck(:id) + assert_includes ids, @acme.id + assert_not_includes ids, @volley.id + end + + test "filters by list position range" do + ids = query({ "preset" => "all", "list_min" => 1, "list_max" => 5 }).relation.pluck(:id) + assert_includes ids, @acme.id + assert_not_includes ids, @volley.id + end + + test "history never excludes orgs already mailed" do + previous = create_mailing(audience: "all") + previous.rebuild_recipients! + previous.mailing_recipients.find_by!(organization: @acme).update!(status: "sent", sent_at: 1.day.ago) + + mailing = create_mailing(audience: "all") + ids = query({ "preset" => "all", "history_kind" => "never" }, mailing: mailing).relation.pluck(:id) + assert_not_includes ids, @acme.id + assert_includes ids, @volley.id + end + + test "exclude customers drops active customers" do + @volley.update!(status: "active_customer") + ids = query({ "preset" => "all", "exclude_customers" => true }).relation.pluck(:id) + assert_includes ids, @acme.id + assert_not_includes ids, @volley.id + end + + test "recipient builder uses audience filters" do + mailing = create_mailing(audience: "all", audience_filters: { "preset" => "all", "sports" => ["Pallavolo"] }) + mailing.rebuild_recipients! + assert_equal [@volley.id], mailing.mailing_recipients.map(&:organization_id) + end + + private + + def query(filters = {}, mailing: nil) + Mailings::AudienceQuery.new(@project, Mailings::AudienceFilters.new(filters), mailing: mailing) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index da77788..067686a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -50,14 +50,16 @@ module MailMarketingTestHelper ) end - def create_campaign_org(name:, email:, ab_variant: nil, send_status: "to_send", list_position: nil) + def create_campaign_org(name:, email:, ab_variant: nil, send_status: "to_send", list_position: nil, **org_attrs) project = projects(:matchlivetv) org = Organization.new( - name: name, - status: "prospect", - organization_type: "societa_sportiva", - email: email, - list_position: list_position + { + name: name, + status: "prospect", + organization_type: "societa_sportiva", + email: email, + list_position: list_position + }.merge(org_attrs) ) org.organization_projects.build(project: project) org.save!