Commit iniziale di eminuxCRM: CRM Rails con pipeline, campagne email e Docker.
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:
@@ -0,0 +1,18 @@
|
||||
class Activity < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :organization
|
||||
belongs_to :contact, optional: true
|
||||
belongs_to :opportunity, optional: true
|
||||
|
||||
validates :activity_type, inclusion: { in: Catalog::ACTIVITY_TYPES.keys }
|
||||
validates :subject, :happened_at, presence: true
|
||||
|
||||
scope :recent_first, -> { order(happened_at: :desc, id: :desc) }
|
||||
scope :chronological, -> { order(happened_at: :asc, id: :asc) }
|
||||
|
||||
def activity_type_label
|
||||
Catalog.label_for(Catalog::ACTIVITY_TYPES, activity_type)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
@@ -0,0 +1,193 @@
|
||||
module Catalog
|
||||
ORGANIZATION_STATUSES = {
|
||||
"prospect" => "Prospect",
|
||||
"active_customer" => "Cliente attivo",
|
||||
"inactive_customer" => "Cliente inattivo",
|
||||
"partner" => "Partner",
|
||||
"lost" => "Perso"
|
||||
}.freeze
|
||||
|
||||
ORGANIZATION_TYPES = {
|
||||
"societa_sportiva" => "Società sportiva",
|
||||
"organizzatore_evento" => "Organizzatore evento",
|
||||
"federazione" => "Federazione",
|
||||
"comitato" => "Comitato",
|
||||
"azienda" => "Azienda",
|
||||
"altro" => "Altro"
|
||||
}.freeze
|
||||
|
||||
TEAM_GENDERS = {
|
||||
"female" => "Femminile",
|
||||
"male" => "Maschile",
|
||||
"mixed" => "Maschile e femminile"
|
||||
}.freeze
|
||||
|
||||
STREAMING_STATUSES = {
|
||||
"not_detected" => "Non rilevato",
|
||||
"limited" => "Limitato",
|
||||
"yes_partial" => "Sì / parziale",
|
||||
"yes" => "Sì",
|
||||
"yes_sportcam" => "Sì – SportCam"
|
||||
}.freeze
|
||||
|
||||
SEND_STATUSES = {
|
||||
"to_send" => "Da inviare",
|
||||
"sent" => "Inviato",
|
||||
"no_send" => "Non inviare"
|
||||
}.freeze
|
||||
|
||||
AB_VARIANTS = {
|
||||
"A" => "Test A",
|
||||
"B" => "Test B"
|
||||
}.freeze
|
||||
|
||||
MAIL_ENCRYPTIONS = {
|
||||
"starttls" => "STARTTLS (porta 587)",
|
||||
"tls" => "SSL/TLS (porta 465)",
|
||||
"none" => "Nessuna"
|
||||
}.freeze
|
||||
|
||||
MAIL_AUTH_METHODS = {
|
||||
"plain" => "PLAIN",
|
||||
"login" => "LOGIN",
|
||||
"cram_md5" => "CRAM-MD5"
|
||||
}.freeze
|
||||
|
||||
MAILING_AUDIENCES = {
|
||||
"to_send" => "Da inviare (campagna)",
|
||||
"test_a" => "Solo chi è già Test A in scheda",
|
||||
"test_b" => "Solo chi è già Test B in scheda",
|
||||
"all" => "Tutte le organizzazioni del progetto"
|
||||
}.freeze
|
||||
|
||||
MAILING_AB_ASSIGNMENTS = {
|
||||
"from_record" => "Usa il Test A/B già in scheda",
|
||||
"split" => "Suddividi a metà (A / B)"
|
||||
}.freeze
|
||||
|
||||
MAILING_STATUSES = {
|
||||
"draft" => "Bozza",
|
||||
"sending" => "Invio in corso",
|
||||
"sent" => "Inviata",
|
||||
"cancelled" => "Annullata"
|
||||
}.freeze
|
||||
|
||||
MAILING_RECIPIENT_STATUSES = {
|
||||
"pending" => "Da inviare",
|
||||
"skipped" => "Escluso",
|
||||
"queued" => "In coda",
|
||||
"sent" => "Inviata",
|
||||
"failed" => "Errore"
|
||||
}.freeze
|
||||
|
||||
PIPELINE_STAGES = {
|
||||
"to_contact" => "Da contattare",
|
||||
"contacted" => "Contattato",
|
||||
"replied" => "Ha risposto",
|
||||
"interested" => "Interessato",
|
||||
"demo_trial" => "Demo / Trial",
|
||||
"first_use" => "Primo utilizzo",
|
||||
"proposal" => "Proposta",
|
||||
"won" => "Cliente",
|
||||
"lost" => "Perso"
|
||||
}.freeze
|
||||
|
||||
PIPELINE_ORDER = PIPELINE_STAGES.keys.freeze
|
||||
|
||||
OPEN_PIPELINE_STAGES = %w[to_contact contacted replied interested demo_trial first_use proposal].freeze
|
||||
|
||||
LEAD_SOURCES = {
|
||||
"outbound" => "Contatto diretto",
|
||||
"campaign" => "Campagna lancio",
|
||||
"referral" => "Referral",
|
||||
"federation" => "Federazione/comitato",
|
||||
"event" => "Evento",
|
||||
"website" => "Sito",
|
||||
"organic" => "Organico",
|
||||
"partner" => "Partner",
|
||||
"social" => "Social",
|
||||
"other" => "Altro"
|
||||
}.freeze
|
||||
|
||||
LOST_REASONS = {
|
||||
"no_response" => "Nessuna risposta",
|
||||
"not_interested" => "Non interessato",
|
||||
"price" => "Prezzo",
|
||||
"competitor" => "Usa già altra soluzione",
|
||||
"no_streaming" => "Non fa streaming",
|
||||
"timing" => "Timing",
|
||||
"technical" => "Problema tecnico",
|
||||
"deferred" => "Decisione rimandata",
|
||||
"other" => "Altro"
|
||||
}.freeze
|
||||
|
||||
ACTIVITY_TYPES = {
|
||||
"note" => "Nota",
|
||||
"email_sent" => "Email inviata",
|
||||
"email_received" => "Email ricevuta",
|
||||
"call" => "Telefonata",
|
||||
"meeting" => "Meeting",
|
||||
"demo" => "Demo",
|
||||
"trial_started" => "Trial attivato",
|
||||
"follow_up" => "Follow-up",
|
||||
"first_use" => "Primo utilizzo",
|
||||
"proposal_sent" => "Proposta inviata",
|
||||
"won" => "Vinto",
|
||||
"lost" => "Perso",
|
||||
"other" => "Altro"
|
||||
}.freeze
|
||||
|
||||
TASK_TYPES = {
|
||||
"follow_up" => "Follow-up",
|
||||
"call" => "Chiamata",
|
||||
"email" => "Email",
|
||||
"meeting" => "Meeting",
|
||||
"demo" => "Demo",
|
||||
"proposal" => "Proposta",
|
||||
"generic" => "Generico"
|
||||
}.freeze
|
||||
|
||||
TASK_PRIORITIES = {
|
||||
"low" => "Bassa",
|
||||
"normal" => "Normale",
|
||||
"high" => "Alta",
|
||||
"urgent" => "Urgente"
|
||||
}.freeze
|
||||
|
||||
TASK_STATUSES = {
|
||||
"pending" => "In corso",
|
||||
"completed" => "Completato",
|
||||
"cancelled" => "Annullato"
|
||||
}.freeze
|
||||
|
||||
CONTACT_METHODS = {
|
||||
"email" => "Email",
|
||||
"phone" => "Telefono",
|
||||
"mobile" => "Cellulare",
|
||||
"whatsapp" => "WhatsApp",
|
||||
"meeting" => "Incontro"
|
||||
}.freeze
|
||||
|
||||
GOAL_METRICS = {
|
||||
"customers_acquired" => "Clienti acquisiti",
|
||||
"won_value" => "Valore opportunità vinte",
|
||||
"trials" => "Numero trial",
|
||||
"first_uses" => "Numero primi utilizzi"
|
||||
}.freeze
|
||||
|
||||
STAGE_PROBABILITIES = {
|
||||
"to_contact" => 5,
|
||||
"contacted" => 10,
|
||||
"replied" => 20,
|
||||
"interested" => 40,
|
||||
"demo_trial" => 55,
|
||||
"first_use" => 70,
|
||||
"proposal" => 80,
|
||||
"won" => 100,
|
||||
"lost" => 0
|
||||
}.freeze
|
||||
|
||||
def self.label_for(hash, key)
|
||||
hash[key.to_s] || key.to_s.humanize
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
module Auditable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
belongs_to :created_by, class_name: "User", optional: true
|
||||
belongs_to :updated_by, class_name: "User", optional: true
|
||||
|
||||
before_create :set_created_by
|
||||
before_save :set_updated_by
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_created_by
|
||||
self.created_by_id ||= Current.user&.id
|
||||
end
|
||||
|
||||
def set_updated_by
|
||||
self.updated_by_id = Current.user&.id if Current.user
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
module HtmlBlankable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def clears_blank_html(*attributes)
|
||||
before_validation do
|
||||
attributes.each do |attribute|
|
||||
value = public_send(attribute)
|
||||
public_send("#{attribute}=", "") if HtmlBlankable.blank_html?(value)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.blank_html?(html)
|
||||
html.to_s.gsub(/<[^>]*>/, " ").gsub(" ", " ").gsub(/\s+/, " ").strip.blank?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
class Contact < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :organization
|
||||
has_many :activities, dependent: :nullify
|
||||
has_many :tasks, dependent: :nullify
|
||||
has_many :mailing_recipients, dependent: :nullify
|
||||
|
||||
validates :first_name, :last_name, presence: true
|
||||
validates :preferred_contact_method, inclusion: { in: Catalog::CONTACT_METHODS.keys }, allow_blank: true
|
||||
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
|
||||
|
||||
before_save :ensure_single_primary, if: -> { primary_contact? && organization_id.present? }
|
||||
|
||||
scope :primary_first, -> { order(primary_contact: :desc, last_name: :asc, first_name: :asc) }
|
||||
scope :search, ->(query) {
|
||||
return all if query.blank?
|
||||
|
||||
q = "%#{sanitize_sql_like(query.strip)}%"
|
||||
where(
|
||||
"first_name ILIKE :q OR last_name ILIKE :q OR email ILIKE :q OR phone ILIKE :q OR mobile ILIKE :q",
|
||||
q: q
|
||||
)
|
||||
}
|
||||
|
||||
def full_name
|
||||
"#{first_name} #{last_name}"
|
||||
end
|
||||
|
||||
def preferred_contact_method_label
|
||||
Catalog.label_for(Catalog::CONTACT_METHODS, preferred_contact_method)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_single_primary
|
||||
scope = organization.contacts
|
||||
scope = scope.where.not(id: id) if persisted?
|
||||
scope.update_all(primary_contact: false)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
attribute :project
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
class MailIdentity < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
encrypts :smtp_password
|
||||
|
||||
require "openssl"
|
||||
|
||||
has_many :mailings, dependent: :restrict_with_exception
|
||||
|
||||
ENCRYPTIONS = Catalog::MAIL_ENCRYPTIONS.keys.freeze
|
||||
|
||||
validates :name, :from_name, :from_email, :smtp_host, :smtp_port, presence: true
|
||||
validates :from_email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :reply_to, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
|
||||
validates :smtp_port, numericality: { in: 1..65535 }
|
||||
validates :encryption, inclusion: { in: ENCRYPTIONS }
|
||||
validates :smtp_authentication, inclusion: { in: Catalog::MAIL_AUTH_METHODS.keys }
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(:name) }
|
||||
|
||||
def from_header
|
||||
%(#{from_name} <#{from_email}>)
|
||||
end
|
||||
|
||||
def smtp_settings
|
||||
settings = {
|
||||
address: smtp_host,
|
||||
port: smtp_port,
|
||||
enable_starttls_auto: encryption == "starttls",
|
||||
ssl: encryption == "tls",
|
||||
openssl_verify_mode: verify_ssl? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
||||
}
|
||||
if smtp_username.present?
|
||||
settings[:user_name] = smtp_username
|
||||
settings[:password] = smtp_password
|
||||
settings[:authentication] = smtp_authentication.to_sym
|
||||
end
|
||||
settings
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
class MailTemplate < ApplicationRecord
|
||||
include Auditable
|
||||
include HtmlBlankable
|
||||
|
||||
belongs_to :project, optional: true
|
||||
has_many :mailings, dependent: :nullify
|
||||
has_many :mailings_as_b, class_name: "Mailing", foreign_key: :mail_template_b_id, dependent: :nullify
|
||||
|
||||
clears_blank_html :body_html
|
||||
validates :name, :subject, :body_html, presence: true
|
||||
|
||||
scope :for_project, ->(project) {
|
||||
where(project_id: [nil, project&.id].compact).order(:name)
|
||||
}
|
||||
|
||||
def preview_html(organization: nil, contact: nil, project: nil, opportunity: nil)
|
||||
MailMerge.render(body_html, organization:, contact:, project:, opportunity:)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
class Mailing < ApplicationRecord
|
||||
include Auditable
|
||||
include HtmlBlankable
|
||||
|
||||
belongs_to :project
|
||||
belongs_to :mail_identity
|
||||
belongs_to :mail_template, optional: true
|
||||
belongs_to :mail_template_b, class_name: "MailTemplate", optional: true
|
||||
has_many :mailing_recipients, dependent: :destroy
|
||||
has_many :organizations, through: :mailing_recipients
|
||||
has_many_attached :files
|
||||
|
||||
validates :name, :subject, :body_html, presence: true
|
||||
validates :status, inclusion: { in: Catalog::MAILING_STATUSES.keys }
|
||||
validates :audience, inclusion: { in: Catalog::MAILING_AUDIENCES.keys }
|
||||
validates :ab_assignment, inclusion: { in: Catalog::MAILING_AB_ASSIGNMENTS.keys }
|
||||
validates :interval_seconds, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 86_400 }
|
||||
validates :subject_b, :body_html_b, presence: true, if: :ab_test?
|
||||
clears_blank_html :body_html, :body_html_b
|
||||
|
||||
scope :recent, -> { order(created_at: :desc) }
|
||||
scope :for_project, ->(project) { where(project_id: project.id) }
|
||||
|
||||
def status_label
|
||||
Catalog.label_for(Catalog::MAILING_STATUSES, status)
|
||||
end
|
||||
|
||||
def audience_label
|
||||
Catalog.label_for(Catalog::MAILING_AUDIENCES, audience)
|
||||
end
|
||||
|
||||
def ab_assignment_label
|
||||
Catalog.label_for(Catalog::MAILING_AB_ASSIGNMENTS, ab_assignment)
|
||||
end
|
||||
|
||||
def ab_from_record?
|
||||
ab_assignment == "from_record"
|
||||
end
|
||||
|
||||
def subject_for(variant)
|
||||
ab_test? && variant.to_s.upcase == "B" ? subject_b : subject
|
||||
end
|
||||
|
||||
def body_for(variant)
|
||||
ab_test? && variant.to_s.upcase == "B" ? body_html_b : body_html
|
||||
end
|
||||
|
||||
def variant_pending_count(variant)
|
||||
mailing_recipients.pending.where(ab_variant: variant).count
|
||||
end
|
||||
|
||||
def variant_sent_count(variant)
|
||||
mailing_recipients.sent.where(ab_variant: variant).count
|
||||
end
|
||||
|
||||
def draft?
|
||||
status == "draft"
|
||||
end
|
||||
|
||||
def sending?
|
||||
status == "sending"
|
||||
end
|
||||
|
||||
def sent?
|
||||
status == "sent"
|
||||
end
|
||||
|
||||
def editable?
|
||||
draft?
|
||||
end
|
||||
|
||||
def pending_count
|
||||
mailing_recipients.pending.count
|
||||
end
|
||||
|
||||
def skipped_count
|
||||
mailing_recipients.skipped.count
|
||||
end
|
||||
|
||||
def sent_count
|
||||
mailing_recipients.sent.count
|
||||
end
|
||||
|
||||
def failed_count
|
||||
mailing_recipients.failed.count
|
||||
end
|
||||
|
||||
def rebuild_recipients!
|
||||
mailing_recipients.delete_all
|
||||
Mailings::RecipientBuilder.new(self).call
|
||||
end
|
||||
|
||||
def queue_send!
|
||||
raise "Nessun destinatario da inviare" if pending_count.zero?
|
||||
|
||||
update!(status: "sending", queued_at: Time.current)
|
||||
first = mailing_recipients.pending.order(:id).first
|
||||
SendMailingRecipientJob.perform_later(first.id)
|
||||
end
|
||||
|
||||
def mark_finished_if_done!
|
||||
return unless sending?
|
||||
return if mailing_recipients.pending.exists? || mailing_recipients.queued.exists?
|
||||
|
||||
update!(status: "sent", completed_at: Time.current)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
class MailingRecipient < ApplicationRecord
|
||||
belongs_to :mailing
|
||||
belongs_to :organization
|
||||
belongs_to :contact, optional: true
|
||||
|
||||
validates :status, inclusion: { in: Catalog::MAILING_RECIPIENT_STATUSES.keys }
|
||||
validates :ab_variant, inclusion: { in: Catalog::AB_VARIANTS.keys }, allow_blank: true
|
||||
|
||||
scope :pending, -> { where(status: "pending") }
|
||||
scope :skipped, -> { where(status: "skipped") }
|
||||
scope :sent, -> { where(status: "sent") }
|
||||
scope :failed, -> { where(status: "failed") }
|
||||
scope :queued, -> { where(status: "queued") }
|
||||
scope :ordered, -> { joins(:organization).order("organizations.list_position ASC NULLS LAST", "organizations.name ASC") }
|
||||
|
||||
def status_label
|
||||
Catalog.label_for(Catalog::MAILING_RECIPIENT_STATUSES, status)
|
||||
end
|
||||
|
||||
def pending?
|
||||
status == "pending"
|
||||
end
|
||||
|
||||
def email_ok?
|
||||
email.present? && email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||
end
|
||||
|
||||
def merge_context
|
||||
opportunity = organization.campaign_opportunity(mailing.project)
|
||||
{
|
||||
organization: organization,
|
||||
contact: contact,
|
||||
project: mailing.project,
|
||||
opportunity: opportunity,
|
||||
ab_variant: ab_variant.presence || opportunity&.ab_variant
|
||||
}
|
||||
end
|
||||
|
||||
def rendered_html
|
||||
MailMerge.render(mailing.body_for(ab_variant), **merge_context)
|
||||
end
|
||||
|
||||
def rendered_subject_line
|
||||
MailMerge.render(mailing.subject_for(ab_variant), **merge_context)
|
||||
end
|
||||
|
||||
def deliver!
|
||||
with_lock do
|
||||
return if status.in?(%w[sent skipped])
|
||||
|
||||
update!(status: "queued")
|
||||
end
|
||||
|
||||
html = rendered_html
|
||||
subject_line = rendered_subject_line
|
||||
CampaignMailer.outreach(self, html: html, subject: subject_line).deliver_now
|
||||
record_success!(subject_line)
|
||||
rescue StandardError => e
|
||||
update!(status: "failed", error_message: e.message.to_s.truncate(500))
|
||||
ensure
|
||||
mailing.mark_finished_if_done!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def record_success!(subject_line)
|
||||
transaction do
|
||||
update!(status: "sent", sent_at: Time.current, rendered_subject: subject_line, error_message: nil)
|
||||
sync_opportunity!
|
||||
log_activity!(subject_line)
|
||||
end
|
||||
end
|
||||
|
||||
def sync_opportunity!
|
||||
opportunity = organization.campaign_opportunity(mailing.project)
|
||||
return unless opportunity
|
||||
|
||||
attrs = {}
|
||||
attrs[:send_status] = "sent" if opportunity.send_status == "to_send"
|
||||
attrs[:sent_on] ||= Date.current if opportunity.sent_on.blank?
|
||||
attrs[:pipeline_stage] = "contacted" if opportunity.pipeline_stage == "to_contact"
|
||||
attrs[:ab_variant] = ab_variant if mailing.ab_test? && ab_variant.present? && opportunity.ab_variant.blank?
|
||||
opportunity.update!(attrs) if attrs.any?
|
||||
end
|
||||
|
||||
def log_activity!(subject_line)
|
||||
variant_note = "variante #{ab_variant}" if mailing.ab_test? && ab_variant.present?
|
||||
organization.activities.create!(
|
||||
activity_type: "email_sent",
|
||||
subject: subject_line.presence || mailing.name,
|
||||
description: ["Campagna email: #{mailing.name}", variant_note].compact.join(" · "),
|
||||
happened_at: Time.current,
|
||||
user: mailing.created_by,
|
||||
contact: contact,
|
||||
opportunity: organization.campaign_opportunity(mailing.project)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,158 @@
|
||||
class Opportunity < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :organization
|
||||
belongs_to :project
|
||||
belongs_to :assigned_user, class_name: "User", optional: true
|
||||
|
||||
has_many :activities, dependent: :nullify
|
||||
has_many :tasks, dependent: :nullify
|
||||
|
||||
validates :name, presence: true
|
||||
validates :pipeline_stage, inclusion: { in: Catalog::PIPELINE_STAGES.keys }
|
||||
validates :lost_reason, inclusion: { in: Catalog::LOST_REASONS.keys }, allow_blank: true
|
||||
validates :ab_variant, inclusion: { in: Catalog::AB_VARIANTS.keys }, allow_blank: true
|
||||
validates :send_status, inclusion: { in: Catalog::SEND_STATUSES.keys }, allow_blank: true
|
||||
validates :probability, numericality: { in: 0..100 }, allow_nil: true
|
||||
validates :estimated_value, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
||||
validate :lost_reason_required_when_lost
|
||||
|
||||
before_validation :set_default_probability, on: :create
|
||||
before_validation :default_project_from_current, on: :create
|
||||
before_save :track_stage_change
|
||||
after_save :handle_stage_side_effects
|
||||
after_save :ensure_organization_in_project
|
||||
|
||||
scope :open_stage, -> { where(pipeline_stage: Catalog::OPEN_PIPELINE_STAGES) }
|
||||
scope :won, -> { where(pipeline_stage: "won") }
|
||||
scope :lost, -> { where(pipeline_stage: "lost") }
|
||||
scope :in_stage, ->(stage) { where(pipeline_stage: stage) }
|
||||
scope :for_project, ->(project) {
|
||||
return none if project.nil?
|
||||
|
||||
where(project_id: project.id)
|
||||
}
|
||||
|
||||
def pipeline_stage_label
|
||||
Catalog.label_for(Catalog::PIPELINE_STAGES, pipeline_stage)
|
||||
end
|
||||
|
||||
def lost_reason_label
|
||||
Catalog.label_for(Catalog::LOST_REASONS, lost_reason)
|
||||
end
|
||||
|
||||
def ab_variant_label
|
||||
Catalog.label_for(Catalog::AB_VARIANTS, ab_variant)
|
||||
end
|
||||
|
||||
def send_status_label
|
||||
Catalog.label_for(Catalog::SEND_STATUSES, send_status)
|
||||
end
|
||||
|
||||
def open?
|
||||
Catalog::OPEN_PIPELINE_STAGES.include?(pipeline_stage)
|
||||
end
|
||||
|
||||
def won?
|
||||
pipeline_stage == "won"
|
||||
end
|
||||
|
||||
def lost?
|
||||
pipeline_stage == "lost"
|
||||
end
|
||||
|
||||
def next_pending_task
|
||||
tasks.pending.order(:due_at).first
|
||||
end
|
||||
|
||||
def days_in_current_stage
|
||||
anchor = stage_changed_at || created_at
|
||||
return 0 unless anchor
|
||||
|
||||
((Time.current - anchor) / 1.day).floor
|
||||
end
|
||||
|
||||
def move_to_stage!(new_stage, lost_reason: nil, notes: nil, user: Current.user)
|
||||
attrs = { pipeline_stage: new_stage }
|
||||
attrs[:lost_reason] = lost_reason if lost_reason.present?
|
||||
attrs[:notes] = [self.notes, notes].compact_blank.join("\n") if notes.present?
|
||||
attrs[:probability] = Catalog::STAGE_PROBABILITIES.fetch(new_stage.to_s, probability)
|
||||
update!(attrs)
|
||||
|
||||
create_stage_activity!(user) if saved_change_to_pipeline_stage?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_default_probability
|
||||
self.probability ||= Catalog::STAGE_PROBABILITIES.fetch(pipeline_stage, 0)
|
||||
end
|
||||
|
||||
def track_stage_change
|
||||
return unless pipeline_stage_changed?
|
||||
|
||||
self.stage_changed_at = Time.current
|
||||
self.first_contacted_at ||= Time.current if pipeline_stage != "to_contact"
|
||||
|
||||
case pipeline_stage
|
||||
when "won"
|
||||
self.won_at ||= Time.current
|
||||
self.lost_at = nil
|
||||
self.probability = 100
|
||||
when "lost"
|
||||
self.lost_at ||= Time.current
|
||||
self.won_at = nil
|
||||
self.probability = 0
|
||||
else
|
||||
self.won_at = nil
|
||||
self.lost_at = nil
|
||||
end
|
||||
end
|
||||
|
||||
def handle_stage_side_effects
|
||||
return unless saved_change_to_pipeline_stage?
|
||||
|
||||
organization.mark_as_active_customer! if won?
|
||||
end
|
||||
|
||||
def lost_reason_required_when_lost
|
||||
return unless pipeline_stage == "lost"
|
||||
return if lost_reason.present?
|
||||
|
||||
errors.add(:lost_reason, "è obbligatorio quando l'opportunità è persa")
|
||||
end
|
||||
|
||||
def create_stage_activity!(user)
|
||||
return unless user
|
||||
|
||||
type = won? ? "won" : lost? ? "lost" : "other"
|
||||
subject = if won?
|
||||
"Cliente #{product.presence || name}"
|
||||
elsif lost?
|
||||
"Perso: #{lost_reason_label}"
|
||||
else
|
||||
"Stage: #{pipeline_stage_label}"
|
||||
end
|
||||
|
||||
activities.create!(
|
||||
activity_type: type,
|
||||
subject: subject,
|
||||
description: notes,
|
||||
happened_at: Time.current,
|
||||
user: user,
|
||||
organization: organization,
|
||||
created_by: user,
|
||||
updated_by: user
|
||||
)
|
||||
end
|
||||
|
||||
def default_project_from_current
|
||||
self.project ||= Current.project
|
||||
end
|
||||
|
||||
def ensure_organization_in_project
|
||||
return unless project && organization
|
||||
|
||||
organization.ensure_in_project!(project)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,116 @@
|
||||
class Organization < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :assigned_user, class_name: "User", optional: true
|
||||
|
||||
has_many :organization_projects, dependent: :destroy
|
||||
has_many :projects, through: :organization_projects
|
||||
has_many :contacts, dependent: :destroy
|
||||
has_many :opportunities, dependent: :destroy
|
||||
has_many :activities, dependent: :destroy
|
||||
has_many :tasks, dependent: :destroy
|
||||
has_many :mailing_recipients, dependent: :destroy
|
||||
|
||||
has_one :primary_contact, -> { where(primary_contact: true) }, class_name: "Contact", inverse_of: :organization
|
||||
|
||||
validates :name, presence: true
|
||||
validates :status, inclusion: { in: Catalog::ORGANIZATION_STATUSES.keys }
|
||||
validates :organization_type, inclusion: { in: Catalog::ORGANIZATION_TYPES.keys }
|
||||
validates :lead_source, inclusion: { in: Catalog::LEAD_SOURCES.keys }, allow_blank: true
|
||||
validates :team_gender, inclusion: { in: Catalog::TEAM_GENDERS.keys }, allow_blank: true
|
||||
validates :streaming_status, inclusion: { in: Catalog::STREAMING_STATUSES.keys }, allow_blank: true
|
||||
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
|
||||
validate :must_have_at_least_one_project
|
||||
before_validation :assign_current_project_if_needed, on: :create
|
||||
|
||||
scope :prospects, -> { where(status: "prospect") }
|
||||
scope :customers, -> { where(status: %w[active_customer inactive_customer]) }
|
||||
scope :active_customers, -> { where(status: "active_customer") }
|
||||
scope :for_project, ->(project) {
|
||||
return none if project.nil?
|
||||
|
||||
joins(:organization_projects).where(organization_projects: { project_id: project.id }).distinct
|
||||
}
|
||||
scope :search, ->(query) {
|
||||
return all if query.blank?
|
||||
|
||||
q = "%#{sanitize_sql_like(query.strip)}%"
|
||||
left_joins(:contacts).where(
|
||||
"organizations.name ILIKE :q OR organizations.city ILIKE :q OR organizations.email ILIKE :q OR organizations.phone ILIKE :q OR organizations.website ILIKE :q OR contacts.first_name ILIKE :q OR contacts.last_name ILIKE :q OR contacts.email ILIKE :q OR contacts.phone ILIKE :q OR contacts.mobile ILIKE :q",
|
||||
q: q
|
||||
).distinct
|
||||
}
|
||||
|
||||
def status_label
|
||||
Catalog.label_for(Catalog::ORGANIZATION_STATUSES, status)
|
||||
end
|
||||
|
||||
def organization_type_label
|
||||
Catalog.label_for(Catalog::ORGANIZATION_TYPES, organization_type)
|
||||
end
|
||||
|
||||
def lead_source_label
|
||||
Catalog.label_for(Catalog::LEAD_SOURCES, lead_source)
|
||||
end
|
||||
|
||||
def team_gender_label
|
||||
Catalog.label_for(Catalog::TEAM_GENDERS, team_gender)
|
||||
end
|
||||
|
||||
def streaming_status_label
|
||||
Catalog.label_for(Catalog::STREAMING_STATUSES, streaming_status)
|
||||
end
|
||||
|
||||
def campaign_opportunity(project = nil)
|
||||
list = opportunities.to_a
|
||||
list = list.select { |opp| opp.project_id == project.id } if project
|
||||
list.max_by(&:updated_at)
|
||||
end
|
||||
|
||||
def primary_pipeline_stage(project = nil)
|
||||
scope = opportunities
|
||||
scope = scope.where(project_id: project.id) if project
|
||||
scope.open_stage.order(updated_at: :desc).first&.pipeline_stage ||
|
||||
scope.order(updated_at: :desc).first&.pipeline_stage
|
||||
end
|
||||
|
||||
def next_pending_task
|
||||
tasks.pending.order(:due_at).first
|
||||
end
|
||||
|
||||
def last_activity_at
|
||||
activities.maximum(:happened_at)
|
||||
end
|
||||
|
||||
def days_since_last_activity
|
||||
return nil unless last_activity_at
|
||||
|
||||
((Time.current - last_activity_at) / 1.day).floor
|
||||
end
|
||||
|
||||
def mark_as_active_customer!
|
||||
update!(status: "active_customer") if status != "active_customer"
|
||||
end
|
||||
|
||||
def ensure_in_project!(project)
|
||||
return if project.nil?
|
||||
return if projects.exists?(project.id)
|
||||
|
||||
projects << project
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_current_project_if_needed
|
||||
return if organization_projects.any? || project_ids.reject(&:blank?).any?
|
||||
return unless Current.project
|
||||
|
||||
organization_projects.build(project: Current.project)
|
||||
end
|
||||
|
||||
def must_have_at_least_one_project
|
||||
return if organization_projects.any? || project_ids.reject(&:blank?).any?
|
||||
|
||||
errors.add(:projects, "seleziona almeno un progetto")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class OrganizationProject < ApplicationRecord
|
||||
belongs_to :organization
|
||||
belongs_to :project
|
||||
|
||||
validates :organization_id, uniqueness: { scope: :project_id }
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class Product < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
validates :name, :code, presence: true
|
||||
validates :code, uniqueness: true
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(:position, :name) }
|
||||
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
class Project < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
has_many :user_projects, dependent: :destroy
|
||||
has_many :users, through: :user_projects
|
||||
has_many :organization_projects, dependent: :destroy
|
||||
has_many :organizations, through: :organization_projects
|
||||
has_many :opportunities, dependent: :restrict_with_exception
|
||||
has_many :sales_goals, dependent: :nullify
|
||||
has_many :mail_templates, dependent: :destroy
|
||||
has_many :mailings, dependent: :destroy
|
||||
|
||||
validates :name, :code, presence: true
|
||||
validates :code, uniqueness: { case_sensitive: false },
|
||||
format: { with: /\A[a-z0-9_-]+\z/, message: "usa solo lettere minuscole, numeri, - e _" }
|
||||
|
||||
before_validation :normalize_code
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(:position, :name) }
|
||||
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_code
|
||||
self.code = code.to_s.strip.downcase.parameterize(separator: "_")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
class SalesGoal < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :project, optional: true
|
||||
|
||||
validates :name, :metric, :target_value, :start_date, :end_date, presence: true
|
||||
validates :metric, inclusion: { in: Catalog::GOAL_METRICS.keys }
|
||||
validates :target_value, numericality: { greater_than: 0 }
|
||||
validate :end_date_after_start_date
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :for_project, ->(project) {
|
||||
return none if project.nil?
|
||||
|
||||
where(project_id: project.id)
|
||||
}
|
||||
scope :current, ->(project = nil) {
|
||||
today = Time.zone.today
|
||||
scope = active.where("start_date <= ? AND end_date >= ?", today, today)
|
||||
scope = scope.where(project_id: project.id) if project
|
||||
scope
|
||||
}
|
||||
|
||||
def metric_label
|
||||
Catalog.label_for(Catalog::GOAL_METRICS, metric)
|
||||
end
|
||||
|
||||
def current_value
|
||||
opps = project ? Opportunity.for_project(project) : Opportunity.all
|
||||
activities = if project
|
||||
Activity.joins(:organization)
|
||||
.joins("INNER JOIN organization_projects ON organization_projects.organization_id = activities.organization_id")
|
||||
.where(organization_projects: { project_id: project.id })
|
||||
else
|
||||
Activity.all
|
||||
end
|
||||
|
||||
case metric
|
||||
when "customers_acquired"
|
||||
opps.won.where(won_at: start_date.beginning_of_day..end_date.end_of_day).count
|
||||
when "won_value"
|
||||
opps.won.where(won_at: start_date.beginning_of_day..end_date.end_of_day).sum(:estimated_value).to_f
|
||||
when "trials"
|
||||
activities.where(activity_type: "trial_started", happened_at: start_date.beginning_of_day..end_date.end_of_day).count
|
||||
when "first_uses"
|
||||
activities.where(activity_type: "first_use", happened_at: start_date.beginning_of_day..end_date.end_of_day).count
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
def progress_percentage
|
||||
return 0 if target_value.to_f.zero?
|
||||
|
||||
[[(current_value.to_f / target_value.to_f * 100).round, 0].max, 100].min
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def end_date_after_start_date
|
||||
return if start_date.blank? || end_date.blank?
|
||||
return if end_date >= start_date
|
||||
|
||||
errors.add(:end_date, "deve essere successiva alla data di inizio")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
class Task < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :organization
|
||||
belongs_to :contact, optional: true
|
||||
belongs_to :opportunity, optional: true
|
||||
belongs_to :assigned_user, class_name: "User", optional: true
|
||||
|
||||
validates :title, :due_at, presence: true
|
||||
validates :priority, inclusion: { in: Catalog::TASK_PRIORITIES.keys }
|
||||
validates :task_type, inclusion: { in: Catalog::TASK_TYPES.keys }
|
||||
validates :status, inclusion: { in: Catalog::TASK_STATUSES.keys }
|
||||
|
||||
scope :pending, -> { where(status: "pending") }
|
||||
scope :completed, -> { where(status: "completed") }
|
||||
scope :overdue, -> { pending.where("due_at < ?", Time.zone.now.beginning_of_day) }
|
||||
scope :due_today, -> { pending.where(due_at: Time.zone.now.all_day) }
|
||||
scope :upcoming, ->(days = 7) {
|
||||
pending.where(due_at: Time.zone.now.tomorrow.beginning_of_day..(Time.zone.now + days.days).end_of_day)
|
||||
}
|
||||
scope :ordered, -> { order(Arel.sql("CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END"), :due_at) }
|
||||
scope :for_project, ->(project) {
|
||||
return none if project.nil?
|
||||
|
||||
where(
|
||||
id: left_joins(:opportunity)
|
||||
.joins("INNER JOIN organization_projects ON organization_projects.organization_id = tasks.organization_id")
|
||||
.where(
|
||||
"organization_projects.project_id = :pid OR opportunities.project_id = :pid",
|
||||
pid: project.id
|
||||
)
|
||||
.select("tasks.id")
|
||||
.distinct
|
||||
)
|
||||
}
|
||||
|
||||
def priority_label
|
||||
Catalog.label_for(Catalog::TASK_PRIORITIES, priority)
|
||||
end
|
||||
|
||||
def task_type_label
|
||||
Catalog.label_for(Catalog::TASK_TYPES, task_type)
|
||||
end
|
||||
|
||||
def status_label
|
||||
Catalog.label_for(Catalog::TASK_STATUSES, status)
|
||||
end
|
||||
|
||||
def overdue?
|
||||
pending? && due_at < Time.zone.now.beginning_of_day
|
||||
end
|
||||
|
||||
def due_today?
|
||||
pending? && due_at.to_date == Time.zone.today
|
||||
end
|
||||
|
||||
def pending?
|
||||
status == "pending"
|
||||
end
|
||||
|
||||
def completed?
|
||||
status == "completed"
|
||||
end
|
||||
|
||||
def complete!(user: Current.user, create_activity: true)
|
||||
return false unless pending?
|
||||
|
||||
transaction do
|
||||
update!(status: "completed", completed_at: Time.current, updated_by: user)
|
||||
create_completion_activity!(user) if create_activity && user
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_completion_activity!(user)
|
||||
activities_attrs = {
|
||||
activity_type: activity_type_for_task,
|
||||
subject: title,
|
||||
description: description,
|
||||
happened_at: Time.current,
|
||||
user: user,
|
||||
organization: organization,
|
||||
contact: contact,
|
||||
opportunity: opportunity,
|
||||
created_by: user,
|
||||
updated_by: user
|
||||
}
|
||||
Activity.create!(activities_attrs)
|
||||
end
|
||||
|
||||
def activity_type_for_task
|
||||
{
|
||||
"follow_up" => "follow_up",
|
||||
"call" => "call",
|
||||
"email" => "email_sent",
|
||||
"meeting" => "meeting",
|
||||
"demo" => "demo",
|
||||
"proposal" => "proposal_sent",
|
||||
"generic" => "other"
|
||||
}.fetch(task_type, "other")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
class User < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
has_secure_password
|
||||
|
||||
has_many :assigned_organizations, class_name: "Organization", foreign_key: :assigned_user_id, dependent: :nullify, inverse_of: :assigned_user
|
||||
has_many :assigned_opportunities, class_name: "Opportunity", foreign_key: :assigned_user_id, dependent: :nullify, inverse_of: :assigned_user
|
||||
has_many :assigned_tasks, class_name: "Task", foreign_key: :assigned_user_id, dependent: :nullify, inverse_of: :assigned_user
|
||||
has_many :activities, dependent: :nullify
|
||||
has_many :user_projects, dependent: :destroy
|
||||
has_many :projects, through: :user_projects
|
||||
|
||||
ROLES = %w[admin user].freeze
|
||||
|
||||
validates :email, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :first_name, :last_name, presence: true
|
||||
validates :role, inclusion: { in: ROLES }
|
||||
validates :password, length: { minimum: 8 }, if: -> { password.present? }
|
||||
validate :must_keep_at_least_one_active_admin
|
||||
|
||||
before_validation :normalize_email
|
||||
before_destroy :prevent_destroying_last_admin
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :admins, -> { where(role: "admin") }
|
||||
scope :active_admins, -> { active.admins }
|
||||
|
||||
def admin?
|
||||
role == "admin"
|
||||
end
|
||||
|
||||
def full_name
|
||||
"#{first_name} #{last_name}"
|
||||
end
|
||||
|
||||
def last_active_admin?
|
||||
admin? && active? && self.class.active_admins.where.not(id: id).none?
|
||||
end
|
||||
|
||||
def can_be_deactivated?
|
||||
return true unless admin? && active?
|
||||
|
||||
!last_active_admin?
|
||||
end
|
||||
|
||||
def can_be_destroyed?
|
||||
!last_active_admin?
|
||||
end
|
||||
|
||||
def accessible_projects
|
||||
return Project.active.ordered if admin?
|
||||
|
||||
Project.active.ordered
|
||||
.joins(:user_projects)
|
||||
.where(user_projects: { user_id: id, enabled: true })
|
||||
end
|
||||
|
||||
def can_access_project?(project)
|
||||
return true if admin?
|
||||
return false if project.nil?
|
||||
|
||||
user_projects.exists?(project_id: project.id, enabled: true)
|
||||
end
|
||||
|
||||
def enable_project!(project)
|
||||
up = user_projects.find_or_initialize_by(project: project)
|
||||
up.enabled = true
|
||||
up.save!
|
||||
end
|
||||
|
||||
def disable_project!(project)
|
||||
up = user_projects.find_or_initialize_by(project: project)
|
||||
up.enabled = false
|
||||
up.save!
|
||||
end
|
||||
|
||||
def project_enabled?(project)
|
||||
return true if admin?
|
||||
|
||||
user_projects.exists?(project_id: project.id, enabled: true)
|
||||
end
|
||||
|
||||
def generate_password_reset_token!
|
||||
update!(
|
||||
password_reset_token: SecureRandom.urlsafe_base64(32),
|
||||
password_reset_sent_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def password_reset_token_valid?
|
||||
password_reset_token.present? &&
|
||||
password_reset_sent_at.present? &&
|
||||
password_reset_sent_at > 2.hours.ago
|
||||
end
|
||||
|
||||
def clear_password_reset_token!
|
||||
update!(password_reset_token: nil, password_reset_sent_at: nil)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_email
|
||||
self.email = email.to_s.strip.downcase
|
||||
end
|
||||
|
||||
def must_keep_at_least_one_active_admin
|
||||
return if new_record?
|
||||
|
||||
was_active_admin = role_in_database == "admin" && active_in_database != false
|
||||
return unless was_active_admin
|
||||
|
||||
deactivating = will_save_change_to_active? && !active?
|
||||
demoting = will_save_change_to_role? && role != "admin"
|
||||
return unless deactivating || demoting
|
||||
return if self.class.active_admins.where.not(id: id).exists?
|
||||
|
||||
errors.add(:base, "Deve restare almeno un amministratore attivo. Crea o promuovi un altro admin prima.")
|
||||
end
|
||||
|
||||
def prevent_destroying_last_admin
|
||||
return unless last_active_admin?
|
||||
|
||||
errors.add(:base, "Non puoi eliminare l'unico amministratore attivo.")
|
||||
throw :abort
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class UserProject < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :project
|
||||
|
||||
validates :user_id, uniqueness: { scope: :project_id }
|
||||
end
|
||||
Reference in New Issue
Block a user