From 936930e69182d22c4f6e1ff6ffa9549400a2fe7c Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Wed, 2 Sep 2026 22:43:46 +0200 Subject: [PATCH] Espone API JSON e MCP HTTP per far lavorare gli agenti sul CRM. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .cursor/mcp.json | 10 + .cursor/rules/crm-agenti.mdc | 10 + .env.example | 4 + .mcp.json | 11 + AGENTS.md | 30 ++ CLAUDE.md | 3 + Gemfile | 1 + Gemfile.lock | 10 + README.md | 63 +++- app/controllers/admin_controller.rb | 1 + app/controllers/api/base_controller.rb | 28 ++ .../api/v1/activities_controller.rb | 16 + app/controllers/api/v1/base_controller.rb | 11 + .../api/v1/opportunities_controller.rb | 16 + .../api/v1/organizations_controller.rb | 9 + app/controllers/api/v1/projects_controller.rb | 9 + app/controllers/api/v1/search_controller.rb | 9 + app/controllers/api/v1/tasks_controller.rb | 23 ++ app/controllers/api/v1/today_controller.rb | 9 + app/controllers/api_tokens_controller.rb | 26 ++ app/controllers/mcp_controller.rb | 20 ++ app/models/api_token.rb | 54 +++ app/models/user.rb | 1 + app/services/crm/agent_session.rb | 325 ++++++++++++++++++ app/views/admin/show.html.erb | 8 + app/views/api_tokens/index.html.erb | 53 +++ app/views/settings/show.html.erb | 1 + app/views/shared/_user_menu.html.erb | 1 + bin/crm-mcp | 6 + .../initializers/filter_parameter_logging.rb | 3 +- config/locales/it.yml | 1 + config/routes.rb | 18 + .../20260902220000_create_api_tokens.rb | 16 + db/schema.rb | 17 +- lib/eminux_crm_mcp.rb | 257 ++++++++++++++ mcp/claude.mcp.json.example | 11 + mcp/codex.config.toml.example | 6 + mcp/cursor.mcp.json.example | 10 + mcp/server.rb | 275 +++++++++++++++ test/controllers/api/v1/api_test.rb | 130 +++++++ .../controllers/api_tokens_controller_test.rb | 34 ++ test/controllers/authentication_test.rb | 1 + test/controllers/mcp_controller_test.rb | 126 +++++++ test/models/api_token_test.rb | 24 ++ 44 files changed, 1693 insertions(+), 4 deletions(-) create mode 100644 .cursor/mcp.json create mode 100644 .cursor/rules/crm-agenti.mdc create mode 100644 .mcp.json create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 app/controllers/api/base_controller.rb create mode 100644 app/controllers/api/v1/activities_controller.rb create mode 100644 app/controllers/api/v1/base_controller.rb create mode 100644 app/controllers/api/v1/opportunities_controller.rb create mode 100644 app/controllers/api/v1/organizations_controller.rb create mode 100644 app/controllers/api/v1/projects_controller.rb create mode 100644 app/controllers/api/v1/search_controller.rb create mode 100644 app/controllers/api/v1/tasks_controller.rb create mode 100644 app/controllers/api/v1/today_controller.rb create mode 100644 app/controllers/api_tokens_controller.rb create mode 100644 app/controllers/mcp_controller.rb create mode 100644 app/models/api_token.rb create mode 100644 app/services/crm/agent_session.rb create mode 100644 app/views/api_tokens/index.html.erb create mode 100755 bin/crm-mcp create mode 100644 db/migrate/20260902220000_create_api_tokens.rb create mode 100644 lib/eminux_crm_mcp.rb create mode 100644 mcp/claude.mcp.json.example create mode 100644 mcp/codex.config.toml.example create mode 100644 mcp/cursor.mcp.json.example create mode 100644 mcp/server.rb create mode 100644 test/controllers/api/v1/api_test.rb create mode 100644 test/controllers/api_tokens_controller_test.rb create mode 100644 test/controllers/mcp_controller_test.rb create mode 100644 test/models/api_token_test.rb diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..5540605 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "eminuxcrm": { + "url": "http://localhost:3001/mcp", + "headers": { + "Authorization": "Bearer ${env:CRM_API_TOKEN}" + } + } + } +} diff --git a/.cursor/rules/crm-agenti.mdc b/.cursor/rules/crm-agenti.mdc new file mode 100644 index 0000000..2b60d6c --- /dev/null +++ b/.cursor/rules/crm-agenti.mdc @@ -0,0 +1,10 @@ +--- +description: Come gli agenti usano eminuxCRM (MCP) per task, pipeline e follow-up +alwaysApply: true +--- + +Quando l'utente chiede di interagire con il CRM, usa i tool MCP **eminuxcrm** (non curl). Serve il CRM su localhost:3001 e `CRM_API_TOKEN`. + +Flusso: `list_projects` → `today` → `search` / `get_organization` → `create_activity` / `complete_task` / `create_task` / `update_opportunity_stage`. + +Non inviare mailing, non cancellare record, non gestire utenti. Annota in timeline quello che fai. diff --git a/.env.example b/.env.example index a712a73..3c4e3d5 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,7 @@ SMTP_DOMAIN= # Timezone TZ=Europe/Rome + +# Agenti (Cursor CLI / Claude Code / Codex). Crea il token in CRM → Token API. +# CRM_API_TOKEN= +# CRM_MCP_URL=http://localhost:3001/mcp diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..5d97383 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "eminuxcrm": { + "type": "http", + "url": "http://localhost:3001/mcp", + "headers": { + "Authorization": "Bearer ${CRM_API_TOKEN}" + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9885a10 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# eminuxCRM — istruzioni per agenti (Cursor CLI, Claude Code, Codex) + +Quando l'utente chiede di **interagire con il CRM**, lavorare su prospect, pipeline, task, follow-up o "cosa fare oggi", usa i tool MCP **eminuxcrm**. Non usare `curl` sull'API se i tool sono disponibili. + +## Come collegarti + +1. Il CRM deve essere avviato (`docker compose up`, porta **3001**). +2. Crea un token in UI: **Token API** (menu utente). +3. Esporta il token **prima** di lanciare l'agente: + +```bash +export CRM_API_TOKEN='crm_…' +``` + +Endpoint MCP: `http://localhost:3001/mcp` (Bearer). In produzione usa l’URL pubblico del CRM (`https:///mcp`). Config già nel repo: `.cursor/mcp.json` (Cursor / Cursor CLI) e `.mcp.json` (Claude Code). Per il CRM in LAN/produzione cambia `url` e tieni il token in `CRM_API_TOKEN`. + +## Flusso di lavoro + +1. `list_projects` — ottieni i `project_code` (matchlivetv, riskmeter, cardoo, …). +2. `today` con quel codice — task scaduti / oggi / in arrivo e opportunità ferme. +3. `search` o `get_organization` per il contesto. +4. Agisci con `create_activity`, `complete_task`, `create_task`, `update_opportunity_stage`. + +## Limiti + +- Non inviare mailing, non cancellare record, non gestire utenti o SMTP. +- Restai nello scope dei progetti abilitati per il token. +- Annota sempre in timeline (`create_activity`) quello che fai, così l'utente lo vede in scheda. + +Frase tipo: *«Interagisci col CRM su MatchLiveTV: dimmi cosa c'è da fare oggi e completa i follow-up ovvi.»* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..845b472 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# eminuxCRM — Claude Code + +Leggi [AGENTS.md](AGENTS.md). I tool MCP `eminuxcrm` sono in `.mcp.json` (`http://localhost:3001/mcp`). Serve `CRM_API_TOKEN` nell'ambiente e il CRM avviato. diff --git a/Gemfile b/Gemfile index 4e7cd3f..6140e5a 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,7 @@ gem "tailwindcss-rails" gem "bcrypt", "~> 3.1.7" gem "pagy", "~> 9.3" gem "csv" +gem "mcp" gem "tzinfo-data", platforms: %i[ windows jruby ] gem "bootsnap", require: false gem "image_processing", "~> 1.2" diff --git a/Gemfile.lock b/Gemfile.lock index f08256a..d785297 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,6 +113,7 @@ GEM ffi (1.17.4-x86_64-linux-gnu) globalid (1.4.0) activesupport (>= 6.1) + hana (1.3.7) i18n (1.15.2) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -129,6 +130,11 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.21.2) + json_schemer (2.5.0) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) language_server-protocol (3.17.0.6) lint_roller (1.1.0) logger (1.7.0) @@ -143,6 +149,8 @@ GEM net-smtp marcel (1.2.1) matrix (0.4.3) + mcp (1.4.0) + json_schemer (>= 2.4) mini_magick (5.3.3) logger mini_mime (1.1.5) @@ -273,6 +281,7 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) + simpleidn (0.3.0) stimulus-rails (1.3.4) railties (>= 6.0.0) tailwindcss-rails (4.6.0) @@ -318,6 +327,7 @@ DEPENDENCIES debug image_processing (~> 1.2) importmap-rails + mcp pagy (~> 9.3) pg (~> 1.1) propshaft diff --git a/README.md b/README.md index ad818e1..4a98df3 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,66 @@ Pipeline stages: Da contattare → Contattato → Ha risposto → Interessato 4. Metti un reverse proxy (Caddy/Nginx) con HTTPS davanti alla porta 3000 5. Esegui backup periodici con `bin/backup` +## API JSON per agenti + +I client HTML restano su cookie di sessione. Gli agenti (Cursor, Claude Code, Codex) usano un token Bearer. + +1. Nel CRM: **Token API** (menu utente, oppure Impostazioni piattaforma se sei admin) +2. Crea un token: il valore `crm_…` si vede **una sola volta** +3. Header: `Authorization: Bearer crm_…` + +Endpoint v1 (tutti tranne `GET /api/v1/projects` richiedono `:project_code`): + +| Metodo | Path | Ruolo | +|--------|------|--------| +| GET | `/api/v1/projects` | progetti accessibili | +| GET | `/api/v1/p/:project_code/today` | task scaduti / oggi / in arrivo + opportunità ferme | +| GET | `/api/v1/p/:project_code/search?q=` | org, contatti, opportunità | +| GET | `/api/v1/p/:project_code/organizations/:id` | scheda operativa | +| POST | `/api/v1/p/:project_code/tasks` | crea task | +| POST | `/api/v1/p/:project_code/tasks/:id/complete` | completa task | +| POST | `/api/v1/p/:project_code/activities` | annota timeline | +| PATCH | `/api/v1/p/:project_code/opportunities/:id/stage` | cambia stage pipeline | + +L’agente agisce come l’utente del token (stessi progetti, stesso `created_by`). Fuori da questa v1: mailing, utenti, delete, import CSV. + +Esempio: + +```bash +curl -sS -H "Authorization: Bearer crm_…" \ + http://localhost:3001/api/v1/p/matchlivetv/today +``` + +## MCP (Cursor CLI / Claude Code / Codex) + +Gli agenti parlano con il CRM su **HTTP**: `http://localhost:3001/mcp` (stesso Bearer del token API). Non serve Ruby sull’host. + +1. Avvia il CRM (`docker compose up`) +2. Crea un token in **Token API** +3. Esporta il token e lancia l’agente dalla root del repo: + +```bash +export CRM_API_TOKEN='crm_…' +# Cursor CLI: +agent "Interagisci col CRM su MatchLiveTV: cosa c'è da fare oggi?" +# Claude Code: +claude "Interagisci col CRM su MatchLiveTV: cosa c'è da fare oggi?" +``` + +Config già nel repo (il token sta solo in env, non nei file): + +- Cursor / Cursor CLI: [`.cursor/mcp.json`](.cursor/mcp.json) +- Claude Code: [`.mcp.json`](.mcp.json) +- Codex: copia [mcp/codex.config.toml.example](mcp/codex.config.toml.example) in `~/.codex/config.toml` + +Istruzioni per gli agenti: [AGENTS.md](AGENTS.md). + +Tool: `list_projects`, `today`, `search`, `get_organization`, `create_task`, `complete_task`, `create_activity`, `update_opportunity_stage`. + +Stdio (`mcp/server.rb` / `bin/crm-mcp`) resta come alternativa se un client non parla HTTP. + +Esempio in chat: *«Interagisci col CRM e fai alcune cose per me su MatchLiveTV.»* + ## Sviluppo locale senza Docker (opzionale) ```bash @@ -174,8 +234,7 @@ bin/dev # server + tailwind watch ## Volutamente fuori scope (fase successiva) - Integrazioni Gmail/SMTP avanzate, sync email, calendario -- Stripe, webhook, API REST pubblica -- Multi-tenant / multi-progetto +- Stripe, webhook - Dark mode, BI avanzata, microservizi ## Licenza diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index acf1851..78c4b6e 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -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 diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb new file mode 100644 index 0000000..b092d11 --- /dev/null +++ b/app/controllers/api/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/activities_controller.rb b/app/controllers/api/v1/activities_controller.rb new file mode 100644 index 0000000..a202df8 --- /dev/null +++ b/app/controllers/api/v1/activities_controller.rb @@ -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 diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb new file mode 100644 index 0000000..afe65a0 --- /dev/null +++ b/app/controllers/api/v1/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/opportunities_controller.rb b/app/controllers/api/v1/opportunities_controller.rb new file mode 100644 index 0000000..431c897 --- /dev/null +++ b/app/controllers/api/v1/opportunities_controller.rb @@ -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 diff --git a/app/controllers/api/v1/organizations_controller.rb b/app/controllers/api/v1/organizations_controller.rb new file mode 100644 index 0000000..571d0cf --- /dev/null +++ b/app/controllers/api/v1/organizations_controller.rb @@ -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 diff --git a/app/controllers/api/v1/projects_controller.rb b/app/controllers/api/v1/projects_controller.rb new file mode 100644 index 0000000..d8f518a --- /dev/null +++ b/app/controllers/api/v1/projects_controller.rb @@ -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 diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb new file mode 100644 index 0000000..f4963cb --- /dev/null +++ b/app/controllers/api/v1/search_controller.rb @@ -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 diff --git a/app/controllers/api/v1/tasks_controller.rb b/app/controllers/api/v1/tasks_controller.rb new file mode 100644 index 0000000..5ca151d --- /dev/null +++ b/app/controllers/api/v1/tasks_controller.rb @@ -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 diff --git a/app/controllers/api/v1/today_controller.rb b/app/controllers/api/v1/today_controller.rb new file mode 100644 index 0000000..c144034 --- /dev/null +++ b/app/controllers/api/v1/today_controller.rb @@ -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 diff --git a/app/controllers/api_tokens_controller.rb b/app/controllers/api_tokens_controller.rb new file mode 100644 index 0000000..22e68c8 --- /dev/null +++ b/app/controllers/api_tokens_controller.rb @@ -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 diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb new file mode 100644 index 0000000..31a1f59 --- /dev/null +++ b/app/controllers/mcp_controller.rb @@ -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 diff --git a/app/models/api_token.rb b/app/models/api_token.rb new file mode 100644 index 0000000..9bb8984 --- /dev/null +++ b/app/models/api_token.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 2bb5259..52e3d9f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/services/crm/agent_session.rb b/app/services/crm/agent_session.rb new file mode 100644 index 0000000..fbc5e12 --- /dev/null +++ b/app/services/crm/agent_session.rb @@ -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 diff --git a/app/views/admin/show.html.erb b/app/views/admin/show.html.erb index c97c9f9..5051ebd 100644 --- a/app/views/admin/show.html.erb +++ b/app/views/admin/show.html.erb @@ -28,5 +28,13 @@

