feat(ext-api): API REST /secretaria/* (agents/nodes/calendar/numbers/conversations)

Expõe o contrato de administração esperado pelo painel da Secretária no satélite
(antes só havia /secretaria/ask|numbers + /sec/* parcial → 404 em agents/calendar):
- agents: GET/POST/PUT/DELETE
- brain nodes: GET/POST por agente (/agents/:agentId/nodes) + PUT/DELETE (/nodes/:id)
- calendar: GET/POST/PUT/DELETE
- numbers: POST/PUT/DELETE (GET já existia)
- conversations (teste): list/create/patch/delete, /messages, /chat (brain.chat),
  /finalize (brain.finalizeProtocol)
Backed pelas tabelas sec_* (globais/single-tenant no motor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-02 04:59:43 +02:00
parent ab6038ccaf
commit 9dbdd359ce
3 changed files with 441 additions and 1 deletions
@@ -889,6 +889,268 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
// ═══════════════════════════════════════════════════════════════════════════
// Secretária — API REST de administração (/secretaria/*) do painel do satélite.
// As tabelas sec_* são GLOBAIS no motor (single-tenant); estes endpoints
// expõem o contrato completo (agents/nodes/calendar/numbers/conversations)
// esperado pelo frontend, reaproveitando a lógica dos /sec/*.
// ═══════════════════════════════════════════════════════════════════════════
// ── Agents ──────────────────────────────────────────────────────────────────
router.get('/secretaria/agents', async (_req, res) => {
try {
res.json(await db('sec_agents').orderBy('created_at'));
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/secretaria/agents', async (req, res) => {
try {
const b = req.body ?? {};
const [a] = await db('sec_agents').insert({
id: uuid(), name: b.name ?? 'Agente', description: b.description ?? null,
model: b.model ?? 'gemini-2.0-flash', provider: b.provider ?? 'gemini',
temperature: b.temperature ?? 0.7, context_window: b.context_window ?? 10, active: true,
}).returning('*');
res.status(201).json(a);
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.put('/secretaria/agents/:id', async (req, res) => {
try {
const b = req.body ?? {}, patch = { updated_at: new Date() };
for (const k of ['name', 'description', 'model', 'provider', 'temperature', 'context_window', 'active'])
if (k in b)
patch[k] = b[k];
const [a] = await db('sec_agents').where({ id: req.params['id'] }).update(patch).returning('*');
res.json(a);
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/secretaria/agents/:id', async (req, res) => {
try {
const id = req.params['id'];
await db('sec_brain_nodes').where({ agent_id: id }).delete();
await db('sec_agents').where({ id }).delete();
res.json({ ok: true });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── Brain nodes (por agente) ──────────────────────────────────────────────
router.get('/secretaria/agents/:agentId/nodes', async (req, res) => {
try {
res.json(await db('sec_brain_nodes').where({ agent_id: req.params['agentId'] }).orderBy('sort_order'));
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/secretaria/agents/:agentId/nodes', async (req, res) => {
try {
const b = req.body ?? {};
const [n] = await db('sec_brain_nodes').insert({
id: uuid(), agent_id: req.params['agentId'], type: b.type, title: b.title ?? '', content: b.content ?? '',
node_model: b.node_model ?? null, active: true, sort_order: b.sort_order ?? 99,
}).returning('*');
res.status(201).json(n);
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.put('/secretaria/nodes/:id', async (req, res) => {
try {
const b = req.body ?? {}, patch = { updated_at: new Date() };
for (const k of ['type', 'title', 'content', 'node_model', 'active', 'sort_order'])
if (k in b)
patch[k] = b[k];
const [n] = await db('sec_brain_nodes').where({ id: req.params['id'] }).update(patch).returning('*');
res.json(n);
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/secretaria/nodes/:id', async (req, res) => {
try {
await db('sec_brain_nodes').where({ id: req.params['id'] }).delete();
res.json({ ok: true });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── Calendar ──────────────────────────────────────────────────────────────
router.get('/secretaria/calendar', async (_req, res) => {
try {
res.json(await db('sec_calendar').orderBy('date').orderBy('time_start'));
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/secretaria/calendar', async (req, res) => {
try {
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,
attendee_name: b.attendee_name ?? null, attendee_phone: b.attendee_phone ?? null,
status: b.status ?? 'available', notes: b.notes ?? null,
}).returning('*');
res.status(201).json(s);
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
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'])
if (k in b)
patch[k] = b[k];
const [s] = await db('sec_calendar').where({ id: req.params['id'] }).update(patch).returning('*');
res.json(s);
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/secretaria/calendar/:id', async (req, res) => {
try {
await db('sec_calendar').where({ id: req.params['id'] }).delete();
res.json({ ok: true });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── Numbers (GET acima) — mutações ─────────────────────────────────────────
router.post('/secretaria/numbers', async (req, res) => {
try {
const b = req.body ?? {};
const [n] = await db('sec_numbers').insert({
id: uuid(), instance_id: b.instance_id ?? null, phone: b.phone ?? null, label: b.label ?? null,
role: b.role ?? null, area: b.area ?? null, priority: b.priority ?? 0, active: b.active ?? true, notes: b.notes ?? null,
}).returning('*');
res.status(201).json(n);
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.put('/secretaria/numbers/:id', async (req, res) => {
try {
const b = req.body ?? {}, patch = { updated_at: new Date() };
for (const k of ['instance_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('*');
res.json(n);
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/secretaria/numbers/:id', async (req, res) => {
try {
await db('sec_numbers').where({ id: req.params['id'] }).delete();
res.json({ ok: true });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── Conversations (teste do painel) ────────────────────────────────────────
router.get('/secretaria/conversations', async (req, res) => {
try {
let q = db('sec_conversations')
.select('id', 'agent_id', 'protocol_number', 'contact_name', 'status', 'handoff_mode', 'summary', 'created_at', 'updated_at')
.orderBy('updated_at', 'desc').limit(100);
const agentId = req.query['agent_id'];
if (agentId)
q = q.where({ agent_id: agentId });
res.json(await q);
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/secretaria/conversations', async (req, res) => {
try {
const b = req.body ?? {};
const [c] = await db('sec_conversations').insert({
id: uuid(), agent_id: b.agent_id, contact_name: b.contact_name ?? 'Teste',
protocol_number: brain_1.ProtocolEngine.generateProtocolNumber(), status: 'active', handoff_mode: 'ia',
}).returning('*');
res.status(201).json(c);
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.patch('/secretaria/conversations/:id', async (req, res) => {
try {
const b = req.body ?? {}, patch = { updated_at: new Date() };
for (const k of ['status', 'contact_name', 'handoff_mode', 'summary'])
if (k in b)
patch[k] = b[k];
const [c] = await db('sec_conversations').where({ id: req.params['id'] }).update(patch).returning('*');
res.json(c);
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/secretaria/conversations/:id', async (req, res) => {
try {
const id = req.params['id'];
await db('sec_messages').where({ conversation_id: id }).delete();
await db('sec_conversations').where({ id }).delete();
res.json({ ok: true });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/secretaria/conversations/:id/messages', async (req, res) => {
try {
res.json(await db('sec_messages').where({ conversation_id: req.params['id'] }).orderBy('created_at'));
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/secretaria/conversations/:id/chat', async (req, res) => {
try {
const message = String((req.body ?? {}).message ?? '').trim();
if (!message) {
res.status(400).json({ error: 'message obrigatório' });
return;
}
const brain = new brain_1.ProtocolEngine(db, config);
const reply = await brain.chat(req.params['id'], message, { tenantId: req.extTenantId, hooks });
res.json({ reply });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/secretaria/conversations/:id/finalize', async (req, res) => {
try {
const brain = new brain_1.ProtocolEngine(db, config);
res.json(await brain.finalizeProtocol(req.params['id']));
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── GET /sec/handoff/:chatId ────────────────────────────────────────────── // ── GET /sec/handoff/:chatId ──────────────────────────────────────────────
// Retorna o modo de handoff atual para um chat específico. // Retorna o modo de handoff atual para um chat específico.
router.get('/sec/handoff/:chatId', async (req, res) => { router.get('/sec/handoff/:chatId', async (req, res) => {
File diff suppressed because one or more lines are too long
@@ -935,6 +935,184 @@ export function buildExtRoutes(
} }
}) })
// ═══════════════════════════════════════════════════════════════════════════
// Secretária — API REST de administração (/secretaria/*) do painel do satélite.
// As tabelas sec_* são GLOBAIS no motor (single-tenant); estes endpoints
// expõem o contrato completo (agents/nodes/calendar/numbers/conversations)
// esperado pelo frontend, reaproveitando a lógica dos /sec/*.
// ═══════════════════════════════════════════════════════════════════════════
// ── Agents ──────────────────────────────────────────────────────────────────
router.get('/secretaria/agents', async (_req: Request, res: Response) => {
try { res.json(await db('sec_agents').orderBy('created_at')) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/agents', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}
const [a] = await db('sec_agents').insert({
id: uuid(), name: b.name ?? 'Agente', description: b.description ?? null,
model: b.model ?? 'gemini-2.0-flash', provider: b.provider ?? 'gemini',
temperature: b.temperature ?? 0.7, context_window: b.context_window ?? 10, active: true,
}).returning('*')
res.status(201).json(a)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.put('/secretaria/agents/:id', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
for (const k of ['name', 'description', 'model', 'provider', 'temperature', 'context_window', 'active']) if (k in b) patch[k] = b[k]
const [a] = await db('sec_agents').where({ id: req.params['id'] }).update(patch).returning('*')
res.json(a)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.delete('/secretaria/agents/:id', async (req: Request, res: Response) => {
try {
const id = req.params['id']
await db('sec_brain_nodes').where({ agent_id: id }).delete()
await db('sec_agents').where({ id }).delete()
res.json({ ok: true })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── Brain nodes (por agente) ──────────────────────────────────────────────
router.get('/secretaria/agents/:agentId/nodes', async (req: Request, res: Response) => {
try { res.json(await db('sec_brain_nodes').where({ agent_id: req.params['agentId'] }).orderBy('sort_order')) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/agents/:agentId/nodes', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}
const [n] = await db('sec_brain_nodes').insert({
id: uuid(), agent_id: req.params['agentId'], type: b.type, title: b.title ?? '', content: b.content ?? '',
node_model: b.node_model ?? null, active: true, sort_order: b.sort_order ?? 99,
}).returning('*')
res.status(201).json(n)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.put('/secretaria/nodes/:id', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
for (const k of ['type', 'title', 'content', 'node_model', 'active', 'sort_order']) if (k in b) patch[k] = b[k]
const [n] = await db('sec_brain_nodes').where({ id: req.params['id'] }).update(patch).returning('*')
res.json(n)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.delete('/secretaria/nodes/:id', async (req: Request, res: Response) => {
try { await db('sec_brain_nodes').where({ id: req.params['id'] }).delete(); res.json({ ok: true }) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── Calendar ──────────────────────────────────────────────────────────────
router.get('/secretaria/calendar', async (_req: Request, res: Response) => {
try { res.json(await db('sec_calendar').orderBy('date').orderBy('time_start')) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/calendar', async (req: Request, res: Response) => {
try {
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,
attendee_name: b.attendee_name ?? null, attendee_phone: b.attendee_phone ?? null,
status: b.status ?? 'available', notes: b.notes ?? null,
}).returning('*')
res.status(201).json(s)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.put('/secretaria/calendar/:id', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
for (const k of ['title', 'date', 'time_start', 'time_end', '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('*')
res.json(s)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.delete('/secretaria/calendar/:id', async (req: Request, res: Response) => {
try { await db('sec_calendar').where({ id: req.params['id'] }).delete(); res.json({ ok: true }) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── Numbers (GET acima) — mutações ─────────────────────────────────────────
router.post('/secretaria/numbers', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}
const [n] = await db('sec_numbers').insert({
id: uuid(), instance_id: b.instance_id ?? null, phone: b.phone ?? null, label: b.label ?? null,
role: b.role ?? null, area: b.area ?? null, priority: b.priority ?? 0, active: b.active ?? true, notes: b.notes ?? null,
}).returning('*')
res.status(201).json(n)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.put('/secretaria/numbers/:id', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
for (const k of ['instance_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('*')
res.json(n)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.delete('/secretaria/numbers/:id', async (req: Request, res: Response) => {
try { await db('sec_numbers').where({ id: req.params['id'] }).delete(); res.json({ ok: true }) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── Conversations (teste do painel) ────────────────────────────────────────
router.get('/secretaria/conversations', async (req: Request, res: Response) => {
try {
let q = db('sec_conversations')
.select('id', 'agent_id', 'protocol_number', 'contact_name', 'status', 'handoff_mode', 'summary', 'created_at', 'updated_at')
.orderBy('updated_at', 'desc').limit(100)
const agentId = req.query['agent_id'] as string | undefined
if (agentId) q = q.where({ agent_id: agentId })
res.json(await q)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/conversations', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}
const [c] = await db('sec_conversations').insert({
id: uuid(), agent_id: b.agent_id, contact_name: b.contact_name ?? 'Teste',
protocol_number: ProtocolEngine.generateProtocolNumber(), status: 'active', handoff_mode: 'ia',
}).returning('*')
res.status(201).json(c)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.patch('/secretaria/conversations/:id', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
for (const k of ['status', 'contact_name', 'handoff_mode', 'summary']) if (k in b) patch[k] = b[k]
const [c] = await db('sec_conversations').where({ id: req.params['id'] }).update(patch).returning('*')
res.json(c)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.delete('/secretaria/conversations/:id', async (req: Request, res: Response) => {
try {
const id = req.params['id']
await db('sec_messages').where({ conversation_id: id }).delete()
await db('sec_conversations').where({ id }).delete()
res.json({ ok: true })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.get('/secretaria/conversations/:id/messages', async (req: Request, res: Response) => {
try { res.json(await db('sec_messages').where({ conversation_id: req.params['id'] }).orderBy('created_at')) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/conversations/:id/chat', async (req: Request, res: Response) => {
try {
const message = String((req.body ?? {}).message ?? '').trim()
if (!message) { res.status(400).json({ error: 'message obrigatório' }); return }
const brain = new ProtocolEngine(db, config)
const reply = await brain.chat(req.params['id']!, message, { tenantId: req.extTenantId, hooks })
res.json({ reply })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/conversations/:id/finalize', async (req: Request, res: Response) => {
try {
const brain = new ProtocolEngine(db, config)
res.json(await brain.finalizeProtocol(req.params['id']!))
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── GET /sec/handoff/:chatId ────────────────────────────────────────────── // ── GET /sec/handoff/:chatId ──────────────────────────────────────────────
// Retorna o modo de handoff atual para um chat específico. // Retorna o modo de handoff atual para um chat específico.
router.get('/sec/handoff/:chatId', async (req: Request, res: Response) => { router.get('/sec/handoff/:chatId', async (req: Request, res: Response) => {