diff --git a/.sqlx/query-4476636012dbc3320496e7d0f1bc7ab98a9e430127baa928fdf87ff8ef85d3c7.json b/.sqlx/query-4476636012dbc3320496e7d0f1bc7ab98a9e430127baa928fdf87ff8ef85d3c7.json new file mode 100644 index 00000000..3efaec53 --- /dev/null +++ b/.sqlx/query-4476636012dbc3320496e7d0f1bc7ab98a9e430127baa928fdf87ff8ef85d3c7.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO chat_conversations (user_id, project_id, messages)\n VALUES ($1, NULL, $2)\n ON CONFLICT (user_id) WHERE project_id IS NULL\n DO UPDATE SET messages = EXCLUDED.messages, updated_at = NOW()\n RETURNING id, user_id, project_id, messages, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "messages", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false + ] + }, + "hash": "4476636012dbc3320496e7d0f1bc7ab98a9e430127baa928fdf87ff8ef85d3c7" +} diff --git a/.sqlx/query-5aaa47a88d81ba70ae7da455ac5c98fc77ae7c6422dc8eea31fd89863c83ffd3.json b/.sqlx/query-5aaa47a88d81ba70ae7da455ac5c98fc77ae7c6422dc8eea31fd89863c83ffd3.json new file mode 100644 index 00000000..ceb32634 --- /dev/null +++ b/.sqlx/query-5aaa47a88d81ba70ae7da455ac5c98fc77ae7c6422dc8eea31fd89863c83ffd3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM chat_conversations WHERE user_id = $1 AND project_id IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "5aaa47a88d81ba70ae7da455ac5c98fc77ae7c6422dc8eea31fd89863c83ffd3" +} diff --git a/.sqlx/query-7e50789875fbdf752993b3fabe6031811acea54e4d47bbad2950494e626b807c.json b/.sqlx/query-7e50789875fbdf752993b3fabe6031811acea54e4d47bbad2950494e626b807c.json new file mode 100644 index 00000000..f0fad73d --- /dev/null +++ b/.sqlx/query-7e50789875fbdf752993b3fabe6031811acea54e4d47bbad2950494e626b807c.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, user_id, project_id, messages, created_at, updated_at\n FROM chat_conversations\n WHERE user_id = $1 AND project_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "messages", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false + ] + }, + "hash": "7e50789875fbdf752993b3fabe6031811acea54e4d47bbad2950494e626b807c" +} diff --git a/.sqlx/query-a0aeb1857bed3967f43b4d88d939af74e5fcec7b951cf1f28eef3d4ba9459717.json b/.sqlx/query-a0aeb1857bed3967f43b4d88d939af74e5fcec7b951cf1f28eef3d4ba9459717.json new file mode 100644 index 00000000..8f185554 --- /dev/null +++ b/.sqlx/query-a0aeb1857bed3967f43b4d88d939af74e5fcec7b951cf1f28eef3d4ba9459717.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM chat_conversations WHERE user_id = $1 AND project_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "a0aeb1857bed3967f43b4d88d939af74e5fcec7b951cf1f28eef3d4ba9459717" +} diff --git a/.sqlx/query-b33f8552276056b53e184e7e51c6cbee7b72e99977fd028a28d3e2f45be14225.json b/.sqlx/query-b33f8552276056b53e184e7e51c6cbee7b72e99977fd028a28d3e2f45be14225.json new file mode 100644 index 00000000..b07273bf --- /dev/null +++ b/.sqlx/query-b33f8552276056b53e184e7e51c6cbee7b72e99977fd028a28d3e2f45be14225.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, user_id, project_id, messages, created_at, updated_at\n FROM chat_conversations\n WHERE user_id = $1 AND project_id IS NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "messages", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false + ] + }, + "hash": "b33f8552276056b53e184e7e51c6cbee7b72e99977fd028a28d3e2f45be14225" +} diff --git a/.sqlx/query-b8e47b0c9f1eeb571001c340c3f6593303ef635f9a93707996f86ae4a5db1e99.json b/.sqlx/query-b8e47b0c9f1eeb571001c340c3f6593303ef635f9a93707996f86ae4a5db1e99.json new file mode 100644 index 00000000..339ec891 --- /dev/null +++ b/.sqlx/query-b8e47b0c9f1eeb571001c340c3f6593303ef635f9a93707996f86ae4a5db1e99.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO chat_conversations (user_id, project_id, messages)\n VALUES ($1, $2, $3)\n ON CONFLICT (user_id, project_id) WHERE project_id IS NOT NULL\n DO UPDATE SET messages = EXCLUDED.messages, updated_at = NOW()\n RETURNING id, user_id, project_id, messages, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "messages", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Int4", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false + ] + }, + "hash": "b8e47b0c9f1eeb571001c340c3f6593303ef635f9a93707996f86ae4a5db1e99" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index acb914a0..d316282e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## 2026-02-18 + +### Fixed +- **Container Discovery 403**: Fixed Casbin authorization rules for `/project/:id/containers/discover` (GET) and `/project/:id/containers/import` (POST) + - Migration `20260204120000_casbin_container_discovery_rules` had wrong path prefix `/api/v1/project/...` instead of `/project/...` + - The middleware was rejecting the request with a 403 before CORS headers could be attached, causing the browser to report a misleading "CORS header missing" error + - New migration `20260218100000_fix_casbin_container_discovery_paths` removes the wrong rules and inserts the correct paths + ## 2026-02-03 ### Fixed diff --git a/TODO.md b/TODO.md index b78a0f77..a00b6d5f 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,13 @@ --- +## ✅ Recent Fixes + +### February 16, 2026 - CORS Headers Fix +- [x] Fixed CORS configuration to properly support Authorization header with credentials +- [x] Changed from whitelist (`allowed_headers(vec![...])`) to `.allow_any_header()` + `.expose_any_header()` +- [x] Resolves browser console warning about Authorization header not being covered + ## 🚨 CRITICAL BUGS - ENV VARS NOT SAVED TO project_app > **Date Identified**: 2026-02-02 diff --git a/migrations/20260217120000_casbin_server_ssh_validate.down.sql b/migrations/20260217120000_casbin_server_ssh_validate.down.sql new file mode 100644 index 00000000..a265c698 --- /dev/null +++ b/migrations/20260217120000_casbin_server_ssh_validate.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/server/:id/ssh-key/validate' + AND v2 = 'POST'; diff --git a/migrations/20260217120000_casbin_server_ssh_validate.up.sql b/migrations/20260217120000_casbin_server_ssh_validate.up.sql new file mode 100644 index 00000000..0fc5fa1a --- /dev/null +++ b/migrations/20260217120000_casbin_server_ssh_validate.up.sql @@ -0,0 +1,6 @@ +-- Add missing Casbin rule for SSH key validate endpoint +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/server/:id/ssh-key/validate', 'POST', '', '', ''), + ('p', 'root', '/server/:id/ssh-key/validate', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/migrations/20260218100000_fix_casbin_container_discovery_paths.down.sql b/migrations/20260218100000_fix_casbin_container_discovery_paths.down.sql new file mode 100644 index 00000000..6c1fcfe5 --- /dev/null +++ b/migrations/20260218100000_fix_casbin_container_discovery_paths.down.sql @@ -0,0 +1,18 @@ +-- Revert fix: remove correct-path rules and restore the original (wrong) ones +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 IN ( + '/project/:id/containers/discover', + '/project/:id/containers/import' + ); + +-- Re-insert the original (incorrect) rules so rolling back is clean +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/api/v1/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'group_admin', '/api/v1/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'root', '/api/v1/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'group_user', '/api/v1/project/:id/containers/import', 'POST', '', '', ''), + ('p', 'group_admin', '/api/v1/project/:id/containers/import', 'POST', '', '', ''), + ('p', 'root', '/api/v1/project/:id/containers/import', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/migrations/20260218100000_fix_casbin_container_discovery_paths.up.sql b/migrations/20260218100000_fix_casbin_container_discovery_paths.up.sql new file mode 100644 index 00000000..3e39b252 --- /dev/null +++ b/migrations/20260218100000_fix_casbin_container_discovery_paths.up.sql @@ -0,0 +1,22 @@ +-- Fix Casbin rules for container discovery and import endpoints +-- The original migration used wrong path prefix '/api/v1/project/...' +-- Correct paths are '/project/:id/containers/discover' and '/project/:id/containers/import' + +-- Remove incorrectly-prefixed rules +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 IN ( + '/api/v1/project/:id/containers/discover', + '/api/v1/project/:id/containers/import' + ); + +-- Insert rules with correct paths +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'group_admin', '/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'root', '/project/:id/containers/discover', 'GET', '', '', ''), + ('p', 'group_user', '/project/:id/containers/import', 'POST', '', '', ''), + ('p', 'group_admin', '/project/:id/containers/import', 'POST', '', '', ''), + ('p', 'root', '/project/:id/containers/import', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/migrations/20260219120000_create_chat_conversations.down.sql b/migrations/20260219120000_create_chat_conversations.down.sql new file mode 100644 index 00000000..d3a54996 --- /dev/null +++ b/migrations/20260219120000_create_chat_conversations.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS chat_conversations; diff --git a/migrations/20260219120000_create_chat_conversations.up.sql b/migrations/20260219120000_create_chat_conversations.up.sql new file mode 100644 index 00000000..b06f60d4 --- /dev/null +++ b/migrations/20260219120000_create_chat_conversations.up.sql @@ -0,0 +1,18 @@ +-- Chat conversations: persists AI chat history per user per project +CREATE TABLE chat_conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(255) NOT NULL, + project_id INTEGER, -- NULL = canvas / onboarding mode + messages JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- One row per (user, project) pair; partial indexes allow NULL project_id +CREATE UNIQUE INDEX idx_chat_conv_user_project + ON chat_conversations(user_id, project_id) + WHERE project_id IS NOT NULL; + +CREATE UNIQUE INDEX idx_chat_conv_user_no_project + ON chat_conversations(user_id) + WHERE project_id IS NULL; diff --git a/migrations/20260219130000_casbin_chat_rules.down.sql b/migrations/20260219130000_casbin_chat_rules.down.sql new file mode 100644 index 00000000..f4f6558d --- /dev/null +++ b/migrations/20260219130000_casbin_chat_rules.down.sql @@ -0,0 +1,3 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v1 = '/chat/history'; diff --git a/migrations/20260219130000_casbin_chat_rules.up.sql b/migrations/20260219130000_casbin_chat_rules.up.sql new file mode 100644 index 00000000..9447d738 --- /dev/null +++ b/migrations/20260219130000_casbin_chat_rules.up.sql @@ -0,0 +1,11 @@ +-- Allow authenticated users and admins to access chat history endpoints + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES + ('p', 'group_user', '/chat/history', 'GET', '', '', ''), + ('p', 'group_user', '/chat/history', 'PUT', '', '', ''), + ('p', 'group_user', '/chat/history', 'DELETE', '', '', ''), + ('p', 'group_admin', '/chat/history', 'GET', '', '', ''), + ('p', 'group_admin', '/chat/history', 'PUT', '', '', ''), + ('p', 'group_admin', '/chat/history', 'DELETE', '', '', '') +ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/src/connectors/install_service/client.rs b/src/connectors/install_service/client.rs index 1440fbfa..fe82c82c 100644 --- a/src/connectors/install_service/client.rs +++ b/src/connectors/install_service/client.rs @@ -30,6 +30,12 @@ impl InstallServiceConnector for InstallServiceClient { payload.id = Some(deployment_id); // Force-set deployment_hash in case deserialization overwrote it payload.deployment_hash = Some(deployment_hash.clone()); + + // Determine routing before server is moved into payload: + // If server has an existing IP, deploy to it directly (own flow). + // Otherwise, use the cloud provider to decide (own vs tfa). + let has_existing_ip = server.srv_ip.as_ref().map_or(false, |ip| !ip.is_empty()); + payload.server = Some(server.into()); payload.cloud = Some(cloud_creds.into()); payload.stack = form_stack.clone().into(); @@ -43,18 +49,25 @@ impl InstallServiceConnector for InstallServiceClient { payload ); - let provider = payload - .cloud - .as_ref() - .map(|form| { - if form.provider.contains("own") { - "own" - } else { - "tfa" - } - }) - .unwrap_or("tfa") - .to_string(); + let provider = if has_existing_ip { + // Server already has an IP → deploy to existing server via SSH (own flow) + tracing::info!("Server has existing IP, routing to 'own' flow"); + "own" + } else { + // No IP → provision new server via cloud provider (tfa or own) + payload + .cloud + .as_ref() + .map(|form| { + if form.provider.contains("own") { + "own" + } else { + "tfa" + } + }) + .unwrap_or("tfa") + } + .to_string(); let routing_key = format!("install.start.{}.all.all", provider); tracing::debug!("Route: {:?}", routing_key); diff --git a/src/console/commands/mq/listener.rs b/src/console/commands/mq/listener.rs index ca0556ad..7317b246 100644 --- a/src/console/commands/mq/listener.rs +++ b/src/console/commands/mq/listener.rs @@ -145,6 +145,7 @@ impl crate::console::commands::CallableTrait for ListenCommand { "completed", "paused", "failed", + "cancelled", "in_progress", "error", "wait_resume", diff --git a/src/db/chat.rs b/src/db/chat.rs new file mode 100644 index 00000000..49d3a3ed --- /dev/null +++ b/src/db/chat.rs @@ -0,0 +1,101 @@ +use crate::models::ChatConversation; +use serde_json::Value; +use sqlx::PgPool; + +pub async fn fetch( + pool: &PgPool, + user_id: &str, + project_id: Option, +) -> Result, sqlx::Error> { + match project_id { + Some(pid) => { + sqlx::query_as!( + ChatConversation, + r#"SELECT id, user_id, project_id, messages, created_at, updated_at + FROM chat_conversations + WHERE user_id = $1 AND project_id = $2"#, + user_id, + pid + ) + .fetch_optional(pool) + .await + } + None => { + sqlx::query_as!( + ChatConversation, + r#"SELECT id, user_id, project_id, messages, created_at, updated_at + FROM chat_conversations + WHERE user_id = $1 AND project_id IS NULL"#, + user_id + ) + .fetch_optional(pool) + .await + } + } +} + +pub async fn upsert( + pool: &PgPool, + user_id: &str, + project_id: Option, + messages: Value, +) -> Result { + match project_id { + Some(pid) => { + sqlx::query_as!( + ChatConversation, + r#"INSERT INTO chat_conversations (user_id, project_id, messages) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, project_id) WHERE project_id IS NOT NULL + DO UPDATE SET messages = EXCLUDED.messages, updated_at = NOW() + RETURNING id, user_id, project_id, messages, created_at, updated_at"#, + user_id, + pid, + messages + ) + .fetch_one(pool) + .await + } + None => { + sqlx::query_as!( + ChatConversation, + r#"INSERT INTO chat_conversations (user_id, project_id, messages) + VALUES ($1, NULL, $2) + ON CONFLICT (user_id) WHERE project_id IS NULL + DO UPDATE SET messages = EXCLUDED.messages, updated_at = NOW() + RETURNING id, user_id, project_id, messages, created_at, updated_at"#, + user_id, + messages + ) + .fetch_one(pool) + .await + } + } +} + +pub async fn delete( + pool: &PgPool, + user_id: &str, + project_id: Option, +) -> Result { + let result = match project_id { + Some(pid) => { + sqlx::query!( + r#"DELETE FROM chat_conversations WHERE user_id = $1 AND project_id = $2"#, + user_id, + pid + ) + .execute(pool) + .await? + } + None => { + sqlx::query!( + r#"DELETE FROM chat_conversations WHERE user_id = $1 AND project_id IS NULL"#, + user_id + ) + .execute(pool) + .await? + } + }; + Ok(result.rows_affected()) +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 8c0aa777..570ce4dd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,5 +1,6 @@ pub mod agent; pub(crate) mod agreement; +pub mod chat; pub mod client; pub(crate) mod cloud; pub mod command; diff --git a/src/mcp/registry.rs b/src/mcp/registry.rs index 191266a4..e73ec3c3 100644 --- a/src/mcp/registry.rs +++ b/src/mcp/registry.rs @@ -28,6 +28,8 @@ use crate::mcp::tools::{ DeleteProxyTool, // Ansible Roles tools DeployRoleTool, + // Stack Recommendations + RecommendStackServicesTool, DiagnoseDeploymentTool, DiscoverStackServicesTool, EscalateToSupportTool, @@ -243,6 +245,12 @@ impl ToolRegistry { registry.register("validate_role_vars", Box::new(ValidateRoleVarsTool)); registry.register("deploy_role", Box::new(DeployRoleTool)); + // Stack Recommendations + registry.register( + "recommend_stack_services", + Box::new(RecommendStackServicesTool), + ); + registry } diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index cc06cc0d..e9622002 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -7,6 +7,7 @@ pub mod marketplace_admin; pub mod monitoring; pub mod project; pub mod proxy; +pub mod recommendations; pub mod support; pub mod templates; pub mod user_service; @@ -20,6 +21,7 @@ pub use marketplace_admin::*; pub use monitoring::*; pub use project::*; pub use proxy::*; +pub use recommendations::*; pub use support::*; pub use templates::*; pub use user_service::*; diff --git a/src/mcp/tools/project.rs b/src/mcp/tools/project.rs index 9d2e5a6e..d77af5f8 100644 --- a/src/mcp/tools/project.rs +++ b/src/mcp/tools/project.rs @@ -8,6 +8,85 @@ use crate::mcp::registry::{ToolContext, ToolHandler}; use crate::services::ProjectAppService; use serde::Deserialize; use std::sync::Arc; +use uuid::Uuid; + +fn build_project_payload( + name: &str, + description: Option<&str>, + apps: &[Value], +) -> (serde_json::Value, serde_json::Value) { + let mut stack_code = crate::models::sanitize_project_name(name); + if stack_code.len() < 3 { + stack_code = "app-stack".to_string(); + } + + let project_name = if name.trim().is_empty() { + "New project".to_string() + } else { + name.to_string() + }; + + let network_id = Uuid::new_v4().simple().to_string()[..16].to_string(); + + let metadata = json!({ + "custom": { + "web": [], + "feature": [], + "service": [], + "networks": [ + { + "id": network_id, + "ipam": null, + "name": "default_network", + "driver": null, + "labels": null, + "external": null, + "internal": null, + "attachable": null, + "driver_opts": null, + "enable_ipv6": null + } + ], + "project_name": project_name, + "project_git_url": null, + "project_overview": description, + "custom_stack_code": stack_code, + "project_description": description, + "custom_stack_category": null, + "custom_stack_description": null, + "custom_stack_short_description": null, + "apps": apps + } + }); + + let request_json = json!({ + "ssl": "letsencrypt", + "custom": { + "web": [], + "code": stack_code, + "feature": [], + "service": [], + "networks": [ + { + "id": network_id, + "name": "default_network" + } + ], + "project_name": project_name, + "connection_mode": "ssh", + "project_git_url": null, + "project_overview": description, + "custom_stack_code": stack_code, + "project_description": description, + "custom_stack_category": null, + "custom_stack_description": null, + "custom_stack_short_description": null, + "apps": apps + } + }); + + (metadata, request_json) +} /// List user's projects pub struct ListProjectsTool; @@ -118,12 +197,15 @@ impl ToolHandler for CreateProjectTool { return Err("Project name too long (max 255 characters)".to_string()); } - // Create a new Project model with empty metadata/request + let (metadata, request_json) = + build_project_payload(¶ms.name, params.description.as_deref(), ¶ms.apps); + + // Create a new Project model with normalized metadata/request payload let project = crate::models::Project::new( context.user.id.clone(), params.name.clone(), - serde_json::json!({}), - serde_json::json!(params.apps), + metadata, + request_json, ); let project = db::project::insert(&context.pg_pool, project) diff --git a/src/mcp/tools/recommendations.rs b/src/mcp/tools/recommendations.rs new file mode 100644 index 00000000..13d030db --- /dev/null +++ b/src/mcp/tools/recommendations.rs @@ -0,0 +1,1400 @@ +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; + +/// Recommend complementary services for a stack based on the selected template(s). +/// +/// Returns categorized recommendations (production vs development) with +/// suggested configurations (env vars, ports, volumes) tailored to the +/// deployment method (SSH/Ansible roles or Status Panel apps). +pub struct RecommendStackServicesTool; + +/// A single service recommendation with its rationale and configuration. +#[derive(serde::Serialize, Clone)] +struct ServiceRecommendation { + /// App/role code (e.g. "redis", "nginx", "traefik") + code: String, + /// Human-readable name + name: String, + /// Why this service is recommended + reason: String, + /// "required" | "recommended" | "optional" + priority: String, + /// "database" | "cache" | "proxy" | "monitoring" | "search" | "queue" | "mail" | "storage" | "security" | "devtool" | "runtime" + category: String, + /// Docker image (for Status Panel / docker-compose method) + docker_image: String, + /// Ansible role name (for SSH method); empty if not available + ansible_role: String, + /// Whether we have a local Ansible role for this + has_local_role: bool, + /// Whether we have a local app template for this + has_local_app: bool, + /// Suggested environment variables + environment: Value, + /// Suggested port mappings + ports: Value, + /// Suggested volume mounts + volumes: Value, + /// Additional notes / configuration tips + notes: String, +} + +/// Knowledge base: for a given "primary" app, which companion services make sense? +struct StackBlueprint { + /// Which primary codes trigger this blueprint + triggers: Vec<&'static str>, + /// Production recommendations + production: Vec, + /// Development-only extras + development: Vec, +} + +fn build_blueprints() -> Vec { + vec![ + // ── WordPress ──────────────────────────────────────────────── + StackBlueprint { + triggers: vec!["wordpress", "wordpress_prod", "wordpress_dev", "wordpress_woocommerce"], + production: vec![ + ServiceRecommendation { + code: "mysql".into(), + name: "MySQL".into(), + reason: "WordPress requires a MySQL-compatible database".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "mysql:8.0".into(), + ansible_role: "mysql".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "MYSQL_ROOT_PASSWORD": "changeme_root", + "MYSQL_DATABASE": "wordpress", + "MYSQL_USER": "wordpress", + "MYSQL_PASSWORD": "changeme_wp" + }), + ports: json!([{"host_port": "3306", "container_port": "3306"}]), + volumes: json!([{"host_path": "mysql_data", "container_path": "/var/lib/mysql"}]), + notes: "Change default passwords before deploying to production.".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Object caching dramatically improves WordPress performance".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Install WP Redis plugin for object cache. Set WP_REDIS_HOST=redis".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with automatic SSL certificate management".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "Handles SSL termination and routing for all services.".into(), + }, + ServiceRecommendation { + code: "telegraf".into(), + name: "Telegraf".into(), + reason: "System and container metrics collection for monitoring".into(), + priority: "optional".into(), + category: "monitoring".into(), + docker_image: "telegraf:1.30-alpine".into(), + ansible_role: "telegraf".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock:ro"} + ]), + notes: "Feeds metrics to InfluxDB or TryDirect monitoring dashboard.".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "phpmyadmin".into(), + name: "phpMyAdmin".into(), + reason: "Web-based database management for development".into(), + priority: "recommended".into(), + category: "devtool".into(), + docker_image: "phpmyadmin:latest".into(), + ansible_role: "phpmyadmin".into(), + has_local_role: true, + has_local_app: true, + environment: json!({"PMA_HOST": "mysql", "PMA_PORT": "3306"}), + ports: json!([{"host_port": "8080", "container_port": "80"}]), + volumes: json!([]), + notes: "Remove in production. Accessible at port 8080.".into(), + }, + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Catches all outgoing emails for development testing".into(), + priority: "optional".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "SMTP on 1025, web UI on 8025. Configure WordPress SMTP plugin to use mailhog:1025.".into(), + }, + ], + }, + + // ── Django ─────────────────────────────────────────────────── + StackBlueprint { + triggers: vec!["django"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Django's preferred production database".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "django_db", + "POSTGRES_USER": "django", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "Set DATABASE_URL=postgres://django:changeme@postgres:5432/django_db in Django.".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Cache backend and Celery broker for async tasks".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Use as Django cache (django-redis) and Celery broker (CELERY_BROKER_URL=redis://redis:6379/0).".into(), + }, + ServiceRecommendation { + code: "nginx".into(), + name: "Nginx".into(), + reason: "Reverse proxy and static file server for Django".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "nginx:1.25-alpine".into(), + ansible_role: "".into(), + has_local_role: false, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "static_files", "container_path": "/usr/share/nginx/html/static"}, + {"host_path": "media_files", "container_path": "/usr/share/nginx/html/media"} + ]), + notes: "Serves static/media files and proxies to gunicorn.".into(), + }, + ServiceRecommendation { + code: "rabbitmq".into(), + name: "RabbitMQ".into(), + reason: "Message broker for Celery task queue (alternative to Redis)".into(), + priority: "optional".into(), + category: "queue".into(), + docker_image: "rabbitmq:3-management-alpine".into(), + ansible_role: "rabbitmq".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "RABBITMQ_DEFAULT_USER": "django", + "RABBITMQ_DEFAULT_PASS": "changeme" + }), + ports: json!([ + {"host_port": "5672", "container_port": "5672"}, + {"host_port": "15672", "container_port": "15672"} + ]), + volumes: json!([{"host_path": "rabbitmq_data", "container_path": "/var/lib/rabbitmq"}]), + notes: "Management UI on port 15672. Use if you need advanced routing beyond Redis pub/sub.".into(), + }, + ServiceRecommendation { + code: "telegraf".into(), + name: "Telegraf".into(), + reason: "Metrics collection for monitoring".into(), + priority: "optional".into(), + category: "monitoring".into(), + docker_image: "telegraf:1.30-alpine".into(), + ansible_role: "telegraf".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([]), + volumes: json!([{"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock:ro"}]), + notes: "Feeds metrics to InfluxDB or TryDirect monitoring.".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Email testing without sending real emails".into(), + priority: "recommended".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "Configure Django EMAIL_HOST=mailhog, EMAIL_PORT=1025.".into(), + }, + ], + }, + + // ── Flask / FastAPI ────────────────────────────────────────── + StackBlueprint { + triggers: vec!["flask", "fastapi"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Reliable production database for Python web apps".into(), + priority: "recommended".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "app_db", + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "Set DATABASE_URL in your app environment.".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Caching, session storage, and task queue support".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Use as cache layer or Celery/ARQ broker.".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with automatic SSL".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "Auto-discovers containers via Docker labels.".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Email testing".into(), + priority: "optional".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "".into(), + }, + ], + }, + + // ── Node.js / Next.js / Express ────────────────────────────── + StackBlueprint { + triggers: vec!["nodejs", "nextjs", "express"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Reliable relational database for Node.js apps".into(), + priority: "recommended".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "app_db", + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "Or use MongoDB if your app uses Mongoose/document model.".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Session store, caching, and BullMQ job queue".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Excellent for connect-redis sessions and BullMQ job processing.".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with automatic HTTPS".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Email testing".into(), + priority: "optional".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "".into(), + }, + ], + }, + + // ── Laravel / PHP ──────────────────────────────────────────── + StackBlueprint { + triggers: vec!["laravel", "LAMP", "magento", "symfony", "pimcore6_prod", "pimcore6_dev"], + production: vec![ + ServiceRecommendation { + code: "mysql".into(), + name: "MySQL".into(), + reason: "Primary database for PHP/Laravel applications".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "mysql:8.0".into(), + ansible_role: "mysql".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "MYSQL_ROOT_PASSWORD": "changeme_root", + "MYSQL_DATABASE": "laravel", + "MYSQL_USER": "laravel", + "MYSQL_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "3306", "container_port": "3306"}]), + volumes: json!([{"host_path": "mysql_data", "container_path": "/var/lib/mysql"}]), + notes: "Set DB_HOST=mysql, DB_DATABASE=laravel in .env".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Cache, session driver, and queue worker backend".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Set CACHE_DRIVER=redis, SESSION_DRIVER=redis, QUEUE_CONNECTION=redis in Laravel.".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with SSL termination".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "phpmyadmin".into(), + name: "phpMyAdmin".into(), + reason: "Web database manager for development".into(), + priority: "recommended".into(), + category: "devtool".into(), + docker_image: "phpmyadmin:latest".into(), + ansible_role: "phpmyadmin".into(), + has_local_role: true, + has_local_app: true, + environment: json!({"PMA_HOST": "mysql"}), + ports: json!([{"host_port": "8080", "container_port": "80"}]), + volumes: json!([]), + notes: "".into(), + }, + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Catch outgoing emails in development".into(), + priority: "recommended".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "Set MAIL_HOST=mailhog, MAIL_PORT=1025 in .env".into(), + }, + ], + }, + + // ── Ruby on Rails ──────────────────────────────────────────── + StackBlueprint { + triggers: vec!["ror_restful"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Rails default production database".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "rails_production", + "POSTGRES_USER": "rails", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Action Cable, Sidekiq, and cache store".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Set REDIS_URL=redis://redis:6379/0".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with SSL".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Email testing in development".into(), + priority: "optional".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "".into(), + }, + ], + }, + + // ── AI / ML Stacks ─────────────────────────────────────────── + StackBlueprint { + triggers: vec!["openwebui", "langflow", "flowise", "litellm", "ai-workbench", "dify", "tensorflow"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Persistent storage for AI/ML metadata and configurations".into(), + priority: "recommended".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "ai_db", + "POSTGRES_USER": "ai", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "qdrant".into(), + name: "Qdrant".into(), + reason: "Vector database for RAG, embeddings, and semantic search".into(), + priority: "recommended".into(), + category: "database".into(), + docker_image: "qdrant/qdrant:latest".into(), + ansible_role: "qdrant".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "6333", "container_port": "6333"}, + {"host_port": "6334", "container_port": "6334"} + ]), + volumes: json!([{"host_path": "qdrant_data", "container_path": "/qdrant/storage"}]), + notes: "REST API on 6333, gRPC on 6334. Essential for RAG workflows.".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Caching layer for LLM responses and session management".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Cache LLM responses to reduce API costs.".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy for secure HTTPS access".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ServiceRecommendation { + code: "minio".into(), + name: "MinIO".into(), + reason: "S3-compatible object storage for models, datasets, and artifacts".into(), + priority: "optional".into(), + category: "storage".into(), + docker_image: "minio/minio:latest".into(), + ansible_role: "minio".into(), + has_local_role: true, + has_local_app: false, + environment: json!({ + "MINIO_ROOT_USER": "minioadmin", + "MINIO_ROOT_PASSWORD": "minioadmin" + }), + ports: json!([ + {"host_port": "9000", "container_port": "9000"}, + {"host_port": "9001", "container_port": "9001"} + ]), + volumes: json!([{"host_path": "minio_data", "container_path": "/data"}]), + notes: "API on 9000, console on 9001.".into(), + }, + ], + development: vec![], + }, + + // ── ELK / Monitoring ───────────────────────────────────────── + StackBlueprint { + triggers: vec!["elk", "elk_wazuh", "ewazuh", "wazuh", "zabbix"], + production: vec![ + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy for dashboard access".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ServiceRecommendation { + code: "telegraf".into(), + name: "Telegraf".into(), + reason: "System metrics collection agent".into(), + priority: "recommended".into(), + category: "monitoring".into(), + docker_image: "telegraf:1.30-alpine".into(), + ansible_role: "telegraf".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([]), + volumes: json!([{"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock:ro"}]), + notes: "".into(), + }, + ], + development: vec![], + }, + + // ── GitLab ─────────────────────────────────────────────────── + StackBlueprint { + triggers: vec!["gitlab_server"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "GitLab's required database backend".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "gitlabhq_production", + "POSTGRES_USER": "gitlab", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Required by GitLab for caching and background jobs".into(), + priority: "required".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "SSL termination and routing".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ServiceRecommendation { + code: "minio".into(), + name: "MinIO".into(), + reason: "Object storage for Git LFS, artifacts, uploads".into(), + priority: "optional".into(), + category: "storage".into(), + docker_image: "minio/minio:latest".into(), + ansible_role: "minio".into(), + has_local_role: true, + has_local_app: false, + environment: json!({ + "MINIO_ROOT_USER": "minioadmin", + "MINIO_ROOT_PASSWORD": "minioadmin" + }), + ports: json!([ + {"host_port": "9000", "container_port": "9000"}, + {"host_port": "9001", "container_port": "9001"} + ]), + volumes: json!([{"host_path": "minio_data", "container_path": "/data"}]), + notes: "Replaces local file storage for scalability.".into(), + }, + ], + development: vec![], + }, + + // ── Mautic (Marketing Automation) ──────────────────────────── + StackBlueprint { + triggers: vec!["mautic"], + production: vec![ + ServiceRecommendation { + code: "mysql".into(), + name: "MySQL".into(), + reason: "Mautic's primary database".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "mysql:8.0".into(), + ansible_role: "mysql".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "MYSQL_ROOT_PASSWORD": "changeme_root", + "MYSQL_DATABASE": "mautic", + "MYSQL_USER": "mautic", + "MYSQL_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "3306", "container_port": "3306"}]), + volumes: json!([{"host_path": "mysql_data", "container_path": "/var/lib/mysql"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "rabbitmq".into(), + name: "RabbitMQ".into(), + reason: "Message queue for Mautic campaign processing".into(), + priority: "recommended".into(), + category: "queue".into(), + docker_image: "rabbitmq:3-management-alpine".into(), + ansible_role: "rabbitmq".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "RABBITMQ_DEFAULT_USER": "mautic", + "RABBITMQ_DEFAULT_PASS": "changeme" + }), + ports: json!([ + {"host_port": "5672", "container_port": "5672"}, + {"host_port": "15672", "container_port": "15672"} + ]), + volumes: json!([{"host_path": "rabbitmq_data", "container_path": "/var/lib/rabbitmq"}]), + notes: "Processes email campaigns asynchronously.".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with SSL".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![ + ServiceRecommendation { + code: "mailhog".into(), + name: "MailHog".into(), + reason: "Catch test campaign emails in development".into(), + priority: "recommended".into(), + category: "mail".into(), + docker_image: "mailhog/mailhog:latest".into(), + ansible_role: "mailhog".into(), + has_local_role: true, + has_local_app: false, + environment: json!({}), + ports: json!([ + {"host_port": "1025", "container_port": "1025"}, + {"host_port": "8025", "container_port": "8025"} + ]), + volumes: json!([]), + notes: "Prevents sending real campaign emails during testing.".into(), + }, + ], + }, + + // ── MongoDB-based stacks ───────────────────────────────────── + StackBlueprint { + triggers: vec!["mongodb", "rocketchat", "wekan"], + production: vec![ + ServiceRecommendation { + code: "mongodb".into(), + name: "MongoDB".into(), + reason: "Document database required by this application".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "mongo:7".into(), + ansible_role: "mongodb".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "MONGO_INITDB_ROOT_USERNAME": "admin", + "MONGO_INITDB_ROOT_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "27017", "container_port": "27017"}]), + volumes: json!([{"host_path": "mongodb_data", "container_path": "/data/db"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with SSL".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![], + }, + + // ── E-Commerce (WooCommerce, OroCommerce, Sylius, Oscar) ──── + StackBlueprint { + triggers: vec!["wordpress_woocommerce", "orocommerce", "sylius", "oscar"], + production: vec![ + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Session & cache for e-commerce performance".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "Dramatically improves page load for product catalogs.".into(), + }, + ServiceRecommendation { + code: "elasticsearch".into(), + name: "Elasticsearch".into(), + reason: "Full-text product search".into(), + priority: "optional".into(), + category: "search".into(), + docker_image: "elasticsearch:8.12.0".into(), + ansible_role: "".into(), + has_local_role: false, + has_local_app: false, + environment: json!({ + "discovery.type": "single-node", + "xpack.security.enabled": "false", + "ES_JAVA_OPTS": "-Xms512m -Xmx512m" + }), + ports: json!([{"host_port": "9200", "container_port": "9200"}]), + volumes: json!([{"host_path": "es_data", "container_path": "/usr/share/elasticsearch/data"}]), + notes: "From Docker Hub. Needs 2GB+ RAM. Improves product search dramatically.".into(), + }, + ], + development: vec![], + }, + + // ── CRM / Project Management ───────────────────────────────── + StackBlueprint { + triggers: vec!["orocrm", "suitecrm", "redmine", "taiga"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Primary database".into(), + priority: "required".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "app_db", + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Caching and session storage".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy with SSL".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![], + }, + + // ── Container Management (Portainer, Dockge, Komodo) ───────── + StackBlueprint { + triggers: vec!["portainer", "portainer-ce", "dockge", "komodo"], + production: vec![ + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Secure HTTPS access to management dashboard".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ServiceRecommendation { + code: "telegraf".into(), + name: "Telegraf".into(), + reason: "Metrics collection to complement container management".into(), + priority: "optional".into(), + category: "monitoring".into(), + docker_image: "telegraf:1.30-alpine".into(), + ansible_role: "telegraf".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([]), + volumes: json!([{"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock:ro"}]), + notes: "".into(), + }, + ], + development: vec![], + }, + + // ── Go (Gin) ──────────────────────────────────────────────── + StackBlueprint { + triggers: vec!["gin"], + production: vec![ + ServiceRecommendation { + code: "postgres".into(), + name: "PostgreSQL".into(), + reason: "Popular database choice for Go services".into(), + priority: "recommended".into(), + category: "database".into(), + docker_image: "postgres:16-alpine".into(), + ansible_role: "postgres".into(), + has_local_role: true, + has_local_app: true, + environment: json!({ + "POSTGRES_DB": "app_db", + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "changeme" + }), + ports: json!([{"host_port": "5432", "container_port": "5432"}]), + volumes: json!([{"host_path": "postgres_data", "container_path": "/var/lib/postgresql/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "redis".into(), + name: "Redis".into(), + reason: "Caching and session storage".into(), + priority: "recommended".into(), + category: "cache".into(), + docker_image: "redis:7-alpine".into(), + ansible_role: "redis".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([{"host_port": "6379", "container_port": "6379"}]), + volumes: json!([{"host_path": "redis_data", "container_path": "/data"}]), + notes: "".into(), + }, + ServiceRecommendation { + code: "traefik".into(), + name: "Traefik".into(), + reason: "Reverse proxy".into(), + priority: "recommended".into(), + category: "proxy".into(), + docker_image: "traefik:v3.0".into(), + ansible_role: "traefik".into(), + has_local_role: true, + has_local_app: true, + environment: json!({}), + ports: json!([ + {"host_port": "80", "container_port": "80"}, + {"host_port": "443", "container_port": "443"} + ]), + volumes: json!([ + {"host_path": "/var/run/docker.sock", "container_path": "/var/run/docker.sock"}, + {"host_path": "traefik_certs", "container_path": "/letsencrypt"} + ]), + notes: "".into(), + }, + ], + development: vec![], + }, + ] +} + +#[async_trait] +impl ToolHandler for RecommendStackServicesTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + /// App/role codes currently in the stack (e.g. ["wordpress", "mysql"]) + current_services: Vec, + /// "production" or "development" + #[serde(default = "default_stack_type")] + stack_type: String, + /// "ssh" or "status_panel" + #[serde(default = "default_deployment_method")] + deployment_method: String, + } + + fn default_stack_type() -> String { + "production".into() + } + fn default_deployment_method() -> String { + "ssh".into() + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + let current_codes: Vec = params + .current_services + .iter() + .map(|s| s.to_lowercase().trim().to_string()) + .collect(); + + let blueprints = build_blueprints(); + let is_prod = params.stack_type.to_lowercase() != "development"; + let is_ssh = params.deployment_method.to_lowercase() == "ssh"; + + // Collect matching production recommendations + let mut prod_recs: Vec = Vec::new(); + let mut dev_recs: Vec = Vec::new(); + let mut matched_templates: Vec = Vec::new(); + + for bp in &blueprints { + let matches: Vec<&str> = bp + .triggers + .iter() + .filter(|t| current_codes.iter().any(|c| c == *t)) + .copied() + .collect(); + + if matches.is_empty() { + continue; + } + + matched_templates.extend(matches.iter().map(|s| s.to_string())); + + // Add production recommendations + for rec in &bp.production { + if !current_codes.contains(&rec.code.to_lowercase()) + && !prod_recs.iter().any(|r| r.code == rec.code) + { + prod_recs.push(rec.clone()); + } + } + + // Add development recommendations (if requested) + if !is_prod { + for rec in &bp.development { + if !current_codes.contains(&rec.code.to_lowercase()) + && !dev_recs.iter().any(|r| r.code == rec.code) + && !prod_recs.iter().any(|r| r.code == rec.code) + { + dev_recs.push(rec.clone()); + } + } + } + } + + // Sort: required first, then recommended, then optional + let priority_order = |p: &str| match p { + "required" => 0, + "recommended" => 1, + "optional" => 2, + _ => 3, + }; + prod_recs.sort_by_key(|r| priority_order(&r.priority)); + dev_recs.sort_by_key(|r| priority_order(&r.priority)); + + // Filter based on deployment method + let filter_for_method = |recs: &[ServiceRecommendation]| -> Vec { + recs.iter() + .map(|r| { + let mut rec = json!({ + "code": r.code, + "name": r.name, + "reason": r.reason, + "priority": r.priority, + "category": r.category, + "docker_image": r.docker_image, + "has_local_role": r.has_local_role, + "has_local_app": r.has_local_app, + "environment": r.environment, + "ports": r.ports, + "volumes": r.volumes, + }); + if !r.notes.is_empty() { + rec["notes"] = json!(r.notes); + } + if is_ssh && r.has_local_role { + rec["ansible_role"] = json!(r.ansible_role); + rec["install_method"] = json!("ansible_role"); + } else { + rec["install_method"] = json!("docker_compose"); + } + rec + }) + .collect() + }; + + let production_json = filter_for_method(&prod_recs); + let development_json = filter_for_method(&dev_recs); + + let total = production_json.len() + development_json.len(); + let summary = if matched_templates.is_empty() { + "No matching blueprints found for the current services. You can still add services manually via the template selector or Docker Hub search.".to_string() + } else { + format!( + "Found {} recommendation(s) for stack containing [{}]. {} for production{}.", + total, + matched_templates.join(", "), + production_json.len(), + if !development_json.is_empty() { + format!(", {} for development", development_json.len()) + } else { + String::new() + } + ) + }; + + let result = json!({ + "matched_templates": matched_templates, + "stack_type": params.stack_type, + "deployment_method": params.deployment_method, + "summary": summary, + "production": production_json, + "development": development_json, + "instructions": "Present these recommendations to the user grouped by purpose. For each service, explain why it's needed and show the suggested configuration. Ask the user which services to add, then use create_project_app to add each selected service with the suggested configuration." + }); + + tracing::info!( + "Recommended {} services for stack with [{}]", + total, + current_codes.join(", ") + ); + + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&result).unwrap(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "recommend_stack_services".to_string(), + description: "Get AI-powered service recommendations for a stack based on the selected template(s). Returns categorized suggestions (production vs development) with configurations (env vars, ports, volumes) tailored to the deployment method (SSH/Ansible or Status Panel). Use this when a user selects a template to suggest complementary services.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "current_services": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of app/role codes currently in the stack (e.g. [\"wordpress\", \"mysql\"])" + }, + "stack_type": { + "type": "string", + "enum": ["production", "development"], + "description": "Whether this is a production or development stack (default: production)" + }, + "deployment_method": { + "type": "string", + "enum": ["ssh", "status_panel"], + "description": "Deployment method: 'ssh' for Ansible roles, 'status_panel' for Docker Compose (default: ssh)" + } + }, + "required": ["current_services"] + }), + } + } +} diff --git a/src/models/chat.rs b/src/models/chat.rs new file mode 100644 index 00000000..4243973e --- /dev/null +++ b/src/models/chat.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChatConversation { + pub id: Uuid, + pub user_id: String, + pub project_id: Option, + pub messages: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index a08d33d5..d9bafa60 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,5 +1,6 @@ mod agent; mod agreement; +mod chat; mod client; mod cloud; mod command; @@ -16,6 +17,7 @@ pub mod user; pub use agent::*; pub use agreement::*; +pub use chat::*; pub use client::*; pub use cloud::*; pub use command::*; diff --git a/src/routes/chat/delete.rs b/src/routes/chat/delete.rs new file mode 100644 index 00000000..2112f2f9 --- /dev/null +++ b/src/routes/chat/delete.rs @@ -0,0 +1,27 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{delete, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct Query { + pub project_id: Option, +} + +/// DELETE /chat/history?project_id={id} +/// Clears the stored chat conversation for the logged-in user. +#[tracing::instrument(name = "Delete chat history.")] +#[delete("/history")] +pub async fn item( + user: web::ReqData>, + query: web::Query, + pg_pool: web::Data, +) -> Result { + db::chat::delete(pg_pool.get_ref(), &user.id, query.project_id) + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string())) + .map(|_| JsonResponse::::build().ok("OK")) +} diff --git a/src/routes/chat/get.rs b/src/routes/chat/get.rs new file mode 100644 index 00000000..29b412c3 --- /dev/null +++ b/src/routes/chat/get.rs @@ -0,0 +1,31 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{get, web, Responder, Result}; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct Query { + pub project_id: Option, +} + +/// GET /chat/history?project_id={id} +/// Returns the saved chat conversation for the logged-in user. +/// project_id is optional; omit for canvas/onboarding mode. +#[tracing::instrument(name = "Get chat history.")] +#[get("/history")] +pub async fn item( + user: web::ReqData>, + query: web::Query, + pg_pool: web::Data, +) -> Result { + db::chat::fetch(pg_pool.get_ref(), &user.id, query.project_id) + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string())) + .and_then(|conv| match conv { + Some(c) => Ok(JsonResponse::build().set_item(Some(c)).ok("OK")), + None => Err(JsonResponse::not_found("No chat history found")), + }) +} diff --git a/src/routes/chat/mod.rs b/src/routes/chat/mod.rs new file mode 100644 index 00000000..b99105e5 --- /dev/null +++ b/src/routes/chat/mod.rs @@ -0,0 +1,3 @@ +pub mod delete; +pub mod get; +pub mod upsert; diff --git a/src/routes/chat/upsert.rs b/src/routes/chat/upsert.rs new file mode 100644 index 00000000..51c7111a --- /dev/null +++ b/src/routes/chat/upsert.rs @@ -0,0 +1,29 @@ +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use actix_web::{put, web, Responder, Result}; +use serde::Deserialize; +use serde_json::Value; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct ChatHistoryRequest { + pub project_id: Option, + pub messages: Value, +} + +/// PUT /chat/history +/// Upserts the chat conversation for the logged-in user. +#[tracing::instrument(name = "Upsert chat history.")] +#[put("/history")] +pub async fn item( + user: web::ReqData>, + web::Json(body): web::Json, + pg_pool: web::Data, +) -> Result { + db::chat::upsert(pg_pool.get_ref(), &user.id, body.project_id, body.messages) + .await + .map(|conv| JsonResponse::build().set_item(conv).ok("OK")) + .map_err(|err| JsonResponse::internal_server_error(err.to_string())) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 27c48022..a8f824cf 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod project; pub(crate) mod server; pub(crate) mod agreement; +pub(crate) mod chat; pub(crate) mod marketplace; pub use project::*; diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index 8beeb22e..ac173585 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -4,9 +4,8 @@ use crate::connectors::{ }; use crate::db; use crate::forms; -use crate::helpers::compressor::compress; use crate::helpers::project::builder::DcBuilder; -use crate::helpers::{JsonResponse, MqManager}; +use crate::helpers::{JsonResponse, MqManager, VaultClient}; use crate::models; use actix_web::{post, web, web::Data, Responder, Result}; use serde_valid::Validate; @@ -14,7 +13,7 @@ use sqlx::PgPool; use std::sync::Arc; use uuid::Uuid; -#[tracing::instrument(name = "Deploy for every user", skip(user_service, install_service))] +#[tracing::instrument(name = "Deploy for every user", skip(user_service, install_service, vault_client))] #[post("/{id}/deploy")] pub async fn item( user: web::ReqData>, @@ -25,6 +24,7 @@ pub async fn item( sets: Data, user_service: Data>, install_service: Data>, + vault_client: Data, ) -> Result { let id = path.0; tracing::debug!("User {:?} is deploying project: {}", user, id); @@ -88,31 +88,124 @@ pub async fn item( form.cloud.user_id = Some(user.id.clone()); form.cloud.project_id = Some(id); - // Save cloud credentials if requested + // Save cloud credentials if requested, capturing the returned cloud with its DB id let cloud_creds: models::Cloud = (&form.cloud).into(); - // let cloud_creds = forms::Cloud::decode_model(cloud_creds, false); - - if Some(true) == cloud_creds.save_token { + let cloud_creds = if Some(true) == cloud_creds.save_token { db::cloud::insert(pg_pool.get_ref(), cloud_creds.clone()) .await - .map(|cloud| cloud) .map_err(|_| { JsonResponse::::build() .internal_server_error("Internal Server Error") + })? + } else { + cloud_creds + }; + + // Handle server: if server_id provided, update existing; otherwise create new + let server = if let Some(server_id) = form.server.server_id { + // Update existing server + let existing = db::server::fetch(pg_pool.get_ref(), server_id) + .await + .map_err(|_| { + JsonResponse::::build().internal_server_error("Failed to fetch server") + })? + .ok_or_else(|| { + JsonResponse::::build().not_found("Server not found") })?; - } - // Save server type and region - let mut server: models::Server = (&form.server).into(); - server.user_id = user.id.clone(); - server.project_id = id; - let server = db::server::insert(pg_pool.get_ref(), server) - .await - .map(|server| server) - .map_err(|_| { - JsonResponse::::build().internal_server_error("Internal Server Error") - })?; + // Verify ownership + if existing.user_id != user.id { + return Err(JsonResponse::::build().not_found("Server not found")); + } + + let mut server = existing; + server.disk_type = form.server.disk_type.clone(); + server.region = form.server.region.clone(); + server.server = form.server.server.clone(); + server.zone = form.server.zone.clone().or(server.zone); + server.os = form.server.os.clone(); + server.project_id = id; + // Preserve existing srv_ip if form doesn't provide one + server.srv_ip = form.server.srv_ip.clone().or(server.srv_ip); + server.ssh_user = form.server.ssh_user.clone().or(server.ssh_user); + server.ssh_port = form.server.ssh_port.or(server.ssh_port); + server.name = form.server.name.clone().or(server.name); + if form.server.connection_mode.is_some() { + server.connection_mode = form.server.connection_mode.clone().unwrap(); + } + + db::server::update(pg_pool.get_ref(), server) + .await + .map_err(|_| { + JsonResponse::::build().internal_server_error("Failed to update server") + })? + } else { + // Create new server + let mut server: models::Server = (&form.server).into(); + server.user_id = user.id.clone(); + server.project_id = id; + // Set cloud_id from saved cloud credentials (if cloud was saved, it has a DB id) + if cloud_creds.id != 0 { + server.cloud_id = Some(cloud_creds.id); + } + + db::server::insert(pg_pool.get_ref(), server) + .await + .map_err(|_| { + JsonResponse::::build().internal_server_error("Internal Server Error") + })? + }; + + // Auto-generate SSH key for new servers that don't have one yet + let server = if server.key_status != "active" { + match VaultClient::generate_ssh_keypair() { + Ok((public_key, private_key)) => { + match vault_client + .get_ref() + .store_ssh_key(&user.id, server.id, &public_key, &private_key) + .await + { + Ok(vault_path) => { + tracing::info!( + "Auto-generated SSH key for server {} (vault_key_path: {})", + server.id, + vault_path + ); + db::server::update_ssh_key_status( + pg_pool.get_ref(), + server.id, + Some(vault_path), + "active", + ) + .await + .unwrap_or_else(|e| { + tracing::warn!("Failed to update SSH key status: {}", e); + server + }) + } + Err(e) => { + tracing::warn!( + "Failed to store auto-generated SSH key in Vault for server {}: {}", + server.id, + e + ); + server + } + } + } + Err(e) => { + tracing::warn!( + "Failed to auto-generate SSH keypair for server {}: {}", + server.id, + e + ); + server + } + } + } else { + server + }; // Store deployment attempts into deployment table in db let json_request = dc.project.metadata.clone(); @@ -157,7 +250,7 @@ pub async fn item( }) .map_err(|err| JsonResponse::::build().internal_server_error(err)) } -#[tracing::instrument(name = "Deploy, when cloud token is saved", skip(user_service))] +#[tracing::instrument(name = "Deploy, when cloud token is saved", skip(user_service, install_service, vault_client))] #[post("/{id}/deploy/{cloud_id}")] pub async fn saved_item( user: web::ReqData>, @@ -167,6 +260,8 @@ pub async fn saved_item( mq_manager: Data, sets: Data, user_service: Data>, + install_service: Data>, + vault_client: Data, ) -> Result { let id = path.0; let cloud_id = path.1; @@ -270,11 +365,12 @@ pub async fn saved_item( server.disk_type = form.server.disk_type.clone(); server.region = form.server.region.clone(); server.server = form.server.server.clone(); - server.zone = form.server.zone.clone(); + server.zone = form.server.zone.clone().or(server.zone); server.os = form.server.os.clone(); server.project_id = id; - server.srv_ip = form.server.srv_ip.clone(); - server.ssh_user = form.server.ssh_user.clone(); + // Preserve existing srv_ip if form doesn't provide one + server.srv_ip = form.server.srv_ip.clone().or(server.srv_ip); + server.ssh_user = form.server.ssh_user.clone().or(server.ssh_user); server.ssh_port = form.server.ssh_port.or(server.ssh_port); server.name = form.server.name.clone().or(server.name); if form.server.connection_mode.is_some() { @@ -301,17 +397,55 @@ pub async fn saved_item( })? }; - // Building Payload for the 3-d party service through RabbitMQ - // let mut payload = forms::project::Payload::default(); - let mut payload = forms::project::Payload::try_from(&dc.project) - .map_err(|err| JsonResponse::::build().bad_request(err))?; - - payload.server = Some(server.into()); - payload.cloud = Some(cloud.into()); - payload.stack = form.stack.clone().into(); - payload.user_token = Some(user.id.clone()); - payload.user_email = Some(user.email.clone()); - payload.docker_compose = Some(compress(fc.as_str())); + // Auto-generate SSH key for new servers that don't have one yet + let server = if server.key_status != "active" { + match VaultClient::generate_ssh_keypair() { + Ok((public_key, private_key)) => { + match vault_client + .get_ref() + .store_ssh_key(&user.id, server.id, &public_key, &private_key) + .await + { + Ok(vault_path) => { + tracing::info!( + "Auto-generated SSH key for server {} (vault_key_path: {})", + server.id, + vault_path + ); + db::server::update_ssh_key_status( + pg_pool.get_ref(), + server.id, + Some(vault_path), + "active", + ) + .await + .unwrap_or_else(|e| { + tracing::warn!("Failed to update SSH key status: {}", e); + server + }) + } + Err(e) => { + tracing::warn!( + "Failed to store auto-generated SSH key in Vault for server {}: {}", + server.id, + e + ); + server + } + } + } + Err(e) => { + tracing::warn!( + "Failed to auto-generate SSH keypair for server {}: {}", + server.id, + e + ); + server + } + } + } else { + server + }; // Store deployment attempts into deployment table in db let json_request = dc.project.metadata.clone(); @@ -326,39 +460,35 @@ pub async fn saved_item( let result = db::deployment::insert(pg_pool.get_ref(), deployment) .await - .map(|deployment| { - payload.id = Some(deployment.id); - deployment - }) .map_err(|_| { JsonResponse::::build().internal_server_error("Internal Server Error") })?; let deployment_id = result.id; - // Set deployment_hash in payload before publishing to RabbitMQ - payload.deployment_hash = Some(deployment_hash); - tracing::debug!("Save deployment result: {:?}", result); - tracing::debug!( - "Send project data (deployment_hash = {:?}): {:?}", - payload.deployment_hash, - payload - ); - // Send Payload - mq_manager - .publish( - "install".to_string(), - "install.start.tfa.all.all".to_string(), - &payload, + // Delegate to install service connector (determines own vs tfa routing) + install_service + .deploy( + user.id.clone(), + user.email.clone(), + id, + deployment_id, + deployment_hash, + &dc.project, + cloud, + server, + &form.stack, + fc, + mq_manager.get_ref(), ) .await - .map_err(|err| JsonResponse::::build().internal_server_error(err)) - .map(|_| { + .map(|project_id| { JsonResponse::::build() - .set_id(id) + .set_id(project_id) .set_meta(serde_json::json!({ "deployment_id": deployment_id })) .ok("Success") }) + .map_err(|err| JsonResponse::::build().internal_server_error(err)) } diff --git a/src/routes/project/discover.rs b/src/routes/project/discover.rs index fe6b6e63..9dbc3ef8 100644 --- a/src/routes/project/discover.rs +++ b/src/routes/project/discover.rs @@ -12,6 +12,8 @@ use serde_json::json; use sqlx::PgPool; use std::sync::Arc; +const BLOCKED_SYSTEM_CONTAINERS: [&str; 3] = ["status", "status_agent", "telegraf"]; + /// Discovered container that's not registered in project_app #[derive(Debug, Serialize, Clone)] pub struct DiscoveredContainer { @@ -247,6 +249,15 @@ pub async fn discover_containers( "Discovered containers" ); + // Exclude system containers from discovery/import candidates + running_containers.retain(|container| { + !is_blocked_system_container( + &container.name, + &container.image, + container.app_code.as_deref(), + ) + }); + // Classify containers let mut registered = Vec::new(); let mut unregistered = Vec::new(); @@ -343,6 +354,14 @@ pub async fn import_containers( let mut errors = Vec::new(); for container in &body.containers { + if is_blocked_system_container(&container.container_name, &container.image, Some(&container.app_code)) { + errors.push(format!( + "Container '{}' is a system container and cannot be imported", + container.container_name + )); + continue; + } + // Check if app_code already exists let existing = db::project_app::fetch_by_project_and_code( pg_pool.get_ref(), @@ -531,3 +550,36 @@ fn capitalize(s: &str) -> String { Some(f) => f.to_uppercase().chain(c).collect(), } } + +fn is_blocked_system_container(container_name: &str, image: &str, app_code: Option<&str>) -> bool { + let mut candidates: Vec = vec![normalize_container_token(container_name)]; + + if let Some(code) = app_code { + candidates.push(normalize_container_token(code)); + } + + if let Some(compose_parts) = extract_compose_service(container_name) { + candidates.push(normalize_container_token(&compose_parts.service)); + } + + if let Some(img_name) = image.split('/').last() { + if let Some(name_without_tag) = img_name.split(':').next() { + candidates.push(normalize_container_token(name_without_tag)); + } + } + + candidates + .iter() + .any(|candidate| BLOCKED_SYSTEM_CONTAINERS.contains(&candidate.as_str())) +} + +fn normalize_container_token(value: &str) -> String { + value + .trim_start_matches('/') + .trim() + .to_lowercase() + .split(['-', '_']) + .filter(|part| !part.is_empty()) + .collect::>() + .join("_") +} diff --git a/src/routes/server/delete.rs b/src/routes/server/delete.rs index 3ee9ad5f..ebc9d87e 100644 --- a/src/routes/server/delete.rs +++ b/src/routes/server/delete.rs @@ -1,32 +1,157 @@ use crate::db; -use crate::helpers::JsonResponse; +use crate::helpers::{JsonResponse, VaultClient}; use crate::models; use crate::models::Server; -use actix_web::{delete, web, Responder, Result}; +use actix_web::{delete, get, web, Responder, Result}; use sqlx::PgPool; use std::sync::Arc; -#[tracing::instrument(name = "Delete user's server.")] +/// Preview what would be deleted if the server is removed. +/// Returns: ssh_key_shared, affected_deployments, agent_count +#[tracing::instrument(name = "Preview server deletion impact.")] +#[get("/{id}/delete-preview")] +pub async fn delete_preview( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let (id,) = path.into_inner(); + + let server = db::server::fetch(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .and_then(|server| match server { + Some(server) if server.user_id != user.id => { + Err(JsonResponse::::build().bad_request("Access forbidden")) + } + Some(server) => Ok(server), + None => Err(JsonResponse::::build().not_found("")), + })?; + + // Check if SSH key is shared with other servers + let ssh_key_shared = if let Some(ref vault_path) = server.vault_key_path { + let user_servers = db::server::fetch_by_user(pg_pool.get_ref(), &user.id) + .await + .unwrap_or_default(); + + user_servers.iter().any(|s| { + s.id != server.id + && s.vault_key_path.as_deref() == Some(vault_path.as_str()) + }) + } else { + false + }; + + // Find affected deployments via project + let mut affected_deployments: Vec = Vec::new(); + let mut agent_count: usize = 0; + + if let Ok(Some(deployment)) = + db::deployment::fetch_by_project_id(pg_pool.get_ref(), server.project_id).await + { + affected_deployments.push(serde_json::json!({ + "deployment_hash": deployment.deployment_hash, + "status": deployment.status, + })); + + // Check for agent + if let Ok(Some(_agent)) = + db::agent::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment.deployment_hash) + .await + { + agent_count += 1; + } + } + + Ok(JsonResponse::::build().set_item(serde_json::json!({ + "ssh_key_shared": ssh_key_shared, + "affected_deployments": affected_deployments, + "agent_count": agent_count, + })).ok("Delete preview")) +} + +#[tracing::instrument(name = "Delete user's server with cleanup.")] #[delete("/{id}")] pub async fn item( user: web::ReqData>, path: web::Path<(i32,)>, pg_pool: web::Data, + vault_client: web::Data, ) -> Result { - // Get server apps of logged user only let (id,) = path.into_inner(); let server = db::server::fetch(pg_pool.get_ref(), id) .await - .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .map_err(|err| JsonResponse::::build().internal_server_error(err)) .and_then(|server| match server { Some(server) if server.user_id != user.id => { - Err(JsonResponse::::build().bad_request("Delete is forbidden")) + Err(JsonResponse::::build().bad_request("Delete is forbidden")) } Some(server) => Ok(server), - None => Err(JsonResponse::::build().not_found("")), + None => Err(JsonResponse::::build().not_found("")), })?; + // 1. Check if SSH key is shared before cleaning up + let ssh_key_shared = if let Some(ref vault_path) = server.vault_key_path { + let user_servers = db::server::fetch_by_user(pg_pool.get_ref(), &user.id) + .await + .unwrap_or_default(); + + user_servers.iter().any(|s| { + s.id != server.id + && s.vault_key_path.as_deref() == Some(vault_path.as_str()) + }) + } else { + false + }; + + // 2. Delete SSH key from Vault if not shared and key exists + if !ssh_key_shared && server.vault_key_path.is_some() { + if let Err(e) = vault_client + .delete_ssh_key(&user.id, server.id) + .await + { + tracing::warn!( + "Failed to delete SSH key from Vault for server {}: {}. Continuing with server deletion.", + server.id, + e + ); + } + } + + // 3. Clean up agents linked via deployment → project + if let Ok(Some(deployment)) = + db::deployment::fetch_by_project_id(pg_pool.get_ref(), server.project_id).await + { + // Delete agent record + if let Ok(Some(agent)) = + db::agent::fetch_by_deployment_hash(pg_pool.get_ref(), &deployment.deployment_hash) + .await + { + // Delete agent token from Vault + if let Err(e) = vault_client + .delete_agent_token(&deployment.deployment_hash) + .await + { + tracing::warn!( + "Failed to delete agent token from Vault for deployment {}: {}", + deployment.deployment_hash, + e + ); + } + + // Delete agent record from DB + if let Err(e) = db::agent::delete(pg_pool.get_ref(), agent.id).await { + tracing::warn!( + "Failed to delete agent record for deployment {}: {}", + deployment.deployment_hash, + e + ); + } + } + } + + // 4. Delete server record from DB db::server::delete(pg_pool.get_ref(), server.id) .await .map_err(|err| JsonResponse::::build().internal_server_error(err)) diff --git a/src/routes/server/ssh_key.rs b/src/routes/server/ssh_key.rs index 5501dc0c..30dbdf53 100644 --- a/src/routes/server/ssh_key.rs +++ b/src/routes/server/ssh_key.rs @@ -269,6 +269,9 @@ pub struct ValidateResponse { /// Available memory in MB #[serde(skip_serializing_if = "Option::is_none")] pub memory_available_mb: Option, + /// Public key stored in Vault (shown only on auth failure for debugging) + #[serde(skip_serializing_if = "Option::is_none")] + pub vault_public_key: Option, } /// Validate SSH connection for a server @@ -347,6 +350,13 @@ pub async fn validate_key( } }; + // Also fetch public key so we can include it in failed auth responses for debugging + let vault_public_key = vault_client + .get_ref() + .fetch_ssh_public_key(&user.id, server_id) + .await + .ok(); + // Get SSH connection parameters let ssh_port = server.ssh_port.unwrap_or(22) as u16; let ssh_user = server.ssh_user.clone().unwrap_or_else(|| "root".to_string()); @@ -376,6 +386,8 @@ pub async fn validate_key( message, connected: check_result.connected, authenticated: check_result.authenticated, + // Include vault public key in response when auth fails (helps debug key mismatch) + vault_public_key: if !check_result.authenticated { vault_public_key } else { None }, username: check_result.username, disk_total_gb: check_result.disk_total_gb, disk_available_gb: check_result.disk_available_gb, diff --git a/src/routes/server/update.rs b/src/routes/server/update.rs index 9a3ae812..a5ed65dc 100644 --- a/src/routes/server/update.rs +++ b/src/routes/server/update.rs @@ -37,6 +37,44 @@ pub async fn item( server.project_id = server_row.project_id; server.user_id = user.id.clone(); + // Preserve existing values when form fields are not provided (None) + // This prevents accidental data loss (e.g., IP getting wiped to NULL) + if server.srv_ip.is_none() { + server.srv_ip = server_row.srv_ip.clone(); + } + if server.ssh_port.is_none() { + server.ssh_port = server_row.ssh_port; + } + if server.ssh_user.is_none() { + server.ssh_user = server_row.ssh_user.clone(); + } + if server.name.is_none() { + server.name = server_row.name.clone(); + } + if server.cloud_id.is_none() { + server.cloud_id = server_row.cloud_id; + } + if server.region.is_none() { + server.region = server_row.region.clone(); + } + if server.zone.is_none() { + server.zone = server_row.zone.clone(); + } + if server.server.is_none() { + server.server = server_row.server.clone(); + } + if server.os.is_none() { + server.os = server_row.os.clone(); + } + if server.disk_type.is_none() { + server.disk_type = server_row.disk_type.clone(); + } + if server.vault_key_path.is_none() { + server.vault_key_path = server_row.vault_key_path.clone(); + } + // Preserve key_status from existing record (not settable via form) + server.key_status = server_row.key_status.clone(); + tracing::debug!("Updating server {:?}", server); db::server::update(pg_pool.get_ref(), server) diff --git a/src/startup.rs b/src/startup.rs index 3437a032..51bdbfd6 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -88,11 +88,13 @@ pub async fn run( .allow_any_origin() .allow_any_method() .allowed_headers(vec![ - actix_web::http::header::AUTHORIZATION, - actix_web::http::header::CONTENT_TYPE, - actix_web::http::header::ACCEPT, + http::header::AUTHORIZATION, + http::header::CONTENT_TYPE, + http::header::ACCEPT, + http::header::ORIGIN, + http::header::HeaderName::from_static("x-requested-with"), ]) - .supports_credentials() + .expose_any_header() .max_age(3600), ) .wrap(TracingLogger::default()) @@ -250,6 +252,7 @@ pub async fn run( .service(crate::routes::server::get::list) .service(crate::routes::server::get::list_by_project) .service(crate::routes::server::update::item) + .service(crate::routes::server::delete::delete_preview) .service(crate::routes::server::delete::item) .service(crate::routes::server::ssh_key::generate_key) .service(crate::routes::server::ssh_key::upload_key) @@ -263,6 +266,12 @@ pub async fn run( .service(crate::routes::agreement::get_handler) .service(crate::routes::agreement::accept_handler), ) + .service( + web::scope("/chat") + .service(crate::routes::chat::get::item) + .service(crate::routes::chat::upsert::item) + .service(crate::routes::chat::delete::item), + ) .service(web::resource("/mcp").route(web::get().to(mcp::mcp_websocket))) .app_data(json_config.clone()) .app_data(api_pool.clone())