Permette di leggere i bounce dalla stessa MailIdentity SMTP, aggiornare le email in anagrafica e saltare gli indirizzi invalidi nei mailing. Co-authored-by: Cursor <cursoragent@cursor.com>
528 lines
19 KiB
Ruby
528 lines
19 KiB
Ruby
module Crm
|
|
class AgentSession
|
|
Result = Struct.new(:ok, :status, :body, keyword_init: true)
|
|
|
|
def initialize(user)
|
|
@user = user
|
|
Current.user = user
|
|
end
|
|
|
|
def projects
|
|
ok(projects: @user.accessible_projects.map { |project| project_json(project) })
|
|
end
|
|
|
|
def today(project_code)
|
|
with_project(project_code) do
|
|
tasks = tasks_scope
|
|
stalled = opportunities_scope.open_stage
|
|
.where("stage_changed_at < ? OR (stage_changed_at IS NULL AND opportunities.created_at < ?)", 7.days.ago, 7.days.ago)
|
|
.includes(:organization, :assigned_user)
|
|
|
|
ok(
|
|
project: project_json(@project),
|
|
overdue_tasks: tasks.overdue.includes(:organization, :contact, :opportunity, :assigned_user).ordered.map { |t| task_json(t) },
|
|
today_tasks: tasks.due_today.includes(:organization, :contact, :opportunity, :assigned_user).ordered.map { |t| task_json(t) },
|
|
upcoming_tasks: tasks.upcoming.includes(:organization, :contact, :opportunity, :assigned_user).ordered.map { |t| task_json(t) },
|
|
stalled_opportunities: stalled.map { |o| opportunity_json(o) }
|
|
)
|
|
end
|
|
end
|
|
|
|
def search(project_code, query)
|
|
with_project(project_code) do
|
|
query = query.to_s.strip
|
|
if query.blank?
|
|
return ok(query: query, organizations: [], contacts: [], opportunities: [])
|
|
end
|
|
|
|
organizations = organizations_scope.search(query).includes(:assigned_user).limit(20)
|
|
contacts = Contact.joins(:organization).merge(organizations_scope).search(query).includes(:organization).limit(20)
|
|
opportunities = opportunities_scope.joins(:organization)
|
|
.where("opportunities.name ILIKE :q OR organizations.name ILIKE :q", q: "%#{ActiveRecord::Base.sanitize_sql_like(query)}%")
|
|
.includes(:organization, :assigned_user)
|
|
.limit(20)
|
|
|
|
ok(
|
|
query: query,
|
|
organizations: organizations.map { |o| organization_summary(o) },
|
|
contacts: contacts.map { |c| contact_json(c).merge(organization_name: c.organization.name) },
|
|
opportunities: opportunities.map { |o| opportunity_json(o) }
|
|
)
|
|
end
|
|
end
|
|
|
|
def organization(project_code, id)
|
|
with_project(project_code) do
|
|
org = organizations_scope.includes(:assigned_user, :contacts).find(id)
|
|
open_opportunities = org.opportunities.for_project(@project).open_stage.includes(:assigned_user).order(updated_at: :desc)
|
|
pending_tasks = org.tasks.pending.ordered.includes(:assigned_user, :contact, :opportunity)
|
|
recent_activities = org.activities.includes(:user, :contact, :opportunity).recent_first.limit(20)
|
|
|
|
ok(
|
|
organization: organization_json(org),
|
|
contacts: org.contacts.primary_first.map { |c| contact_json(c) },
|
|
open_opportunities: open_opportunities.map { |o| opportunity_json(o) },
|
|
pending_tasks: pending_tasks.map { |t| task_json(t) },
|
|
recent_activities: recent_activities.map { |a| activity_json(a) }
|
|
)
|
|
rescue ActiveRecord::RecordNotFound
|
|
err("Organizzazione non trovata", status: :not_found)
|
|
end
|
|
end
|
|
|
|
def create_task(project_code, attrs)
|
|
with_project(project_code) do
|
|
attrs = attrs.to_h.symbolize_keys
|
|
organization = organizations_scope.find(attrs[:organization_id])
|
|
task = Task.new(attrs.slice(:title, :description, :contact_id, :opportunity_id, :assigned_user_id, :due_at, :priority, :task_type))
|
|
task.organization = organization
|
|
task.assigned_user ||= @user
|
|
task.priority = "normal" if task.priority.blank?
|
|
task.task_type = "follow_up" if task.task_type.blank?
|
|
|
|
if task.opportunity_id.present? && opportunities_scope.where(id: task.opportunity_id).none?
|
|
return err("Opportunità non trovata", status: :not_found)
|
|
end
|
|
|
|
if task.save
|
|
ok({ task: task_json(task) }, status: :created)
|
|
else
|
|
validation_error(task)
|
|
end
|
|
rescue ActiveRecord::RecordNotFound
|
|
err("Organizzazione non trovata", status: :not_found)
|
|
end
|
|
end
|
|
|
|
def complete_task(project_code, id)
|
|
with_project(project_code) do
|
|
task = tasks_scope.find(id)
|
|
unless task.complete!(user: @user)
|
|
return err("Il task non può essere completato", status: :unprocessable_entity)
|
|
end
|
|
|
|
ok(task: task_json(task.reload))
|
|
rescue ActiveRecord::RecordNotFound
|
|
err("Task non trovato", status: :not_found)
|
|
end
|
|
end
|
|
|
|
def create_activity(project_code, attrs)
|
|
with_project(project_code) do
|
|
attrs = attrs.to_h.symbolize_keys
|
|
organization = organizations_scope.find(attrs[:organization_id])
|
|
activity = organization.activities.build(attrs.slice(:activity_type, :subject, :description, :happened_at, :contact_id, :opportunity_id))
|
|
activity.user = @user
|
|
activity.happened_at ||= Time.current
|
|
|
|
if activity.opportunity_id.present? && opportunities_scope.where(id: activity.opportunity_id).none?
|
|
return err("Opportunità non trovata", status: :not_found)
|
|
end
|
|
|
|
if activity.save
|
|
maybe_update_pipeline_from_activity!(activity, lost_reason: attrs[:lost_reason])
|
|
ok({ activity: activity_json(activity) }, status: :created)
|
|
else
|
|
validation_error(activity)
|
|
end
|
|
rescue ActiveRecord::RecordNotFound
|
|
err("Organizzazione non trovata", status: :not_found)
|
|
end
|
|
end
|
|
|
|
def update_opportunity_stage(project_code, id, pipeline_stage:, lost_reason: nil, notes: nil)
|
|
with_project(project_code) do
|
|
opportunity = opportunities_scope.find(id)
|
|
new_stage = pipeline_stage.to_s
|
|
unless Catalog::PIPELINE_STAGES.key?(new_stage)
|
|
return err("Stage non valido", status: :unprocessable_entity)
|
|
end
|
|
|
|
opportunity.move_to_stage!(new_stage, lost_reason: lost_reason, notes: notes, user: @user)
|
|
ok(opportunity: opportunity_json(opportunity.reload))
|
|
rescue ActiveRecord::RecordNotFound
|
|
err("Opportunità non trovata", status: :not_found)
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
validation_error(e.record)
|
|
end
|
|
end
|
|
|
|
# Aggiorna email organizzazione/contatto: bounce, sostituzione, flag non valida + nota timeline.
|
|
def update_organization_email(project_code, attrs)
|
|
with_project(project_code) do
|
|
attrs = attrs.to_h.symbolize_keys
|
|
organization = organizations_scope.includes(:contacts).find(attrs[:organization_id])
|
|
contact = nil
|
|
if attrs[:contact_id].present?
|
|
contact = organization.contacts.find(attrs[:contact_id])
|
|
elsif attrs[:update_primary_contact] != false
|
|
contact = organization.primary_contact || organization.contacts.order(:id).first
|
|
end
|
|
|
|
bounced = attrs[:bounced_email].to_s.strip.downcase.presence
|
|
new_email = attrs.key?(:email) ? attrs[:email].to_s.strip.downcase.presence : :unchanged
|
|
mark_invalid = if attrs.key?(:email_invalid)
|
|
ActiveModel::Type::Boolean.new.cast(attrs[:email_invalid])
|
|
elsif bounced.present? && (new_email == :unchanged || new_email.nil? || new_email == bounced)
|
|
true
|
|
elsif new_email.is_a?(String) && new_email != bounced
|
|
false
|
|
else
|
|
nil
|
|
end
|
|
|
|
previous_org_email = organization.email
|
|
previous_contact_email = contact&.email
|
|
|
|
ActiveRecord::Base.transaction do
|
|
org_changes = {}
|
|
if bounced
|
|
org_changes[:bounced_email] = bounced
|
|
end
|
|
unless new_email == :unchanged
|
|
org_changes[:email] = new_email
|
|
end
|
|
unless mark_invalid.nil?
|
|
org_changes[:email_invalid] = mark_invalid
|
|
end
|
|
if attrs[:website].present?
|
|
org_changes[:website] = attrs[:website].to_s.strip
|
|
end
|
|
organization.update!(org_changes) if org_changes.any?
|
|
|
|
if contact
|
|
contact_changes = {}
|
|
if bounced
|
|
contact_changes[:bounced_email] = bounced
|
|
end
|
|
unless new_email == :unchanged
|
|
contact_changes[:email] = new_email
|
|
end
|
|
unless mark_invalid.nil?
|
|
contact_changes[:email_invalid] = mark_invalid
|
|
end
|
|
contact.update!(contact_changes) if contact_changes.any?
|
|
end
|
|
|
|
subject = attrs[:activity_subject].presence || begin
|
|
if new_email.is_a?(String) && new_email.present? && new_email != bounced
|
|
"Email aggiornata dopo bounce"
|
|
else
|
|
"Email non valida (bounce)"
|
|
end
|
|
end
|
|
description = attrs[:activity_description].presence || build_email_update_description(
|
|
bounced: bounced,
|
|
new_email: new_email == :unchanged ? nil : new_email,
|
|
previous_org_email: previous_org_email,
|
|
previous_contact_email: previous_contact_email,
|
|
reason: attrs[:bounce_reason]
|
|
)
|
|
|
|
activity = organization.activities.create!(
|
|
user: @user,
|
|
contact: contact,
|
|
activity_type: "note",
|
|
subject: subject,
|
|
description: description,
|
|
happened_at: Time.current
|
|
)
|
|
|
|
ok(
|
|
organization: organization_json(organization.reload),
|
|
contact: contact ? contact_json(contact.reload) : nil,
|
|
activity: activity_json(activity)
|
|
)
|
|
end
|
|
rescue ActiveRecord::RecordNotFound
|
|
err("Organizzazione o contatto non trovato", status: :not_found)
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
validation_error(e.record)
|
|
end
|
|
end
|
|
|
|
# Legge bounce IMAP dall'account SMTP/IMAP (stesse credenziali MailIdentity).
|
|
def check_mail_bounces(project_code = nil, attrs = {})
|
|
attrs = attrs.to_h.symbolize_keys
|
|
if project_code.present?
|
|
project = Project.active.find_by(code: project_code.to_s)
|
|
return err("Progetto non trovato", status: :not_found) if project.nil?
|
|
return err("Progetto non accessibile", status: :forbidden) unless @user.can_access_project?(project)
|
|
|
|
@project = project
|
|
Current.project = project
|
|
end
|
|
|
|
identity = find_mail_identity(attrs)
|
|
return err("Account email non trovato", status: :not_found) if identity.nil?
|
|
|
|
since_days = (attrs[:since_days].presence || 14).to_i.clamp(1, 90)
|
|
result = Mailings::ImapBounceReader.new(
|
|
identity,
|
|
since: since_days.days.ago,
|
|
mailbox: attrs[:mailbox].presence || "INBOX",
|
|
limit: (attrs[:limit].presence || 200).to_i.clamp(1, 500)
|
|
).call
|
|
|
|
unless result.ok
|
|
return err(result.error || "Lettura IMAP fallita", status: :unprocessable_entity, extra: { identity: result.identity })
|
|
end
|
|
|
|
bounces = enrich_bounces_with_crm(result.bounces)
|
|
ok(
|
|
identity: result.identity,
|
|
since_days: since_days,
|
|
scanned: result.scanned,
|
|
bounce_count: bounces.size,
|
|
unique_failed_emails: bounces.flat_map { |b| b[:failed_emails] }.uniq.sort,
|
|
bounces: bounces
|
|
)
|
|
end
|
|
|
|
private
|
|
|
|
def find_mail_identity(attrs)
|
|
scope = MailIdentity.active
|
|
if attrs[:mail_identity_id].present?
|
|
scope.find_by(id: attrs[:mail_identity_id])
|
|
elsif attrs[:from_email].present?
|
|
scope.find_by("LOWER(from_email) = ?", attrs[:from_email].to_s.downcase.strip)
|
|
else
|
|
scope.imap_enabled.ordered.find_by("LOWER(from_email) = ?", "info@matchlivetv.it") ||
|
|
scope.imap_enabled.ordered.first
|
|
end
|
|
end
|
|
|
|
def enrich_bounces_with_crm(bounces)
|
|
return bounces if @project.nil?
|
|
|
|
orgs = organizations_scope.includes(:contacts).to_a
|
|
bounces.map do |bounce|
|
|
matches = []
|
|
Array(bounce[:failed_emails]).each do |email|
|
|
org = orgs.find { |o| o.email.to_s.downcase == email } ||
|
|
orgs.find { |o| o.contacts.any? { |c| c.email.to_s.downcase == email } } ||
|
|
orgs.find { |o| o.bounced_email.to_s.downcase == email }
|
|
next unless org
|
|
|
|
matches << {
|
|
organization_id: org.id,
|
|
name: org.name,
|
|
current_email: org.email,
|
|
email_invalid: org.email_invalid?,
|
|
sport: org.sport
|
|
}
|
|
end
|
|
if matches.empty? && bounce[:club_hint].present?
|
|
hint = bounce[:club_hint].to_s.downcase
|
|
org = orgs.find { |o| o.name.to_s.downcase == hint } ||
|
|
orgs.find { |o| o.name.to_s.downcase.include?(hint) || hint.include?(o.name.to_s.downcase) }
|
|
if org
|
|
matches << {
|
|
organization_id: org.id,
|
|
name: org.name,
|
|
current_email: org.email,
|
|
email_invalid: org.email_invalid?,
|
|
sport: org.sport,
|
|
matched_by: "club_hint"
|
|
}
|
|
end
|
|
end
|
|
bounce.merge(crm_matches: matches)
|
|
end
|
|
end
|
|
|
|
def build_email_update_description(bounced:, new_email:, previous_org_email:, previous_contact_email:, reason: nil)
|
|
lines = []
|
|
lines << "Bounce / email non recapitabile: #{bounced}" if bounced.present?
|
|
lines << "Motivo: #{reason}" if reason.present?
|
|
lines << "Email organizzazione precedente: #{previous_org_email}" if previous_org_email.present?
|
|
lines << "Email contatto precedente: #{previous_contact_email}" if previous_contact_email.present?
|
|
if new_email.present?
|
|
lines << "Nuova email impostata: #{new_email}"
|
|
elsif bounced.present?
|
|
lines << "Nessuna email alternativa trovata; indirizzo marcato come non valido."
|
|
end
|
|
lines.join("\n")
|
|
end
|
|
|
|
|
|
def with_project(code)
|
|
project = Project.active.find_by(code: code.to_s)
|
|
return err("Progetto non trovato", status: :not_found) if project.nil?
|
|
return err("Progetto non accessibile", status: :forbidden) unless @user.can_access_project?(project)
|
|
|
|
@project = project
|
|
Current.project = project
|
|
yield
|
|
end
|
|
|
|
def organizations_scope
|
|
Organization.for_project(@project)
|
|
end
|
|
|
|
def opportunities_scope
|
|
Opportunity.for_project(@project)
|
|
end
|
|
|
|
def tasks_scope
|
|
Task.for_project(@project)
|
|
end
|
|
|
|
def ok(body = nil, status: :ok, **fields)
|
|
Result.new(ok: true, status: status, body: body || fields)
|
|
end
|
|
|
|
def err(message, status:, extra: {})
|
|
Result.new(ok: false, status: status, body: { error: message }.merge(extra))
|
|
end
|
|
|
|
def validation_error(record)
|
|
err(record.errors.full_messages.to_sentence, status: :unprocessable_entity, extra: { errors: record.errors.full_messages })
|
|
end
|
|
|
|
def maybe_update_pipeline_from_activity!(activity, lost_reason: nil)
|
|
opportunity = activity.opportunity || activity.organization.opportunities.for_project(@project).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
|
|
|
|
reason = lost_reason.presence || "other" if target == "lost"
|
|
opportunity.move_to_stage!(target, lost_reason: reason, user: @user)
|
|
end
|
|
|
|
def user_json(user)
|
|
return if user.nil?
|
|
|
|
{ id: user.id, name: user.full_name }
|
|
end
|
|
|
|
def project_json(project)
|
|
{ id: project.id, code: project.code, name: project.name, description: project.description }
|
|
end
|
|
|
|
def organization_summary(org)
|
|
{
|
|
id: org.id,
|
|
name: org.name,
|
|
status: org.status,
|
|
status_label: org.status_label,
|
|
organization_type: org.organization_type,
|
|
organization_type_label: org.organization_type_label,
|
|
sport: org.sport,
|
|
city: org.city,
|
|
region: org.region,
|
|
country: org.country,
|
|
email: org.email,
|
|
email_invalid: org.email_invalid,
|
|
bounced_email: org.bounced_email,
|
|
phone: org.phone,
|
|
website: org.website,
|
|
lead_source: org.lead_source,
|
|
assigned_user: user_json(org.assigned_user)
|
|
}
|
|
end
|
|
|
|
def organization_json(org)
|
|
organization_summary(org).merge(
|
|
address: org.address,
|
|
province: org.province,
|
|
legal_name: org.legal_name,
|
|
vat_number: org.vat_number,
|
|
notes: org.notes,
|
|
commercial_fit: org.commercial_fit,
|
|
streaming_status: org.streaming_status,
|
|
team_gender: org.team_gender
|
|
)
|
|
end
|
|
|
|
def contact_json(contact)
|
|
{
|
|
id: contact.id,
|
|
organization_id: contact.organization_id,
|
|
first_name: contact.first_name,
|
|
last_name: contact.last_name,
|
|
full_name: contact.full_name,
|
|
role: contact.role,
|
|
email: contact.email,
|
|
email_invalid: contact.email_invalid,
|
|
bounced_email: contact.bounced_email,
|
|
phone: contact.phone,
|
|
mobile: contact.mobile,
|
|
primary_contact: contact.primary_contact,
|
|
preferred_contact_method: contact.preferred_contact_method
|
|
}
|
|
end
|
|
|
|
def opportunity_json(opportunity)
|
|
{
|
|
id: opportunity.id,
|
|
organization_id: opportunity.organization_id,
|
|
organization_name: opportunity.organization&.name,
|
|
project_id: opportunity.project_id,
|
|
name: opportunity.name,
|
|
pipeline_stage: opportunity.pipeline_stage,
|
|
pipeline_stage_label: opportunity.pipeline_stage_label,
|
|
estimated_value: opportunity.estimated_value,
|
|
probability: opportunity.probability,
|
|
product: opportunity.product,
|
|
lost_reason: opportunity.lost_reason,
|
|
notes: opportunity.notes,
|
|
assigned_user: user_json(opportunity.assigned_user),
|
|
stage_changed_at: opportunity.stage_changed_at&.iso8601,
|
|
expected_close_date: opportunity.expected_close_date&.iso8601
|
|
}
|
|
end
|
|
|
|
def task_json(task)
|
|
{
|
|
id: task.id,
|
|
title: task.title,
|
|
description: task.description,
|
|
due_at: task.due_at&.iso8601,
|
|
priority: task.priority,
|
|
priority_label: task.priority_label,
|
|
task_type: task.task_type,
|
|
task_type_label: task.task_type_label,
|
|
status: task.status,
|
|
status_label: task.status_label,
|
|
organization_id: task.organization_id,
|
|
organization_name: task.organization&.name,
|
|
contact_id: task.contact_id,
|
|
opportunity_id: task.opportunity_id,
|
|
assigned_user: user_json(task.assigned_user),
|
|
completed_at: task.completed_at&.iso8601
|
|
}
|
|
end
|
|
|
|
def activity_json(activity)
|
|
{
|
|
id: activity.id,
|
|
activity_type: activity.activity_type,
|
|
activity_type_label: activity.activity_type_label,
|
|
subject: activity.subject,
|
|
description: activity.description,
|
|
happened_at: activity.happened_at&.iso8601,
|
|
organization_id: activity.organization_id,
|
|
contact_id: activity.contact_id,
|
|
opportunity_id: activity.opportunity_id,
|
|
user: user_json(activity.user)
|
|
}
|
|
end
|
|
end
|
|
end
|