feat(newwhats): ponte de agenda real (slots/book) para a Secretária
Expõe /api/nw/agenda/* (server-to-server, segredo compartilhado x-nw-agenda-secret, preso ao clinica_id) para o motor operar na agenda REAL do scoreodonto: - GET /slots — horários livres (dentistas_horarios ou fallback comercial seg–sex 08–18/30min) menos os agendamentos existentes e o passado. - POST /book — cria agendamento real com anti-overbooking (mesma regra do server) e find-or-create de paciente pelo telefone do WhatsApp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// Ponte de agenda: expõe /api/nw/agenda/* para o MOTOR (tools da Secretária)
|
||||
// operarem na agenda REAL do scoreodonto — em vez do calendário isolado do motor.
|
||||
//
|
||||
// Segurança: chamada server-to-server autenticada por segredo compartilhado
|
||||
// (header x-nw-agenda-secret === webhookSecret configurado). Todo acesso é
|
||||
// preso ao clinica_id enviado (o motor conhece a clínica da instância).
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /slots — horários livres (jornada dos dentistas − agendamentos)
|
||||
// POST /book — cria agendamento real (anti-overbooking + vínculo de paciente)
|
||||
import express from 'express';
|
||||
import { getConfig } from './config.js';
|
||||
|
||||
const SLOT_MIN = 30; // duração padrão do slot (min)
|
||||
const HORIZON_DAYS = 7; // janela padrão de busca
|
||||
const MAX_SLOTS = 20;
|
||||
// A agenda do scoreodonto é livre (quase ninguém preenche dentistas_horarios).
|
||||
// Sem jornada configurada, oferecemos horário comercial padrão (seg–sex 08–18).
|
||||
const DEFAULT_INI = '08:00';
|
||||
const DEFAULT_FIM = '18:00';
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const fmt = (d) => `${ymd(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||
|
||||
// Overbooking: MESMA regra do server.js (agrupa o dentista por usuario_id/email,
|
||||
// para o mesmo profissional atuando em clínicas diferentes). Cópia da lógica —
|
||||
// se a regra do scoreodonto mudar, revisar aqui também.
|
||||
async function findConflict(db, dentistaId, start, end) {
|
||||
if (!dentistaId || !start) return null;
|
||||
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
|
||||
let ids = [dentistaId];
|
||||
const uid = dr[0]?.usuario_id || null;
|
||||
const email = (dr[0]?.email || '').trim().toLowerCase() || null;
|
||||
if (uid) {
|
||||
const { rows } = await db.query('SELECT id FROM dentistas WHERE usuario_id = $1', [uid]);
|
||||
if (rows.length) ids = rows.map((r) => r.id);
|
||||
} else if (email) {
|
||||
const { rows } = await db.query('SELECT id FROM dentistas WHERE lower(trim(email)) = $1', [email]);
|
||||
if (rows.length) ids = rows.map((r) => r.id);
|
||||
}
|
||||
const { rows } = await db.query(
|
||||
`SELECT id FROM agendamentos
|
||||
WHERE dentistaid = ANY($1)
|
||||
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))
|
||||
AND start_time < $3 AND COALESCE(end_time, start_time) > $2
|
||||
LIMIT 1`,
|
||||
[ids, start, end || start]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export function createAgendaBridge(pool) {
|
||||
const router = express.Router();
|
||||
|
||||
// Auth server-to-server por segredo compartilhado.
|
||||
router.use((req, res, next) => {
|
||||
const secret = getConfig().webhookSecret;
|
||||
if (!secret || req.headers['x-nw-agenda-secret'] !== secret) {
|
||||
return res.status(401).json({ error: 'Segredo da ponte inválido' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// ── GET /slots ──────────────────────────────────────────────────────────────
|
||||
// Query: clinica_id (obrig.), dentista_id?, date? (YYYY-MM-DD), days?, duration?
|
||||
router.get('/slots', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||||
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
|
||||
const duration = Math.max(10, parseInt(req.query.duration, 10) || SLOT_MIN);
|
||||
const days = Math.min(30, Math.max(1, parseInt(req.query.days, 10) || HORIZON_DAYS));
|
||||
const from = req.query.date ? new Date(`${req.query.date}T00:00:00`) : new Date();
|
||||
|
||||
try {
|
||||
// Dentistas no escopo da clínica.
|
||||
const dParams = [clinicaId];
|
||||
let dWhere = 'clinica_id = $1';
|
||||
if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; }
|
||||
const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams);
|
||||
if (!dentistas.length) return res.json({ slots: [], motivo: 'Nenhum dentista cadastrado na clínica.' });
|
||||
|
||||
// Jornadas configuradas (opcional).
|
||||
const { rows: horarios } = await pool.query(
|
||||
`SELECT dentista_id, dia_semana, hora_inicio, hora_fim
|
||||
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1`, [clinicaId]);
|
||||
const hoursBy = new Map();
|
||||
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push(h); hoursBy.set(h.dentista_id, a); }
|
||||
|
||||
const start0 = new Date(from); start0.setHours(0, 0, 0, 0);
|
||||
const end0 = new Date(start0); end0.setDate(end0.getDate() + days);
|
||||
const { rows: busy } = await pool.query(
|
||||
`SELECT dentistaid, start_time, end_time FROM agendamentos
|
||||
WHERE clinica_id = $1 AND start_time >= $2 AND start_time < $3
|
||||
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`,
|
||||
[clinicaId, start0.toISOString(), end0.toISOString()]);
|
||||
const busyBy = new Map();
|
||||
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
|
||||
|
||||
const now = new Date();
|
||||
const slots = [];
|
||||
for (let i = 0; i < days && slots.length < MAX_SLOTS; i++) {
|
||||
const day = new Date(start0); day.setDate(day.getDate() + i);
|
||||
const dow = day.getDay();
|
||||
for (const dent of dentistas) {
|
||||
if (slots.length >= MAX_SLOTS) break;
|
||||
const cfg = hoursBy.get(dent.id);
|
||||
// Janelas do dia: jornada configurada OU padrão comercial (seg–sex).
|
||||
const windows = (cfg && cfg.length)
|
||||
? cfg.filter((h) => h.dia_semana === dow).map((h) => [h.hora_inicio, h.hora_fim])
|
||||
: ((dow >= 1 && dow <= 5) ? [[DEFAULT_INI, DEFAULT_FIM]] : []);
|
||||
const bs = busyBy.get(dent.id) || [];
|
||||
for (const [ini, fim] of windows) {
|
||||
const [hi, mi] = String(ini).split(':').map(Number);
|
||||
const [hf, mf] = String(fim).split(':').map(Number);
|
||||
let t = new Date(day); t.setHours(hi, mi || 0, 0, 0);
|
||||
const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0);
|
||||
while (t.getTime() + duration * 60000 <= limit.getTime() && slots.length < MAX_SLOTS) {
|
||||
const s = new Date(t);
|
||||
const e = new Date(t.getTime() + duration * 60000);
|
||||
t = e;
|
||||
if (s <= now) continue;
|
||||
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
|
||||
slots.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
slots.sort((a, b) => a.start.localeCompare(b.start));
|
||||
res.json({ slots: slots.slice(0, MAX_SLOTS) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /book ────────────────────────────────────────────────────────────────
|
||||
// Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? }
|
||||
router.post('/book', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.dentista_id || !b.start) {
|
||||
return res.status(400).json({ error: 'clinica_id, dentista_id e start obrigatórios' });
|
||||
}
|
||||
const end = b.end || new Date(new Date(b.start).getTime() + SLOT_MIN * 60000).toISOString();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const conflict = await findConflict(client, b.dentista_id, b.start, end);
|
||||
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário já ocupado.', conflict_id: conflict.id }); }
|
||||
|
||||
// Vincula paciente pelo telefone (escopo da clínica; casa pelos últimos 8 dígitos).
|
||||
let pacienteId = null;
|
||||
let pacienteCriado = false;
|
||||
if (b.paciente_celular) {
|
||||
const tel = String(b.paciente_celular).replace(/\D/g, '').slice(-8);
|
||||
if (tel) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT id FROM pacientes
|
||||
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2 LIMIT 1`,
|
||||
[b.clinica_id, tel]);
|
||||
pacienteId = rows[0]?.id || null;
|
||||
}
|
||||
}
|
||||
// Não encontrado → cria um paciente mínimo (lead do WhatsApp vira paciente).
|
||||
if (!pacienteId && b.paciente_nome) {
|
||||
const pid = `pac_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
||||
await client.query(
|
||||
'INSERT INTO pacientes (id, nome, telefone, clinica_id) VALUES ($1,$2,$3,$4)',
|
||||
[pid, b.paciente_nome, b.paciente_celular || null, b.clinica_id]);
|
||||
pacienteId = pid;
|
||||
pacienteCriado = true;
|
||||
}
|
||||
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1 AND clinica_id = $2', [b.dentista_id, b.clinica_id]);
|
||||
const setorId = dd[0]?.setor_id || null;
|
||||
|
||||
const id = `ag_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
||||
const { rows: ins } = await client.query(
|
||||
`INSERT INTO agendamentos
|
||||
(id, clinica_id, pacientenome, pacientecelular, pacienteid, dentistaid, procedimento,
|
||||
start_time, end_time, status, setor_id, created_by_nome, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW())
|
||||
RETURNING id, start_time, end_time, dentistaid`,
|
||||
[id, b.clinica_id, b.paciente_nome || 'Cliente WhatsApp', b.paciente_celular || null,
|
||||
pacienteId, b.dentista_id, b.procedimento || 'Consulta', b.start, end, setorId]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true, agendamento: ins[0], paciente_vinculado: !!pacienteId, paciente_criado: pacienteCriado });
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
|
||||
res.status(500).json({ error: e.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { getConfig, isConfigured, loadConfigFromDb, saveConfigToDb } from './con
|
||||
|
||||
export { syncKnowledge, buildKnowledgeText } from './sync-knowledge.js'
|
||||
export { createWsTunnel } from './proxy.js'
|
||||
import { createAgendaBridge } from './agenda-bridge.js'
|
||||
|
||||
// Valida uma chave contra o motor. Envia um email-sonda (a chave mestra exige
|
||||
// x-nw-email). 200 = ok com dados; 404 = chave válida mas email-sonda inexistente
|
||||
@@ -31,6 +32,10 @@ export function registerNewwhats(app, pool, opts = {}) {
|
||||
// passthrough middleware caso o guard não seja fornecido (dev)
|
||||
const superadminGuard = opts.superadminGuard || ((_req, _res, next) => next());
|
||||
|
||||
// ── Ponte de agenda (motor → agenda real do scoreodonto) ───────────────────
|
||||
// Auth própria por segredo compartilhado (x-nw-agenda-secret). Server-to-server.
|
||||
app.use('/api/nw/agenda', createAgendaBridge(pool));
|
||||
|
||||
// Carrega a config do banco para o cache (não bloqueia o boot).
|
||||
loadConfigFromDb(pool).then((c) =>
|
||||
console.log(`[newwhats] config carregada — ${isConfigured() ? 'configurada' : 'AGUARDANDO config'} (clinica=${c.clinicaId || '—'})`)
|
||||
|
||||
Reference in New Issue
Block a user