<%= @mail_identities_count %> account

Configura SMTP →
<% 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 %> +
Agenti
+

Token API

+

Autenticazione Bearer per Cursor, Claude Code e Codex. Il valore in chiaro si vede una sola volta.

+

<%= @api_tokens_count %> attivi

+
Gestisci token →
+ <% end %> diff --git a/app/views/api_tokens/index.html.erb b/app/views/api_tokens/index.html.erb new file mode 100644 index 0000000..d119bb0 --- /dev/null +++ b/app/views/api_tokens/index.html.erb @@ -0,0 +1,53 @@ +
+
+

Token API

+

+ Servono agli agenti (Cursor, Claude Code, Codex) per lavorare sul CRM. Il valore in chiaro si vede una sola volta. +

+
+ + <% if @revealed_token.present? %> +
+

Nuovo token — copialo ora

+

Non verrà più mostrato. Incollalo in CRM_API_TOKEN nella config MCP.

+
<%= @revealed_token %>
+
+ <% end %> + +
+

Nuovo token

+ <%= form_with url: api_tokens_path, method: :post, class: "mt-4 flex flex-col gap-3 sm:flex-row sm:items-end" do %> +
+ + <%= text_field_tag :name, nil, placeholder: "es. Cursor sul portatile", required: true, class: input_class %> +
+ <%= submit_tag "Crea token", class: "#{btn_primary} sm:mb-0" %> + <% end %> +
+ +
+ <% if @api_tokens.empty? %> +

