Commit iniziale di eminuxCRM: CRM Rails con pipeline, campagne email e Docker.
CI / scan_ruby (push) Failing after 11m20s
CI / scan_js (push) Successful in 10m35s
CI / lint (push) Has been cancelled

Include autenticazione, progetti isolati, mail marketing HTML con SMTP, test A/B e editor WYSIWYG.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 23:01:48 +02:00
co-authored by Cursor
commit c4e5f289cf
258 changed files with 11293 additions and 0 deletions
@@ -0,0 +1,208 @@
require "csv"
class CampaignImport::MatchlivetvLaunch
CAMPAIGN_NAME = "Campagna lancio 01".freeze
SPORT = "Pallavolo".freeze
Result = Struct.new(:wiped_organizations, :imported, :errors, keyword_init: true)
STREAMING_MAP = {
"NON RILEVATO" => "not_detected",
"LIMITATO" => "limited",
"SI / PARZIALE" => "yes_partial",
"SI" => "yes",
"SI - SPORTCAM" => "yes_sportcam",
"SI SPORTCAM" => "yes_sportcam"
}.freeze
GENDER_MAP = {
"F" => "female",
"M" => "male",
"M/F" => "mixed"
}.freeze
SEND_MAP = {
"DA INVIARE" => "to_send",
"INVIATO" => "sent",
"NON INVIARE" => "no_send"
}.freeze
def initialize(path:, user: nil, project: nil, wipe: true)
@path = Pathname.new(path)
@user = user
@project = project
@wipe = wipe
end
def call
raise ArgumentError, "File non trovato: #{@path}" unless @path.exist?
@project ||= Project.find_by!(code: "matchlivetv")
@user ||= User.find_by!(role: "admin")
Current.user = @user
Current.project = @project
wiped = @wipe ? wipe_project! : 0
imported = 0
errors = []
rows.each_with_index do |row, idx|
import_row!(row)
imported += 1
rescue StandardError => e
errors << { line: idx + 2, name: row["Società"], message: e.message }
end
Result.new(wiped_organizations: wiped, imported: imported, errors: errors)
ensure
Current.user = nil
Current.project = nil
end
private
def rows
CSV.read(@path, headers: true, encoding: "bom|utf-8")
end
def wipe_project!
destroyed = 0
Organization.transaction do
Opportunity.where(project_id: @project.id).find_each(&:destroy!)
org_ids = OrganizationProject.where(project_id: @project.id).pluck(:organization_id)
OrganizationProject.where(project_id: @project.id).delete_all
Organization.where(id: org_ids).find_each do |org|
next if org.organization_projects.exists?
org.destroy!
destroyed += 1
end
end
destroyed
end
def import_row!(row)
name = cell(row, "Società")
raise "Società vuota" if name.blank?
org = Organization.new(
name: name,
organization_type: "societa_sportiva",
sport: SPORT,
country: "Italia",
region: cell(row, "Regione"),
province: cell(row, "Prov."),
email: cell(row, "Email verificata")&.downcase,
website: cell(row, "Sito / profilo"),
source_url: cell(row, "Fonte contatto / ricerca"),
commercial_fit: cell(row, "Evidenza / fit commerciale"),
team_gender: GENDER_MAP.fetch(normalize_key(cell(row, "M/F"))) { raise "M/F sconosciuto: #{row['M/F']}" },
streaming_status: streaming_status_for(cell(row, "Streaming rilevato")),
list_position: integer_cell(row, "N."),
verified_at: parse_date(cell(row, "Data verifica")),
status: yes?(cell(row, "Conversione")) ? "active_customer" : "prospect",
lead_source: "campaign",
assigned_user: @user,
notes: cell(row, "Note follow-up")
)
org.projects = [@project]
org.save!
first_name, last_name, role = contact_from(org.email, org.name)
org.contacts.create!(
first_name: first_name,
last_name: last_name,
role: role,
email: org.email,
preferred_contact_method: "email",
primary_contact: true
)
converted = yes?(cell(row, "Conversione"))
demo = yes?(cell(row, "Demo / Trial"))
stage = if converted
"won"
elsif demo
"demo_trial"
else
"to_contact"
end
org.opportunities.create!(
name: CAMPAIGN_NAME,
project: @project,
pipeline_stage: stage,
product: cell(row, "Piano"),
assigned_user: @user,
ab_variant: cell(row, "Test A/B").to_s.upcase.presence,
send_status: SEND_MAP.fetch(normalize_key(cell(row, "Stato invio")), "to_send"),
sent_on: parse_date(cell(row, "Data invio")),
outcome: cell(row, "Esito"),
demo_trial: demo,
converted: converted,
notes: [cell(row, "Evidenza / fit commerciale"), cell(row, "Note follow-up")].compact_blank.join("\n\n")
)
end
def cell(row, header)
value = row[header].to_s.strip
value.presence
end
def integer_cell(row, header)
raw = cell(row, header)
return if raw.blank?
raw.to_i
end
def normalize_key(value)
I18n.transliterate(value.to_s)
.gsub(/[\u2013\u2014\u2212]/, "-")
.encode("ASCII", invalid: :replace, undef: :replace, replace: "")
.strip
.upcase
.gsub(/\s+/, " ")
end
def streaming_status_for(value)
key = normalize_key(value)
return STREAMING_MAP[key] if STREAMING_MAP.key?(key)
return "yes_sportcam" if key.include?("SPORTCAM")
return "yes_partial" if key.include?("PARZIALE")
return "not_detected" if key.include?("NON RILEVATO")
return "limited" if key.include?("LIMITATO")
return "yes" if key == "SI" || key.start_with?("SI ")
raise "Streaming sconosciuto: #{value}"
end
def yes?(value)
%w[SI YES TRUE 1].include?(normalize_key(value))
end
def parse_date(value)
return if value.blank?
return Date.iso8601(value) if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
return Date.strptime(value, "%d/%m/%Y") if value.match?(/\A\d{1,2}\/\d{1,2}\/\d{4}\z/)
serial = Float(value)
Date.new(1899, 12, 30) + serial.to_i
rescue ArgumentError, TypeError
nil
end
def contact_from(email, org_name)
local = email.to_s.split("@").first.to_s
if local.match?(/\A[a-z]+[._][a-z]+\z/i)
first, last = local.split(/[._]/)
[first.capitalize, last.capitalize, "Contatto"]
elsif local.match?(/\A[a-z]\.[a-z]+\z/i)
initial, last = local.split(".")
["#{initial.upcase}.", last.capitalize, "Contatto"]
else
token = org_name.to_s.split(/[\s\/-]+/).last.presence || "Società"
["Segreteria", token, "Segreteria"]
end
end
end
+42
View File
@@ -0,0 +1,42 @@
require "csv"
class CsvExport
def self.organizations(scope)
CSV.generate(headers: true) do |csv|
csv << %w[id list_position name team_gender streaming_status region province website email status lead_source commercial_fit source_url verified_at assigned_user notes created_at]
scope.includes(:assigned_user).find_each do |org|
csv << [
org.id, org.list_position, org.name, org.team_gender, org.streaming_status, org.region,
org.province, org.website, org.email, org.status, org.lead_source, org.commercial_fit,
org.source_url, org.verified_at, org.assigned_user&.full_name, org.notes, org.created_at
]
end
end
end
def self.contacts(scope)
CSV.generate(headers: true) do |csv|
csv << %w[id organization_name first_name last_name role email phone mobile preferred_contact_method primary_contact notes]
scope.includes(:organization).find_each do |contact|
csv << [
contact.id, contact.organization.name, contact.first_name, contact.last_name, contact.role,
contact.email, contact.phone, contact.mobile, contact.preferred_contact_method,
contact.primary_contact, contact.notes
]
end
end
end
def self.opportunities(scope)
CSV.generate(headers: true) do |csv|
csv << %w[id organization_name name pipeline_stage ab_variant send_status sent_on outcome demo_trial converted estimated_value product assigned_user notes]
scope.includes(:organization, :assigned_user).find_each do |opp|
csv << [
opp.id, opp.organization.name, opp.name, opp.pipeline_stage, opp.ab_variant, opp.send_status,
opp.sent_on, opp.outcome, opp.demo_trial, opp.converted, opp.estimated_value, opp.product,
opp.assigned_user&.full_name, opp.notes
]
end
end
end
end
+127
View File
@@ -0,0 +1,127 @@
require "csv"
class CsvImport::Organizations
STANDARD_HEADERS = %w[
organization_name organization_type sport country region province city website
organization_email contact_first_name contact_last_name contact_role contact_email
contact_phone lead_source notes
].freeze
Result = Struct.new(:imported, :skipped, :errors, keyword_init: true)
def initialize(file:, user:, mapping: nil, project: nil)
@file = file
@user = user
@mapping = mapping
@project = project || Current.project
end
def preview(limit: 10)
rows = []
CSV.foreach(@file.path, headers: true, encoding: "bom|utf-8") do |row|
rows << row.to_h
break if rows.size >= limit
end
{ headers: rows.first&.keys || [], rows: rows }
end
def import!
imported = 0
skipped = 0
errors = []
CSV.foreach(@file.path, headers: true, encoding: "bom|utf-8").with_index(2) do |row, line|
Current.user = @user
attrs = mapped_attrs(row)
if duplicate?(attrs)
skipped += 1
next
end
Organization.transaction do
org = Organization.new(
name: attrs[:organization_name],
organization_type: normalize_type(attrs[:organization_type]),
sport: attrs[:sport],
country: attrs[:country].presence || "Italia",
region: attrs[:region],
province: attrs[:province],
city: attrs[:city],
website: normalize_website(attrs[:website]),
email: attrs[:organization_email],
lead_source: normalize_lead_source(attrs[:lead_source]),
notes: attrs[:notes],
status: "prospect",
assigned_user: @user
)
org.projects = [@project].compact
org.save!
if attrs[:contact_first_name].present? || attrs[:contact_last_name].present?
org.contacts.create!(
first_name: attrs[:contact_first_name].presence || "N/D",
last_name: attrs[:contact_last_name].presence || "N/D",
role: attrs[:contact_role],
email: attrs[:contact_email],
phone: attrs[:contact_phone],
primary_contact: true
)
end
end
imported += 1
rescue StandardError => e
errors << { line: line, message: e.message, row: row.to_h }
end
Result.new(imported: imported, skipped: skipped, errors: errors)
ensure
Current.user = nil
end
private
def mapped_attrs(row)
source = row.to_h.transform_keys { |k| k.to_s.strip }
STANDARD_HEADERS.index_with do |header|
key = @mapping&.dig(header) || header
source[key].to_s.strip.presence
end.symbolize_keys
end
def duplicate?(attrs)
name = attrs[:organization_name].to_s
email = attrs[:organization_email].to_s.downcase
website = normalize_website(attrs[:website])
return true if name.present? && Organization.where("LOWER(name) = ?", name.downcase).exists?
return true if email.present? && Organization.where("LOWER(email) = ?", email).exists?
return true if website.present? && Organization.where("LOWER(website) = ?", website.downcase).exists?
false
end
def normalize_website(value)
return if value.blank?
value.to_s.strip.downcase.sub(%r{\Ahttps?://}, "").sub(%r{/\z}, "")
end
def normalize_type(value)
return "altro" if value.blank?
key = value.to_s.downcase.gsub(/\s+/, "_")
return key if Catalog::ORGANIZATION_TYPES.key?(key)
Catalog::ORGANIZATION_TYPES.find { |_k, label| label.downcase == value.to_s.downcase }&.first || "altro"
end
def normalize_lead_source(value)
return if value.blank?
key = value.to_s.downcase.gsub(/\s+/, "_")
return key if Catalog::LEAD_SOURCES.key?(key)
Catalog::LEAD_SOURCES.find { |_k, label| label.downcase == value.to_s.downcase }&.first || "other"
end
end
+167
View File
@@ -0,0 +1,167 @@
class Dashboard::Metrics
def initialize(scope: Opportunity.all, project: nil)
@scope = scope
@project = project
end
def stage_counts
@stage_counts ||= Catalog::PIPELINE_ORDER.index_with { |stage| @scope.where(pipeline_stage: stage).count }
end
def prospect_count
organizations_scope.prospects.count
end
def open_pipeline_value
@scope.open_stage.sum(:estimated_value).to_f
end
def won_value
@scope.won.sum(:estimated_value).to_f
end
def conversion_rates
stages = Catalog::OPEN_PIPELINE_STAGES + %w[won]
rates = {}
stages.each_cons(2) do |from, to|
from_count = cumulative_reached(from)
to_count = cumulative_reached(to)
rates["#{from}_to_#{to}"] = percentage(to_count, from_count)
end
rates
end
def funnel_steps
counts = {
"contacted" => reached_or_beyond("contacted"),
"replied" => reached_or_beyond("replied"),
"interested" => reached_or_beyond("interested"),
"first_use" => reached_or_beyond("first_use"),
"won" => @scope.won.count
}
steps = []
previous = nil
counts.each do |stage, count|
rate = previous ? percentage(count, previous) : nil
steps << { stage: stage, label: Catalog.label_for(Catalog::PIPELINE_STAGES, stage), count: count, rate: rate }
previous = count
end
steps
end
def avg_days_to_won
records = @scope.won.where.not(first_contacted_at: nil, won_at: nil)
return nil if records.empty?
total = records.sum { |o| ((o.won_at - o.first_contacted_at) / 1.day) }
(total / records.size).round(1)
end
def avg_days_since_last_activity
orgs = organizations_scope.left_joins(:activities)
.select("organizations.id, MAX(activities.happened_at) AS last_at")
.group("organizations.id")
.having("MAX(activities.happened_at) IS NOT NULL")
return nil if orgs.empty?
days = orgs.map { |o| ((Time.current - o.last_at) / 1.day) }
(days.sum / days.size).round(1)
end
def prospects_without_activity_over_7_days
organizations_scope.prospects
.left_joins(:activities)
.group("organizations.id")
.having("MAX(activities.happened_at) IS NULL OR MAX(activities.happened_at) < ?", 7.days.ago)
.count
.size
end
def overdue_tasks_count
tasks_scope.overdue.count
end
def opportunities_without_next_action
opportunities_missing_next_action.count
end
def demo_to_won_conversion
demo = reached_or_beyond("demo_trial")
percentage(@scope.won.count, demo)
end
def first_use_to_won_conversion
first_use = reached_or_beyond("first_use")
percentage(@scope.won.count, first_use)
end
def arpa
won = @scope.won.where.not(estimated_value: nil)
return 0 if won.empty?
(won.sum(:estimated_value).to_f / won.count).round(2)
end
def attention_items
{
to_send_count: @scope.where(send_status: "to_send").count,
interested_without_followup: interested_without_followup,
stalled_opportunities: stalled_opportunities,
overdue_tasks: tasks_scope.overdue.includes(:organization, :contact, :assigned_user).ordered.limit(20),
organizations_without_contacts: organizations_scope.left_joins(:contacts).where(contacts: { id: nil }).limit(20),
opportunities_without_value: advanced_open_stage.where(estimated_value: [nil, 0]).includes(:organization).limit(20),
opportunities_without_next_action: opportunities_missing_next_action.merge(advanced_open_stage).limit(20)
}
end
private
def organizations_scope
@project ? Organization.for_project(@project) : Organization.all
end
def tasks_scope
@project ? Task.for_project(@project) : Task.all
end
def cumulative_reached(stage)
reached_or_beyond(stage)
end
def reached_or_beyond(stage)
idx = Catalog::PIPELINE_ORDER.index(stage)
return 0 unless idx
stages = Catalog::PIPELINE_ORDER[idx..] - ["lost"]
@scope.where(pipeline_stage: stages).count
end
def percentage(part, whole)
return 0 if whole.to_i.zero?
((part.to_f / whole) * 100).round
end
def interested_without_followup
base = @scope.where(pipeline_stage: "interested")
with_pending = Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
base.where.not(id: with_pending).includes(:organization).limit(20)
end
def stalled_opportunities
@scope.open_stage
.where("stage_changed_at < ? OR (stage_changed_at IS NULL AND opportunities.created_at < ?)", 7.days.ago, 7.days.ago)
.includes(:organization, :assigned_user)
.limit(20)
end
def opportunities_missing_next_action
with_pending = Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
@scope.open_stage.where.not(id: with_pending).includes(:organization)
end
def advanced_open_stage
@scope.open_stage.where.not(pipeline_stage: "to_contact")
end
end
+62
View File
@@ -0,0 +1,62 @@
module MailMerge
TOKEN = /\{\{\s*([a-z0-9_.]+)\s*\}\}/i
class << self
def catalog
{
"societa" => "Nome società",
"regione" => "Regione",
"provincia" => "Provincia",
"citta" => "Città",
"email" => "Email",
"sito" => "Sito / profilo",
"sport" => "Sport",
"mf" => "M/F",
"streaming" => "Streaming rilevato",
"fit" => "Fit commerciale",
"n_lista" => "N. in lista",
"contatto_nome" => "Nome contatto",
"contatto_ruolo" => "Ruolo contatto",
"contatto_email" => "Email contatto",
"test_ab" => "Test A/B",
"progetto" => "Nome progetto",
"piano" => "Piano / prodotto"
}
end
def variables_for(organization: nil, contact: nil, project: nil, opportunity: nil, ab_variant: nil)
org = organization
contact ||= org&.primary_contact || org&.contacts&.first
opportunity ||= org&.campaign_opportunity(project) if org && project
{
"societa" => org&.name,
"organizzazione.nome" => org&.name,
"regione" => org&.region,
"provincia" => org&.province,
"citta" => org&.city,
"email" => (contact&.email.presence || org&.email),
"sito" => org&.website,
"sport" => org&.sport,
"mf" => org&.team_gender_label,
"streaming" => org&.streaming_status_label,
"fit" => org&.commercial_fit,
"n_lista" => org&.list_position,
"contatto_nome" => contact&.full_name,
"contatto_ruolo" => contact&.role,
"contatto_email" => contact&.email,
"test_ab" => ab_variant.presence || opportunity&.ab_variant,
"progetto" => project&.name,
"piano" => opportunity&.product
}.transform_values { |value| value.to_s }
end
def render(template, **context)
vars = variables_for(**context)
template.to_s.gsub(TOKEN) do
key = Regexp.last_match(1).to_s.downcase
vars[key].to_s
end
end
end
end
+48
View File
@@ -0,0 +1,48 @@
class Mailings::InlineImages
BLOB_PATH = %r{/rails/active_storage/blobs/(?:redirect/|proxy/)?([^/?#]+)}
def self.call(mailer, html)
new(mailer).call(html)
end
def initialize(mailer)
@mailer = mailer
end
def call(html)
fragment = Nokogiri::HTML::DocumentFragment.parse(html.to_s)
fragment.css("img").each_with_index do |img, index|
blob = blob_from(img["src"])
next unless blob
name = "inline-#{index}-#{blob.filename}"
@mailer.attachments.inline[name] = {
mime_type: blob.content_type,
content: blob.download
}
img["src"] = @mailer.attachments[name].url
width = img["width"].presence
styles = ["max-width: 100%", "height: auto"]
styles << "width: #{width}px" if width.present?
img["style"] = [img["style"], *styles].compact.join("; ")
end
fragment.css("figure").each do |figure|
image = figure.at_css("img")
image ? figure.replace(image) : figure.remove
end
fragment.to_html
end
private
def blob_from(src)
signed_id = src.to_s[BLOB_PATH, 1]
return if signed_id.blank?
ActiveStorage::Blob.find_signed(signed_id)
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
nil
end
end
@@ -0,0 +1,67 @@
class Mailings::RecipientBuilder
def initialize(mailing)
@mailing = mailing
end
def call
split_index = 0
ordered_scope.each do |org|
contact = org.primary_contact || org.contacts.min_by(&:id)
email = contact&.email.presence || org.email.presence
attrs = {
organization: org,
contact: contact,
email: email&.downcase
}
if email.blank?
attrs[:status] = "skipped"
attrs[:skip_reason] = "manca email"
elsif !email.match?(URI::MailTo::EMAIL_REGEXP)
attrs[:status] = "skipped"
attrs[:skip_reason] = "email non valida"
else
attrs[:status] = "pending"
end
assign_variant!(attrs, org, split_index)
split_index += 1 if attrs[:status] == "pending"
@mailing.mailing_recipients.create!(attrs)
end
@mailing.mailing_recipients.ordered
end
private
def ordered_scope
scope.reorder(Arel.sql("organizations.list_position ASC NULLS LAST"), "organizations.name ASC")
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)
end
def assign_variant!(attrs, org, split_index)
return unless @mailing.ab_test?
opportunity = org.campaign_opportunity(@mailing.project)
recorded = opportunity&.ab_variant.to_s.upcase
variant =
if @mailing.ab_from_record? && recorded.in?(%w[A B])
recorded
else
split_index.even? ? "A" : "B"
end
attrs[:ab_variant] = variant
end
end
+81
View File
@@ -0,0 +1,81 @@
class Reports::Builder
def initialize(project: nil)
@project = project
@opp_scope = project ? Opportunity.for_project(project) : Opportunity.all
@org_scope = project ? Organization.for_project(project) : Organization.all
end
def funnel
metrics = Dashboard::Metrics.new(scope: @opp_scope, project: @project)
{
stages: Catalog::PIPELINE_ORDER.map { |s| { stage: s, label: Catalog.label_for(Catalog::PIPELINE_STAGES, s), count: metrics.stage_counts[s] } },
conversions: metrics.conversion_rates,
funnel: metrics.funnel_steps
}
end
def lead_sources
Catalog::LEAD_SOURCES.map do |key, label|
orgs = @org_scope.where(lead_source: key)
opps = @opp_scope.joins(:organization).where(organizations: { lead_source: key })
won = opps.won.count
leads = orgs.count
{
key: key,
label: label,
leads: leads,
opportunities: opps.count,
customers: won,
conversion_rate: leads.zero? ? 0 : ((won.to_f / leads) * 100).round
}
end
end
def won_lost_by_month(months: 6)
start_date = months.months.ago.beginning_of_month
won = @opp_scope.won.where("won_at >= ?", start_date).group("DATE_TRUNC('month', won_at)").count
lost = @opp_scope.lost.where("lost_at >= ?", start_date).group("DATE_TRUNC('month', lost_at)").count
keys = (won.keys + lost.keys).uniq.sort
keys.map do |month|
{
month: month.to_date,
won: won[month] || 0,
lost: lost[month] || 0
}
end
end
def lost_reasons
@opp_scope.lost.group(:lost_reason).count.map do |reason, count|
{
key: reason,
label: Catalog.label_for(Catalog::LOST_REASONS, reason),
count: count
}
end.sort_by { |r| -r[:count] }
end
def sales_owners
User.active.map do |user|
open_opps = user.assigned_opportunities.merge(@opp_scope).open_stage
won_opps = user.assigned_opportunities.merge(@opp_scope).won
{
user: user,
open_count: open_opps.count,
open_value: open_opps.sum(:estimated_value).to_f,
won_count: won_opps.count,
won_value: won_opps.sum(:estimated_value).to_f
}
end
end
def revenue_by_month(months: 6)
start_date = months.months.ago.beginning_of_month
@opp_scope.won
.where("won_at >= ?", start_date)
.group("DATE_TRUNC('month', won_at)")
.sum(:estimated_value)
.sort_by { |month, _| month }
.map { |month, value| { month: month.to_date, value: value.to_f } }
end
end