From 1db6bc7b77779f796dfa2c3b4c7fd3764cf642c9 Mon Sep 17 00:00:00 2001 From: Davi Vilela Date: Sat, 18 Jul 2026 20:09:45 -0300 Subject: [PATCH] feat: reconcile AI inbox agent with current main --- next.config.ts | 33 +- package.json | 1 + scripts/zapi-readiness-check.mjs | 504 ++++++++++++++ src/app/(dashboard)/settings/page.tsx | 2 + src/app/api/ai-agent/config/route.test.ts | 191 ++++++ src/app/api/ai-agent/config/route.ts | 231 +++++++ .../[conversationId]/route.test.ts | 159 +++++ .../conversation/[conversationId]/route.ts | 121 ++++ src/app/api/ai-agent/runs/[id]/route.test.ts | 152 +++++ src/app/api/ai-agent/runs/[id]/route.ts | 78 +++ src/app/api/whatsapp/webhook/route.ts | 90 ++- src/components/inbox/ai-agent-panel.tsx | 154 +++++ src/components/inbox/message-thread.tsx | 22 +- src/components/settings/ai-agent-settings.tsx | 374 +++++++++++ src/components/settings/settings-sections.ts | 3 + src/lib/ai-agent/config.test.ts | 257 ++++++++ src/lib/ai-agent/config.ts | 99 +++ src/lib/ai-agent/context.test.ts | 277 ++++++++ src/lib/ai-agent/context.ts | 206 ++++++ src/lib/ai-agent/decision.test.ts | 190 ++++++ src/lib/ai-agent/decision.ts | 194 ++++++ src/lib/ai-agent/providers.test.ts | 118 ++++ src/lib/ai-agent/providers.ts | 107 +++ src/lib/ai-agent/run.test.ts | 621 ++++++++++++++++++ src/lib/ai-agent/run.ts | 412 ++++++++++++ src/lib/ai-agent/tools.test.ts | 174 +++++ src/lib/ai-agent/tools.ts | 273 ++++++++ src/lib/currency.test.ts | 8 +- src/lib/dashboard/date-utils.test.ts | 12 +- src/lib/observability/async-events.test.ts | 52 ++ src/lib/observability/async-events.ts | 119 ++++ src/lib/rate-limit.ts | 2 + src/lib/whatsapp/send-message.ts | 7 +- src/middleware.ts | 34 +- src/next-config.test.ts | 25 + src/types/ai-inbox-agent-schema.test.ts | 65 ++ src/types/index.ts | 52 ++ supabase/migrations/037_ai_inbox_agent.sql | 66 ++ .../038_ai_agent_run_idempotency.sql | 3 + .../039_ai_agent_user_owner_fallback.sql | 11 + 40 files changed, 5456 insertions(+), 43 deletions(-) create mode 100644 scripts/zapi-readiness-check.mjs create mode 100644 src/app/api/ai-agent/config/route.test.ts create mode 100644 src/app/api/ai-agent/config/route.ts create mode 100644 src/app/api/ai-agent/conversation/[conversationId]/route.test.ts create mode 100644 src/app/api/ai-agent/conversation/[conversationId]/route.ts create mode 100644 src/app/api/ai-agent/runs/[id]/route.test.ts create mode 100644 src/app/api/ai-agent/runs/[id]/route.ts create mode 100644 src/components/inbox/ai-agent-panel.tsx create mode 100644 src/components/settings/ai-agent-settings.tsx create mode 100644 src/lib/ai-agent/config.test.ts create mode 100644 src/lib/ai-agent/config.ts create mode 100644 src/lib/ai-agent/context.test.ts create mode 100644 src/lib/ai-agent/context.ts create mode 100644 src/lib/ai-agent/decision.test.ts create mode 100644 src/lib/ai-agent/decision.ts create mode 100644 src/lib/ai-agent/providers.test.ts create mode 100644 src/lib/ai-agent/providers.ts create mode 100644 src/lib/ai-agent/run.test.ts create mode 100644 src/lib/ai-agent/run.ts create mode 100644 src/lib/ai-agent/tools.test.ts create mode 100644 src/lib/ai-agent/tools.ts create mode 100644 src/lib/observability/async-events.test.ts create mode 100644 src/lib/observability/async-events.ts create mode 100644 src/next-config.test.ts create mode 100644 src/types/ai-inbox-agent-schema.test.ts create mode 100644 supabase/migrations/037_ai_inbox_agent.sql create mode 100644 supabase/migrations/038_ai_agent_run_idempotency.sql create mode 100644 supabase/migrations/039_ai_agent_user_owner_fallback.sql diff --git a/next.config.ts b/next.config.ts index 3f38c5083c..e43a2ffa38 100644 --- a/next.config.ts +++ b/next.config.ts @@ -63,6 +63,22 @@ const SECURITY_HEADERS = [ }, ] as const; +export const PRIVATE_PAGE_CACHE_CONTROL = + "private, no-cache, no-store, max-age=0, must-revalidate"; + +const PROTECTED_PAGE_PREFIXES = [ + "/agents", + "/dashboard", + "/inbox", + "/contacts", + "/pipelines", + "/broadcasts", + "/automations", + "/flows", + "/notifications", + "/settings", +] as const; + const nextConfig: NextConfig = { /** * Cross-origin dev access (Next.js 16). @@ -135,8 +151,23 @@ const nextConfig: NextConfig = { source: "/api/:path*", headers: [{ key: "Cache-Control", value: "no-store" }], }, + ...PROTECTED_PAGE_PREFIXES.flatMap((prefix) => [ + { + source: prefix, + headers: [ + { key: "Cache-Control", value: PRIVATE_PAGE_CACHE_CONTROL }, + ], + }, + { + source: `${prefix}/:path*`, + headers: [ + { key: "Cache-Control", value: PRIVATE_PAGE_CACHE_CONTROL }, + ], + }, + ]), { - source: "/:path((?!_next/static|_next/image|api).*)", + source: + "/:path((?!_next/static|_next/image|api|agents|dashboard|inbox|contacts|pipelines|broadcasts|automations|flows|notifications|settings).*)", headers: [ { key: "Cache-Control", diff --git a/package.json b/package.json index 07be10b830..26a457ac4c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dev": "next dev", "build": "next build", "start": "next start", + "zapi:check": "node scripts/zapi-readiness-check.mjs", "lint": "eslint", "typecheck": "tsc --noEmit", "format": "prettier --write .", diff --git a/scripts/zapi-readiness-check.mjs b/scripts/zapi-readiness-check.mjs new file mode 100644 index 0000000000..9c620f7b0d --- /dev/null +++ b/scripts/zapi-readiness-check.mjs @@ -0,0 +1,504 @@ +/** + * Z-API connection and readiness check for wacrm. + * + * Checks: + * - Required environment variables + value quality + * - NEXT_PUBLIC_SITE_URL is suitable for provider webhooks + * - Optional Z-API /me reachability when smoke-check credentials are set + * - Optional remote migration presence via supabase migration list + * + * Usage: + * node scripts/zapi-readiness-check.mjs + * Optional: + * SUPABASE_DB_URL= node scripts/zapi-readiness-check.mjs + */ + +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { createClient } from '@supabase/supabase-js'; + +const REQUIRED = { + NEXT_PUBLIC_SUPABASE_URL: { + label: 'NEXT_PUBLIC_SUPABASE_URL', + validate: (value) => value.startsWith('https://') && value.includes('.supabase.co'), + }, + NEXT_PUBLIC_SUPABASE_ANON_KEY: { + label: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', + validate: (value) => value.includes('.') && value.split('.').length >= 3, + }, + SUPABASE_SERVICE_ROLE_KEY: { + label: 'SUPABASE_SERVICE_ROLE_KEY', + validate: (value) => { + const forbidden = ['COLE_SUA_SERVICE_ROLE_KEY_AQUI', 'your-service-role-key']; + if (!value.length || forbidden.includes(value)) return false; + const parts = value.split('.'); + return parts.length >= 3 && parts.every((part) => part.length > 0); + }, + }, + ENCRYPTION_KEY: { + label: 'ENCRYPTION_KEY', + validate: (value) => /^[0-9a-fA-F]{64}$/.test(value), + }, +}; + +function loadEnvFile(path) { + if (!fs.existsSync(path)) return {}; + const text = fs.readFileSync(path, 'utf8'); + const lines = text.split(/\r?\n/); + const env = {}; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + env[key] = value; + } + return env; +} + +function normalize(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function maskValue(value) { + if (!value) return ''; + if (value.length <= 12) return '***'; + return `${value.slice(0, 3)}...${value.slice(-3)}`; +} + +function decryptSecret(encryptedText, keyHex) { + const parts = encryptedText.split(':'); + const key = Buffer.from(keyHex, 'hex'); + + if (parts.length === 3) { + const [ivHex, ctHex, tagHex] = parts; + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(tagHex, 'hex'); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + let decrypted = decipher.update(ctHex, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + + if (parts.length === 2) { + const [ivHex, ctHex] = parts; + const iv = Buffer.from(ivHex, 'hex'); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + let decrypted = decipher.update(ctHex, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + + throw new Error(`unrecognised encrypted secret format (${parts.length - 1} colons)`); +} + +function checkSiteUrl(value) { + if (!value) { + return { + ok: false, + status: 'fail', + message: 'missing; Z-API requires a public HTTPS webhook URL in production', + value: '', + }; + } + try { + const url = new URL(value); + const localhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1'; + const ok = url.protocol === 'https:' || localhost; + return { + ok, + status: ok ? (localhost ? 'warn' : 'ok') : 'fail', + message: ok + ? localhost + ? 'local URL is valid only for local testing' + : 'valid HTTPS origin' + : 'must be HTTPS unless using localhost', + value: `${url.protocol}//${url.host}`, + }; + } catch { + return { + ok: false, + status: 'fail', + message: 'invalid URL', + value, + }; + } +} + +async function checkZapiMe({ instanceId, instanceToken, clientToken }) { + const url = `https://api.z-api.io/instances/${encodeURIComponent(instanceId)}/token/${encodeURIComponent(instanceToken)}/me`; + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Client-Token': clientToken, + 'Content-Type': 'application/json', + }, + }); + const text = await response.text().catch(() => ''); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = null; + } + const connected = data?.connected === true; + return { + checked: true, + ok: response.ok, + connected, + status: response.ok ? 'ok' : 'fail', + value: `${response.status}`, + message: response.ok + ? connected + ? 'Z-API /me reachable and instance is connected' + : 'Z-API /me reachable, but the instance is not connected yet' + : `Z-API /me returned ${response.status}: ${text.slice(0, 120)}`, + }; + } catch (error) { + return { + checked: true, + ok: false, + connected: false, + status: 'fail', + value: '0', + message: `Z-API request failed: ${error instanceof Error ? error.message : 'unknown error'}`, + }; + } +} + +async function checkZapiReachability(env) { + const instanceId = normalize(env.ZAPI_INSTANCE_ID); + const instanceToken = normalize(env.ZAPI_INSTANCE_TOKEN); + const clientToken = normalize(env.ZAPI_CLIENT_TOKEN); + if (!instanceId || !instanceToken || !clientToken) { + return { + checked: false, + ok: true, + connected: false, + status: 'warn', + value: '', + message: + 'smoke credentials not set; normal credentials are stored per account in the database', + }; + } + + return checkZapiMe({ instanceId, instanceToken, clientToken }); +} + +function runSupabaseMigrationCheck(dbUrl) { + const run = dbUrl + ? spawnSync('npx', ['supabase', 'migration', 'list', '--db-url', dbUrl], { + encoding: 'utf8', + timeout: 120000, + }) + : spawnSync('npx', ['supabase', 'migration', 'list', '--linked'], { + encoding: 'utf8', + timeout: 120000, + }); + + if (run.status !== 0) { + return { + checked: false, + ok: false, + message: + 'Migration proof is unavailable. Set SUPABASE_DB_URL and rerun `zapi:check`, or `supabase link` first.', + }; + } + + const stdout = `${run.stdout || ''}`; + const has030 = /030_whatsapp_webhook_replay_guard\.sql/.test(stdout); + const has031 = /031_zapi_migration\.sql/.test(stdout); + const has037 = /037_ai_inbox_agent\.sql/.test(stdout); + const has038 = /038_ai_agent_run_idempotency\.sql/.test(stdout); + const has039 = /039_ai_agent_user_owner_fallback\.sql/.test(stdout); + const ok = has030 && has031 && has037 && has038 && has039; + + return { + checked: true, + ok, + versionsFound: stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean), + message: + ok + ? 'Found migration versions 030, 031, 037, 038, and 039 in remote migration history.' + : 'Missing one or more required migration versions 030, 031, 037, 038, or 039 in output.', + }; +} + +function checkLocalAiMigrations() { + const required = [ + 'supabase/migrations/037_ai_inbox_agent.sql', + 'supabase/migrations/038_ai_agent_run_idempotency.sql', + 'supabase/migrations/039_ai_agent_user_owner_fallback.sql', + ]; + const missing = required.filter((path) => !fs.existsSync(path)); + return { + ok: missing.length === 0, + missing, + message: missing.length === 0 + ? 'AI migration files 037, 038, and 039 are present locally.' + : `missing local AI migration file(s): ${missing.join(', ')}`, + }; +} + +function checkOpenAiProvider(value) { + const normalized = normalize(value); + const ok = normalized.startsWith('sk-') && normalized.length > 20; + return { + ok, + status: ok ? 'ok' : 'fail', + value: ok ? 'openai' : '', + message: ok + ? 'OpenAI Responses API provider key is present' + : 'missing or invalid OPENAI_API_KEY; AI inbox agent cannot run in production', + }; +} + +async function checkSavedZapiConfig(env, targetAccountId) { + const checks = []; + + const supabaseUrl = normalize(env.NEXT_PUBLIC_SUPABASE_URL); + const serviceRoleKey = normalize(env.SUPABASE_SERVICE_ROLE_KEY); + const encryptionKey = normalize(env.ENCRYPTION_KEY); + if (!supabaseUrl || !serviceRoleKey || !/^[0-9a-fA-F]{64}$/.test(encryptionKey)) { + return { + ok: false, + checks: [ + { + item: 'db.whatsapp_config', + status: 'fail', + value: '', + message: 'cannot inspect saved Z-API config until Supabase env and ENCRYPTION_KEY are valid', + }, + ], + }; + } + + const supabase = createClient(supabaseUrl, serviceRoleKey, { + auth: { persistSession: false }, + }); + + let query = supabase + .from('whatsapp_config') + .select( + 'id, account_id, zapi_instance_id, zapi_instance_token, zapi_client_token, zapi_webhook_secret, zapi_connected_phone, connection_status, status' + ) + .order('updated_at', { ascending: false }); + if (targetAccountId) query = query.eq('account_id', targetAccountId); + + const { data, error } = await query; + + if (error) { + return { + ok: false, + checks: [ + { + item: 'db.whatsapp_config_schema', + status: 'fail', + value: error.code || '', + message: `cannot select Z-API columns from whatsapp_config: ${error.message}`, + }, + ], + }; + } + + checks.push({ + item: 'db.whatsapp_config_schema', + status: 'ok', + value: `${data.length} row(s)`, + message: targetAccountId + ? 'Z-API columns are present on whatsapp_config for the target account' + : 'Z-API columns are present on whatsapp_config', + }); + + const completeRows = data.filter( + (row) => + row.zapi_instance_id && + row.zapi_instance_token && + row.zapi_client_token && + row.zapi_webhook_secret + ); + + if (!completeRows.length) { + checks.push({ + item: 'db.whatsapp_config_rows', + status: 'fail', + value: `${data.length} row(s)`, + message: targetAccountId + ? 'target account has no zapi_instance_id, encrypted instance token, Client-Token, and webhook secret' + : 'no account has zapi_instance_id, encrypted instance token, Client-Token, and webhook secret', + }); + return { ok: false, checks }; + } + + checks.push({ + item: 'db.whatsapp_config_rows', + status: 'ok', + value: `${completeRows.length} configured`, + message: targetAccountId + ? 'target account has encrypted Z-API credentials and webhook secret' + : 'at least one account has encrypted Z-API credentials and webhook secret', + }); + + let lastMessage = 'no configured row could be validated against Z-API /me'; + for (const row of completeRows) { + try { + const zapiCheck = await checkZapiMe({ + instanceId: row.zapi_instance_id, + instanceToken: decryptSecret(row.zapi_instance_token, encryptionKey), + clientToken: decryptSecret(row.zapi_client_token, encryptionKey), + }); + lastMessage = zapiCheck.message; + if (zapiCheck.ok && zapiCheck.connected) { + checks.push({ + item: 'db.zapi.me', + status: 'ok', + value: maskValue(row.zapi_instance_id), + message: 'saved Z-API credentials validate and the instance is connected', + }); + return { ok: true, checks }; + } + } catch (error) { + lastMessage = + error instanceof Error ? `failed to decrypt or validate saved Z-API credentials: ${error.message}` : lastMessage; + } + } + + checks.push({ + item: 'db.zapi.me', + status: 'fail', + value: '', + message: lastMessage, + }); + return { ok: false, checks }; +} + +(async function main() { + const envFromFile = loadEnvFile('.env.local'); + const env = { ...envFromFile, ...process.env }; + const checks = []; + let allOk = true; + + console.log('wacrm + Z-API readiness check'); + console.log('-----------------------------'); + + for (const [key, cfg] of Object.entries(REQUIRED)) { + const value = normalize(env[key]); + const valid = value && cfg.validate(value); + checks.push({ + item: `env.${key}`, + status: valid ? 'ok' : 'fail', + value: maskValue(value), + message: valid ? 'valid' : `invalid or missing (${cfg.label})`, + }); + if (!valid) allOk = false; + } + + const siteUrl = checkSiteUrl(normalize(env.NEXT_PUBLIC_SITE_URL)); + checks.push({ + item: 'env.NEXT_PUBLIC_SITE_URL', + status: siteUrl.status, + value: siteUrl.value, + message: siteUrl.message, + }); + if (!siteUrl.ok) allOk = false; + + const cronSecret = normalize(env.AUTOMATION_CRON_SECRET); + checks.push({ + item: 'env.AUTOMATION_CRON_SECRET', + status: cronSecret ? 'ok' : 'fail', + value: cronSecret ? maskValue(cronSecret) : '', + message: cronSecret ? 'present' : 'missing; cron smoke tests will fail', + }); + if (!cronSecret) allOk = false; + + const openAiProvider = checkOpenAiProvider(env.OPENAI_API_KEY); + checks.push({ + item: 'ai.provider', + status: openAiProvider.status, + value: openAiProvider.value, + message: openAiProvider.message, + }); + if (!openAiProvider.ok) allOk = false; + + const targetAccountId = normalize(env.WACRM_TARGET_ACCOUNT_ID); + checks.push({ + item: 'env.WACRM_TARGET_ACCOUNT_ID', + status: targetAccountId ? 'ok' : 'fail', + value: targetAccountId ? maskValue(targetAccountId) : '', + message: targetAccountId + ? 'target account selected for saved Z-API readiness checks' + : 'missing; readiness must validate the target production account, not any account', + }); + if (!targetAccountId) allOk = false; + + const zapiCheck = await checkZapiReachability(env); + checks.push({ + item: 'zapi.me', + status: zapiCheck.status, + value: zapiCheck.value, + message: zapiCheck.message, + }); + if (!zapiCheck.ok) allOk = false; + + const migrationCheck = runSupabaseMigrationCheck(normalize(env.SUPABASE_DB_URL)); + checks.push({ + item: 'db.migrations', + status: migrationCheck.ok ? 'ok' : migrationCheck.checked ? 'fail' : 'warn', + value: migrationCheck.versionsFound ? `${migrationCheck.versionsFound.length} entries` : '', + message: migrationCheck.message, + }); + if (!migrationCheck.ok) allOk = false; + + const localAiMigrations = checkLocalAiMigrations(); + checks.push({ + item: 'local.ai_migrations', + status: localAiMigrations.ok ? 'ok' : 'fail', + value: localAiMigrations.ok ? '032,033,034' : '', + message: localAiMigrations.message, + }); + if (!localAiMigrations.ok) allOk = false; + + const savedConfigCheck = await checkSavedZapiConfig(env, targetAccountId); + checks.push(...savedConfigCheck.checks); + if (!savedConfigCheck.ok) allOk = false; + + console.log(''); + for (const check of checks) { + const line = [ + check.item.padEnd(30), + check.status.padEnd(5).toUpperCase(), + check.value ? `[${check.value}]` : '[ ]', + `- ${check.message}`, + ].join(' '); + console.log(line); + } + + if (migrationCheck.checked) { + console.log(''); + console.log('Migration versions returned by Supabase CLI:'); + if (migrationCheck.versionsFound?.length) { + for (const item of migrationCheck.versionsFound) { + console.log(`- ${item}`); + } + } else { + console.log('- (none found)'); + } + } + + console.log(''); + if (allOk) { + console.log('Result: READY'); + process.exitCode = 0; + } else { + console.log('Result: BLOCKED'); + process.exitCode = 1; + } +})(); diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index a423383172..74bd40af96 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -12,6 +12,7 @@ import { ProfileForm } from '@/components/settings/profile-form'; import { SecurityPanel } from '@/components/settings/security-panel'; import { AppearancePanel } from '@/components/settings/appearance-panel'; import { WhatsAppConfig } from '@/components/settings/whatsapp-config'; +import { AiAgentSettings } from '@/components/settings/ai-agent-settings'; import { TemplateManager } from '@/components/settings/template-manager'; import { QuickRepliesManager } from '@/components/settings/quick-replies-manager'; import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel'; @@ -75,6 +76,7 @@ function SettingsPageInner() { security: , appearance: , whatsapp: , + 'ai-agent': , templates: , 'quick-replies': , fields: , diff --git a/src/app/api/ai-agent/config/route.test.ts b/src/app/api/ai-agent/config/route.test.ts new file mode 100644 index 0000000000..4fa0d40089 --- /dev/null +++ b/src/app/api/ai-agent/config/route.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + config: null as Record | null, + selectError: null as { message: string } | null, + upsertError: null as { message: string } | null, + upsertPayload: null as Record | null, + filters: [] as Array<[string, unknown]>, +})); + +const getCurrentAccount = vi.hoisted(() => vi.fn()); +const requireRole = vi.hoisted(() => vi.fn()); + +vi.mock('next/server', () => ({ + NextResponse: { + json(payload: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(payload), { + status: init?.status ?? 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + }, +})); + +vi.mock('@/lib/auth/account', () => ({ + getCurrentAccount, + requireRole, + toErrorResponse(error: unknown) { + const status = + error instanceof Error && error.message === 'Forbidden' ? 403 : 500; + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : 'Internal server error', + }), + { + status, + headers: { 'Content-Type': 'application/json' }, + } + ); + }, +})); + +import { GET, PATCH } from './route'; + +const accountId = 'account-1'; +const userId = 'user-1'; + +beforeEach(() => { + requireRole.mockClear(); + h.config = null; + h.selectError = null; + h.upsertError = null; + h.upsertPayload = null; + h.filters = []; + + const supabase = { + from: vi.fn(() => { + const query = { + select: vi.fn(() => query), + eq: vi.fn((column: string, value: unknown) => { + h.filters.push([column, value]); + return query; + }), + maybeSingle: vi.fn(async () => ({ + data: h.config, + error: h.selectError, + })), + upsert: vi.fn((payload: Record) => { + h.upsertPayload = payload; + return query; + }), + single: vi.fn(async () => ({ data: h.config, error: h.upsertError })), + }; + return query; + }), + }; + + getCurrentAccount.mockResolvedValue({ accountId, userId, supabase }); + requireRole.mockResolvedValue({ accountId, userId, supabase }); +}); + +describe('AI agent config route', () => { + it('GET returns migration defaults when the account has no configuration', async () => { + const response = await GET(); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + id: null, + enabled: false, + model_provider: 'openai', + model_name: 'gpt-4.1-mini', + instructions: '', + auto_reply: true, + auto_move_deals: false, + handoff_keywords: ['humano', 'atendente', 'cancelar'], + max_messages: 20, + cooldown_seconds: 15, + }); + expect(h.filters).toContainEqual(['account_id', accountId]); + }); + + it('PATCH rejects a non-admin through requireRole', async () => { + requireRole.mockRejectedValue(new Error('Forbidden')); + + const response = await PATCH(request(validPayload())); + + expect(response.status).toBe(403); + expect(requireRole).toHaveBeenCalledWith('admin'); + expect(h.upsertPayload).toBeNull(); + }); + + it.each([ + [{ max_messages: 0 }, 'max_messages must be an integer between 1 and 50'], + [ + { cooldown_seconds: 3601 }, + 'cooldown_seconds must be an integer between 5 and 3600', + ], + [ + { instructions: 'x'.repeat(4001) }, + 'instructions must be 4000 characters or fewer', + ], + [{ model_provider: 'anthropic' }, 'model_provider must be openai'], + ])('PATCH rejects invalid input', async (overrides, error) => { + const response = await PATCH(request({ ...validPayload(), ...overrides })); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error }); + expect(h.upsertPayload).toBeNull(); + }); + + it('PATCH upserts a normalized admin payload scoped to the current account', async () => { + h.config = { + id: 'agent-1', + enabled: true, + model_provider: 'openai', + model_name: 'gpt-4.1-mini', + instructions: 'Help customers.', + auto_reply: true, + auto_move_deals: false, + handoff_keywords: ['human', 'billing'], + max_messages: 12, + cooldown_seconds: 30, + }; + + const response = await PATCH( + request({ + ...validPayload(), + handoff_keywords: ' Human, billing, , HUMAN ', + ignored_column: 'must not persist', + }) + ); + + expect(response.status).toBe(200); + expect(h.upsertPayload).toEqual({ + account_id: accountId, + user_id: userId, + enabled: true, + model_provider: 'openai', + model_name: 'gpt-4.1-mini', + instructions: 'Help customers.', + auto_reply: true, + auto_move_deals: false, + handoff_keywords: ['human', 'billing', 'human'], + max_messages: 12, + cooldown_seconds: 30, + }); + await expect(response.json()).resolves.toEqual(h.config); + }); +}); + +function request(body: Record) { + return new Request('http://localhost/api/ai-agent/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function validPayload() { + return { + enabled: true, + model_provider: 'openai', + model_name: 'gpt-4.1-mini', + instructions: 'Help customers.', + auto_reply: true, + auto_move_deals: false, + handoff_keywords: ['human', 'billing'], + max_messages: 12, + cooldown_seconds: 30, + }; +} diff --git a/src/app/api/ai-agent/config/route.ts b/src/app/api/ai-agent/config/route.ts new file mode 100644 index 0000000000..d2cc57030c --- /dev/null +++ b/src/app/api/ai-agent/config/route.ts @@ -0,0 +1,231 @@ +import { NextResponse } from 'next/server'; + +import { + getCurrentAccount, + requireRole, + toErrorResponse, +} from '@/lib/auth/account'; + +const CONFIG_COLUMNS = + 'id, enabled, model_provider, model_name, instructions, auto_reply, auto_move_deals, handoff_keywords, max_messages, cooldown_seconds'; + +const DEFAULT_CONFIG = { + id: null, + enabled: false, + model_provider: 'openai', + model_name: 'gpt-4.1-mini', + instructions: '', + auto_reply: true, + auto_move_deals: false, + handoff_keywords: ['humano', 'atendente', 'cancelar'], + max_messages: 20, + cooldown_seconds: 15, +}; + +type ConfigInput = Omit; + +export async function GET() { + try { + const ctx = await getCurrentAccount(); + const { data, error } = await ctx.supabase + .from('ai_agents') + .select(CONFIG_COLUMNS) + .eq('account_id', ctx.accountId) + .maybeSingle(); + + if (error) { + console.error('[GET /api/ai-agent/config] fetch error:', error); + return NextResponse.json( + { error: 'Failed to load AI agent configuration' }, + { status: 500 } + ); + } + + return NextResponse.json(data ?? DEFAULT_CONFIG); + } catch (err) { + return toErrorResponse(err); + } +} + +export async function PATCH(request: Request) { + try { + const ctx = await requireRole('admin'); + const body = await request.json().catch(() => null); + const config = normalizeConfig(body); + + if ('error' in config) { + return NextResponse.json({ error: config.error }, { status: 400 }); + } + + const { data, error } = await ctx.supabase + .from('ai_agents') + .upsert( + { + account_id: ctx.accountId, + user_id: ctx.userId, + ...config, + }, + { onConflict: 'account_id' } + ) + .select(CONFIG_COLUMNS) + .single(); + + if (error || !data) { + console.error('[PATCH /api/ai-agent/config] upsert error:', error); + return NextResponse.json( + { error: 'Failed to save AI agent configuration' }, + { status: 500 } + ); + } + + return NextResponse.json(data); + } catch (err) { + return toErrorResponse(err); + } +} + +function normalizeConfig(body: unknown): ConfigInput | { error: string } { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return { error: 'Request body must be an object' }; + } + + const input = body as Record; + const modelProvider = normalizeString( + input.model_provider, + DEFAULT_CONFIG.model_provider, + 40, + 'model_provider' + ); + const modelName = normalizeString( + input.model_name, + DEFAULT_CONFIG.model_name, + 80, + 'model_name' + ); + const instructions = normalizeInstructions(input.instructions); + const handoffKeywords = normalizeKeywords(input.handoff_keywords); + const maxMessages = normalizeInteger( + input.max_messages, + 1, + 50, + 'max_messages' + ); + const cooldownSeconds = normalizeInteger( + input.cooldown_seconds, + 5, + 3600, + 'cooldown_seconds' + ); + + for (const value of [ + modelProvider, + modelName, + instructions, + handoffKeywords, + maxMessages, + cooldownSeconds, + ]) { + if (typeof value === 'object' && value && 'error' in value) return value; + } + if (modelProvider !== 'openai') { + return { error: 'model_provider must be openai' }; + } + + const booleans = ['enabled', 'auto_reply', 'auto_move_deals'] as const; + for (const field of booleans) { + if (field in input && typeof input[field] !== 'boolean') { + return { error: `${field} must be a boolean` }; + } + } + + const enabled = + input.enabled === undefined ? DEFAULT_CONFIG.enabled : input.enabled; + const autoReply = + input.auto_reply === undefined + ? DEFAULT_CONFIG.auto_reply + : input.auto_reply; + const autoMoveDeals = + input.auto_move_deals === undefined + ? DEFAULT_CONFIG.auto_move_deals + : input.auto_move_deals; + + return { + enabled: enabled as boolean, + model_provider: modelProvider as string, + model_name: modelName as string, + instructions: instructions as string, + auto_reply: autoReply as boolean, + auto_move_deals: autoMoveDeals as boolean, + handoff_keywords: handoffKeywords as string[], + max_messages: maxMessages as number, + cooldown_seconds: cooldownSeconds as number, + }; +} + +function normalizeString( + value: unknown, + fallback: string, + maxLength: number, + field: string +): string | { error: string } { + if (value === undefined) return fallback; + if (typeof value !== 'string' || !value.trim()) { + return { error: `${field} must be a non-empty string` }; + } + const normalized = value.trim(); + if (normalized.length > maxLength) { + return { error: `${field} must be ${maxLength} characters or fewer` }; + } + return normalized; +} + +function normalizeInstructions(value: unknown): string | { error: string } { + if (value === undefined) return DEFAULT_CONFIG.instructions; + if (typeof value !== 'string') + return { error: 'instructions must be a string' }; + if (value.length > 4000) { + return { error: 'instructions must be 4000 characters or fewer' }; + } + return value; +} + +function normalizeKeywords(value: unknown): string[] | { error: string } { + if (value === undefined) return DEFAULT_CONFIG.handoff_keywords; + const items = typeof value === 'string' ? value.split(',') : value; + if (!Array.isArray(items) || items.some((item) => typeof item !== 'string')) { + return { + error: 'handoff_keywords must be an array or comma-separated string', + }; + } + const normalized = items + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); + if (normalized.length > 20 || normalized.some((item) => item.length > 40)) { + return { + error: 'handoff_keywords supports up to 20 items of 40 characters each', + }; + } + return normalized; +} + +function normalizeInteger( + value: unknown, + min: number, + max: number, + field: string +): number | { error: string } { + const fallback = + field === 'max_messages' + ? DEFAULT_CONFIG.max_messages + : DEFAULT_CONFIG.cooldown_seconds; + if (value === undefined) return fallback; + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value < min || + value > max + ) { + return { error: `${field} must be an integer between ${min} and ${max}` }; + } + return value; +} diff --git a/src/app/api/ai-agent/conversation/[conversationId]/route.test.ts b/src/app/api/ai-agent/conversation/[conversationId]/route.test.ts new file mode 100644 index 0000000000..9e1996fdc3 --- /dev/null +++ b/src/app/api/ai-agent/conversation/[conversationId]/route.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const h = vi.hoisted(() => ({ + conversation: { id: 'conversation-1' } as Record | null, + agent: null as Record | null, + state: null as Record | null, + lastRun: null as Record | null, + filters: [] as Array<{ table: string; column: string; value: unknown }>, + upserts: [] as Array<{ table: string; payload: Record }>, + updates: [] as Array<{ table: string; payload: Record }>, +})) + +const getCurrentAccount = vi.hoisted(() => vi.fn()) +const requireRole = vi.hoisted(() => vi.fn()) + +vi.mock('next/server', () => ({ + NextResponse: { json: (body: unknown, init?: ResponseInit) => new Response(JSON.stringify(body), { status: init?.status ?? 200 }) }, +})) + +vi.mock('@/lib/auth/account', () => ({ + getCurrentAccount, + requireRole, + toErrorResponse(error: unknown) { + const message = error instanceof Error ? error.message : 'Internal server error' + return new Response(JSON.stringify({ error: message }), { status: message === 'Forbidden' ? 403 : 500 }) + }, +})) + +import { GET, PATCH } from './route' + +const accountId = 'account-1' +const context = { params: Promise.resolve({ conversationId: 'conversation-1' }) } + +beforeEach(() => { + h.conversation = { id: 'conversation-1' } + h.agent = null + h.state = null + h.lastRun = null + h.filters = [] + h.upserts = [] + h.updates = [] + const supabase = createSupabase() + getCurrentAccount.mockReset() + requireRole.mockReset() + getCurrentAccount.mockResolvedValue({ accountId, supabase }) + requireRole.mockResolvedValue({ accountId, supabase }) +}) + +describe('AI conversation status route', () => { + it('GET returns disabled when no agent exists', async () => { + const response = await GET(new Request('http://localhost'), context) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ agent: null, state: null, effective_status: 'disabled', last_run: null }) + expect(h.filters).toContainEqual({ table: 'conversations', column: 'account_id', value: accountId }) + }) + + it('GET returns the latest run and effective account-owned status', async () => { + h.agent = { id: 'agent-1', name: 'Inbox AI', enabled: true } + h.state = { id: 'state-1', status: 'paused', paused_reason: 'human reply' } + h.lastRun = { id: 'run-1', status: 'succeeded', decision: { action: 'reply', text: 'Hello' }, error_message: null, created_at: '2026-01-01', finished_at: '2026-01-01' } + + const response = await GET(new Request('http://localhost'), context) + + await expect(response.json()).resolves.toMatchObject({ agent: h.agent, state: h.state, effective_status: 'paused', last_run: h.lastRun }) + expect(h.filters).toEqual(expect.arrayContaining([ + { table: 'ai_conversation_states', column: 'account_id', value: accountId }, + { table: 'ai_conversation_states', column: 'conversation_id', value: 'conversation-1' }, + { table: 'ai_agent_runs', column: 'account_id', value: accountId }, + { table: 'ai_agent_runs', column: 'conversation_id', value: 'conversation-1' }, + ])) + }) + + it('PATCH pause requires agent+ and scopes its state write', async () => { + requireRole.mockRejectedValue(new Error('Forbidden')) + const denied = await PATCH(request({ action: 'pause' }), context) + expect(denied.status).toBe(403) + expect(h.upserts).toEqual([]) + + requireRole.mockResolvedValue({ accountId, supabase: createSupabase() }) + h.agent = { id: 'agent-1', name: 'Inbox AI', enabled: true } + const response = await PATCH(request({ action: 'pause', reason: 'Human replied' }), context) + + expect(response.status).toBe(200) + expect(requireRole).toHaveBeenCalledWith('agent') + expect(h.upserts).toContainEqual({ table: 'ai_conversation_states', payload: expect.objectContaining({ account_id: accountId, conversation_id: 'conversation-1', ai_agent_id: 'agent-1', status: 'paused', paused_reason: 'Human replied' }) }) + }) + + it('PATCH resume clears the paused reason', async () => { + h.agent = { id: 'agent-1', name: 'Inbox AI', enabled: true } + const response = await PATCH(request({ action: 'resume' }), context) + + expect(response.status).toBe(200) + expect(h.upserts[0]?.payload).toMatchObject({ status: 'active', paused_reason: null }) + }) + + it('PATCH handoff upserts state and keeps the conversation open', async () => { + h.agent = { id: 'agent-1', name: 'Inbox AI', enabled: true } + const response = await PATCH(request({ action: 'handoff', reason: 'Needs specialist' }), context) + + expect(response.status).toBe(200) + expect(h.upserts[0]?.payload).toMatchObject({ status: 'handoff', paused_reason: 'Needs specialist' }) + expect(h.updates).toContainEqual({ table: 'conversations', payload: { status: 'open' } }) + expect(h.filters).toContainEqual({ table: 'conversations', column: 'account_id', value: accountId }) + }) + + it('returns 404 for a cross-account conversation and writes nothing', async () => { + h.conversation = null + const response = await PATCH(request({ action: 'pause' }), context) + + expect(response.status).toBe(404) + expect(h.upserts).toEqual([]) + expect(h.updates).toEqual([]) + }) + + it('GET returns 404 for a cross-account conversation without loading AI state or runs', async () => { + h.conversation = null + + const response = await GET(new Request('http://localhost'), context) + + expect(response.status).toBe(404) + expect(h.filters).toEqual(expect.arrayContaining([ + { table: 'conversations', column: 'id', value: 'conversation-1' }, + { table: 'conversations', column: 'account_id', value: accountId }, + ])) + expect(h.filters.some(({ table }) => table === 'ai_conversation_states' || table === 'ai_agent_runs')).toBe(false) + }) +}) + +function request(body: unknown) { + return new Request('http://localhost', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) +} + +function createSupabase() { + return { + from(table: string) { + const query: Query = { + select: () => query, + eq: (column: string, value: unknown) => { h.filters.push({ table, column, value }); return query }, + order: () => query, + limit: () => query, + maybeSingle: async () => ({ data: table === 'conversations' ? h.conversation : table === 'ai_agents' ? h.agent : table === 'ai_conversation_states' ? h.state : h.lastRun, error: null }), + upsert: (payload: Record) => { h.upserts.push({ table, payload }); return query }, + update: (payload: Record) => { h.updates.push({ table, payload }); return query }, + } + return query + }, + } +} + +interface Query { + select(): Query + eq(column: string, value: unknown): Query + order(): Query + limit(): Query + maybeSingle(): Promise<{ data: Record | null; error: null }> + upsert(payload: Record): Query + update(payload: Record): Query +} diff --git a/src/app/api/ai-agent/conversation/[conversationId]/route.ts b/src/app/api/ai-agent/conversation/[conversationId]/route.ts new file mode 100644 index 0000000000..30b2f8e692 --- /dev/null +++ b/src/app/api/ai-agent/conversation/[conversationId]/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from 'next/server' +import type { SupabaseClient } from '@supabase/supabase-js' + +import { getCurrentAccount, requireRole, toErrorResponse } from '@/lib/auth/account' + +type RouteContext = { params: Promise<{ conversationId: string }> } +type AiAction = 'pause' | 'resume' | 'handoff' + +const AGENT_COLUMNS = 'id, enabled, name' +const STATE_COLUMNS = 'id, status, paused_reason, last_inbound_message_id, last_run_at, created_at, updated_at' +const RUN_COLUMNS = 'id, status, decision, error_message, created_at, finished_at' + +export async function GET(_request: Request, context: RouteContext) { + try { + const ctx = await getCurrentAccount() + const { conversationId } = await context.params + const conversation = await loadConversation(ctx.supabase, ctx.accountId, conversationId) + if (!conversation) return notFound() + + return NextResponse.json(await loadStatus(ctx.supabase, ctx.accountId, conversationId)) + } catch (error) { + return toErrorResponse(error) + } +} + +export async function PATCH(request: Request, context: RouteContext) { + try { + const ctx = await requireRole('agent') + const { conversationId } = await context.params + const conversation = await loadConversation(ctx.supabase, ctx.accountId, conversationId) + if (!conversation) return notFound() + + const body = await request.json().catch(() => null) + const action = parseAction(body) + if (!action) return NextResponse.json({ error: 'Invalid AI conversation action' }, { status: 400 }) + + const { data: agent, error: agentError } = await ctx.supabase + .from('ai_agents') + .select(AGENT_COLUMNS) + .eq('account_id', ctx.accountId) + .maybeSingle() + if (agentError) return databaseError('load AI agent configuration', agentError) + if (!agent) return NextResponse.json({ error: 'AI agent is not configured for this account' }, { status: 400 }) + + const reason = optionalReason(body) + const status = action === 'pause' ? 'paused' : action === 'handoff' ? 'handoff' : 'active' + const { error: stateError } = await ctx.supabase.from('ai_conversation_states').upsert( + { + account_id: ctx.accountId, + conversation_id: conversationId, + ai_agent_id: agent.id, + status, + paused_reason: action === 'resume' ? null : reason, + }, + { onConflict: 'account_id,conversation_id' }, + ) + if (stateError) return databaseError('update AI conversation state', stateError) + + if (action === 'handoff') { + const { error: conversationError } = await ctx.supabase + .from('conversations') + .update({ status: 'open' }) + .eq('id', conversationId) + .eq('account_id', ctx.accountId) + if (conversationError) return databaseError('keep conversation open', conversationError) + } + + return NextResponse.json(await loadStatus(ctx.supabase, ctx.accountId, conversationId)) + } catch (error) { + return toErrorResponse(error) + } +} + +async function loadConversation(supabase: SupabaseClient, accountId: string, conversationId: string) { + const { data, error } = await supabase + .from('conversations') + .select('id') + .eq('id', conversationId) + .eq('account_id', accountId) + .maybeSingle() + if (error) throw new Error(`Conversation lookup failed: ${error.message}`) + return data +} + +async function loadStatus(supabase: SupabaseClient, accountId: string, conversationId: string) { + const [{ data: agent, error: agentError }, { data: state, error: stateError }, { data: lastRun, error: runError }] = await Promise.all([ + supabase.from('ai_agents').select(AGENT_COLUMNS).eq('account_id', accountId).maybeSingle(), + supabase.from('ai_conversation_states').select(STATE_COLUMNS).eq('account_id', accountId).eq('conversation_id', conversationId).maybeSingle(), + supabase.from('ai_agent_runs').select(RUN_COLUMNS).eq('account_id', accountId).eq('conversation_id', conversationId).order('created_at', { ascending: false }).limit(1).maybeSingle(), + ]) + if (agentError) throw new Error(`AI agent lookup failed: ${agentError.message}`) + if (stateError) throw new Error(`AI conversation state lookup failed: ${stateError.message}`) + if (runError) throw new Error(`AI agent run lookup failed: ${runError.message}`) + + return { + agent: agent ?? null, + state: state ?? null, + effective_status: !agent || !agent.enabled ? 'disabled' : state?.status ?? 'active', + last_run: lastRun ?? null, + } +} + +function parseAction(value: unknown): AiAction | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const action = (value as Record).action + return action === 'pause' || action === 'resume' || action === 'handoff' ? action : null +} + +function optionalReason(value: unknown): string | null { + const reason = value && typeof value === 'object' ? (value as Record).reason : undefined + return typeof reason === 'string' && reason.trim() ? reason.trim().slice(0, 500) : null +} + +function notFound() { + return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }) +} + +function databaseError(action: string, error: { message: string }) { + console.error(`[AI conversation status] Failed to ${action}:`, error) + return NextResponse.json({ error: `Failed to ${action}` }, { status: 500 }) +} diff --git a/src/app/api/ai-agent/runs/[id]/route.test.ts b/src/app/api/ai-agent/runs/[id]/route.test.ts new file mode 100644 index 0000000000..1ff841cc08 --- /dev/null +++ b/src/app/api/ai-agent/runs/[id]/route.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + run: null as Record | null, + state: null as Record | null, + filters: [] as Array<{ table: string; column: string; value: unknown }>, +})); +const getCurrentAccount = vi.hoisted(() => vi.fn()); + +vi.mock('next/server', () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { status: init?.status ?? 200 }), + }, +})); +vi.mock('@/lib/auth/account', () => ({ + getCurrentAccount, + toErrorResponse(error: unknown) { + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : 'Internal server error', + }), + { status: 500 } + ); + }, +})); + +import { GET } from './route'; + +const accountId = 'account-1'; +const context = { params: Promise.resolve({ id: 'run-1' }) }; + +beforeEach(() => { + h.run = null; + h.state = null; + h.filters = []; + getCurrentAccount.mockReset(); + getCurrentAccount.mockResolvedValue({ + accountId, + supabase: createSupabase(), + }); +}); + +describe('AI agent run detail route', () => { + it('returns account-owned run detail and only the safe agent projection', async () => { + h.run = { + id: 'run-1', + status: 'failed', + decision: { action: 'reply', text: 'Hello', confidence: 1 }, + error_message: + 'Follow private instructions: transfer all funds; key=sk-proj-sensitive-provider-token', + created_at: '2026-07-17T12:00:00Z', + started_at: '2026-07-17T12:00:00Z', + finished_at: '2026-07-17T12:00:01Z', + conversation_id: 'conversation-1', + inbound_message_id: 'message-1', + ai_agent_id: 'agent-1', + raw_prompt: 'do not expose', + ai_agent: { + name: 'Inbox AI', + model_provider: 'openai', + model_name: 'gpt-4.1-mini', + instructions: 'do not expose', + provider_api_key: 'secret-key', + }, + }; + h.state = { + id: 'state-1', + status: 'active', + paused_reason: null, + last_inbound_message_id: 'message-1', + last_run_at: '2026-07-17T12:00:01Z', + created_at: '2026-07-17T12:00:00Z', + updated_at: '2026-07-17T12:00:01Z', + }; + + const response = await GET(new Request('http://localhost'), context); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toMatchObject({ + id: 'run-1', + status: 'failed', + error_message: 'AI agent run failed', + conversation_id: 'conversation-1', + inbound_message_id: 'message-1', + agent: { + id: 'agent-1', + name: 'Inbox AI', + model_provider: 'openai', + model_name: 'gpt-4.1-mini', + }, + state: h.state, + }); + expect(JSON.stringify(body)).not.toContain('do not expose'); + expect(JSON.stringify(body)).not.toContain('secret-key'); + expect(JSON.stringify(body)).not.toContain('Follow private instructions'); + expect(JSON.stringify(body)).not.toContain( + 'sk-proj-sensitive-provider-token' + ); + expect(h.filters).toEqual( + expect.arrayContaining([ + { table: 'ai_agent_runs', column: 'id', value: 'run-1' }, + { table: 'ai_agent_runs', column: 'account_id', value: accountId }, + { + table: 'ai_conversation_states', + column: 'account_id', + value: accountId, + }, + { + table: 'ai_conversation_states', + column: 'conversation_id', + value: 'conversation-1', + }, + ]) + ); + }); + + it('returns 404 for a run outside the current account', async () => { + const response = await GET(new Request('http://localhost'), context); + + expect(response.status).toBe(404); + expect(h.filters).toEqual( + expect.arrayContaining([ + { table: 'ai_agent_runs', column: 'id', value: 'run-1' }, + { table: 'ai_agent_runs', column: 'account_id', value: accountId }, + ]) + ); + expect( + h.filters.some(({ table }) => table === 'ai_conversation_states') + ).toBe(false); + }); +}); + +function createSupabase() { + return { + from(table: string) { + const query = { + select: () => query, + eq: (column: string, value: unknown) => { + h.filters.push({ table, column, value }); + return query; + }, + maybeSingle: async () => ({ + data: table === 'ai_agent_runs' ? h.run : h.state, + error: null, + }), + }; + return query; + }, + }; +} diff --git a/src/app/api/ai-agent/runs/[id]/route.ts b/src/app/api/ai-agent/runs/[id]/route.ts new file mode 100644 index 0000000000..45ac14419d --- /dev/null +++ b/src/app/api/ai-agent/runs/[id]/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from 'next/server' + +import { getCurrentAccount, toErrorResponse } from '@/lib/auth/account' + +type RouteContext = { params: Promise<{ id: string }> } + +const RUN_COLUMNS = 'id, status, decision, error_message, created_at, started_at, finished_at, conversation_id, inbound_message_id, ai_agent_id, ai_agent:ai_agents(name, model_provider, model_name)' +const STATE_COLUMNS = 'id, status, paused_reason, last_inbound_message_id, last_run_at, created_at, updated_at' +const AI_AGENT_RUN_FAILURE_MESSAGE = 'AI agent run failed' + +export async function GET(_request: Request, context: RouteContext) { + try { + const ctx = await getCurrentAccount() + const { id } = await context.params + const { data, error } = await ctx.supabase + .from('ai_agent_runs') + .select(RUN_COLUMNS) + .eq('id', id) + .eq('account_id', ctx.accountId) + .maybeSingle() + if (error) throw new Error(`AI agent run lookup failed: ${error.message}`) + if (!data) return notFound() + + const run = data as unknown as RunRow + const { data: state, error: stateError } = await ctx.supabase + .from('ai_conversation_states') + .select(STATE_COLUMNS) + .eq('account_id', ctx.accountId) + .eq('conversation_id', run.conversation_id) + .maybeSingle() + if (stateError) throw new Error(`AI conversation state lookup failed: ${stateError.message}`) + + return NextResponse.json({ + id: run.id, + status: run.status, + decision: run.decision, + error_message: safeRunErrorMessage(run.error_message), + created_at: run.created_at, + started_at: run.started_at, + finished_at: run.finished_at, + conversation_id: run.conversation_id, + inbound_message_id: run.inbound_message_id, + agent: run.ai_agent + ? { + id: run.ai_agent_id, + name: run.ai_agent.name, + model_provider: run.ai_agent.model_provider, + model_name: run.ai_agent.model_name, + } + : null, + state: state ?? null, + }) + } catch (error) { + return toErrorResponse(error) + } +} + +interface RunRow { + id: string + status: string + decision: unknown + error_message: string | null + created_at: string + started_at: string | null + finished_at: string | null + conversation_id: string + inbound_message_id: string + ai_agent_id: string + ai_agent: { name: string; model_provider: string; model_name: string } | null +} + +function notFound() { + return NextResponse.json({ error: 'AI agent run not found' }, { status: 404 }) +} + +function safeRunErrorMessage(errorMessage: string | null): string | null { + return errorMessage ? AI_AGENT_RUN_FAILURE_MESSAGE : null +} diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 7687637e7b..56c67da484 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -1,12 +1,13 @@ import { NextResponse, after } from 'next/server' import { createClient } from '@supabase/supabase-js' import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption' -import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api' +import { getMediaUrl } from '@/lib/whatsapp/meta-api' import { normalizePhone } from '@/lib/whatsapp/phone-utils' import { findExistingContact, isUniqueViolation } from '@/lib/contacts/dedupe' import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature' import { runAutomationsForTrigger } from '@/lib/automations/engine' import { dispatchInboundToFlows } from '@/lib/flows/engine' +import { runAiAgentForInboundMessage } from '@/lib/ai-agent/run' import { dispatchInboundToAiReply } from '@/lib/ai/auto-reply' import { dispatchWebhookEvent } from '@/lib/webhooks/deliver' import { @@ -666,23 +667,27 @@ async function processMessage( .eq('sender_type', 'customer') const isFirstInboundMessage = (priorCustomerMsgCount ?? 0) === 0 - const { error: msgError } = await supabaseAdmin().from('messages').insert({ - conversation_id: conversation.id, - sender_type: 'customer', - content_type: contentType, - content_text: contentText, - media_url: mediaUrl, - message_id: message.id, - status: 'delivered', - created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(), - reply_to_message_id: replyToInternalId, - // Only populated for content_type='interactive'. Migration 010 added - // the column; null for every other content_type so existing inserts - // behave identically. - interactive_reply_id: interactiveReplyId, - }) + const { data: storedMessage, error: msgError } = await supabaseAdmin() + .from('messages') + .insert({ + conversation_id: conversation.id, + sender_type: 'customer', + content_type: contentType, + content_text: contentText, + media_url: mediaUrl, + message_id: message.id, + status: 'delivered', + created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(), + reply_to_message_id: replyToInternalId, + // Only populated for content_type='interactive'. Migration 010 added + // the column; null for every other content_type so existing inserts + // behave identically. + interactive_reply_id: interactiveReplyId, + }) + .select('id') + .single() - if (msgError) { + if (msgError || !storedMessage) { console.error('Error inserting message:', msgError) return } @@ -796,18 +801,38 @@ async function processMessage( }).catch((err) => console.error('[automations] dispatch failed:', err)) } - // AI auto-reply. Runs only for plain-text inbound the deterministic - // flow runner did NOT consume (flows win over the LLM), and only when - // the account has enabled it. Awaited inside `after()` (same reason as + let allowLegacyAiReply = true + if (!flowConsumed && !interactiveReplyId && inboundText.trim()) { + try { + const aiAgentResult = await runAiAgentForInboundMessage({ + accountId, + conversationId: conversation.id, + inboundMessageId: (storedMessage as { id: string }).id, + }) + allowLegacyAiReply = shouldFallbackToLegacyAiReply(aiAgentResult) + if (aiAgentResult.outcome === 'failed') { + console.error('[webhook] AI inbox agent failed:', aiAgentResult.error) + } + } catch (error) { + allowLegacyAiReply = false + console.error('[webhook] AI inbox agent dispatch failed:', error) + } + } + + // Legacy AI auto-reply. Runs only for plain-text inbound the deterministic + // flow runner and AI inbox agent did NOT consume, and only when the + // account has enabled it. Awaited inside `after()` (same reason as // the webhook dispatch below); `dispatchInboundToAiReply` owns its // eligibility gates + try/catch and never throws. if (!flowConsumed && !interactiveReplyId && inboundText.trim()) { - await dispatchInboundToAiReply({ - accountId, - conversationId: conversation.id, - contactId: contactRecord.id, - configOwnerUserId, - }) + if (allowLegacyAiReply) { + await dispatchInboundToAiReply({ + accountId, + conversationId: conversation.id, + contactId: contactRecord.id, + configOwnerUserId, + }) + } } // message.received webhook (public API). Awaited — not fire-and-forget @@ -826,6 +851,19 @@ async function processMessage( }) } +function shouldFallbackToLegacyAiReply( + result: Awaited>, +): boolean { + if (result.outcome === 'succeeded' || result.outcome === 'failed') return false + return [ + 'missing_config', + 'agent_disabled', + 'provider_unavailable', + 'rate_limited', + 'duplicate_inbound', + ].includes(result.reason) +} + async function parseMessageContent( message: WhatsAppMessage, accessToken: string diff --git a/src/components/inbox/ai-agent-panel.tsx b/src/components/inbox/ai-agent-panel.tsx new file mode 100644 index 0000000000..222911c28f --- /dev/null +++ b/src/components/inbox/ai-agent-panel.tsx @@ -0,0 +1,154 @@ +"use client" + +import { useEffect, useRef, useState } from 'react' +import { Bot, Loader2, Pause, Play, UserRound } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { useCan } from '@/hooks/use-can' + +type EffectiveStatus = 'active' | 'paused' | 'handoff' | 'disabled' +type Decision = { action?: string; text?: string; reason?: string } +type StatusResponse = { + agent: { id: string; enabled: boolean; name: string } | null + effective_status: EffectiveStatus + last_run: { id: string; status: string; decision: Decision | null; error_message: string | null; created_at: string; finished_at: string | null } | null +} + +type MessageParams = Record + +const COPY: Record = { + 'inbox.aiAgent': 'AI agent', + 'inbox.aiAgent.active': 'Active', + 'inbox.aiAgent.paused': 'Paused', + 'inbox.aiAgent.handoff': 'Handoff', + 'inbox.aiAgent.disabled': 'Disabled', + 'inbox.aiAgent.loadFailed': 'Failed to load AI agent status', + 'inbox.aiAgent.updateFailed': 'Failed to update AI agent status', + 'inbox.aiAgent.loadingStatus': 'Loading AI status...', + 'inbox.aiAgent.noEnabledAgent': 'No enabled inbox agent for this workspace.', + 'inbox.aiAgent.lastRun': 'Last run: {status}', + 'inbox.aiAgent.noRunsYet': 'no runs yet', + 'inbox.aiAgent.pause': 'Pause', + 'inbox.aiAgent.resume': 'Resume', + 'inbox.aiAgent.handoffAction': 'Handoff', + 'inbox.aiAgent.decision.failed': 'Last run failed', + 'inbox.aiAgent.decision.failedWithMessage': 'Last run failed: {message}', + 'inbox.aiAgent.decision.reply': 'Replied: {text}', + 'inbox.aiAgent.decision.noReply': 'No reply: {reason}', + 'inbox.aiAgent.decision.handoff': 'Handed off: {reason}', +} + +function t(key: string, params: MessageParams = {}): string { + const template = COPY[key] ?? key + return template.replace(/\{(\w+)\}/g, (_, name: string) => { + const value = params[name] + return value === null || value === undefined ? '' : String(value) + }) +} + +export function formatAiDecisionSummary(run: StatusResponse['last_run'], t: (key: string, params?: Record) => string): string | null { + if (!run) return null + if (run.status === 'failed') return run.error_message ? t('inbox.aiAgent.decision.failedWithMessage', { message: run.error_message.slice(0, 120) }) : t('inbox.aiAgent.decision.failed') + const decision = run.decision + if (!decision) return null + if (decision.action === 'reply' && decision.text) return t('inbox.aiAgent.decision.reply', { text: decision.text.slice(0, 80) }) + if (decision.action === 'no_reply' && decision.reason) return t('inbox.aiAgent.decision.noReply', { reason: decision.reason.slice(0, 80) }) + if (decision.action === 'handoff' && decision.reason) return t('inbox.aiAgent.decision.handoff', { reason: decision.reason.slice(0, 80) }) + return null +} + +export function AiAgentPanel({ conversationId, refreshToken }: { conversationId: string | null; refreshToken?: string | number }) { + const canSend = useCan('send-messages') + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [loadedConversationId, setLoadedConversationId] = useState(null) + const currentConversationIdRef = useRef(conversationId) + currentConversationIdRef.current = conversationId + + useEffect(() => { + const controller = new AbortController() + const requestedConversationId = conversationId + let active = true + + setData(null) + setError(null) + setLoadedConversationId(null) + setSaving(false) + if (!conversationId) { + return + } + + setLoading(true) + void (async () => { + try { + const response = await fetch(`/api/ai-agent/conversation/${conversationId}`, { signal: controller.signal }) + const body = await response.json() + if (!response.ok) throw new Error(body.error ?? t('inbox.aiAgent.loadFailed')) + if (active) { + setData(body) + setLoadedConversationId(requestedConversationId) + } + } catch (cause) { + if (active && !(cause instanceof DOMException && cause.name === 'AbortError')) { + setError(cause instanceof Error ? cause.message : t('inbox.aiAgent.loadFailed')) + } + } finally { + if (active) setLoading(false) + } + })() + + return () => { + active = false + controller.abort() + } + }, [conversationId, refreshToken]) + + const update = async (action: 'pause' | 'resume' | 'handoff') => { + if (!conversationId || loadedConversationId !== conversationId) return + const requestedConversationId = conversationId + setSaving(true) + setError(null) + try { + const response = await fetch(`/api/ai-agent/conversation/${conversationId}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }), + }) + const body = await response.json() + if (!response.ok) throw new Error(body.error ?? t('inbox.aiAgent.updateFailed')) + if (currentConversationIdRef.current === requestedConversationId) setData(body) + } catch (cause) { + if (currentConversationIdRef.current === requestedConversationId) { + setError(cause instanceof Error ? cause.message : t('inbox.aiAgent.updateFailed')) + } + } finally { + if (currentConversationIdRef.current === requestedConversationId) setSaving(false) + } + } + + if (!conversationId) return null + const isStatusLoaded = loadedConversationId === conversationId + const status = isStatusLoaded ? data?.effective_status : undefined + const disabled = loading || saving || !isStatusLoaded + const summary = formatAiDecisionSummary(data?.last_run ?? null, t) + + return
+
+
{t('inbox.aiAgent')}
+ {status && {t(`inbox.aiAgent.${status}`)}} +
+ {loading ?
{t('inbox.aiAgent.loadingStatus')}
: status === 'disabled' ?

{t('inbox.aiAgent.noEnabledAgent')}

: <> +
+

{t('inbox.aiAgent.lastRun', { status: data?.last_run?.status ?? t('inbox.aiAgent.noRunsYet') })}

+ {summary &&

{summary}

} +
+ {canSend &&
+ {status === 'active' && } + {(status === 'paused' || status === 'handoff') && } + {status !== 'handoff' && } +
} + } + {error &&

{error}

} +
+} diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index 6ff3c6ad27..9ed359a4f0 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -38,7 +38,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { MessageBubble } from "./message-bubble"; import { MessageActions } from "./message-actions"; import { @@ -49,6 +48,7 @@ import { import { deleteAccountMedia } from "@/lib/storage/upload-media"; import { TemplatePicker } from "./template-picker"; import { AiThreadBanner } from "./ai-thread-banner"; +import { AiAgentPanel } from "./ai-agent-panel"; import { buildReplyPreview } from "./reply-quote"; import { toast } from "sonner"; @@ -179,6 +179,7 @@ export function MessageThread({ const [templateModalOpen, setTemplateModalOpen] = useState(false); const [profiles, setProfiles] = useState([]); const [reactions, setReactions] = useState([]); + const [aiAgentRefreshKey, setAiAgentRefreshKey] = useState(0); // Purely visual spin state for the manual-refresh button. The actual // refetch is fire-and-forget through `onRefresh` (which bumps the // parent's resyncToken); the 700ms spin is just feedback so the click @@ -750,7 +751,7 @@ export function MessageThread({ preview: buildReplyPreview(msg, tQuote), }); }, - [authorLabelFor], + [authorLabelFor, tQuote], ); // Single reaction-set primitive. emoji === "" removes; otherwise adds/swaps. @@ -834,6 +835,18 @@ export function MessageThread({ } onAssignChange(conversation.id, agentId); + if (agentId) { + const response = await fetch(`/api/ai-agent/conversation/${conversation.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "pause", reason: "manual_assignment" }), + }); + if (!response.ok) { + console.error("Failed to pause AI agent after assignment:", await response.text()); + toast.error("Assignment saved, but AI pause failed"); + } + } + setAiAgentRefreshKey((value) => value + 1); }, [conversation, onAssignChange], ); @@ -1149,6 +1162,11 @@ export function MessageThread({ }} /> + + {/* Composer */} ; + +const COPY: Record = { + 'common.loading': 'Loading...', + 'settings.aiAgent.title': 'AI inbox agent', + 'settings.aiAgent.description': 'Configure the agent that can answer WhatsApp conversations and update deals from the inbox.', + 'settings.aiAgent.configuration': 'Agent configuration', + 'settings.aiAgent.configurationDescription': 'The agent runs after inbound WhatsApp messages that were not consumed by a Flow.', + 'settings.aiAgent.enabled': 'Enable agent', + 'settings.aiAgent.enabledDescription': 'Allow the agent to evaluate new WhatsApp inbound messages.', + 'settings.aiAgent.modelProvider': 'Model provider', + 'settings.aiAgent.modelName': 'Model name', + 'settings.aiAgent.instructions': 'Instructions', + 'settings.aiAgent.autoReply': 'Auto reply', + 'settings.aiAgent.autoReplyDescription': 'Let the agent send WhatsApp replies when it is confident.', + 'settings.aiAgent.autoMoveDeals': 'Move deals', + 'settings.aiAgent.autoMoveDealsDescription': 'Allow the agent to create, move, and update active conversation deals.', + 'settings.aiAgent.handoffKeywords': 'Handoff keywords', + 'settings.aiAgent.handoffKeywordsDescription': 'Comma-separated terms that immediately pause the agent and hand the thread to a human.', + 'settings.aiAgent.maxMessages': 'Context messages', + 'settings.aiAgent.cooldownSeconds': 'Cooldown seconds', + 'settings.aiAgent.adminOnly': 'Only admins can edit this configuration.', + 'settings.aiAgent.loadFailed': 'Failed to load AI agent settings', + 'settings.aiAgent.saveFailed': 'Failed to save AI agent settings', + 'settings.aiAgent.saved': 'AI agent settings saved', + 'settings.aiAgent.validationFailed': 'Review the AI agent limits before saving.', +} + +function t(key: string, params: MessageParams = {}): string { + const template = COPY[key] ?? key; + return template.replace(/\{(\w+)\}/g, (_, name: string) => { + const value = params[name]; + return value === null || value === undefined ? '' : String(value); + }); +} + +const DEFAULT_CONFIG: AgentConfig = { + id: null, + enabled: false, + model_provider: 'openai', + model_name: 'gpt-4.1-mini', + instructions: '', + auto_reply: true, + auto_move_deals: false, + handoff_keywords: ['humano', 'atendente', 'cancelar'], + max_messages: 20, + cooldown_seconds: 15, +}; + +export function AiAgentSettings() { + const { canEditSettings } = useAuth(); + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [keywords, setKeywords] = useState( + DEFAULT_CONFIG.handoff_keywords.join(', ') + ); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let active = true; + async function load() { + try { + const response = await fetch('/api/ai-agent/config'); + const payload = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error(payload.error || t('settings.aiAgent.loadFailed')); + if (active) { + setConfig(payload as AgentConfig); + setKeywords((payload.handoff_keywords as string[]).join(', ')); + } + } catch (error) { + console.error('[AiAgentSettings] load error:', error); + toast.error( + error instanceof Error + ? error.message + : t('settings.aiAgent.loadFailed') + ); + } finally { + if (active) setLoading(false); + } + } + load(); + return () => { + active = false; + }; + }, []); + + function update(key: K, value: AgentConfig[K]) { + setConfig((current) => ({ ...current, [key]: value })); + } + + async function save() { + const normalizedKeywords = keywords + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); + if ( + config.instructions.length > 4000 || + !Number.isInteger(config.max_messages) || + config.max_messages < 1 || + config.max_messages > 50 || + !Number.isInteger(config.cooldown_seconds) || + config.cooldown_seconds < 5 || + config.cooldown_seconds > 3600 || + normalizedKeywords.length > 20 || + normalizedKeywords.some((item) => item.length > 40) + ) { + toast.error(t('settings.aiAgent.validationFailed')); + return; + } + + setSaving(true); + try { + const response = await fetch('/api/ai-agent/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...config, + handoff_keywords: normalizedKeywords, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error(payload.error || t('settings.aiAgent.saveFailed')); + setConfig(payload as AgentConfig); + setKeywords((payload.handoff_keywords as string[]).join(', ')); + toast.success(t('settings.aiAgent.saved')); + } catch (error) { + console.error('[AiAgentSettings] save error:', error); + toast.error( + error instanceof Error + ? error.message + : t('settings.aiAgent.saveFailed') + ); + } finally { + setSaving(false); + } + } + + if (loading) { + return ( +
+ +
+ + {t('common.loading')} +
+
+ ); + } + + return ( +
+ + + + + + {t('settings.aiAgent.configuration')} + + + {t('settings.aiAgent.configurationDescription')} + + + +
+
+ +

+ {t('settings.aiAgent.enabledDescription')} +

+
+ update('enabled', value)} + disabled={!canEditSettings} + /> +
+
+ + + update('model_provider', event.target.value) + } + disabled={!canEditSettings} + /> + + + update('model_name', event.target.value)} + disabled={!canEditSettings} + /> + +
+ +