Nessun token attivo.

+ <% else %> +
    + <% @api_tokens.each do |token| %> +
  • +
    +
    <%= token.name %>
    +
    + <%= token.masked %> + · creato <%= format_dt(token.created_at) %> + <% if token.last_used_at %> + · usato <%= format_dt(token.last_used_at) %> + <% else %> + · mai usato + <% end %> +
    +
    + <%= button_to "Revoca", api_token_path(token), method: :delete, class: "#{btn_danger} text-sm", data: { turbo_confirm: "Revocare questo token?" } %> +
  • + <% end %> +
+ <% end %> +
+
diff --git a/app/views/settings/show.html.erb b/app/views/settings/show.html.erb index c640a13..76ca2ef 100644 --- a/app/views/settings/show.html.erb +++ b/app/views/settings/show.html.erb @@ -66,6 +66,7 @@ <% end %>
  • <%= link_to "Email e campagne", dashboard_mailings_path, class: "hover:underline" %>
  • <%= link_to "Import CSV organizzazioni", new_import_path, class: "hover:underline" %>
  • +
  • <%= link_to "Token API (agenti)", api_tokens_path, class: "hover:underline" %>
  • <%= link_to "Cambio password", edit_password_path, class: "hover:underline" %>
  • diff --git a/app/views/shared/_user_menu.html.erb b/app/views/shared/_user_menu.html.erb index dc025ec..f07b582 100644 --- a/app/views/shared/_user_menu.html.erb +++ b/app/views/shared/_user_menu.html.erb @@ -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" %> diff --git a/bin/crm-mcp b/bin/crm-mcp new file mode 100755 index 0000000..7bd687a --- /dev/null +++ b/bin/crm-mcp @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) +require "bundler/setup" +load File.expand_path("../mcp/server.rb", __dir__) diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index c0b717f..fee58f9 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -4,5 +4,6 @@ # Use this to limit dissemination of sensitive information. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += [ - :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, + :authorization, :plaintext ] diff --git a/config/locales/it.yml b/config/locales/it.yml index 70f2cdb..f7668ce 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -35,6 +35,7 @@ it: mail_template: Template email mailing: Invio email mailing_recipient: Destinatario + api_token: Token API attributes: organization: name: Nome diff --git a/config/routes.rb b/config/routes.rb index 1febf05..18e455b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,24 @@ Rails.application.routes.draw do resources :projects, only: %i[create edit update destroy] resources :users, except: %i[show] resources :mail_identities, except: %i[show] + resources :api_tokens, only: %i[index create destroy] + match "/mcp", to: "mcp#handle", via: %i[get post delete] + + namespace :api do + namespace :v1 do + get "projects", to: "projects#index" + + scope "/p/:project_code" do + get "today", to: "today#show" + get "search", to: "search#show" + get "organizations/:id", to: "organizations#show", as: :organization + post "tasks", to: "tasks#create" + post "tasks/:id/complete", to: "tasks#complete", as: :complete_task + post "activities", to: "activities#create" + patch "opportunities/:id/stage", to: "opportunities#update_stage", as: :opportunity_stage + end + end + end # Ogni progetto = istanza CRM dedicata scope "/p/:project_code" do diff --git a/db/migrate/20260902220000_create_api_tokens.rb b/db/migrate/20260902220000_create_api_tokens.rb new file mode 100644 index 0000000..19d13fe --- /dev/null +++ b/db/migrate/20260902220000_create_api_tokens.rb @@ -0,0 +1,16 @@ +class CreateApiTokens < ActiveRecord::Migration[8.1] + def change + create_table :api_tokens do |t| + t.references :user, null: false, foreign_key: true + t.string :name, null: false + t.string :token_digest, null: false + t.string :token_prefix, null: false + t.datetime :last_used_at + t.datetime :revoked_at + t.timestamps + end + + add_index :api_tokens, :token_digest, unique: true + add_index :api_tokens, :revoked_at + end +end diff --git a/db/schema.rb b/db/schema.rb index d500298..90b62d3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_24_190000) do +ActiveRecord::Schema[8.1].define(version: 2026_09_02_220000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -64,6 +64,20 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_24_190000) do t.index ["user_id"], name: "index_activities_on_user_id" end + create_table "api_tokens", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "last_used_at" + t.string "name", null: false + t.datetime "revoked_at" + t.string "token_digest", null: false + t.string "token_prefix", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["revoked_at"], name: "index_api_tokens_on_revoked_at" + t.index ["token_digest"], name: "index_api_tokens_on_token_digest", unique: true + t.index ["user_id"], name: "index_api_tokens_on_user_id" + end + create_table "contacts", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "created_by_id" @@ -384,6 +398,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_24_190000) do add_foreign_key "activities", "opportunities" add_foreign_key "activities", "organizations" add_foreign_key "activities", "users" + add_foreign_key "api_tokens", "users" add_foreign_key "contacts", "organizations" add_foreign_key "mail_templates", "projects" add_foreign_key "mailing_recipients", "contacts" diff --git a/lib/eminux_crm_mcp.rb b/lib/eminux_crm_mcp.rb new file mode 100644 index 0000000..f82c34f --- /dev/null +++ b/lib/eminux_crm_mcp.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true + +require "json" +require "mcp" + +module EminuxCrmMcp + INSTRUCTIONS = <<~TEXT.freeze + Sei collegato a eminuxCRM. Quando l'utente chiede di lavorare su prospect, pipeline, task o follow-up, usa questi tool (non curl). + 1. list_projects per i project_code (matchlivetv, riskmeter, cardoo, …). + 2. today per capire cosa fare oggi. + 3. search / get_organization per il contesto. + 4. create_activity, complete_task, create_task, update_opportunity_stage per agire. + Non inviare mailing, non cancellare record, non gestire utenti. + TEXT + + module_function + + def allowed_hosts + hosts = %w[localhost 127.0.0.1 [::1] web] + hosts.concat(configured_public_hosts) + hosts << "www.example.com" if defined?(Rails) && Rails.env.test? + hosts.map { |host| normalize_host(host) }.compact.uniq + end + + def configured_public_hosts + values = [ ENV["APP_HOST"] ] + values.concat(ENV.fetch("RAILS_ALLOWED_HOSTS", "").split(",")) + values + end + + def normalize_host(value) + host = value.to_s.strip + return if host.blank? + + host = host.sub(%r{\Ahttps?://}i, "") + host.split("/").first + end + + def server_for(user:) + MCP::Server.new( + name: "eminuxcrm", + version: "1.0.0", + instructions: INSTRUCTIONS, + server_context: { user: user }, + tools: [ + ListProjects, + Today, + Search, + GetOrganization, + CreateTask, + CompleteTask, + CreateActivity, + UpdateOpportunityStage + ] + ) + end + + def json_response(result) + MCP::Tool::Response.new( + [ { type: "text", text: JSON.pretty_generate(result.body.as_json) } ], + error: !result.ok + ) + end + + def session_from(server_context) + user = extract_user(server_context) + raise "MCP senza utente autenticato" if user.nil? + + Crm::AgentSession.new(user) + end + + def extract_user(ctx) + return if ctx.nil? + return ctx[:user] if ctx.is_a?(Hash) + return ctx.user if ctx.respond_to?(:user) && ctx.method(:user).arity.zero? + + inner = ctx.instance_variable_get(:@context) if ctx.respond_to?(:instance_variable_get) + return inner[:user] if inner.is_a?(Hash) + + ctx[:user] if ctx.respond_to?(:[]) + end + + class ListProjects < MCP::Tool + description "Elenca i progetti CRM accessibili all'utente del token (codici da usare negli altri tool)." + + class << self + def call(server_context: nil) + EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).projects) + end + end + end + + class Today < MCP::Tool + description "Cosa fare oggi: task scaduti, di oggi, in arrivo e opportunità ferme da 7+ giorni." + input_schema( + properties: { + project_code: { type: "string", description: "Codice progetto, es. matchlivetv" } + }, + required: %w[project_code] + ) + + class << self + def call(project_code:, server_context: nil) + EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).today(project_code)) + end + end + end + + class Search < MCP::Tool + description "Cerca organizzazioni, contatti e opportunità nel progetto." + input_schema( + properties: { + project_code: { type: "string" }, + q: { type: "string", description: "Testo di ricerca" } + }, + required: %w[project_code q] + ) + + class << self + def call(project_code:, q:, server_context: nil) + EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).search(project_code, q)) + end + end + end + + class GetOrganization < MCP::Tool + description "Scheda organizzazione: anagrafica, contatti, opportunità aperte, task pending e ultime attività." + input_schema( + properties: { + project_code: { type: "string" }, + id: { type: "integer", description: "ID organizzazione" } + }, + required: %w[project_code id] + ) + + class << self + def call(project_code:, id:, server_context: nil) + EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).organization(project_code, id)) + end + end + end + + class CreateTask < MCP::Tool + description "Crea un task (follow-up, chiamata, email, …) su un'organizzazione del progetto." + input_schema( + properties: { + project_code: { type: "string" }, + organization_id: { type: "integer" }, + title: { type: "string" }, + due_at: { type: "string", description: "ISO8601, es. 2026-09-03T10:00:00+02:00" }, + description: { type: "string" }, + contact_id: { type: "integer" }, + opportunity_id: { type: "integer" }, + assigned_user_id: { type: "integer" }, + priority: { type: "string", description: "low, normal, high, urgent" }, + task_type: { type: "string", description: "follow_up, call, email, meeting, demo, proposal, generic" } + }, + required: %w[project_code organization_id title due_at] + ) + + class << self + def call(project_code:, organization_id:, title:, due_at:, description: nil, contact_id: nil, + opportunity_id: nil, assigned_user_id: nil, priority: nil, task_type: nil, server_context: nil) + attrs = { + organization_id: organization_id, + title: title, + due_at: due_at, + description: description, + contact_id: contact_id, + opportunity_id: opportunity_id, + assigned_user_id: assigned_user_id, + priority: priority, + task_type: task_type + }.compact + EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).create_task(project_code, attrs)) + end + end + end + + class CompleteTask < MCP::Tool + description "Segna un task come completato e registra l'attività in timeline." + input_schema( + properties: { + project_code: { type: "string" }, + id: { type: "integer", description: "ID task" } + }, + required: %w[project_code id] + ) + + class << self + def call(project_code:, id:, server_context: nil) + EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).complete_task(project_code, id)) + end + end + end + + class CreateActivity < MCP::Tool + description "Annota un'attività in timeline (nota, chiamata, email inviata/ricevuta, demo, proposta, …)." + input_schema( + properties: { + project_code: { type: "string" }, + organization_id: { type: "integer" }, + activity_type: { type: "string", description: "note, email_sent, email_received, call, meeting, demo, follow_up, proposal_sent, other" }, + subject: { type: "string" }, + description: { type: "string" }, + happened_at: { type: "string", description: "ISO8601; default ora" }, + contact_id: { type: "integer" }, + opportunity_id: { type: "integer" } + }, + required: %w[project_code organization_id activity_type subject] + ) + + class << self + def call(project_code:, organization_id:, activity_type:, subject:, description: nil, happened_at: nil, + contact_id: nil, opportunity_id: nil, server_context: nil) + attrs = { + organization_id: organization_id, + activity_type: activity_type, + subject: subject, + description: description, + happened_at: happened_at, + contact_id: contact_id, + opportunity_id: opportunity_id + }.compact + EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).create_activity(project_code, attrs)) + end + end + end + + class UpdateOpportunityStage < MCP::Tool + description "Sposta un'opportunità di pipeline (to_contact, contacted, replied, interested, demo_trial, first_use, proposal, won, lost)." + input_schema( + properties: { + project_code: { type: "string" }, + id: { type: "integer", description: "ID opportunità" }, + pipeline_stage: { type: "string" }, + lost_reason: { type: "string", description: "Obbligatorio se stage = lost" }, + notes: { type: "string" } + }, + required: %w[project_code id pipeline_stage] + ) + + class << self + def call(project_code:, id:, pipeline_stage:, lost_reason: nil, notes: nil, server_context: nil) + EminuxCrmMcp.json_response( + EminuxCrmMcp.session_from(server_context).update_opportunity_stage( + project_code, + id, + pipeline_stage: pipeline_stage, + lost_reason: lost_reason, + notes: notes + ) + ) + end + end + end +end diff --git a/mcp/claude.mcp.json.example b/mcp/claude.mcp.json.example new file mode 100644 index 0000000..5d97383 --- /dev/null +++ b/mcp/claude.mcp.json.example @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "eminuxcrm": { + "type": "http", + "url": "http://localhost:3001/mcp", + "headers": { + "Authorization": "Bearer ${CRM_API_TOKEN}" + } + } + } +} diff --git a/mcp/codex.config.toml.example b/mcp/codex.config.toml.example new file mode 100644 index 0000000..f95b366 --- /dev/null +++ b/mcp/codex.config.toml.example @@ -0,0 +1,6 @@ +# Codex legge ~/.codex/config.toml (o CODEX_HOME). +# Crea un token in CRM → Token API, poi: + +[mcp_servers.eminuxcrm] +url = "http://localhost:3001/mcp" +bearer_token_env_var = "CRM_API_TOKEN" diff --git a/mcp/cursor.mcp.json.example b/mcp/cursor.mcp.json.example new file mode 100644 index 0000000..5540605 --- /dev/null +++ b/mcp/cursor.mcp.json.example @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "eminuxcrm": { + "url": "http://localhost:3001/mcp", + "headers": { + "Authorization": "Bearer ${env:CRM_API_TOKEN}" + } + } + } +} diff --git a/mcp/server.rb b/mcp/server.rb new file mode 100644 index 0000000..c636544 --- /dev/null +++ b/mcp/server.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "uri" +require "mcp" + +module EminuxCrmMcp + module Client + module_function + + def crm_url + ENV.fetch("CRM_URL", "http://localhost:3001").to_s.sub(%r{/+\z}, "") + end + + def api_token + ENV.fetch("CRM_API_TOKEN", "") + end + + def request(method, path, body: nil) + if api_token.strip.empty? + return error_payload("Imposta CRM_API_TOKEN (token creato in CRM → Token API).") + end + + uri = URI.parse("#{crm_url}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = 10 + http.read_timeout = 30 + + request = http_class(method).new(uri) + request["Authorization"] = "Bearer #{api_token}" + request["Accept"] = "application/json" + if body + request["Content-Type"] = "application/json" + request.body = JSON.generate(body) + end + + response = http.request(request) + payload = parse_json(response.body) + text = JSON.pretty_generate(payload) + MCP::Tool::Response.new([ { type: "text", text: text } ], error: response.code.to_i >= 400) + rescue StandardError => e + error_payload("#{e.class}: #{e.message}") + end + + def http_class(method) + { + get: Net::HTTP::Get, + post: Net::HTTP::Post, + patch: Net::HTTP::Patch + }.fetch(method) + end + + def parse_json(raw) + JSON.parse(raw.to_s) + rescue JSON::ParserError + { error: "Risposta non JSON", body: raw.to_s[0, 2000] } + end + + def error_payload(message) + MCP::Tool::Response.new([ { type: "text", text: message } ], error: true) + end + + def compact(hash) + hash.each_with_object({}) do |(key, value), acc| + acc[key] = value unless value.nil? || value == "" + end + end + end + + class ListProjects < MCP::Tool + description "Elenca i progetti CRM accessibili all'utente del token." + + class << self + def call(server_context: nil) + Client.request(:get, "/api/v1/projects") + end + end + end + + class Today < MCP::Tool + description "Cosa fare oggi nel progetto: task scaduti, di oggi, in arrivo e opportunità ferme da 7+ giorni." + input_schema( + properties: { + project_code: { type: "string", description: "Codice progetto, es. matchlivetv" } + }, + required: %w[project_code] + ) + + class << self + def call(project_code:, server_context: nil) + Client.request(:get, "/api/v1/p/#{encode(project_code)}/today") + end + + def encode(value) + URI.encode_www_form_component(value.to_s) + end + end + end + + class Search < MCP::Tool + description "Cerca organizzazioni, contatti e opportunità nel progetto." + input_schema( + properties: { + project_code: { type: "string", description: "Codice progetto, es. matchlivetv" }, + q: { type: "string", description: "Testo di ricerca" } + }, + required: %w[project_code q] + ) + + class << self + def call(project_code:, q:, server_context: nil) + query = URI.encode_www_form(q: q) + Client.request(:get, "/api/v1/p/#{Today.encode(project_code)}/search?#{query}") + end + end + end + + class GetOrganization < MCP::Tool + description "Scheda organizzazione: anagrafica, contatti, opportunità aperte, task pending e ultime attività." + input_schema( + properties: { + project_code: { type: "string" }, + id: { type: "integer", description: "ID organizzazione" } + }, + required: %w[project_code id] + ) + + class << self + def call(project_code:, id:, server_context: nil) + Client.request(:get, "/api/v1/p/#{Today.encode(project_code)}/organizations/#{id}") + end + end + end + + class CreateTask < MCP::Tool + description "Crea un task (follow-up, chiamata, email, …) su un'organizzazione del progetto." + input_schema( + properties: { + project_code: { type: "string" }, + organization_id: { type: "integer" }, + title: { type: "string" }, + due_at: { type: "string", description: "ISO8601, es. 2026-09-03T10:00:00+02:00" }, + description: { type: "string" }, + contact_id: { type: "integer" }, + opportunity_id: { type: "integer" }, + assigned_user_id: { type: "integer" }, + priority: { type: "string", description: "low, normal, high, urgent" }, + task_type: { type: "string", description: "follow_up, call, email, meeting, demo, proposal, generic" } + }, + required: %w[project_code organization_id title due_at] + ) + + class << self + def call(project_code:, organization_id:, title:, due_at:, description: nil, contact_id: nil, + opportunity_id: nil, assigned_user_id: nil, priority: nil, task_type: nil, server_context: nil) + Client.request( + :post, + "/api/v1/p/#{Today.encode(project_code)}/tasks", + body: Client.compact( + organization_id: organization_id, + title: title, + due_at: due_at, + description: description, + contact_id: contact_id, + opportunity_id: opportunity_id, + assigned_user_id: assigned_user_id, + priority: priority, + task_type: task_type + ) + ) + end + end + end + + class CompleteTask < MCP::Tool + description "Segna un task come completato e registra l'attività in timeline." + input_schema( + properties: { + project_code: { type: "string" }, + id: { type: "integer", description: "ID task" } + }, + required: %w[project_code id] + ) + + class << self + def call(project_code:, id:, server_context: nil) + Client.request(:post, "/api/v1/p/#{Today.encode(project_code)}/tasks/#{id}/complete") + end + end + end + + class CreateActivity < MCP::Tool + description "Annota un'attività in timeline (nota, chiamata, email inviata/ricevuta, demo, proposta, …)." + input_schema( + properties: { + project_code: { type: "string" }, + organization_id: { type: "integer" }, + activity_type: { type: "string", description: "note, email_sent, email_received, call, meeting, demo, follow_up, proposal_sent, other, …" }, + subject: { type: "string" }, + description: { type: "string" }, + happened_at: { type: "string", description: "ISO8601; default ora" }, + contact_id: { type: "integer" }, + opportunity_id: { type: "integer" } + }, + required: %w[project_code organization_id activity_type subject] + ) + + class << self + def call(project_code:, organization_id:, activity_type:, subject:, description: nil, happened_at: nil, + contact_id: nil, opportunity_id: nil, server_context: nil) + Client.request( + :post, + "/api/v1/p/#{Today.encode(project_code)}/activities", + body: Client.compact( + organization_id: organization_id, + activity_type: activity_type, + subject: subject, + description: description, + happened_at: happened_at, + contact_id: contact_id, + opportunity_id: opportunity_id + ) + ) + end + end + end + + class UpdateOpportunityStage < MCP::Tool + description "Sposta un'opportunità di pipeline (to_contact, contacted, replied, interested, demo_trial, first_use, proposal, won, lost)." + input_schema( + properties: { + project_code: { type: "string" }, + id: { type: "integer", description: "ID opportunità" }, + pipeline_stage: { type: "string" }, + lost_reason: { type: "string", description: "Obbligatorio se stage = lost" }, + notes: { type: "string" } + }, + required: %w[project_code id pipeline_stage] + ) + + class << self + def call(project_code:, id:, pipeline_stage:, lost_reason: nil, notes: nil, server_context: nil) + Client.request( + :patch, + "/api/v1/p/#{Today.encode(project_code)}/opportunities/#{id}/stage", + body: Client.compact( + pipeline_stage: pipeline_stage, + lost_reason: lost_reason, + notes: notes + ) + ) + end + end + end +end + +server = MCP::Server.new( + name: "eminuxcrm", + version: "1.0.0", + tools: [ + EminuxCrmMcp::ListProjects, + EminuxCrmMcp::Today, + EminuxCrmMcp::Search, + EminuxCrmMcp::GetOrganization, + EminuxCrmMcp::CreateTask, + EminuxCrmMcp::CompleteTask, + EminuxCrmMcp::CreateActivity, + EminuxCrmMcp::UpdateOpportunityStage + ] +) + +transport = MCP::Server::Transports::StdioTransport.new(server) +transport.open if $PROGRAM_NAME == __FILE__ || File.basename($PROGRAM_NAME.to_s) == "crm-mcp" diff --git a/test/controllers/api/v1/api_test.rb b/test/controllers/api/v1/api_test.rb new file mode 100644 index 0000000..7cc2cf1 --- /dev/null +++ b/test/controllers/api/v1/api_test.rb @@ -0,0 +1,130 @@ +require "test_helper" + +class Api::V1::ApiTest < ActionDispatch::IntegrationTest + setup do + @admin_token = ApiToken.issue!(user: users(:admin), name: "test-admin") + @marco_token = ApiToken.issue!(user: users(:marco), name: "test-marco") + @org = organizations(:acme) + @task = tasks(:follow_up) + @opportunity = opportunities(:deal) + end + + test "rejects missing token" do + get "/api/v1/projects", as: :json + assert_response :unauthorized + assert_equal "Non autenticato", json_body["error"] + end + + test "rejects invalid token" do + get "/api/v1/projects", headers: bearer("crm_invalid"), as: :json + assert_response :unauthorized + end + + test "lists accessible projects" do + get "/api/v1/projects", headers: bearer(@marco_token.plaintext), as: :json + assert_response :success + codes = json_body["projects"].map { |p| p["code"] } + assert_includes codes, "matchlivetv" + assert_not_includes codes, "riskmeter" + end + + test "forbids project the user cannot access" do + get "/api/v1/p/riskmeter/today", headers: bearer(@marco_token.plaintext), as: :json + assert_response :forbidden + end + + test "returns 404 for unknown project" do + get "/api/v1/p/sconosciuto/today", headers: bearer(@admin_token.plaintext), as: :json + assert_response :not_found + end + + test "today includes due tasks" do + get "/api/v1/p/matchlivetv/today", headers: bearer(@admin_token.plaintext), as: :json + assert_response :success + ids = json_body["today_tasks"].map { |t| t["id"] } + assert_includes ids, @task.id + assert_equal "matchlivetv", json_body["project"]["code"] + end + + test "search finds organization" do + get "/api/v1/p/matchlivetv/search", params: { q: "ASD Test" }, headers: bearer(@admin_token.plaintext), as: :json + assert_response :success + org_ids = json_body["organizations"].map { |o| o["id"] } + assert_includes org_ids, @org.id + end + + test "shows organization card" do + get "/api/v1/p/matchlivetv/organizations/#{@org.id}", headers: bearer(@admin_token.plaintext), as: :json + assert_response :success + assert_equal @org.name, json_body["organization"]["name"] + assert json_body["contacts"].any? + assert json_body["open_opportunities"].any? + assert json_body["pending_tasks"].any? + end + + test "creates activity" do + assert_difference -> { Activity.count }, 1 do + post "/api/v1/p/matchlivetv/activities", + params: { + organization_id: @org.id, + activity_type: "note", + subject: "Nota agente", + description: "Creato via API" + }, + headers: bearer(@admin_token.plaintext), + as: :json + end + assert_response :created + assert_equal "Nota agente", json_body["activity"]["subject"] + end + + test "creates and completes task" do + post "/api/v1/p/matchlivetv/tasks", + params: { + organization_id: @org.id, + title: "Richiamare dopo demo", + due_at: 1.day.from_now.iso8601, + task_type: "call", + priority: "high" + }, + headers: bearer(@admin_token.plaintext), + as: :json + assert_response :created + task_id = json_body["task"]["id"] + + post "/api/v1/p/matchlivetv/tasks/#{task_id}/complete", + headers: bearer(@admin_token.plaintext), + as: :json + assert_response :success + assert_equal "completed", json_body["task"]["status"] + end + + test "updates opportunity stage" do + patch "/api/v1/p/matchlivetv/opportunities/#{@opportunity.id}/stage", + params: { pipeline_stage: "demo_trial", notes: "Demo fissata dall'agente" }, + headers: bearer(@admin_token.plaintext), + as: :json + assert_response :success + assert_equal "demo_trial", json_body["opportunity"]["pipeline_stage"] + @opportunity.reload + assert_equal "demo_trial", @opportunity.pipeline_stage + end + + test "rejects lost stage without reason" do + patch "/api/v1/p/matchlivetv/opportunities/#{@opportunity.id}/stage", + params: { pipeline_stage: "lost" }, + headers: bearer(@admin_token.plaintext), + as: :json + assert_response :unprocessable_entity + end + + private + + def bearer(plaintext) + { "Authorization" => "Bearer #{plaintext}" } + end + + def json_body + JSON.parse(response.body) + end +end diff --git a/test/controllers/api_tokens_controller_test.rb b/test/controllers/api_tokens_controller_test.rb new file mode 100644 index 0000000..0c12bfe --- /dev/null +++ b/test/controllers/api_tokens_controller_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class ApiTokensControllerTest < ActionDispatch::IntegrationTest + test "requires login" do + get api_tokens_path + assert_redirected_to login_path + end + + test "user can create and revoke own token" do + login_as users(:marco) + follow_redirect! if response.redirect? + + assert_difference -> { users(:marco).api_tokens.active.count }, 1 do + post api_tokens_path, params: { name: "Codex" } + end + assert_redirected_to api_tokens_path + follow_redirect! + assert_response :success + assert_match(/crm_/, response.body) + + token = users(:marco).api_tokens.active.last + delete api_token_path(token) + assert_redirected_to api_tokens_path + assert token.reload.revoked? + end + + test "non admin can open token page" do + login_as users(:marco) + follow_redirect! if response.redirect? + get api_tokens_path + assert_response :success + assert_match(/Token API/, response.body) + end +end diff --git a/test/controllers/authentication_test.rb b/test/controllers/authentication_test.rb index 433c79f..0832238 100644 --- a/test/controllers/authentication_test.rb +++ b/test/controllers/authentication_test.rb @@ -87,6 +87,7 @@ class AuthenticationTest < ActionDispatch::IntegrationTest assert_response :success assert_match(/Impostazioni piattaforma/, response.body) assert_match(/Utenti/, response.body) + assert_match(/Token API/, response.body) end test "non admin cannot open platform settings" do diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb new file mode 100644 index 0000000..16f7731 --- /dev/null +++ b/test/controllers/mcp_controller_test.rb @@ -0,0 +1,126 @@ +require "test_helper" + +class McpControllerTest < ActionDispatch::IntegrationTest + setup do + @token = ApiToken.issue!(user: users(:admin), name: "mcp-test") + @marco = ApiToken.issue!(user: users(:marco), name: "mcp-marco") + end + + test "rejects missing token" do + post "/mcp", params: rpc("initialize"), as: :json, headers: mcp_headers(nil) + assert_response :unauthorized + end + + test "initialize advertises CRM tools" do + post "/mcp", + params: rpc("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test", version: "1.0" } + }), + as: :json, + headers: mcp_headers(@token.plaintext) + + assert_response :success + names = Array(json_rpc.dig("result", "capabilities", "tools")).presence + listed = tool_names_from_initialize.presence || fetch_tool_names + assert_includes listed, "list_projects" + assert_includes listed, "today" + assert_includes listed, "create_activity" + assert names || listed.any? + end + + test "list_projects returns accessible projects" do + result = call_tool("list_projects", {}, token: @marco.plaintext) + codes = result.fetch("projects").map { |p| p["code"] } + assert_includes codes, "matchlivetv" + assert_not_includes codes, "riskmeter" + end + + test "today requires project access" do + result = call_tool("today", { project_code: "riskmeter" }, token: @marco.plaintext, expect_error: true) + assert_equal "Progetto non accessibile", result["error"] + end + + test "today returns tasks for matchlivetv" do + result = call_tool("today", { project_code: "matchlivetv" }) + ids = result.fetch("today_tasks").map { |t| t["id"] } + assert_includes ids, tasks(:follow_up).id + end + + test "allowed_hosts includes production APP_HOST and RAILS_ALLOWED_HOSTS" do + previous_app = ENV["APP_HOST"] + previous_allowed = ENV["RAILS_ALLOWED_HOSTS"] + ENV["APP_HOST"] = "crm.eminux.it" + ENV["RAILS_ALLOWED_HOSTS"] = "crm.eminux.it, 192.168.1.158, localhost" + + hosts = EminuxCrmMcp.allowed_hosts + assert_includes hosts, "crm.eminux.it" + assert_includes hosts, "192.168.1.158" + assert_includes hosts, "localhost" + ensure + ENV["APP_HOST"] = previous_app + ENV["RAILS_ALLOWED_HOSTS"] = previous_allowed + end + + private + + def mcp_headers(plaintext) + headers = { + "Accept" => "application/json, text/event-stream", + "MCP-Protocol-Version" => "2025-06-18" + } + headers["Authorization"] = "Bearer #{plaintext}" if plaintext.present? + headers + end + + def rpc(method, params = {}, id: 1) + { jsonrpc: "2.0", id: id, method: method, params: params } + end + + def json_rpc + JSON.parse(response.body) + end + + def initialize_mcp!(token) + post "/mcp", + params: rpc("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test", version: "1.0" } + }), + as: :json, + headers: mcp_headers(token) + assert_response :success, response.body + end + + def fetch_tool_names + initialize_mcp!(@token.plaintext) + post "/mcp", params: rpc("tools/list", {}, id: 2), as: :json, headers: mcp_headers(@token.plaintext) + assert_response :success, response.body + Array(json_rpc.dig("result", "tools")).map { |t| t["name"] } + end + + def tool_names_from_initialize + Array(json_rpc.dig("result", "tools")).map { |t| t["name"] } + end + + def call_tool(name, arguments, token: @token.plaintext, expect_error: false) + initialize_mcp!(token) + post "/mcp", + params: rpc("tools/call", { name: name, arguments: arguments }, id: 2), + as: :json, + headers: mcp_headers(token) + assert_response :success, response.body + payload = json_rpc + text = payload.dig("result", "content", 0, "text") || payload.dig("result", "content", 0, :text) + refute_nil text, payload.inspect + parsed = JSON.parse(text) + if expect_error + assert payload.dig("result", "isError") || parsed["error"].present?, payload.inspect + else + assert_nil parsed["error"], payload.inspect + end + parsed + end +end diff --git a/test/models/api_token_test.rb b/test/models/api_token_test.rb new file mode 100644 index 0000000..62b3463 --- /dev/null +++ b/test/models/api_token_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +class ApiTokenTest < ActiveSupport::TestCase + test "issue returns plaintext once and authenticates" do + token = ApiToken.issue!(user: users(:marco), name: "Cursor") + assert token.plaintext.start_with?("crm_") + assert_equal token.plaintext[0, 8], token.token_prefix + + found = ApiToken.authenticate(token.plaintext) + assert_equal token.id, found.id + end + + test "revoked or inactive user does not authenticate" do + token = ApiToken.issue!(user: users(:marco), name: "tmp") + raw = token.plaintext + token.revoke! + assert_nil ApiToken.authenticate(raw) + end + + test "blank token does not authenticate" do + assert_nil ApiToken.authenticate(nil) + assert_nil ApiToken.authenticate("") + end +end