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