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