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 %>
+ <%= hint %>
+
+
0 = in coda una dopo l’altra, senza attesa. Es. 120 = una email ogni 2 minuti.
-0 = in coda una dopo l’altra, senza attesa. Es. 120 = una email ogni 2 minuti.
+ Scegli da dove parti, eventualmente raffina, poi genera la lista. I filtri avanzati stanno nei box sotto. +
+Tre cose bastano: lista di partenza, sport, regione. Il resto è opzionale.
+<%= preset_hints[@filters.preset] %>
+Vuoto = tutti gli sport. Tieni premuto Ctrl o Cmd per più valori.
+Serve soprattutto se la lista di partenza è «tutte».
+<%= @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 @@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.