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
+18
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
+193
View File
@@ -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" => "",
"yes_sportcam" => " 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
View File
+21
View File
@@ -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
+18
View File
@@ -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("&nbsp;", " ").gsub(/\s+/, " ").strip.blank?
end
end
+41
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
class Current < ActiveSupport::CurrentAttributes
attribute :user
attribute :project
end
+41
View File
@@ -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
+19
View File
@@ -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
+107
View File
@@ -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
+98
View File
@@ -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
+158
View File
@@ -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
+116
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
class OrganizationProject < ApplicationRecord
belongs_to :organization
belongs_to :project
validates :organization_id, uniqueness: { scope: :project_id }
end
+13
View File
@@ -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
+31
View File
@@ -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
+66
View File
@@ -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
+104
View File
@@ -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
+127
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
class UserProject < ApplicationRecord
belongs_to :user
belongs_to :project
validates :user_id, uniqueness: { scope: :project_id }
end