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
+55
View File
@@ -0,0 +1,55 @@
class ActivitiesController < ApplicationController
before_action :set_organization, only: %i[create]
def create
@activity = @organization.activities.build(activity_params)
@activity.user = current_user
@activity.happened_at ||= Time.current
if @activity.save
maybe_update_pipeline_from_activity!
redirect_to @organization, notice: "Attività registrata."
else
redirect_to @organization, alert: @activity.errors.full_messages.to_sentence
end
end
private
def set_organization
@organization = Organization.find(params[:organization_id])
end
def activity_params
params.require(:activity).permit(
:activity_type, :subject, :description, :happened_at, :contact_id, :opportunity_id
)
end
def maybe_update_pipeline_from_activity!
opportunity = @activity.opportunity || @organization.opportunities.open_stage.order(updated_at: :desc).first
return unless opportunity
stage_map = {
"email_sent" => "contacted",
"email_received" => "replied",
"call" => "contacted",
"demo" => "demo_trial",
"trial_started" => "demo_trial",
"first_use" => "first_use",
"proposal_sent" => "proposal",
"won" => "won",
"lost" => "lost"
}
target = stage_map[@activity.activity_type]
return unless target
return if opportunity.won? || opportunity.lost?
return if Catalog::PIPELINE_ORDER.index(opportunity.pipeline_stage).to_i >= Catalog::PIPELINE_ORDER.index(target).to_i
attrs = { pipeline_stage: target }
if target == "lost"
attrs[:lost_reason] = params[:lost_reason].presence || "other"
end
opportunity.move_to_stage!(target, lost_reason: attrs[:lost_reason], user: current_user)
end
end
+11
View File
@@ -0,0 +1,11 @@
class AdminController < ApplicationController
before_action :require_admin
def show
@page_title = "Impostazioni"
@users_count = User.count
@active_users_count = User.active.count
@projects_count = Project.count
@mail_identities_count = MailIdentity.count
end
end
+16
View File
@@ -0,0 +1,16 @@
class ApplicationController < ActionController::Base
include Authentication
include ProjectScoping
include Pagy::Backend
allow_browser versions: :modern
before_action :set_current_user
helper_method :page_title
private
def page_title
@page_title || Rails.application.config.x.app_name
end
end
View File
@@ -0,0 +1,52 @@
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_login
helper_method :current_user, :logged_in?
end
private
def current_user
@current_user ||= session[:user_id] ? User.find_by(id: session[:user_id]) : nil
end
def logged_in?
current_user.present? && current_user.active?
end
def require_login
if current_user.present? && !current_user.active?
logout
redirect_to login_path, alert: "Il tuo account è stato disabilitato."
return
end
return if logged_in?
redirect_to login_path, alert: "Effettua l'accesso per continuare."
end
def require_admin
return if current_user&.admin? && current_user.active?
redirect_to root_path, alert: "Accesso riservato agli amministratori."
end
def login_as(user)
reset_session
session[:user_id] = user.id
@current_user = user
Current.user = user
end
def logout
reset_session
@current_user = nil
Current.user = nil
end
def set_current_user
Current.user = current_user
end
end
@@ -0,0 +1,73 @@
module ProjectScoping
extend ActiveSupport::Concern
included do
helper_method :current_project, :available_projects, :in_project_space?
before_action :set_current_project, if: :logged_in?
end
def default_url_options
return {} unless respond_to?(:request) && request&.path&.start_with?("/p/") && current_project
{ project_code: current_project.code }
end
private
def available_projects
@available_projects ||= if current_user&.admin?
Project.active.ordered
elsif current_user
current_user.accessible_projects
else
Project.none
end
end
def current_project
Current.project
end
def in_project_space?
current_project.present? && request.path.start_with?("/p/")
end
def set_current_project
code = params[:project_code].to_s.presence
unless code
Current.project = nil
return
end
project = available_projects.find_by(code: code)
if project.nil?
redirect_to root_path, alert: "Non hai accesso a questo progetto."
return
end
Current.project = project
session[:last_project_code] = project.code
end
def require_current_project!
return if current_project
redirect_to root_path, alert: "Seleziona un progetto per continuare."
end
def organizations_for_current_project
Organization.for_project(current_project)
end
def opportunities_for_current_project
Opportunity.for_project(current_project)
end
def tasks_for_current_project
Task.for_project(current_project)
end
def project_home_path_for(project)
project_root_path(project_code: project.code)
end
end
+73
View File
@@ -0,0 +1,73 @@
class ContactsController < ApplicationController
before_action :require_current_project!
before_action :set_contact, only: %i[show edit update destroy]
before_action :load_organizations, only: %i[new create edit update]
def index
@page_title = "Contatti"
scope = Contact.joins(:organization)
.merge(organizations_for_current_project)
.includes(:organization)
.primary_first
scope = scope.search(params[:q]) if params[:q].present?
scope = scope.where(organization_id: params[:organization_id]) if params[:organization_id].present?
@pagy, @contacts = pagy(scope, items: 30)
respond_to do |format|
format.html
format.csv { send_data CsvExport.contacts(scope), filename: "contacts-#{Date.current}.csv" }
end
end
def show
@page_title = @contact.full_name
end
def new
@page_title = "Nuovo contatto"
@contact = Contact.new(organization_id: params[:organization_id], primary_contact: params[:primary].present?)
end
def create
@contact = Contact.new(contact_params)
if @contact.save
redirect_to @contact.organization, notice: "Contatto creato."
else
render :new, status: :unprocessable_entity
end
end
def edit
@page_title = "Modifica #{@contact.full_name}"
end
def update
if @contact.update(contact_params)
redirect_to @contact.organization, notice: "Contatto aggiornato."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
org = @contact.organization
@contact.destroy!
redirect_to org, notice: "Contatto eliminato."
end
private
def set_contact
@contact = Contact.find(params[:id])
end
def load_organizations
@organizations = organizations_for_current_project.order(:name)
end
def contact_params
params.require(:contact).permit(
:organization_id, :first_name, :last_name, :role, :email, :phone, :mobile,
:preferred_contact_method, :notes, :primary_contact
)
end
end
+17
View File
@@ -0,0 +1,17 @@
class DashboardController < ApplicationController
before_action :require_current_project!
def show
@page_title = "Dashboard"
@goal = SalesGoal.current(current_project).order(created_at: :desc).first
opp_scope = opportunities_for_current_project
@metrics = Dashboard::Metrics.new(scope: opp_scope, project: current_project)
task_scope = tasks_for_current_project
@overdue_tasks = task_scope.overdue.includes(:organization, :contact, :assigned_user).ordered.limit(10)
@today_tasks = task_scope.due_today.includes(:organization, :contact, :assigned_user).ordered.limit(10)
@upcoming_tasks = task_scope.upcoming.includes(:organization, :contact, :assigned_user).ordered.limit(10)
@attention = @metrics.attention_items
@funnel = @metrics.funnel_steps
@stage_counts = @metrics.stage_counts
end
end
+10
View File
@@ -0,0 +1,10 @@
class HomeController < ApplicationController
def index
@page_title = "I miei progetti"
@projects = available_projects
if !current_user.admin? && @projects.one?
redirect_to project_root_path(project_code: @projects.first.code) and return
end
end
end
+25
View File
@@ -0,0 +1,25 @@
class ImportsController < ApplicationController
before_action :require_current_project!
def new
@page_title = "Import CSV"
end
def create
unless params[:file].present?
redirect_to new_import_path, alert: "Seleziona un file CSV." and return
end
importer = CsvImport::Organizations.new(file: params[:file], user: current_user, project: current_project)
if params[:preview].present?
@preview = importer.preview
@page_title = "Anteprima import"
render :preview and return
end
@result = importer.import!
@page_title = "Risultato import"
render :result
end
end
@@ -0,0 +1,63 @@
class MailIdentitiesController < ApplicationController
before_action :require_admin
before_action :set_identity, only: %i[edit update destroy]
def index
@page_title = "Account email"
@mail_identities = MailIdentity.ordered
end
def new
@page_title = "Nuovo account email"
@mail_identity = MailIdentity.new(smtp_port: 587, encryption: "starttls", smtp_authentication: "plain", active: true, verify_ssl: true)
end
def create
@mail_identity = MailIdentity.new(identity_params)
if @mail_identity.save
redirect_to mail_identities_path, notice: "Account email creato. Da qui partono gli invii."
else
@page_title = "Nuovo account email"
render :new, status: :unprocessable_entity
end
end
def edit
@page_title = "Modifica account email"
end
def update
attrs = identity_params
attrs.delete(:smtp_password) if attrs[:smtp_password].blank?
if @mail_identity.update(attrs)
redirect_to mail_identities_path, notice: "Account email aggiornato."
else
@page_title = "Modifica account email"
render :edit, status: :unprocessable_entity
end
end
def destroy
if @mail_identity.mailings.exists?
redirect_to mail_identities_path, alert: "Non puoi eliminare un account già usato in un invio. Disattivalo."
return
end
@mail_identity.destroy!
redirect_to mail_identities_path, notice: "Account email eliminato."
end
private
def set_identity
@mail_identity = MailIdentity.find(params[:id])
end
def identity_params
params.require(:mail_identity).permit(
:name, :from_name, :from_email, :reply_to, :smtp_host, :smtp_port,
:smtp_username, :smtp_password, :smtp_authentication, :encryption,
:verify_ssl, :active
)
end
end
+30
View File
@@ -0,0 +1,30 @@
class MailImagesController < ApplicationController
before_action :require_current_project!
MAX_BYTES = 5.megabytes
ALLOWED_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
def create
file = params[:file]
unless file.respond_to?(:content_type)
return render json: { error: "Seleziona un'immagine." }, status: :unprocessable_entity
end
content_type = Marcel::MimeType.for(file, name: file.original_filename, declared_type: file.content_type)
unless ALLOWED_TYPES.include?(content_type)
return render json: { error: "Formato non valido. Usa JPG, PNG, GIF o WebP." }, status: :unprocessable_entity
end
if file.size > MAX_BYTES
return render json: { error: "L'immagine supera i 5 MB." }, status: :unprocessable_entity
end
blob = ActiveStorage::Blob.create_and_upload!(
io: file,
filename: file.original_filename.presence || "immagine",
content_type: content_type
)
render json: { url: url_for(blob) }
end
end
@@ -0,0 +1,67 @@
class MailTemplatesController < ApplicationController
before_action :require_current_project!
before_action :set_template, only: %i[edit update destroy]
def index
@page_title = "Template email"
@mail_templates = MailTemplate.for_project(current_project)
end
def new
@page_title = "Nuovo template"
@mail_template = MailTemplate.new(project: current_project, body_html: default_html, subject: "MatchLiveTV per {{societa}}")
end
def create
@mail_template = MailTemplate.new(template_params)
@mail_template.project ||= current_project
if @mail_template.save
redirect_to mail_templates_path, notice: "Template salvato."
else
@page_title = "Nuovo template"
render :new, status: :unprocessable_entity
end
end
def edit
@page_title = "Modifica template"
end
def update
if @mail_template.update(template_params)
redirect_to mail_templates_path, notice: "Template aggiornato."
else
@page_title = "Modifica template"
render :edit, status: :unprocessable_entity
end
end
def destroy
if @mail_template.mailings.exists? || @mail_template.mailings_as_b.exists?
redirect_to mail_templates_path, alert: "Template già usato in un invio: non si elimina, puoi solo modificarlo."
return
end
@mail_template.destroy!
redirect_to mail_templates_path, notice: "Template eliminato."
end
private
def set_template
@mail_template = MailTemplate.for_project(current_project).find(params[:id])
end
def template_params
params.require(:mail_template).permit(:name, :subject, :body_html)
end
def default_html
<<~HTML
<p>Ciao {{contatto_nome}},</p>
<p>scriviamo a <strong>{{societa}}</strong> ({{regione}}) per presentarti MatchLiveTV.</p>
<p>Possiamo aiutarvi a trasmettere le giovanili in modo semplice.</p>
<p>A presto,<br>Il team MatchLiveTV</p>
HTML
end
end
+206
View File
@@ -0,0 +1,206 @@
class MailingsController < ApplicationController
before_action :require_current_project!
before_action :set_mailing, only: %i[show edit update destroy queue test_send refresh_recipients update_recipients preview]
before_action :load_form_collections, only: %i[new create edit update]
def index
@page_title = "Email"
@mailings = Mailing.for_project(current_project).includes(:mail_identity).recent
@mail_identities_count = MailIdentity.active.count
end
def new
@page_title = "Nuovo invio"
template = MailTemplate.for_project(current_project).order(:name).first
identity = MailIdentity.active.ordered.first
@mailing = Mailing.new(
project: current_project,
mail_template: template,
mail_identity: identity,
audience: "to_send",
interval_seconds: 0,
ab_assignment: "from_record",
name: "Invio #{l(Time.zone.today)}",
subject: template&.subject,
body_html: template&.body_html
)
end
def create
@mailing = Mailing.new(mailing_params)
@mailing.project = current_project
apply_template_if_needed
if @mailing.save
@mailing.rebuild_recipients!
redirect_to @mailing, notice: "Bozza creata. Controlla i destinatari e poi invia."
else
@page_title = "Nuovo invio"
render :new, status: :unprocessable_entity
end
end
def show
@page_title = @mailing.name
@recipients = @mailing.mailing_recipients.includes(:organization, :contact).ordered
@preview_recipient = preview_recipient
@preview_variant = preview_variant
context = @preview_recipient&.merge_context || { project: current_project, ab_variant: @preview_variant }
@preview_html = MailMerge.render(@mailing.body_for(@preview_variant), **context)
@preview_subject = MailMerge.render(@mailing.subject_for(@preview_variant), **context)
end
def edit
unless @mailing.editable?
redirect_to @mailing, alert: "Questo invio non è più modificabile."
return
end
@page_title = "Modifica invio"
end
def update
unless @mailing.editable?
redirect_to @mailing, alert: "Questo invio non è più modificabile."
return
end
@mailing.assign_attributes(mailing_params)
apply_template_if_needed
if @mailing.save
rebuild_recipients_if_needed
redirect_to @mailing, notice: "Invio aggiornato."
else
@page_title = "Modifica invio"
render :edit, status: :unprocessable_entity
end
end
def destroy
unless @mailing.draft?
redirect_to mailings_path, alert: "Puoi eliminare solo le bozze."
return
end
@mailing.destroy!
redirect_to mailings_path, notice: "Invio eliminato."
end
def refresh_recipients
unless @mailing.editable?
redirect_to @mailing, alert: "Destinatari bloccati: l'invio è già partito."
return
end
@mailing.rebuild_recipients!
redirect_to @mailing, notice: "Lista destinatari aggiornata."
end
def update_recipients
unless @mailing.editable?
redirect_to @mailing, alert: "Non puoi più cambiare i destinatari."
return
end
selected = Array(params[:pending_ids]).map(&:to_i)
@mailing.mailing_recipients.where.not(status: %w[sent queued]).find_each do |recipient|
if selected.include?(recipient.id) && recipient.email_ok?
recipient.update!(status: "pending", skip_reason: nil)
else
reason = recipient.email_ok? ? "escluso dal check" : (recipient.skip_reason.presence || "email non valida")
recipient.update!(status: "skipped", skip_reason: reason)
end
end
redirect_to @mailing, notice: "Selezione destinatari salvata."
end
def queue
unless @mailing.draft? || @mailing.sending?
redirect_to @mailing, alert: "Questo invio è già stato concluso."
return
end
@mailing.queue_send!
redirect_to @mailing, notice: "Invio avviato. Le email partono in background#{@mailing.interval_seconds.positive? ? " con pausa di #{@mailing.interval_seconds}s" : ""}."
rescue StandardError => e
redirect_to @mailing, alert: e.message
end
def test_send
recipient = preview_recipient
unless recipient&.email_ok?
redirect_to @mailing, alert: "Nessun destinatario valido da usare come dati di prova."
return
end
variant = preview_variant
html = MailMerge.render(@mailing.body_for(variant), **recipient.merge_context.merge(ab_variant: variant))
subject = "[TEST#{@mailing.ab_test? ? " #{variant}" : ""}] #{MailMerge.render(@mailing.subject_for(variant), **recipient.merge_context.merge(ab_variant: variant))}"
test_recipient = recipient.dup
test_recipient.email = current_user.email
CampaignMailer.outreach(test_recipient, html: html, subject: subject).deliver_now
redirect_to mailing_path(@mailing, recipient_id: recipient.id, variant: variant), notice: "Email di prova (#{@mailing.ab_test? ? "variante #{variant}" : "unica"}) inviata a #{current_user.email}."
rescue StandardError => e
redirect_to @mailing, alert: "Invio di prova non riuscito: #{e.message}"
end
def preview
redirect_to mailing_path(@mailing, recipient_id: params[:recipient_id])
end
private
def set_mailing
@mailing = Mailing.for_project(current_project).find(params[:id])
end
def load_form_collections
@mail_identities = MailIdentity.active.ordered
@mail_templates = MailTemplate.for_project(current_project)
end
def mailing_params
params.require(:mailing).permit(
:name, :mail_identity_id, :mail_template_id, :mail_template_b_id, :audience,
:subject, :body_html, :subject_b, :body_html_b, :ab_test, :ab_assignment,
:interval_seconds, files: []
)
end
def apply_template_if_needed
copy_template(params.dig(:mailing, :mail_template_id), :subject, :body_html, params[:use_template_content])
copy_template(params.dig(:mailing, :mail_template_b_id), :subject_b, :body_html_b, params[:use_template_b_content])
end
def copy_template(template_id, subject_attr, body_attr, flag)
return if template_id.blank?
return unless ActiveModel::Type::Boolean.new.cast(flag)
template = MailTemplate.for_project(current_project).find_by(id: template_id)
return unless template
@mailing.public_send("#{subject_attr}=", template.subject)
@mailing.public_send("#{body_attr}=", template.body_html)
end
def rebuild_recipients_if_needed
return unless @mailing.editable?
return unless @mailing.saved_change_to_audience? || @mailing.saved_change_to_ab_test? || @mailing.saved_change_to_ab_assignment?
@mailing.rebuild_recipients!
end
def preview_recipient
if params[:recipient_id].present?
@mailing.mailing_recipients.find_by(id: params[:recipient_id])
else
@mailing.mailing_recipients.pending.ordered.first || @mailing.mailing_recipients.ordered.first
end
end
def preview_variant
requested = params[:variant].to_s.upcase
return requested if requested.in?(%w[A B])
return "A" unless @mailing.ab_test?
preview_recipient&.ab_variant.presence_in(%w[A B]) || "A"
end
end
+108
View File
@@ -0,0 +1,108 @@
class OpportunitiesController < ApplicationController
before_action :require_current_project!
before_action :set_opportunity, only: %i[show edit update destroy update_stage]
before_action :load_form_data, only: %i[new create edit update]
def index
@page_title = "Opportunità"
scope = opportunities_for_current_project.includes(:organization, :assigned_user, :project).order(updated_at: :desc)
scope = scope.where(pipeline_stage: params[:pipeline_stage]) if params[:pipeline_stage].present?
scope = scope.where(assigned_user_id: params[:owner]) if params[:owner].present?
@pagy, @opportunities = pagy(scope, items: 30)
respond_to do |format|
format.html
format.csv { send_data CsvExport.opportunities(scope), filename: "opportunities-#{Date.current}.csv" }
end
end
def show
redirect_to @opportunity.organization
end
def new
@page_title = "Nuova opportunità"
@opportunity = Opportunity.new(
organization_id: params[:organization_id],
project: current_project,
assigned_user: current_user,
pipeline_stage: "to_contact",
probability: 5
)
end
def create
@opportunity = Opportunity.new(opportunity_params)
@opportunity.project ||= current_project
if @opportunity.save
redirect_to @opportunity.organization, notice: "Opportunità creata."
else
render :new, status: :unprocessable_entity
end
end
def edit
@page_title = "Modifica opportunità"
end
def update
if @opportunity.update(opportunity_params)
redirect_to @opportunity.organization, notice: "Opportunità aggiornata."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
org = @opportunity.organization
@opportunity.destroy!
redirect_to org, notice: "Opportunità eliminata."
end
def update_stage
stage_params = params.permit(:pipeline_stage, :lost_reason, :notes)
new_stage = stage_params[:pipeline_stage].to_s
unless Catalog::PIPELINE_STAGES.key?(new_stage)
redirect_back fallback_location: pipeline_path, alert: "Stage non valido." and return
end
begin
@opportunity.move_to_stage!(
new_stage,
lost_reason: stage_params[:lost_reason],
notes: stage_params[:notes],
user: current_user
)
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { notice: "Stage aggiornato." }) }
format.html { redirect_back fallback_location: pipeline_path, notice: "Stage aggiornato." }
format.json { render json: { ok: true, stage: @opportunity.pipeline_stage } }
end
rescue ActiveRecord::RecordInvalid => e
respond_to do |format|
format.html { redirect_back fallback_location: pipeline_path, alert: e.record.errors.full_messages.to_sentence }
format.json { render json: { ok: false, errors: e.record.errors.full_messages }, status: :unprocessable_entity }
end
end
end
private
def set_opportunity
@opportunity = Opportunity.find(params[:id])
end
def load_form_data
@organizations = organizations_for_current_project.order(:name)
@users = User.active.order(:first_name)
@products = Product.active.ordered
@projects = available_projects
end
def opportunity_params
params.require(:opportunity).permit(
:organization_id, :project_id, :name, :pipeline_stage, :estimated_value, :probability,
:expected_close_date, :product, :assigned_user_id, :lost_reason, :notes,
:ab_variant, :send_status, :sent_on, :outcome, :demo_trial, :converted
)
end
end
+144
View File
@@ -0,0 +1,144 @@
class OrganizationsController < ApplicationController
before_action :require_current_project!
before_action :set_organization, only: %i[show edit update destroy]
before_action :load_form_collections, only: %i[new create edit update]
def index
@page_title = "Organizzazioni"
scope = organizations_for_current_project.includes(:assigned_user, :contacts, :opportunities, :tasks, :projects)
scope = scope.search(params[:q]) if params[:q].present?
scope = apply_filters(scope)
scope = apply_sort(scope)
@pagy, @organizations = pagy(scope, items: 50)
respond_to do |format|
format.html
format.csv { send_data CsvExport.organizations(scope), filename: "organizations-#{Date.current}.csv" }
end
end
def show
authorize_organization!(@organization)
@page_title = @organization.name
@contacts = @organization.contacts.primary_first
@opportunities = @organization.opportunities.for_project(current_project).includes(:assigned_user, :project).order(updated_at: :desc)
@pending_tasks = @organization.tasks.pending.ordered
@completed_tasks = @organization.tasks.completed.order(completed_at: :desc).limit(10)
@activities = @organization.activities.includes(:user, :contact, :opportunity).recent_first
@next_task = @organization.next_pending_task
@activity = @organization.activities.build(happened_at: Time.current, user: current_user)
@users = User.active.order(:first_name)
end
def new
@page_title = "Nuova organizzazione"
@organization = Organization.new(assigned_user: current_user, country: "Italia", status: "prospect")
@organization.project_ids = [current_project.id] if current_project
end
def create
@organization = Organization.new(organization_params)
if @organization.save
redirect_to @organization, notice: "Organizzazione creata."
else
render :new, status: :unprocessable_entity
end
end
def edit
authorize_organization!(@organization)
@page_title = "Modifica #{@organization.name}"
end
def update
authorize_organization!(@organization)
if @organization.update(organization_params)
redirect_to @organization, notice: "Organizzazione aggiornata."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize_organization!(@organization)
@organization.destroy!
redirect_to organizations_path, notice: "Organizzazione eliminata."
end
private
def set_organization
@organization = Organization.find(params[:id])
end
def authorize_organization!(org)
return if current_user.admin?
return if org.projects.merge(available_projects).exists?
redirect_to organizations_path, alert: "Organizzazione non disponibile per i tuoi progetti." and return
end
def load_form_collections
@users = User.active.order(:first_name)
@projects = available_projects
end
def organization_params
params.require(:organization).permit(
:name, :legal_name, :organization_type, :sport, :country, :region, :province, :city,
:address, :website, :source_url, :phone, :email, :vat_number, :notes, :status, :lead_source,
:assigned_user_id, :list_position, :team_gender, :streaming_status, :commercial_fit, :verified_at,
project_ids: []
)
end
def apply_filters(scope)
scope = scope.where(status: params[:status]) if params[:status].present?
scope = scope.where(lead_source: params[:lead_source]) if params[:lead_source].present?
scope = scope.where(organization_type: params[:organization_type]) if params[:organization_type].present?
scope = scope.where(sport: params[:sport]) if params[:sport].present?
scope = scope.where(country: params[:country]) if params[:country].present?
scope = scope.where(region: params[:region]) if params[:region].present?
scope = scope.where(assigned_user_id: params[:owner]) if params[:owner].present?
scope = scope.where(team_gender: params[:team_gender]) if params[:team_gender].present?
scope = scope.where(streaming_status: params[:streaming_status]) if params[:streaming_status].present?
if params[:customer] == "yes"
scope = scope.customers
elsif params[:customer] == "no"
scope = scope.where.not(status: %w[active_customer inactive_customer])
end
if params[:pipeline_stage].present?
scope = scope.joins(:opportunities).where(opportunities: { pipeline_stage: params[:pipeline_stage], project_id: current_project.id }).distinct
end
if params[:ab_variant].present?
scope = scope.joins(:opportunities).where(opportunities: { ab_variant: params[:ab_variant], project_id: current_project.id }).distinct
end
if params[:overdue_tasks] == "1"
scope = scope.joins(:tasks).merge(Task.overdue).distinct
end
scope
end
def apply_sort(scope)
case params[:sort]
when "name"
scope.order(:name)
when "updated"
scope.order(updated_at: :desc)
when "created"
scope.order(created_at: :desc)
when "lista"
scope.order(Arel.sql("organizations.list_position ASC NULLS LAST, organizations.name ASC"))
else
if current_project&.code == "matchlivetv"
scope.order(Arel.sql("organizations.list_position ASC NULLS LAST, organizations.name ASC"))
else
scope.order(updated_at: :desc)
end
end
end
end
@@ -0,0 +1,41 @@
class PasswordResetsController < ApplicationController
skip_before_action :require_login
def new; end
def create
user = User.active.find_by(email: params[:email].to_s.downcase.strip)
if user
user.generate_password_reset_token!
PasswordMailer.reset(user).deliver_later
end
redirect_to login_path, notice: "Se l'email esiste, riceverai le istruzioni per il reset."
end
def edit
@user = User.find_by(password_reset_token: params[:token])
return if @user&.password_reset_token_valid?
redirect_to new_password_reset_path, alert: "Link di reset non valido o scaduto."
end
def update
@user = User.find_by(password_reset_token: params[:token])
unless @user&.password_reset_token_valid?
redirect_to new_password_reset_path, alert: "Link di reset non valido o scaduto." and return
end
if @user.update(password_params)
@user.clear_password_reset_token!
redirect_to login_path, notice: "Password aggiornata. Ora puoi accedere."
else
render :edit, status: :unprocessable_entity
end
end
private
def password_params
params.require(:user).permit(:password, :password_confirmation)
end
end
+21
View File
@@ -0,0 +1,21 @@
class PasswordsController < ApplicationController
def edit
@user = current_user
end
def update
@user = current_user
if @user.authenticate(params[:current_password]) && @user.update(password_params)
redirect_to root_path, notice: "Password aggiornata."
else
flash.now[:alert] = "Impossibile aggiornare la password. Verifica i dati inseriti."
render :edit, status: :unprocessable_entity
end
end
private
def password_params
params.require(:user).permit(:password, :password_confirmation)
end
end
+12
View File
@@ -0,0 +1,12 @@
class PipelineController < ApplicationController
before_action :require_current_project!
def show
@page_title = "Pipeline"
@opportunities_by_stage = Catalog::PIPELINE_ORDER.index_with do |stage|
opportunities_for_current_project.where(pipeline_stage: stage)
.includes(:organization, :assigned_user, :tasks, organization: :contacts)
.order(updated_at: :desc)
end
end
end
+45
View File
@@ -0,0 +1,45 @@
class ProjectsController < ApplicationController
before_action :require_admin
before_action :set_project, only: %i[edit update destroy]
skip_before_action :set_current_project, only: %i[create], raise: false
def create
@project = Project.new(project_params)
if @project.save
redirect_to settings_path(project_code: @project.code), notice: "Progetto creato. Sei nell'istanza #{@project.name}."
else
redirect_to root_path(new_project: 1), alert: @project.errors.full_messages.to_sentence
end
end
def edit
@page_title = "Modifica progetto"
end
def update
if @project.update(project_params)
redirect_to(current_project ? settings_path : root_path, notice: "Progetto aggiornato.")
else
render :edit, status: :unprocessable_entity
end
end
def destroy
if @project.opportunities.exists?
redirect_to(current_project ? settings_path : root_path, alert: "Impossibile eliminare: ci sono opportunità collegate.")
else
@project.destroy!
redirect_to root_path, notice: "Progetto eliminato."
end
end
private
def set_project
@project = Project.find(params[:id])
end
def project_params
params.require(:project).permit(:name, :code, :description, :active, :position, :color)
end
end
+14
View File
@@ -0,0 +1,14 @@
class ReportsController < ApplicationController
before_action :require_current_project!
def index
@page_title = "Report"
@reports = Reports::Builder.new(project: current_project)
@funnel = @reports.funnel
@lead_sources = @reports.lead_sources
@won_lost = @reports.won_lost_by_month
@lost_reasons = @reports.lost_reasons
@sales_owners = @reports.sales_owners
@revenue = @reports.revenue_by_month
end
end
+20
View File
@@ -0,0 +1,20 @@
class SearchController < ApplicationController
before_action :require_current_project!
def show
@page_title = "Ricerca"
@query = params[:q].to_s.strip
if @query.present?
@organizations = organizations_for_current_project.search(@query).includes(:assigned_user).limit(20)
@contacts = Contact.joins(:organization).merge(organizations_for_current_project).search(@query).includes(:organization).limit(20)
@opportunities = opportunities_for_current_project.joins(:organization)
.where("opportunities.name ILIKE :q OR organizations.name ILIKE :q", q: "%#{ActiveRecord::Base.sanitize_sql_like(@query)}%")
.includes(:organization)
.limit(20)
else
@organizations = []
@contacts = []
@opportunities = []
end
end
end
+43
View File
@@ -0,0 +1,43 @@
class SessionsController < ApplicationController
skip_before_action :require_login, only: %i[new create]
skip_before_action :set_current_project, only: %i[new create], raise: false
def new
redirect_to after_login_path if logged_in?
end
def create
user = User.active.find_by(email: params[:email].to_s.downcase.strip)
if user&.authenticate(params[:password])
login_as(user)
redirect_to after_login_path, notice: "Bentornato, #{user.first_name}!"
else
flash.now[:alert] = "Email o password non validi."
render :new, status: :unprocessable_entity
end
end
def destroy
logout
redirect_to login_path, notice: "Disconnesso correttamente."
end
private
def after_login_path
projects = if current_user.admin?
Project.active.ordered
else
current_user.accessible_projects
end
if projects.one?
project_root_path(project_code: projects.first.code)
elsif (code = session[:last_project_code]) && projects.exists?(code: code)
project_root_path(project_code: code)
else
root_path
end
end
end
+29
View File
@@ -0,0 +1,29 @@
class SettingsController < ApplicationController
def show
@page_title = "Impostazioni"
@goal = SalesGoal.current(current_project).order(created_at: :desc).first || SalesGoal.new(project: current_project)
@goals = SalesGoal.for_project(current_project).order(start_date: :desc)
@products = Product.ordered
@projects = Project.ordered
end
def update_goal
@goal = params[:id].present? ? SalesGoal.find(params[:id]) : SalesGoal.new
attrs = goal_params.merge(active: true)
attrs[:project_id] ||= current_project&.id
if @goal.update(attrs)
if params[:make_current] == "1" && @goal.project_id.present?
SalesGoal.where(project_id: @goal.project_id).where.not(id: @goal.id).update_all(active: false)
end
redirect_to settings_path, notice: "Obiettivo aggiornato."
else
redirect_to settings_path, alert: @goal.errors.full_messages.to_sentence
end
end
private
def goal_params
params.require(:sales_goal).permit(:name, :metric, :target_value, :start_date, :end_date, :active, :project_id)
end
end
+84
View File
@@ -0,0 +1,84 @@
class TasksController < ApplicationController
before_action :require_current_project!
before_action :set_task, only: %i[show edit update destroy complete]
before_action :load_form_data, only: %i[new create edit update]
def index
@page_title = "Task"
tasks = tasks_for_current_project
@overdue_tasks = tasks.overdue.includes(:organization, :contact, :assigned_user).ordered
@today_tasks = tasks.due_today.includes(:organization, :contact, :assigned_user).ordered
@upcoming_tasks = tasks.upcoming(30).includes(:organization, :contact, :assigned_user).ordered
@completed_tasks = tasks.completed.includes(:organization).order(completed_at: :desc).limit(20)
end
def show
redirect_to @task.organization
end
def new
@page_title = "Nuovo task"
@task = Task.new(
organization_id: params[:organization_id],
opportunity_id: params[:opportunity_id],
contact_id: params[:contact_id],
assigned_user: current_user,
due_at: 1.day.from_now.change(hour: 10),
priority: "normal",
task_type: params[:task_type].presence || "follow_up"
)
end
def create
@task = Task.new(task_params)
if @task.save
redirect_to(@task.organization || tasks_path, notice: "Task creato.")
else
render :new, status: :unprocessable_entity
end
end
def edit
@page_title = "Modifica task"
end
def update
if @task.update(task_params)
redirect_to @task.organization, notice: "Task aggiornato."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
org = @task.organization
@task.destroy!
redirect_to org, notice: "Task eliminato."
end
def complete
if @task.complete!(user: current_user)
redirect_back fallback_location: today_path, notice: "Task completato."
else
redirect_back fallback_location: today_path, alert: "Il task non può essere completato."
end
end
private
def set_task
@task = Task.find(params[:id])
end
def load_form_data
@organizations = organizations_for_current_project.order(:name)
@users = User.active.order(:first_name)
end
def task_params
params.require(:task).permit(
:title, :description, :organization_id, :contact_id, :opportunity_id,
:assigned_user_id, :due_at, :priority, :task_type, :status
)
end
end
+20
View File
@@ -0,0 +1,20 @@
class TodayController < ApplicationController
before_action :require_current_project!
def show
@page_title = "Oggi"
tasks = tasks_for_current_project
@overdue_tasks = tasks.overdue.includes(:organization, :contact, :opportunity, :assigned_user).ordered
@today_tasks = tasks.due_today.includes(:organization, :contact, :opportunity, :assigned_user).ordered
@upcoming_tasks = tasks.upcoming.includes(:organization, :contact, :opportunity, :assigned_user).ordered
@stalled = opportunities_for_current_project.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, :tasks)
@new_prospects = organizations_for_current_project.prospects
.left_joins(:opportunities)
.where("opportunities.id IS NULL OR (opportunities.project_id = ? AND opportunities.pipeline_stage = ?)", current_project.id, "to_contact")
.includes(:contacts, :assigned_user, :opportunities)
.distinct
.limit(20)
end
end
+87
View File
@@ -0,0 +1,87 @@
class UsersController < ApplicationController
before_action :require_admin
before_action :set_user, only: %i[edit update destroy]
before_action :load_projects, only: %i[new create edit update]
def index
@page_title = "Utenti"
@users = User.includes(user_projects: :project).order(:first_name, :last_name)
end
def new
@page_title = "Nuovo utente"
@user = User.new(role: "user", active: true)
end
def create
@page_title = "Nuovo utente"
@user = User.new(user_params)
if @user.save
sync_user_projects!(@user, params[:project_ids]) unless @user.admin?
redirect_to users_path, notice: "Utente creato."
else
render :new, status: :unprocessable_entity
end
end
def edit
@page_title = "Modifica utente"
end
def update
@page_title = "Modifica utente"
attrs = user_params
attrs.delete(:password) if attrs[:password].blank?
attrs.delete(:password_confirmation) if attrs[:password].blank?
attrs[:active] = ActiveModel::Type::Boolean.new.cast(attrs[:active]) if attrs.key?(:active)
if @user.update(attrs)
sync_user_projects!(@user, params[:project_ids]) if !@user.admin? && params.key?(:project_ids)
notice = if @user.saved_change_to_active?
@user.active? ? "#{@user.full_name} riabilitato." : "#{@user.full_name} disabilitato."
else
"Utente aggiornato."
end
redirect_to users_path, notice: notice
else
render :edit, status: :unprocessable_entity
end
end
def destroy
if @user == current_user
redirect_to users_path, alert: "Non puoi eliminare il tuo account." and return
end
unless @user.can_be_destroyed?
redirect_to users_path, alert: "Non puoi eliminare l'unico amministratore attivo." and return
end
name = @user.full_name
@user.destroy!
redirect_to users_path, notice: "#{name} eliminato."
end
private
def set_user
@user = User.find(params[:id])
end
def load_projects
@projects = Project.ordered
end
def user_params
params.require(:user).permit(:email, :first_name, :last_name, :role, :password, :password_confirmation, :active)
end
def sync_user_projects!(user, selected_ids)
selected_ids = Array(selected_ids).map(&:presence).compact.map(&:to_i)
Project.find_each do |project|
up = user.user_projects.find_or_initialize_by(project: project)
up.enabled = selected_ids.include?(project.id)
up.save!
end
end
end