diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js new file mode 100644 index 0000000..75662c9 --- /dev/null +++ b/backend/newwhats/agenda-bridge.js @@ -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; +} diff --git a/backend/newwhats/index.js b/backend/newwhats/index.js index f923f03..f8961bd 100644 --- a/backend/newwhats/index.js +++ b/backend/newwhats/index.js @@ -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 || '—'})`)