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
+69 -4
View File
@@ -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])
@@ -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
}
}
+29 -1
View File
@@ -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?
+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)
@@ -0,0 +1,22 @@
<%# locals: name:, title:, hint:, open:, count: 0 %>
<details data-section="<%= name %>" class="<%= card_class %> group" <%= "open" if open %>>
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 p-4 md:px-6 [&::-webkit-details-marker]:hidden">
<div>
<div class="text-sm font-semibold"><%= title %></div>
<% if hint.present? %>
<p class="mt-0.5 text-xs text-zinc-500"><%= hint %></p>
<% end %>
</div>
<div class="flex shrink-0 items-center gap-2">
<% if count.to_i.positive? %>
<span class="rounded-full bg-zinc-900 px-2 py-0.5 text-xs font-medium text-white dark:bg-zinc-100 dark:text-zinc-900"><%= count %></span>
<% end %>
<svg class="size-4 text-zinc-400 transition group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 10.94l3.71-3.71a.75.75 0 1 1 1.06 1.06l-4.24 4.24a.75.75 0 0 1-1.06 0L5.21 8.29a.75.75 0 0 1 .02-1.08Z" clip-rule="evenodd" />
</svg>
</div>
</summary>
<div class="space-y-4 border-t border-zinc-100 p-4 md:p-6 dark:border-zinc-800">
<%= yield %>
</div>
</details>
+10 -15
View File
@@ -16,20 +16,15 @@
<%= f.text_field :name, required: true, class: input_class %>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium">Account mittente</label>
<%= f.collection_select :mail_identity_id, @mail_identities, :id, :name, { prompt: "Scegli SMTP" }, { class: input_class, required: true } %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Lista destinatari</label>
<%= f.select :audience, Catalog::MAILING_AUDIENCES.map { |k, v| [v, k] }, {}, class: input_class %>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium">Pausa tra un invio e laltro (secondi)</label>
<%= f.number_field :interval_seconds, min: 0, max: 86_400, class: input_class %>
<p class="mt-1 text-xs text-zinc-500">0 = in coda una dopo laltra, senza attesa. Es. 120 = una email ogni 2 minuti.</p>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Account mittente</label>
<%= f.collection_select :mail_identity_id, @mail_identities, :id, :name, { prompt: "Scegli SMTP" }, { class: input_class, required: true } %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Pausa tra un invio e laltro (secondi)</label>
<%= f.number_field :interval_seconds, min: 0, max: 86_400, class: input_class %>
<p class="mt-1 text-xs text-zinc-500">0 = in coda una dopo laltra, senza attesa. Es. 120 = una email ogni 2 minuti.</p>
</div>
<div class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700" data-controller="send-window">
@@ -120,7 +115,7 @@
</div>
<div class="flex flex-col gap-2 border-t border-zinc-100 pt-4 sm:flex-row dark:border-zinc-800">
<%= 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" %>
</div>
</div>
+21
View File
@@ -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]
]
%>
<nav class="flex flex-wrap gap-2 text-sm" aria-label="Passi invio">
<% steps.each_with_index do |(label, path, active), index| %>
<% classes = if active
"rounded-full bg-zinc-900 px-3 py-1.5 font-medium text-white dark:bg-zinc-100 dark:text-zinc-900"
else
"rounded-full bg-zinc-100 px-3 py-1.5 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
end %>
<% if path && !active %>
<%= link_to "#{index + 1}. #{label}", path, class: classes %>
<% else %>
<span class="<%= classes %>"><%= index + 1 %>. <%= label %></span>
<% end %>
<% end %>
</nav>
+261
View File
@@ -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")
%>
<div class="mx-auto max-w-5xl space-y-6">
<%= render "mailings/wizard", mailing: @mailing, current: :audience %>
<div>
<h1 class="text-2xl font-semibold tracking-tight">Destinatari</h1>
<p class="mt-1 text-sm text-zinc-500">
Scegli da dove parti, eventualmente raffina, poi genera la lista. I filtri avanzati stanno nei box sotto.
</p>
</div>
<%= 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" } %>
<div class="order-2 space-y-4 xl:order-1">
<section class="<%= card_class %> space-y-4 p-4 md:p-6">
<div>
<h2 class="text-base font-semibold">Chi deve ricevere questa email?</h2>
<p class="mt-1 text-sm text-zinc-500">Tre cose bastano: lista di partenza, sport, regione. Il resto è opzionale.</p>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Lista di partenza</label>
<%= select_tag "filters[preset]", options_for_select(Catalog::MAILING_AUDIENCES.map { |k, v| [v, k] }, @filters.preset), class: input_class %>
<p class="mt-1 text-xs text-zinc-500"><%= preset_hints[@filters.preset] %></p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium">Sport</label>
<%= hidden_field_tag "filters[sports][]", "" %>
<%= select_tag "filters[sports][]", options_for_select(@sport_options, @filters["sports"]),
multiple: true, size: 5, class: compact_select %>
<p class="mt-1 text-xs text-zinc-500">Vuoto = tutti gli sport. Tieni premuto Ctrl o Cmd per più valori.</p>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Regione</label>
<%= hidden_field_tag "filters[regions][]", "" %>
<%= select_tag "filters[regions][]", options_for_select(@region_options, @filters["regions"]),
multiple: true, size: 5, class: compact_select %>
</div>
</div>
<% if extra_parts.any? %>
<div class="flex flex-wrap gap-1.5">
<% extra_parts.each do |part| %>
<span class="rounded-full bg-zinc-100 px-2.5 py-1 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"><%= part %></span>
<% end %>
</div>
<% end %>
</section>
<%= 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 %>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium">M/F</label>
<%= 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 %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Provincia</label>
<%= hidden_field_tag "filters[provinces][]", "" %>
<%= select_tag "filters[provinces][]", options_for_select(@province_options, @filters["provinces"]),
multiple: true, size: 4, class: compact_select %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Streaming</label>
<%= 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 %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Stato organizzazione</label>
<%= 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 %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">N. lista da</label>
<%= number_field_tag "filters[list_min]", @filters["list_min"], min: 1, class: input_class %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">N. lista a</label>
<%= number_field_tag "filters[list_max]", @filters["list_max"], min: 1, class: input_class %>
</div>
</div>
<% 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 %>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium">Stato invio campagna</label>
<%= 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 %>
<p class="mt-1 text-xs text-zinc-500">Serve soprattutto se la lista di partenza è «tutte».</p>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Test A/B in scheda</label>
<%= 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 %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Stage pipeline</label>
<%= 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 %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Valore stimato</label>
<%= select_tag "filters[estimated_value]", options_for_select([["Qualsiasi", "any"], ["Presente", "present"], ["Assente", "blank"]], @filters["estimated_value"]), class: input_class %>
</div>
</div>
<% 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 %>
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium">Criterio</label>
<%= 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 %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Campagna</label>
<%= 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 %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Ultimi N giorni</label>
<%= number_field_tag "filters[history_days]", @filters.history_days, min: 1, placeholder: "30", class: input_class %>
</div>
</div>
<% 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 %>
<label class="flex min-h-11 items-center gap-3 text-sm">
<%= hidden_field_tag "filters[exclude_customers]", "0" %>
<%= check_box_tag "filters[exclude_customers]", "1", @filters["exclude_customers"], class: "size-5" %>
Clienti attivi e inattivi
</label>
<div>
<label class="mb-1 block text-sm font-medium">Chi ha già ricevuto</label>
<%= 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 %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Chi ha aperto</label>
<%= 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 %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Chi ha ricevuto qualcosa negli ultimi N giorni</label>
<%= number_field_tag "filters[exclude_mailed_within_days]", @filters["exclude_mailed_within_days"], min: 1, class: input_class %>
</div>
<% end %>
<div class="flex flex-col gap-2 sm:flex-row xl:hidden">
<%= 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" %>
</div>
</div>
<aside class="order-1 space-y-4 self-start xl:sticky xl:top-4 xl:order-2">
<div class="<%= card_class %> p-4">
<div class="text-xs uppercase tracking-wide text-zinc-500">Corrispondenze</div>
<% if @audience_counts[:with_email].zero? %>
<div class="mt-1 text-3xl font-semibold tabular-nums">0</div>
<p class="mt-1 text-sm text-amber-800 dark:text-amber-200">Nessuna società con email. Allarga i filtri.</p>
<% else %>
<div class="mt-1 text-3xl font-semibold tabular-nums"><%= @audience_counts[:with_email] %></div>
<p class="mt-1 text-sm text-zinc-500">
<%= @audience_counts[:with_email] == 1 ? "società con email valida" : "società con email valida" %>
su <%= @audience_counts[:total] %>
</p>
<% end %>
<% if @mailing.mailing_recipients.any? %>
<p class="mt-3 text-xs text-zinc-500">
Lista già generata: <%= @mailing.pending_count %> da inviare.
Generando di nuovo la sostituisci.
</p>
<% else %>
<p class="mt-3 text-xs text-zinc-500">La lista si congela solo quando premi genera.</p>
<% end %>
<div class="mt-4 hidden flex-col gap-2 xl:flex">
<%= button_tag "Genera lista e rivedi", type: "submit", class: "#{btn_primary} w-full",
data: { action: "audience-preview#apply" } %>
<%= link_to "Vai alla revisione senza rigenerare", @mailing, class: "#{btn_secondary} w-full" %>
</div>
</div>
<div class="<%= card_class %> p-4">
<h2 class="text-sm font-semibold">Anteprima</h2>
<ul class="mt-3 space-y-2 text-sm">
<% @audience_preview.each do |org| %>
<li>
<div class="font-medium"><%= org.name %></div>
<div class="text-xs text-zinc-500"><%= [org.sport, org.region, org.list_position].compact.join(" · ") %></div>
</li>
<% end %>
<% if @audience_preview.empty? %>
<li class="text-zinc-500">Nessuna organizzazione con questi filtri.</li>
<% end %>
</ul>
</div>
</aside>
<% end %>
</div>
+1
View File
@@ -1,4 +1,5 @@
<div class="mx-auto max-w-5xl space-y-6">
<%= render "mailings/wizard", mailing: @mailing, current: :message %>
<div>
<h1 class="text-2xl font-semibold tracking-tight">Modifica invio</h1>
<p class="mt-1 text-sm text-zinc-500"><%= @mailing.name %></p>
+2 -1
View File
@@ -1,7 +1,8 @@
<div class="mx-auto max-w-5xl space-y-6">
<%= render "mailings/wizard", mailing: @mailing, current: :message %>
<div>
<h1 class="text-2xl font-semibold tracking-tight">Nuovo invio</h1>
<p class="mt-1 text-sm text-zinc-500">Scegli mittente, lista e contenuto. Dopo il salvataggio fai il check dei destinatari.</p>
<p class="mt-1 text-sm text-zinc-500">Prima il messaggio, poi i filtri sui destinatari, poi la revisione della lista.</p>
</div>
<%= render "form", mailing: @mailing %>
+10 -1
View File
@@ -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 %>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-xl font-semibold tracking-tight md:text-2xl"><%= @mailing.name %></h1>
@@ -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 %>
</div>
<% if @mailing.editable? && @recipients.empty? %>
<div class="border-b border-zinc-100 px-4 py-4 text-sm text-zinc-600 dark:border-zinc-800 dark:text-zinc-300">
Nessuna lista generata. Raffina i filtri e genera la lista congelata.
<%= link_to "Scegli destinatari", audience_mailing_path(@mailing), class: "font-medium underline" %>
</div>
<% end %>
<%= form_with url: update_recipients_mailing_path(@mailing), method: :patch do %>
<div data-controller="check-all">
<% if @mailing.editable? %>
+2
View File
@@ -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
@@ -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
Generated
+3 -1
View File
@@ -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"
+57 -4
View File
@@ -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
@@ -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
+8 -6
View File
@@ -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!