Aggiunge filtri avanzati per i destinatari delle campagne email.
CI / scan_ruby (push) Failing after 18m40s
CI / scan_js (push) Successful in 11m46s
CI / lint (push) Failing after 13m8s

La scelta della lista passa da un wizard a tre passi, con anteprima del conteggio e lista congelata solo dopo la conferma.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 21:00:11 +02:00
co-authored by Cursor
parent 2db7504629
commit 1f946f63ba
19 changed files with 912 additions and 46 deletions
+153
View File
@@ -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
+153
View File
@@ -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
+3 -13
View File
@@ -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)