arcadia-suite-sv/server/communication/engine.ts

687 lines
26 KiB
TypeScript

import express from "express";
import cors from "cors";
import { Pool, neonConfig } from "@neondatabase/serverless";
import ws from "ws";
neonConfig.webSocketConstructor = ws;
const app = express();
const PORT = 8006;
app.use(cors());
app.use(express.json());
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function query(text: string, params?: any[]) {
const client = await pool.connect();
try {
const result = await client.query(text, params);
return result.rows;
} finally {
client.release();
}
}
async function ensureTables() {
await pool.query(`
CREATE TABLE IF NOT EXISTS comm_channels (
id SERIAL PRIMARY KEY,
tenant_id INTEGER,
type VARCHAR(30) NOT NULL,
name VARCHAR(200) NOT NULL,
identifier VARCHAR(200),
status VARCHAR(30) DEFAULT 'disconnected',
config JSONB,
greeting_message TEXT,
out_of_hours_message TEXT,
schedules JSONB,
source_ref VARCHAR(50),
is_active BOOLEAN DEFAULT true,
last_connected_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS comm_contacts (
id SERIAL PRIMARY KEY,
tenant_id INTEGER,
type VARCHAR(30) DEFAULT 'lead',
name VARCHAR(200) NOT NULL,
email VARCHAR(200),
phone VARCHAR(50),
whatsapp VARCHAR(50),
avatar_url TEXT,
company VARCHAR(200),
trade_name VARCHAR(200),
cnpj VARCHAR(20),
position VARCHAR(100),
website VARCHAR(300),
address TEXT,
city VARCHAR(100),
state VARCHAR(50),
country VARCHAR(50) DEFAULT 'Brasil',
segment VARCHAR(100),
tags TEXT[],
custom_fields JSONB,
lead_score INTEGER DEFAULT 0,
lead_status VARCHAR(30) DEFAULT 'new',
source VARCHAR(50),
source_details TEXT,
assigned_to VARCHAR,
primary_contact_name VARCHAR(200),
primary_contact_email VARCHAR(200),
primary_contact_phone VARCHAR(50),
notes TEXT,
last_contact_at TIMESTAMP,
converted_at TIMESTAMP,
xos_contact_id INTEGER,
crm_client_id INTEGER,
crm_lead_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS comm_threads (
id SERIAL PRIMARY KEY,
tenant_id INTEGER,
contact_id INTEGER REFERENCES comm_contacts(id),
channel_id INTEGER REFERENCES comm_channels(id),
channel VARCHAR(30) NOT NULL,
external_id VARCHAR(200),
status VARCHAR(20) DEFAULT 'open',
priority VARCHAR(20) DEFAULT 'normal',
subject VARCHAR(300),
assigned_to VARCHAR,
queue_id INTEGER,
tags TEXT[],
metadata JSONB,
messages_count INTEGER DEFAULT 0,
unread_count INTEGER DEFAULT 0,
first_response_at TIMESTAMP,
last_message_at TIMESTAMP,
resolved_at TIMESTAMP,
satisfaction_score INTEGER,
satisfaction_comment TEXT,
xos_conversation_id INTEGER,
crm_thread_id INTEGER,
whatsapp_ticket_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS comm_messages (
id SERIAL PRIMARY KEY,
thread_id INTEGER REFERENCES comm_threads(id) ON DELETE CASCADE NOT NULL,
channel_id INTEGER REFERENCES comm_channels(id),
direction VARCHAR(10) NOT NULL,
sender_type VARCHAR(20) NOT NULL,
sender_id VARCHAR,
sender_name VARCHAR(200),
content TEXT,
content_type VARCHAR(30) DEFAULT 'text',
media_url TEXT,
media_type VARCHAR(30),
attachments JSONB,
metadata JSONB,
external_id VARCHAR(200),
status VARCHAR(20) DEFAULT 'sent',
is_from_agent BOOLEAN DEFAULT false,
read_at TIMESTAMP,
delivered_at TIMESTAMP,
xos_message_id INTEGER,
crm_message_id INTEGER,
whatsapp_message_id INTEGER,
email_message_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS comm_queues (
id SERIAL PRIMARY KEY,
tenant_id INTEGER,
name VARCHAR(100) NOT NULL,
color VARCHAR(20) DEFAULT 'blue',
greeting_message TEXT,
out_of_hours_message TEXT,
schedules JSONB,
auto_assign BOOLEAN DEFAULT false,
assignment_method VARCHAR(20) DEFAULT 'round_robin',
order_priority INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
xos_queue_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS comm_queue_members (
id SERIAL PRIMARY KEY,
queue_id INTEGER REFERENCES comm_queues(id) ON DELETE CASCADE NOT NULL,
user_id VARCHAR NOT NULL,
role VARCHAR(20) DEFAULT 'agent',
is_available BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS comm_quick_messages (
id SERIAL PRIMARY KEY,
tenant_id INTEGER,
shortcode VARCHAR(50) NOT NULL,
title VARCHAR(200),
content TEXT NOT NULL,
media_url TEXT,
media_type VARCHAR(30),
category VARCHAR(50),
scope VARCHAR(20) DEFAULT 'company',
user_id VARCHAR,
variables TEXT[],
usage_count INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS comm_events (
id SERIAL PRIMARY KEY,
tenant_id INTEGER,
type VARCHAR(50) NOT NULL,
entity_type VARCHAR(30) NOT NULL,
entity_id INTEGER NOT NULL,
data JSONB,
processed_by_kg BOOLEAN DEFAULT false,
processed_by_agents BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_comm_threads_status ON comm_threads(status);
CREATE INDEX IF NOT EXISTS idx_comm_threads_channel ON comm_threads(channel);
CREATE INDEX IF NOT EXISTS idx_comm_threads_contact ON comm_threads(contact_id);
CREATE INDEX IF NOT EXISTS idx_comm_threads_assigned ON comm_threads(assigned_to);
CREATE INDEX IF NOT EXISTS idx_comm_messages_thread ON comm_messages(thread_id);
CREATE INDEX IF NOT EXISTS idx_comm_messages_created ON comm_messages(created_at);
CREATE INDEX IF NOT EXISTS idx_comm_contacts_type ON comm_contacts(type);
CREATE INDEX IF NOT EXISTS idx_comm_contacts_email ON comm_contacts(email);
CREATE INDEX IF NOT EXISTS idx_comm_contacts_whatsapp ON comm_contacts(whatsapp);
CREATE INDEX IF NOT EXISTS idx_comm_events_type ON comm_events(type);
CREATE INDEX IF NOT EXISTS idx_comm_events_processed ON comm_events(processed_by_kg);
`);
console.log("[Communication Engine] Tables and indexes ensured");
}
// ==================== HEALTH ====================
app.get("/health", async (_req, res) => {
try {
const dbResult = await pool.query("SELECT 1");
const counts = await pool.query(`
SELECT
(SELECT COUNT(*) FROM comm_contacts) as contacts,
(SELECT COUNT(*) FROM comm_threads) as threads,
(SELECT COUNT(*) FROM comm_messages) as messages,
(SELECT COUNT(*) FROM comm_channels) as channels,
(SELECT COUNT(*) FROM comm_events WHERE processed_by_kg = false) as pending_events
`);
const stats = counts.rows[0];
res.json({
status: "ok",
service: "communication-engine",
version: "1.0.0",
database: dbResult.rows.length > 0 ? "connected" : "error",
stats,
uptime: process.uptime(),
});
} catch (error: any) {
res.status(500).json({ status: "error", error: error.message });
}
});
// ==================== UNIFIED CONTACTS ====================
app.get("/v1/contacts", async (req, res) => {
try {
const { type, search, limit = "50", offset = "0" } = req.query;
const xosContacts = await query(`
SELECT id, name, email, phone, whatsapp, company, type, lead_status, lead_score,
source, assigned_to, tags, avatar_url, position, city, state, notes,
last_contact_at, created_at, updated_at,
'xos' as origin, id as xos_contact_id, NULL::int as crm_client_id, NULL::int as crm_lead_id
FROM xos_contacts
WHERE ($1::text IS NULL OR type = $1)
AND ($2::text IS NULL OR name ILIKE '%' || $2 || '%' OR email ILIKE '%' || $2 || '%' OR company ILIKE '%' || $2 || '%')
ORDER BY updated_at DESC
`, [type || null, search || null]);
const crmClients = await query(`
SELECT id, name, email, phone, NULL as whatsapp, NULL as company, 'customer' as type,
status as lead_status, 0 as lead_score, source, NULL as assigned_to,
NULL as tags, NULL as avatar_url, NULL as position, city, state, notes,
NULL as last_contact_at, created_at, updated_at,
'crm_client' as origin, NULL::int as xos_contact_id, id as crm_client_id, NULL::int as crm_lead_id
FROM crm_clients
WHERE ($1::text IS NULL OR $1 = 'customer')
AND ($2::text IS NULL OR name ILIKE '%' || $2 || '%' OR email ILIKE '%' || $2 || '%')
ORDER BY updated_at DESC
`, [type || null, search || null]);
const crmLeads = await query(`
SELECT id, name, email, phone, NULL as whatsapp, company, 'lead' as type,
status as lead_status, 0 as lead_score, source, assigned_to,
tags, NULL as avatar_url, position, NULL as city, NULL as state, notes,
NULL as last_contact_at, created_at, updated_at,
'crm_lead' as origin, NULL::int as xos_contact_id, NULL::int as crm_client_id, id as crm_lead_id
FROM crm_leads
WHERE ($1::text IS NULL OR $1 = 'lead')
AND ($2::text IS NULL OR name ILIKE '%' || $2 || '%' OR email ILIKE '%' || $2 || '%' OR company ILIKE '%' || $2 || '%')
ORDER BY updated_at DESC
`, [type || null, search || null]);
const all = [...xosContacts, ...crmClients, ...crmLeads]
.sort((a, b) => new Date(b.updated_at || b.created_at).getTime() - new Date(a.updated_at || a.created_at).getTime())
.slice(parseInt(offset as string), parseInt(offset as string) + parseInt(limit as string));
res.json({
data: all,
total: xosContacts.length + crmClients.length + crmLeads.length,
sources: {
xos_contacts: xosContacts.length,
crm_clients: crmClients.length,
crm_leads: crmLeads.length,
},
});
} catch (error: any) {
console.error("[Contacts]", error);
res.status(500).json({ error: error.message });
}
});
app.get("/v1/contacts/:id", async (req, res) => {
try {
const { id } = req.params;
const { origin } = req.query;
let contact;
if (origin === "crm_client") {
const rows = await query("SELECT *, 'crm_client' as origin FROM crm_clients WHERE id = $1", [id]);
contact = rows[0];
} else if (origin === "crm_lead") {
const rows = await query("SELECT *, 'crm_lead' as origin FROM crm_leads WHERE id = $1", [id]);
contact = rows[0];
} else {
const rows = await query("SELECT *, 'xos' as origin FROM xos_contacts WHERE id = $1", [id]);
contact = rows[0];
}
if (!contact) return res.status(404).json({ error: "Contact not found" });
const threads = await query(`
SELECT * FROM xos_conversations WHERE contact_id = $1
UNION ALL
SELECT ct.* FROM crm_threads ct
WHERE ct.contact_phone = $2 OR ct.contact_email = $3
ORDER BY updated_at DESC LIMIT 10
`, [contact.id, contact.phone, contact.email]).catch(() => []);
res.json({ contact, threads, origin: contact.origin });
} catch (error: any) {
console.error("[Contact Detail]", error);
res.status(500).json({ error: error.message });
}
});
// ==================== UNIFIED THREADS (CONVERSATIONS) ====================
app.get("/v1/threads", async (req, res) => {
try {
const { status, channel, limit = "50", offset = "0" } = req.query;
const xosConvs = await query(`
SELECT cv.id, cv.channel, cv.status, cv.priority, cv.subject,
cv.assigned_to, cv.messages_count, cv.created_at, cv.updated_at,
c.name as contact_name, c.email as contact_email, c.whatsapp as contact_whatsapp, c.avatar_url as contact_avatar,
(SELECT content FROM xos_messages WHERE conversation_id = cv.id ORDER BY created_at DESC LIMIT 1) as last_message,
'xos' as origin, cv.id as xos_conversation_id, NULL::int as crm_thread_id, NULL::int as whatsapp_ticket_id
FROM xos_conversations cv
LEFT JOIN xos_contacts c ON cv.contact_id = c.id
WHERE ($1::text IS NULL OR cv.status = $1)
AND ($2::text IS NULL OR cv.channel = $2)
ORDER BY cv.updated_at DESC
`, [status || null, channel || null]);
const crmThreads = await query(`
SELECT t.id,
CASE WHEN ch.type IS NOT NULL THEN ch.type ELSE 'chat' END as channel,
t.status, t.priority, NULL as subject,
t.assigned_to_id as assigned_to, t.unread_count as messages_count,
t.created_at, t.updated_at,
t.contact_name, t.contact_email, NULL as contact_whatsapp, NULL as contact_avatar,
(SELECT content FROM crm_messages WHERE thread_id = t.id ORDER BY created_at DESC LIMIT 1) as last_message,
'crm' as origin, NULL::int as xos_conversation_id, t.id as crm_thread_id, NULL::int as whatsapp_ticket_id
FROM crm_threads t
LEFT JOIN crm_channels ch ON t.channel_id = ch.id
WHERE ($1::text IS NULL OR t.status = $1)
AND ($2::text IS NULL OR ch.type = $2)
ORDER BY t.updated_at DESC
`, [status || null, channel || null]);
const whatsappTickets = await query(`
SELECT t.id, 'whatsapp' as channel,
t.status, 'normal' as priority, NULL as subject,
NULL as assigned_to, t.unread_count as messages_count,
t.created_at, t.updated_at,
COALESCE(c.name, c.push_name, c.phone_number) as contact_name,
NULL as contact_email, c.phone_number as contact_whatsapp, NULL as contact_avatar,
(SELECT body FROM whatsapp_messages WHERE ticket_id = t.id ORDER BY timestamp DESC LIMIT 1) as last_message,
'whatsapp' as origin, NULL::int as xos_conversation_id, NULL::int as crm_thread_id, t.id as whatsapp_ticket_id
FROM whatsapp_tickets t
LEFT JOIN whatsapp_contacts c ON t.contact_id = c.id
WHERE ($1::text IS NULL OR t.status = $1)
AND ($2::text IS NULL OR $2 = 'whatsapp')
ORDER BY t.updated_at DESC
`, [status || null, channel || null]);
const all = [...xosConvs, ...crmThreads, ...whatsappTickets]
.sort((a, b) => new Date(b.updated_at || b.created_at).getTime() - new Date(a.updated_at || a.created_at).getTime())
.slice(parseInt(offset as string), parseInt(offset as string) + parseInt(limit as string));
res.json({
data: all,
total: xosConvs.length + crmThreads.length + whatsappTickets.length,
sources: {
xos_conversations: xosConvs.length,
crm_threads: crmThreads.length,
whatsapp_tickets: whatsappTickets.length,
},
});
} catch (error: any) {
console.error("[Threads]", error);
res.status(500).json({ error: error.message });
}
});
app.get("/v1/threads/:origin/:id/messages", async (req, res) => {
try {
const { origin, id } = req.params;
let messages: any[] = [];
if (origin === "xos") {
messages = await query(`
SELECT id, direction, sender_type, sender_name, content, content_type,
attachments, external_id, read_at, delivered_at, created_at,
'xos' as origin
FROM xos_messages WHERE conversation_id = $1 ORDER BY created_at ASC
`, [id]);
} else if (origin === "crm") {
messages = await query(`
SELECT id, direction,
CASE WHEN is_from_agent = 'true' THEN 'bot' ELSE CASE WHEN direction = 'outbound' THEN 'user' ELSE 'contact' END END as sender_type,
NULL as sender_name, content, type as content_type,
NULL as attachments, external_id, NULL as read_at, NULL as delivered_at, created_at,
'crm' as origin
FROM crm_messages WHERE thread_id = $1 ORDER BY created_at ASC
`, [id]);
} else if (origin === "whatsapp") {
messages = await query(`
SELECT id, CASE WHEN from_me = 1 THEN 'outbound' ELSE 'inbound' END as direction,
CASE WHEN from_me = 1 THEN 'user' ELSE 'contact' END as sender_type,
NULL as sender_name, body as content, message_type as content_type,
NULL as attachments, message_id as external_id, NULL as read_at, NULL as delivered_at, timestamp as created_at,
'whatsapp' as origin
FROM whatsapp_messages WHERE ticket_id = $1 ORDER BY timestamp ASC
`, [id]);
}
res.json({ data: messages, origin, threadId: id });
} catch (error: any) {
console.error("[Messages]", error);
res.status(500).json({ error: error.message });
}
});
// ==================== UNIFIED CHANNELS ====================
app.get("/v1/channels", async (_req, res) => {
try {
const crmCh = await query(`
SELECT id, type, name, identifier, status, last_connected_at, created_at,
'crm_channels' as origin
FROM crm_channels ORDER BY created_at DESC
`);
const whatsappSessions = await query(`
SELECT id, 'whatsapp' as type, session_name as name, phone_number as identifier,
status, connected_at as last_connected_at, created_at,
'whatsapp_sessions' as origin
FROM whatsapp_sessions ORDER BY created_at DESC
`).catch(() => []);
const emailAccounts = await query(`
SELECT id, 'email' as type, COALESCE(display_name, email) as name, email as identifier,
status, NULL as last_connected_at, created_at,
'email_accounts' as origin
FROM email_accounts ORDER BY created_at DESC
`).catch(() => []);
res.json({
data: [...crmCh, ...whatsappSessions, ...emailAccounts],
sources: {
crm_channels: crmCh.length,
whatsapp_sessions: whatsappSessions.length,
email_accounts: emailAccounts.length,
},
});
} catch (error: any) {
console.error("[Channels]", error);
res.status(500).json({ error: error.message });
}
});
// ==================== UNIFIED QUEUES ====================
app.get("/v1/queues", async (_req, res) => {
try {
const xosQueues = await query(`
SELECT q.*,
(SELECT COUNT(*) FROM xos_conversations WHERE status = 'open') as open_threads,
'xos' as origin
FROM xos_queues q WHERE q.is_active = true ORDER BY q.order_priority
`);
res.json({ data: xosQueues });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ==================== UNIFIED QUICK MESSAGES ====================
app.get("/v1/quick-messages", async (_req, res) => {
try {
const xosQm = await query(`
SELECT id, shortcode, title, content, media_url, media_type, scope, usage_count, created_at,
'xos' as origin
FROM xos_quick_messages ORDER BY usage_count DESC
`);
const crmQm = await query(`
SELECT id, shortcut as shortcode, title, content, media_url, NULL as media_type,
CASE WHEN is_global = 'true' THEN 'company' ELSE 'personal' END as scope,
0 as usage_count, created_at,
'crm' as origin
FROM crm_quick_messages ORDER BY created_at DESC
`);
res.json({ data: [...xosQm, ...crmQm] });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ==================== STATS (for agents/dashboard) ====================
app.get("/v1/stats", async (_req, res) => {
try {
const stats = await query(`
SELECT
(SELECT COUNT(*) FROM xos_contacts) + (SELECT COUNT(*) FROM crm_clients) + (SELECT COUNT(*) FROM crm_leads) as total_contacts,
(SELECT COUNT(*) FROM xos_contacts WHERE type = 'lead') + (SELECT COUNT(*) FROM crm_leads) as total_leads,
(SELECT COUNT(*) FROM xos_contacts WHERE type = 'customer') + (SELECT COUNT(*) FROM crm_clients) as total_customers,
(SELECT COUNT(*) FROM xos_conversations WHERE status = 'open') as open_conversations_xos,
(SELECT COUNT(*) FROM crm_threads WHERE status = 'open') as open_threads_crm,
(SELECT COUNT(*) FROM whatsapp_tickets WHERE status = 'open') as open_whatsapp,
(SELECT COUNT(*) FROM xos_tickets WHERE status IN ('open', 'pending', 'in_progress')) as open_tickets,
(SELECT COUNT(*) FROM xos_messages) + (SELECT COUNT(*) FROM crm_messages) + (SELECT COUNT(*) FROM whatsapp_messages) as total_messages,
(SELECT COUNT(*) FROM comm_events WHERE processed_by_kg = false) as pending_kg_events
`);
const row = stats[0] || {};
res.json({
contacts: {
total: parseInt(row.total_contacts || "0"),
leads: parseInt(row.total_leads || "0"),
customers: parseInt(row.total_customers || "0"),
},
threads: {
open: parseInt(row.open_conversations_xos || "0") + parseInt(row.open_threads_crm || "0") + parseInt(row.open_whatsapp || "0"),
bySource: {
xos: parseInt(row.open_conversations_xos || "0"),
crm: parseInt(row.open_threads_crm || "0"),
whatsapp: parseInt(row.open_whatsapp || "0"),
},
},
tickets: { open: parseInt(row.open_tickets || "0") },
messages: { total: parseInt(row.total_messages || "0") },
intelligence: { pendingEvents: parseInt(row.pending_kg_events || "0") },
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ==================== EVENTS (for Knowledge Graph / Agents) ====================
app.get("/v1/events/pending", async (req, res) => {
try {
const { limit = "100" } = req.query;
const events = await query(`
SELECT * FROM comm_events
WHERE processed_by_kg = false
ORDER BY created_at ASC
LIMIT $1
`, [parseInt(limit as string)]);
res.json({ data: events, count: events.length });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.post("/v1/events/:id/ack", async (req, res) => {
try {
const { id } = req.params;
const { processor } = req.body; // "kg" or "agents"
const field = processor === "agents" ? "processed_by_agents" : "processed_by_kg";
await query(`UPDATE comm_events SET ${field} = true WHERE id = $1`, [id]);
res.json({ ok: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.post("/v1/events", async (req, res) => {
try {
const { type, entityType, entityId, data, tenantId } = req.body;
const result = await query(`
INSERT INTO comm_events (tenant_id, type, entity_type, entity_id, data)
VALUES ($1, $2, $3, $4, $5) RETURNING *
`, [tenantId || 1, type, entityType, entityId, JSON.stringify(data || {})]);
res.status(201).json(result[0]);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ==================== AGENT CONTEXT ENDPOINT ====================
app.get("/v1/agent/context/:contactIdentifier", async (req, res) => {
try {
const { contactIdentifier } = req.params;
const contacts = await query(`
SELECT * FROM xos_contacts
WHERE phone = $1 OR whatsapp = $1 OR email = $1 OR name ILIKE '%' || $1 || '%'
LIMIT 5
`, [contactIdentifier]);
const crmClients = await query(`
SELECT * FROM crm_clients
WHERE phone = $1 OR email = $1 OR name ILIKE '%' || $1 || '%'
LIMIT 5
`, [contactIdentifier]);
const threads: any[] = [];
for (const contact of contacts) {
const convs = await query(`
SELECT cv.*,
(SELECT content FROM xos_messages WHERE conversation_id = cv.id ORDER BY created_at DESC LIMIT 1) as last_message
FROM xos_conversations cv WHERE cv.contact_id = $1 ORDER BY cv.updated_at DESC LIMIT 5
`, [contact.id]);
threads.push(...convs.map((c: any) => ({ ...c, origin: "xos" })));
}
const tickets = await query(`
SELECT t.* FROM xos_tickets t
JOIN xos_contacts c ON t.contact_id = c.id
WHERE c.phone = $1 OR c.whatsapp = $1 OR c.email = $1
ORDER BY t.created_at DESC LIMIT 5
`, [contactIdentifier]).catch(() => []);
const deals = await query(`
SELECT d.*, s.name as stage_name FROM xos_deals d
LEFT JOIN xos_pipeline_stages s ON d.stage_id = s.id
JOIN xos_contacts c ON d.contact_id = c.id
WHERE c.phone = $1 OR c.whatsapp = $1 OR c.email = $1
ORDER BY d.updated_at DESC LIMIT 5
`, [contactIdentifier]).catch(() => []);
res.json({
contacts: [...contacts.map((c: any) => ({ ...c, origin: "xos" })), ...crmClients.map((c: any) => ({ ...c, origin: "crm" }))],
threads,
tickets,
deals,
summary: {
totalContacts: contacts.length + crmClients.length,
openThreads: threads.filter((t: any) => t.status === "open").length,
openTickets: tickets.filter((t: any) => ["open", "pending", "in_progress"].includes(t.status)).length,
activeDeals: deals.filter((d: any) => d.status === "active" || d.status === "negotiation").length,
},
});
} catch (error: any) {
console.error("[Agent Context]", error);
res.status(500).json({ error: error.message });
}
});
// ==================== START ====================
async function start() {
console.log("[Communication Engine] Iniciando na porta " + PORT + "...");
await ensureTables();
app.listen(PORT, "0.0.0.0", () => {
console.log(`[Communication Engine] Rodando em http://0.0.0.0:${PORT}`);
console.log("[Communication Engine] Endpoints:");
console.log(" GET /health");
console.log(" GET /v1/contacts");
console.log(" GET /v1/contacts/:id");
console.log(" GET /v1/threads");
console.log(" GET /v1/threads/:origin/:id/messages");
console.log(" GET /v1/channels");
console.log(" GET /v1/queues");
console.log(" GET /v1/quick-messages");
console.log(" GET /v1/stats");
console.log(" GET /v1/events/pending");
console.log(" POST /v1/events");
console.log(" POST /v1/events/:id/ack");
console.log(" GET /v1/agent/context/:contactIdentifier");
});
}
start().catch(console.error);