Espone API JSON e MCP HTTP per far lavorare gli agenti sul CRM.

Gli agenti autenticati con token Bearer possono leggere today/pipeline e annotare attività, con host MCP allineati a quelli di produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 22:43:46 +02:00
co-authored by Cursor
parent 1f946f63ba
commit 936930e691
44 changed files with 1693 additions and 4 deletions
+1
View File
@@ -7,5 +7,6 @@ class AdminController < ApplicationController
@active_users_count = User.active.count
@projects_count = Project.count
@mail_identities_count = MailIdentity.count
@api_tokens_count = current_user.api_tokens.active.count
end
end
+28
View File
@@ -0,0 +1,28 @@
module Api
class BaseController < ActionController::API
wrap_parameters false
before_action :authenticate_api_token!
attr_reader :current_user, :current_api_token
private
def authenticate_api_token!
token = ApiToken.authenticate(bearer_token)
unless token
render json: { error: "Non autenticato" }, status: :unauthorized
return
end
@current_api_token = token
@current_user = token.user
Current.user = @current_user
token.touch_last_used!
end
def bearer_token
header = request.authorization.to_s.presence || request.headers["Authorization"].to_s
header[/Bearer\s+(.+)/i, 1]
end
end
end
@@ -0,0 +1,16 @@
module Api
module V1
class ActivitiesController < BaseController
def create
render_agent Crm::AgentSession.new(current_user).create_activity(params[:project_code], activity_params)
end
private
def activity_params
source = params[:activity].presence || params
source.permit(:organization_id, :activity_type, :subject, :description, :happened_at, :contact_id, :opportunity_id, :lost_reason)
end
end
end
end
+11
View File
@@ -0,0 +1,11 @@
module Api
module V1
class BaseController < Api::BaseController
private
def render_agent(result)
render json: result.body, status: result.status
end
end
end
end
@@ -0,0 +1,16 @@
module Api
module V1
class OpportunitiesController < BaseController
def update_stage
source = params[:opportunity].presence || params
render_agent Crm::AgentSession.new(current_user).update_opportunity_stage(
params[:project_code],
params[:id],
pipeline_stage: source[:pipeline_stage],
lost_reason: source[:lost_reason],
notes: source[:notes]
)
end
end
end
end
@@ -0,0 +1,9 @@
module Api
module V1
class OrganizationsController < BaseController
def show
render_agent Crm::AgentSession.new(current_user).organization(params[:project_code], params[:id])
end
end
end
end
@@ -0,0 +1,9 @@
module Api
module V1
class ProjectsController < BaseController
def index
render_agent Crm::AgentSession.new(current_user).projects
end
end
end
end
@@ -0,0 +1,9 @@
module Api
module V1
class SearchController < BaseController
def show
render_agent Crm::AgentSession.new(current_user).search(params[:project_code], params[:q])
end
end
end
end
@@ -0,0 +1,23 @@
module Api
module V1
class TasksController < BaseController
def create
render_agent Crm::AgentSession.new(current_user).create_task(params[:project_code], task_params)
end
def complete
render_agent Crm::AgentSession.new(current_user).complete_task(params[:project_code], params[:id])
end
private
def task_params
source = params[:task].presence || params
source.permit(
:title, :description, :organization_id, :contact_id, :opportunity_id,
:assigned_user_id, :due_at, :priority, :task_type
)
end
end
end
end
@@ -0,0 +1,9 @@
module Api
module V1
class TodayController < BaseController
def show
render_agent Crm::AgentSession.new(current_user).today(params[:project_code])
end
end
end
end
+26
View File
@@ -0,0 +1,26 @@
class ApiTokensController < ApplicationController
before_action :set_api_token, only: :destroy
def index
@page_title = "Token API"
@api_tokens = current_user.api_tokens.active.order(created_at: :desc)
@revealed_token = session.delete(:revealed_api_token)
end
def create
token = ApiToken.issue!(user: current_user, name: params[:name])
session[:revealed_api_token] = token.plaintext
redirect_to api_tokens_path, notice: "Token creato. Copialo ora: non sarà più visibile."
end
def destroy
@api_token.revoke!
redirect_to api_tokens_path, notice: "Token revocato."
end
private
def set_api_token
@api_token = current_user.api_tokens.active.find(params[:id])
end
end
+20
View File
@@ -0,0 +1,20 @@
class McpController < Api::BaseController
def handle
request.body.rewind if request.body.respond_to?(:rewind)
server = EminuxCrmMcp.server_for(user: current_user)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
server,
stateless: true,
serve_subscriptions_listen: false,
enable_json_response: true,
allowed_hosts: EminuxCrmMcp.allowed_hosts,
dns_rebinding_protection: !Rails.env.test?
)
status, headers, body = transport.handle_request(request)
headers.each { |key, value| response.set_header(key, value) }
self.status = status
self.response_body = body
end
end
+54
View File
@@ -0,0 +1,54 @@
class ApiToken < ApplicationRecord
PREFIX = "crm_"
belongs_to :user
attr_accessor :plaintext
validates :name, presence: true
validates :token_digest, presence: true, uniqueness: true
validates :token_prefix, presence: true
scope :active, -> { where(revoked_at: nil) }
def self.digest(token)
Digest::SHA256.hexdigest(token.to_s)
end
def self.authenticate(plaintext)
return if plaintext.blank?
token = active.find_by(token_digest: digest(plaintext))
return unless token&.user&.active?
token
end
def self.issue!(user:, name:)
raw = "#{PREFIX}#{SecureRandom.urlsafe_base64(32)}"
record = create!(
user: user,
name: name.to_s.strip.presence || "Agente",
token_digest: digest(raw),
token_prefix: raw[0, 8]
)
record.plaintext = raw
record
end
def revoked?
revoked_at.present?
end
def revoke!
update!(revoked_at: Time.current)
end
def touch_last_used!
update_column(:last_used_at, Time.current)
end
def masked
"#{token_prefix}"
end
end
+1
View File
@@ -9,6 +9,7 @@ class User < ApplicationRecord
has_many :activities, dependent: :nullify
has_many :user_projects, dependent: :destroy
has_many :projects, through: :user_projects
has_many :api_tokens, dependent: :destroy
ROLES = %w[admin user].freeze
+325
View File
@@ -0,0 +1,325 @@
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
private
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,
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,
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
+8
View File
@@ -28,5 +28,13 @@
<p class="mt-4 text-sm text-zinc-700 dark:text-zinc-300"><%= @mail_identities_count %> account</p>
<div class="mt-4 text-sm font-medium">Configura SMTP →</div>
<% end %>
<%= link_to api_tokens_path, class: "rounded-2xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6 transition hover:border-zinc-400 dark:hover:border-zinc-500" do %>
<div class="text-xs font-semibold uppercase tracking-wide text-zinc-400">Agenti</div>
<h2 class="mt-2 text-xl font-semibold tracking-tight">Token API</h2>
<p class="mt-2 text-sm text-zinc-500">Autenticazione Bearer per Cursor, Claude Code e Codex. Il valore in chiaro si vede una sola volta.</p>
<p class="mt-4 text-sm text-zinc-700 dark:text-zinc-300"><%= @api_tokens_count %> attivi</p>
<div class="mt-4 text-sm font-medium">Gestisci token →</div>
<% end %>
</div>
</div>
+53
View File
@@ -0,0 +1,53 @@
<div class="space-y-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Token API</h1>
<p class="mt-1 text-sm text-zinc-500">
Servono agli agenti (Cursor, Claude Code, Codex) per lavorare sul CRM. Il valore in chiaro si vede una sola volta.
</p>
</div>
<% if @revealed_token.present? %>
<div class="rounded-xl border border-amber-300 bg-amber-50 p-5 text-amber-950 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-100">
<p class="text-sm font-semibold">Nuovo token — copialo ora</p>
<p class="mt-1 text-xs">Non verrà più mostrato. Incollalo in <code>CRM_API_TOKEN</code> nella config MCP.</p>
<pre class="mt-3 overflow-x-auto rounded-lg bg-white px-3 py-2 text-sm text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100"><%= @revealed_token %></pre>
</div>
<% end %>
<section class="<%= card_class %> p-5">
<h2 class="text-lg font-semibold tracking-tight">Nuovo token</h2>
<%= form_with url: api_tokens_path, method: :post, class: "mt-4 flex flex-col gap-3 sm:flex-row sm:items-end" do %>
<div class="flex-1">
<label class="mb-1 block text-xs font-medium text-zinc-500" for="name">Nome</label>
<%= text_field_tag :name, nil, placeholder: "es. Cursor sul portatile", required: true, class: input_class %>
</div>
<%= submit_tag "Crea token", class: "#{btn_primary} sm:mb-0" %>
<% end %>
</section>
<section class="<%= card_class %>">
<% if @api_tokens.empty? %>
<p class="p-5 text-sm text-zinc-500">Nessun token attivo.</p>
<% else %>
<ul class="divide-y divide-zinc-100 dark:divide-zinc-800">
<% @api_tokens.each do |token| %>
<li class="flex flex-wrap items-center justify-between gap-3 p-4">
<div>
<div class="font-medium"><%= token.name %></div>
<div class="mt-0.5 text-xs text-zinc-500">
<%= token.masked %>
· creato <%= format_dt(token.created_at) %>
<% if token.last_used_at %>
· usato <%= format_dt(token.last_used_at) %>
<% else %>
· mai usato
<% end %>
</div>
</div>
<%= button_to "Revoca", api_token_path(token), method: :delete, class: "#{btn_danger} text-sm", data: { turbo_confirm: "Revocare questo token?" } %>
</li>
<% end %>
</ul>
<% end %>
</section>
</div>
+1
View File
@@ -66,6 +66,7 @@
<% end %>
<li><%= link_to "Email e campagne", dashboard_mailings_path, class: "hover:underline" %></li>
<li><%= link_to "Import CSV organizzazioni", new_import_path, class: "hover:underline" %></li>
<li><%= link_to "Token API (agenti)", api_tokens_path, class: "hover:underline" %></li>
<li><%= link_to "Cambio password", edit_password_path, class: "hover:underline" %></li>
</ul>
</section>
+1
View File
@@ -9,6 +9,7 @@
<%= link_to "Impostazioni", admin_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
<%= link_to "Utenti", users_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
<% end %>
<%= link_to "Token API", api_tokens_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
<%= link_to "Password", edit_password_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
<%= button_to "Esci", logout_path, method: :delete, class: "block w-full px-4 py-2.5 text-left text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
</div>