feat(secretaria): Secretária IA separada por sessão (agente por número)

Núcleo desta mudança (separação por sessão):
- migrate.ts: sec_numbers.agent_id (FK→sec_agents, ON DELETE SET NULL) e
  sec_calendar.instance_id, com índices (aditivo/idempotente).
- ext-api routes.ts: runtime resolve o agente pelo NÚMERO que recebeu a
  mensagem (sec_numbers[instanceId].agent_id, fallback: 1º ativo); rotas
  admin /secretaria/numbers e /secretaria/calendar aceitam os novos campos.
- brain.ts/tools.ts: instanceId no contexto; sec_calendar interno filtrado
  por sessão; parse de jid robusto (.pop(), suporta 2 e 3 partes).
- secretaria/routes.ts: paridade (agent_id em numbers, instance_id no calendar).

Testado em dev (newwhats.dev refrescado): agentes resolvidos por instância;
fallback e FK ON DELETE SET NULL verificados via ext-api.

Obs.: por estarem no mesmo working tree não-commitado do newwhats.local,
ext-api/routes.ts e secretaria/tools.ts também trazem o trabalho acumulado
da integração do satélite (ações de inbox: delete/react/typing/labels/forward
e admin /secretaria/*; tools identificar_numero/cadastrar_paciente do odonto).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-05 19:51:25 +02:00
parent 8a53236502
commit 213990d51f
15 changed files with 960 additions and 85 deletions
@@ -186,8 +186,8 @@ function scheduleHandoffTimeout(convId, extChatId, tenantId, db, hooks) {
try {
await db('sec_conversations').where({ id: convId })
.update({ handoff_mode: 'ia', handoff_human_at: null });
// chatId = parte após o primeiro ":" no extChatId ("tenantId:chatId")
const chatId = extChatId.includes(':') ? extChatId.split(':').slice(1).join(':') : extChatId;
// jid = último segmento do extChatId ("tenantId:jid" ou "tenantId:instanceId:jid")
const chatId = extChatId.split(':').pop() ?? extChatId;
hooks.emit('ext:handoff', { tenantId, conversationId: convId, chatId, mode: 'ia', reason: 'timeout' });
}
catch { }
@@ -301,10 +301,22 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } });
if (activeBot)
return;
// Canal deste número (sec_numbers da instância): define o agente/cérebro e a
// clínica (escopo da ponte de agenda). É o que separa a Secretária por sessão.
const channel = await db('sec_numbers').where({ instance_id: instanceId }).first();
// ext_chat_id = tenant:jid (2 partes) — mantido assim porque as rotas de
// handoff reconstroem a chave só com o jid. A separação por sessão vem do
// AGENTE resolvido pelo número (channel.agent_id), não da chave da conversa.
// (Limitação conhecida: o MESMO contato falando com dois números do mesmo
// tenant reusa a 1ª conversa/agente — caso raro, follow-up.)
const extKey = `${tenantId}:${jid}`;
let conv = await db('sec_conversations').where({ ext_chat_id: extKey, status: 'active' }).first();
if (!conv) {
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first();
// Agente do número (channel.agent_id); fallback: primeiro agente ativo (legado).
const agent = (channel?.agent_id
? await db('sec_agents').where({ id: channel.agent_id, active: true }).first()
: null)
?? await db('sec_agents').where({ active: true }).orderBy('created_at').first();
if (!agent)
return;
// Nome legível: Contact (agenda/WhatsApp) → telefone formatado → jid.
@@ -329,11 +341,9 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
// SEC-14: indicador de digitação antes de chamar a IA
const sock = manager?.getSocket(instanceId);
await sock?.sendPresenceUpdate('composing', jid).catch(() => { });
// Modelo de canal: a clínica vem do NÚMERO que recebeu a mensagem
// (sec_numbers.clinica_id da instância) — escopo da ponte de agenda.
const channel = await db('sec_numbers').where({ instance_id: instanceId }).first();
// Clínica e agenda vêm do NÚMERO que recebeu a mensagem (channel, resolvido acima).
const brain = new brain_1.ProtocolEngine(db, config);
const reply = await brain.chat(conv.id, text.trim(), { tenantId, hooks, clinicaId: channel?.clinica_id });
const reply = await brain.chat(conv.id, text.trim(), { tenantId, hooks, clinicaId: channel?.clinica_id, instanceId });
await sock?.sendPresenceUpdate('paused', jid).catch(() => { });
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply });
});
@@ -698,6 +708,10 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', { messageId, mediaUrl });
}
catch { }
try {
hooks.emit('ext:message.media_ready', { tenantId, messageId, mediaUrl, timestamp: Date.now() });
}
catch { }
// Thumbnail (imagem, não-figurinha) → carregamento rápido nas prévias. Best-effort:
// grava no Wasabi como <mediaUrl>.thumb.webp e é servido/cacheado igual à mídia.
if (mediaStorage?.isRegistered?.() && !mediaUrl.startsWith('/media/') && /^image\//i.test(mime) && contentType !== 'stickerMessage') {
@@ -720,6 +734,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
// ── GET /inbox ────────────────────────────────────────────────────────────
// Lista chats da instância com snapshot da última mensagem.
router.get('/inbox', async (req, res) => {
var _a;
const tenantId = req.extTenantId;
const sessionId = req.query['session'];
const search = req.query['search'];
@@ -784,6 +799,17 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
},
},
});
// Etiquetas por chat (join chat_labels + labels do tenant).
const chatIds = chats.map((c) => c.id);
const labelRows = chatIds.length
? await db('chat_labels as cl').join('labels as l', 'l.id', 'cl.label_id')
.whereIn('cl.chat_id', chatIds).where('l.tenant_id', tenantId)
.select('cl.chat_id', 'l.id', 'l.name', 'l.color')
: [];
const labelsByChat = {};
for (const r of labelRows) {
(labelsByChat[_a = r.chat_id] ?? (labelsByChat[_a] = [])).push({ id: r.id, name: r.name, color: r.color });
}
// Resolve nome de exibição: Contact.name > Contact.verifiedName > Contact.notify > Chat.name
const result = chats.map((c) => {
const ct = c.contact;
@@ -799,6 +825,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
lastMessageAt: c.lastMessageAt,
isPinned: c.isPinned,
isArchived: c.isArchived,
labels: labelsByChat[c.id] ?? [],
lastMessage: lastMsg
? { body: lastMsg.body, type: lastMsg.type, fromMe: lastMsg.fromMe, status: lastMsg.status, timestamp: lastMsg.timestamp }
: null,
@@ -837,13 +864,38 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
return;
}
try {
// profilePictureUrl pode TRAVAR (contato com foto restrita/privacidade ou
// sem resposta do WhatsApp). Corremos contra um timeout para responder
// rápido (404 → cai nas iniciais) e nunca estourar 504 no gateway.
const url = await Promise.race([
sock.profilePictureUrl(jid, 'image').catch(() => null),
new Promise((resolve) => setTimeout(() => resolve(null), 5000)),
]);
// profilePictureUrl pode TRAVAR (sem resposta do WhatsApp) ou FALHAR na
// alta-resolução ('image') mesmo quando a foto EXISTE e é visível no app.
// Tentamos 'image' e, se vier vazio/erro, caímos para 'preview' (baixa-res,
// bem mais confiável). Cada tentativa corre contra um timeout para nunca
// estourar 504 no gateway. O erro real é logado (antes era silenciado).
const raceTimeout = (p, ms = 8000) => Promise.race([p, new Promise((r) => setTimeout(() => r(null), ms))]);
const tryFetch = (target, type) => raceTimeout(sock.profilePictureUrl(target, type).catch((e) => {
console.warn(`[avatar] ${target} ${type} falhou:`, e?.message || e);
return null;
}));
let url = (await tryFetch(jid, 'image')) || (await tryFetch(jid, 'preview'));
// O WhatsApp novo endereça contatos por @lid (identidade oculta): a foto NÃO
// resolve pelo JID de telefone (@s.whatsapp.net) — só pelo @lid. Resolvemos o
// @lid pelo mapa lid↔pn do engine (infinite/baileys7 mantêm um LIDMappingStore,
// populado quando a mensagem chega) e, em último caso, via onWhatsApp().
if (!url && jid.endsWith('@s.whatsapp.net')) {
let lid;
try {
const map = sock.signalRepository?.lidMapping;
if (map?.getLIDForPN) {
lid = (await raceTimeout(Promise.resolve(map.getLIDForPN(jid)), 6000)) ?? undefined;
}
}
catch { /* engine sem lidMapping */ }
if (!lid) {
const info = await raceTimeout(sock.onWhatsApp(jid).catch(() => null), 8000);
lid = Array.isArray(info) ? info[0]?.lid : undefined;
}
if (lid) {
url = (await tryFetch(lid, 'image')) || (await tryFetch(lid, 'preview'));
}
}
if (!url) {
// Sem foto acessível → limpa a URL obsoleta do banco para não servir de
// novo a URL 404 (futuras cargas vêm avatar=null → iniciais na hora).
@@ -851,7 +903,8 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
where: { tenantId, instanceId: sessionId, jid, avatarUrl: { not: null } },
data: { avatarUrl: null },
});
res.status(404).json({ avatar: null });
// 200 (não 404) — "sem foto" é resultado válido; evita ruído de erro no console.
res.status(200).json({ avatar: null });
return;
}
await prisma.contact.updateMany({
@@ -904,6 +957,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
timestamp: true,
pushName: true,
senderJid: true,
reactions: true,
},
});
// mediaRecoverable: já tem URL, ou tem o WAMessage salvo (mediaPayload) / arquivo
@@ -1070,13 +1124,269 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
res.status(500).json({ error: err.message ?? 'Erro ao apagar mensagem' });
}
});
// ── DELETE /inbox/:chatId/messages ────────────────────────────────────────
// "Limpar conversa": remove TODAS as mensagens do chat no motor, mantendo o chat.
// Limpeza LOCAL do inbox (não revoga nada no WhatsApp) — análogo ao "Limpar conversa".
// A permissão (dono OU can_delete_msg) é validada no satélite (proxy) antes daqui.
router.delete('/inbox/:chatId/messages', async (req, res) => {
const tenantId = req.extTenantId;
const chatId = req.params['chatId'];
try {
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { id: true } });
if (!chat) {
res.status(404).json({ error: 'Chat não encontrado' });
return;
}
const { count } = await prisma.message.deleteMany({ where: { chatId, tenantId } });
res.json({ ok: true, deleted: count });
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro ao limpar conversa' });
}
});
// ── POST /inbox/:chatId/messages/:messageId/react ─────────────────────────
// Reage a uma mensagem (emoji). emoji vazio = remove a reação. :messageId = id
// interno (uuid). Envia via Baileys ({ react: { text, key } }).
router.post('/inbox/:chatId/messages/:messageId/react', async (req, res) => {
const tenantId = req.extTenantId;
const chatId = req.params['chatId'];
const messageId = req.params['messageId'];
const { emoji } = req.body;
try {
const msg = await prisma.message.findFirst({
where: { id: messageId, chatId, tenantId },
select: { messageId: true, fromMe: true, remoteJid: true, senderJid: true, instanceId: true, reactions: true },
});
if (!msg) {
res.status(404).json({ error: 'Mensagem não encontrada' });
return;
}
const sock = manager.getSocket(msg.instanceId);
if (!sock) {
res.status(400).json({ error: 'Instância não conectada' });
return;
}
const key = { remoteJid: msg.remoteJid, fromMe: msg.fromMe, id: msg.messageId };
if (msg.remoteJid.endsWith('@g.us') && msg.senderJid)
key.participant = msg.senderJid;
await sock.sendMessage(msg.remoteJid, { react: { text: emoji ?? '', key } });
// Persiste a MINHA reação (chave 'me') para sobreviver ao reload.
const r = { ...(msg.reactions || {}) };
if (emoji)
r['me'] = emoji;
else
delete r['me'];
await prisma.message.update({ where: { id: messageId }, data: { reactions: r } }).catch(() => { });
res.json({ ok: true });
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro ao reagir' });
}
});
// ── POST /inbox/:chatId/typing ────────────────────────────────────────────
// Envia MINHA presença ao contato: state = 'composing' | 'paused' | 'recording'.
router.post('/inbox/:chatId/typing', async (req, res) => {
const tenantId = req.extTenantId;
const chatId = req.params['chatId'];
const state = (req.body?.state ?? 'composing');
try {
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { jid: true, instanceId: true } });
if (!chat) {
res.status(404).json({ error: 'Chat não encontrado' });
return;
}
const sock = manager.getSocket(chat.instanceId);
if (!sock) {
res.status(400).json({ error: 'Instância não conectada' });
return;
}
const st = ['composing', 'paused', 'recording'].includes(state) ? state : 'composing';
await sock.sendPresenceUpdate(st, chat.jid);
res.json({ ok: true });
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro ao enviar presença' });
}
});
// ═══ Etiquetas (labels) — por tenant; atribuídas a chats ═══════════════════
router.get('/labels', async (req, res) => {
try {
const rows = await db('labels').where({ tenant_id: req.extTenantId }).orderBy('created_at', 'asc');
res.json(rows.map(l => ({ id: l.id, name: l.name, color: l.color })));
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro ao listar etiquetas' });
}
});
router.post('/labels', async (req, res) => {
const { name, color } = req.body;
if (!name?.trim()) {
res.status(400).json({ error: 'Campo "name" obrigatório' });
return;
}
try {
const id = uuid();
const c = (typeof color === 'string' && color) ? color : '#00a884';
await db('labels').insert({ id, tenant_id: req.extTenantId, name: name.trim(), color: c });
res.json({ id, name: name.trim(), color: c });
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro ao criar etiqueta' });
}
});
router.patch('/labels/:id', async (req, res) => {
const { name, color } = req.body;
const patch = {};
if (typeof name === 'string' && name.trim())
patch['name'] = name.trim();
if (typeof color === 'string' && color)
patch['color'] = color;
if (!Object.keys(patch).length) {
res.status(400).json({ error: 'Nada para atualizar' });
return;
}
try {
const n = await db('labels').where({ id: req.params['id'], tenant_id: req.extTenantId }).update(patch);
if (!n) {
res.status(404).json({ error: 'Etiqueta não encontrada' });
return;
}
res.json({ ok: true });
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro ao editar etiqueta' });
}
});
router.delete('/labels/:id', async (req, res) => {
try {
const n = await db('labels').where({ id: req.params['id'], tenant_id: req.extTenantId }).del();
res.json({ ok: true, deleted: n });
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro ao apagar etiqueta' });
}
});
// Define as etiquetas de um chat (substitui o conjunto). Body: { labelIds: string[] }.
router.put('/inbox/:chatId/labels', async (req, res) => {
const tenantId = req.extTenantId;
const chatId = req.params['chatId'];
const { labelIds } = req.body;
if (!Array.isArray(labelIds)) {
res.status(400).json({ error: 'labelIds (array) obrigatório' });
return;
}
try {
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { id: true } });
if (!chat) {
res.status(404).json({ error: 'Chat não encontrado' });
return;
}
const valid = await db('labels').where({ tenant_id: tenantId }).whereIn('id', labelIds.length ? labelIds : ['']).pluck('id');
await db('chat_labels').where({ chat_id: chatId }).del();
if (valid.length)
await db('chat_labels').insert(valid.map(id => ({ chat_id: chatId, label_id: id })));
res.json({ ok: true, labelIds: valid });
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro ao aplicar etiquetas' });
}
});
// ── PATCH /inbox/:chatId/messages/:messageId ──────────────────────────────
// Edita uma mensagem de texto enviada por você (edit do WhatsApp via Baileys).
router.patch('/inbox/:chatId/messages/:messageId', async (req, res) => {
const tenantId = req.extTenantId;
const chatId = req.params['chatId'];
const messageId = req.params['messageId'];
const { text } = req.body;
if (!text?.trim()) {
res.status(400).json({ error: 'Campo "text" obrigatório' });
return;
}
try {
const msg = await prisma.message.findFirst({
where: { id: messageId, chatId, tenantId },
select: { messageId: true, fromMe: true, remoteJid: true, instanceId: true },
});
if (!msg) {
res.status(404).json({ error: 'Mensagem não encontrada' });
return;
}
if (!msg.fromMe) {
res.status(400).json({ error: 'Só é possível editar mensagens enviadas por você.' });
return;
}
const sock = manager.getSocket(msg.instanceId);
if (!sock) {
res.status(400).json({ error: 'Instância não conectada' });
return;
}
await sock.sendMessage(msg.remoteJid, { text: text.trim(), edit: { remoteJid: msg.remoteJid, fromMe: true, id: msg.messageId } });
await prisma.message.update({ where: { id: messageId }, data: { body: text.trim() } }).catch(() => { });
res.json({ ok: true });
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro ao editar mensagem' });
}
});
// ── POST /inbox/:chatId/messages/:messageId/forward ───────────────────────
// Encaminha uma mensagem para outros chats. Body: { toChatIds: string[] } (ids internos).
router.post('/inbox/:chatId/messages/:messageId/forward', async (req, res) => {
const tenantId = req.extTenantId;
const chatId = req.params['chatId'];
const messageId = req.params['messageId'];
const { toChatIds } = req.body;
if (!Array.isArray(toChatIds) || toChatIds.length === 0) {
res.status(400).json({ error: 'toChatIds (array) obrigatório' });
return;
}
try {
const msg = await prisma.message.findFirst({
where: { id: messageId, chatId, tenantId },
select: { messageId: true, fromMe: true, remoteJid: true, senderJid: true, body: true, mediaPayload: true, instanceId: true },
});
if (!msg) {
res.status(404).json({ error: 'Mensagem não encontrada' });
return;
}
const sock = manager.getSocket(msg.instanceId);
if (!sock) {
res.status(400).json({ error: 'Instância não conectada' });
return;
}
// WAMessage a encaminhar: usa o payload salvo (mídia/rico) ou reconstrói texto.
const key = { remoteJid: msg.remoteJid, fromMe: msg.fromMe, id: msg.messageId };
if (msg.remoteJid.endsWith('@g.us') && msg.senderJid)
key.participant = msg.senderJid;
let waMsg;
if (msg.mediaPayload) {
const d = deserializeMediaPayload(msg.mediaPayload);
waMsg = { key: d?.key ?? key, message: d?.message ?? { conversation: msg.body ?? '' } };
}
else {
waMsg = { key, message: { conversation: msg.body ?? '' } };
}
const targets = await prisma.chat.findMany({ where: { id: { in: toChatIds }, tenantId }, select: { jid: true } });
let forwarded = 0;
for (const t of targets) {
try {
await sock.sendMessage(t.jid, { forward: waMsg });
forwarded++;
}
catch { /* segue os demais */ }
}
res.json({ ok: true, forwarded });
}
catch (err) {
res.status(500).json({ error: err.message ?? 'Erro ao encaminhar' });
}
});
// ── POST /inbox/:chatId/send ──────────────────────────────────────────────
// Envia mensagem de texto ou rica para o chat.
// Body: { text?, footer?, nativeButtons?, nativeList?, nativeCarousel?, poll? }
router.post('/inbox/:chatId/send', async (req, res) => {
const tenantId = req.extTenantId;
const chatId = req.params['chatId'];
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = req.body;
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll, replyToMessageId } = req.body;
const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll);
if (!text && !hasRich) {
res.status(400).json({ error: 'Campo "text" ou conteúdo rico obrigatório' });
@@ -1096,6 +1406,29 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
res.status(400).json({ error: 'Instância não conectada' });
return;
}
// Reply/citação: monta o WAMessage citado (key + message) COM o conteúdo da
// mensagem original. Sem o quotedMessage o destinatário vê "Aguardando mensagem"
// (o WhatsApp não resolve só pelo stanzaId). replyToMessageId = messageId do WA.
let quoted;
if (replyToMessageId) {
const orig = await prisma.message.findFirst({
where: { messageId: replyToMessageId, chatId, tenantId },
select: { messageId: true, fromMe: true, senderJid: true, body: true, mediaPayload: true },
});
if (orig?.messageId) {
const key = { remoteJid: chat.jid, fromMe: orig.fromMe, id: orig.messageId };
if (chat.jid.endsWith('@g.us') && orig.senderJid)
key.participant = orig.senderJid;
let message;
if (orig.mediaPayload) {
const wa = deserializeMediaPayload(orig.mediaPayload);
message = wa?.message ?? undefined;
}
if (!message)
message = { conversation: orig.body ?? '' };
quoted = { key, message };
}
}
// Usa o rich-message builder para converter nativeButtons/nativeList/
// nativeCarousel nos protos corretos do @whiskeysockets/baileys.
// O footer legado (aninhado em nativeList) é extraído para o top-level.
@@ -1110,7 +1443,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
nativeList: cleanList,
nativeCarousel: nativeCarousel,
poll: poll,
});
}, quoted);
// Echo em tempo real: todos os operadores do número veem o envio na hora.
if (waMessageId) {
await echoSentMessage({
@@ -1590,9 +1923,15 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
}
});
// ── Calendar ──────────────────────────────────────────────────────────────
router.get('/secretaria/calendar', async (_req, res) => {
router.get('/secretaria/calendar', async (req, res) => {
try {
res.json(await db('sec_calendar').orderBy('date').orderBy('time_start'));
// Escopo por sessão: instance_id na query → slots daquele número + globais
// (instance_id NULL). Sem query → todos (compat).
const instanceId = req.query['instance_id']?.trim() || undefined;
let q = db('sec_calendar').orderBy('date').orderBy('time_start');
if (instanceId)
q = q.where((qb) => qb.whereNull('instance_id').orWhere('instance_id', instanceId));
res.json(await q);
}
catch (err) {
res.status(500).json({ error: err.message });
@@ -1603,6 +1942,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
const b = req.body ?? {};
const [s] = await db('sec_calendar').insert({
id: uuid(), title: b.title ?? null, date: b.date, time_start: b.time_start, time_end: b.time_end,
instance_id: b.instance_id ?? null,
attendee_name: b.attendee_name ?? null, attendee_phone: b.attendee_phone ?? null,
status: b.status ?? 'available', notes: b.notes ?? null,
}).returning('*');
@@ -1615,7 +1955,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
router.put('/secretaria/calendar/:id', async (req, res) => {
try {
const b = req.body ?? {}, patch = { updated_at: new Date() };
for (const k of ['title', 'date', 'time_start', 'time_end', 'attendee_name', 'attendee_phone', 'status', 'notes'])
for (const k of ['title', 'date', 'time_start', 'time_end', 'instance_id', 'attendee_name', 'attendee_phone', 'status', 'notes'])
if (k in b)
patch[k] = b[k];
const [s] = await db('sec_calendar').where({ id: req.params['id'] }).update(patch).returning('*');
@@ -1640,6 +1980,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
const b = req.body ?? {};
const [n] = await db('sec_numbers').insert({
id: uuid(), instance_id: b.instance_id ?? null, clinica_id: b.clinica_id ?? null,
agent_id: b.agent_id ?? null,
phone: b.phone ?? null, label: b.label ?? b.phone ?? 'Número',
role: b.role ?? 'clinic', area: b.area ?? null, priority: b.priority ?? 10, active: b.active ?? true, notes: b.notes ?? null,
}).returning('*');
@@ -1652,7 +1993,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
router.put('/secretaria/numbers/:id', async (req, res) => {
try {
const b = req.body ?? {}, patch = { updated_at: new Date() };
for (const k of ['instance_id', 'clinica_id', 'phone', 'label', 'role', 'area', 'priority', 'active', 'notes'])
for (const k of ['instance_id', 'clinica_id', 'agent_id', 'phone', 'label', 'role', 'area', 'priority', 'active', 'notes'])
if (k in b)
patch[k] = b[k];
const [n] = await db('sec_numbers').where({ id: req.params['id'] }).update(patch).returning('*');
@@ -1805,7 +2146,9 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
cancelHandoffTimeout(conv.id);
}
await db('sec_conversations').where({ id: conv.id }).update(updateData);
res.json({ mode, conversationId: conv.id });
// Emite p/ outros operadores verem a pausa/retomada ao vivo (satélite via ws-bridge).
hooks.emit('ext:handoff', { tenantId, conversationId: conv.id, chatId, mode, reason: 'manual', handoffHumanAt: updateData.handoff_human_at ?? null }).catch(() => { });
res.json({ mode, conversationId: conv.id, handoffHumanAt: updateData.handoff_human_at ?? null });
}
catch (err) {
res.status(500).json({ error: err.message });