feat(newwhats): agenda inteligente — especialidades, situações, dúvidas, checklist do dono e pendências
Satélite da Secretária IA: - /contexto (dentistas+especialidades+situações+cautelas) e /duvida (encaminhar ao humano sem agendar); agenda_pedidos ganha tipo=duvida + resumo. - Situações da clínica: tabela agenda_situacoes + config (dono) + aba na UI. - Checklist do dono (checklist-defs.js + agenda_checklist): perguntas Sim/Não conferidas contra o banco (dentistas/pacientes) → pendências de config + cautelas p/ a IA não chutar. - Área de PENDÊNCIAS DO DONO (PendenciasDono.tsx): engloba config + fila operacional (horários/dúvidas); botão+badge no /wa-secretaria (só o dono). - Fila humana (PedidosPendentes): cards de dúvida + resolver; /resolver. - Botão DESLIGAR global da Secretária no ThinNav (secPowerApi, visível a todos). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
// POST /book — cria agendamento real (anti-overbooking + vínculo de paciente)
|
||||
import express from 'express';
|
||||
import { getConfig } from './config.js';
|
||||
import { cautelasParaIA } from './checklist-defs.js';
|
||||
import { resolverFeriado, feriadoNacionalNome } from './feriados.js';
|
||||
|
||||
const SLOT_MIN = 60; // duração/espaçamento padrão do slot (1h entre atendimentos)
|
||||
@@ -344,6 +345,57 @@ export function createAgendaBridge(pool) {
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /duvida ──────────────────────────────────────────────────────────────
|
||||
// Encaminha uma DÚVIDA/caso incerto à secretária humana (sem agendar). Cria um
|
||||
// agenda_pedidos tipo='duvida' com o resumo; entra na mesma fila de pendentes.
|
||||
router.post('/duvida', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.resumo) return res.status(400).json({ error: 'clinica_id e resumo obrigatórios' });
|
||||
try {
|
||||
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO agenda_pedidos (id, clinica_id, tipo, resumo, paciente_nome, paciente_telefone, status)
|
||||
VALUES ($1,$2,'duvida',$3,$4,$5,'pendente')`,
|
||||
[id, b.clinica_id, String(b.resumo).slice(0, 2000),
|
||||
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null]);
|
||||
res.json({ ok: true, pedido_id: id });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET /contexto ─────────────────────────────────────────────────────────────
|
||||
// Contexto clínico p/ a Secretária: dentistas + especialidades + "situações"
|
||||
// (regras da clínica). Query: clinica_id.
|
||||
router.get('/contexto', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||||
try {
|
||||
const { rows: dentistas } = await pool.query(
|
||||
`SELECT nome, especialidade, especialidades FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0 ORDER BY nome`, [clinicaId]);
|
||||
let situacoes = [];
|
||||
try {
|
||||
const { rows } = await pool.query(`SELECT texto FROM agenda_situacoes WHERE clinica_id = $1 AND ativo = 1 ORDER BY ordem, created_at`, [clinicaId]);
|
||||
situacoes = rows.map((r) => r.texto);
|
||||
} catch { /* tabela pode não existir ainda */ }
|
||||
// Cautelas do checklist do dono (declaração × dados reais) → a IA não chuta.
|
||||
let cautelas = [];
|
||||
try {
|
||||
const respostas = {};
|
||||
const { rows } = await pool.query(`SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1`, [clinicaId]);
|
||||
for (const r of rows) respostas[r.chave] = r.resposta == null ? null : Number(r.resposta);
|
||||
const nPac = await pool.query(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`, [clinicaId]).then((x) => Number(x.rows[0]?.n || 0)).catch(() => 0);
|
||||
cautelas = cautelasParaIA(respostas, { dentistas: dentistas.length, pacientes: nPac });
|
||||
} catch { /* checklist novo/indisponível */ }
|
||||
res.json({
|
||||
dentistas: dentistas.map((d) => ({
|
||||
nome: d.nome,
|
||||
especialidades: Array.isArray(d.especialidades) ? d.especialidades : (d.especialidade ? [d.especialidade] : []),
|
||||
})),
|
||||
situacoes,
|
||||
cautelas,
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET /dentista-contato ─────────────────────────────────────────────────────
|
||||
// Resolve o WhatsApp de um dentista da clínica (Parte A: a IA aciona o Dr).
|
||||
// Query: clinica_id (obrig.), nome? OU dentista_id?
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import express from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { feriadosNacionais } from './feriados.js';
|
||||
import { CHECKLIST_QUESTOES, pendenciasDeConfig } from './checklist-defs.js';
|
||||
|
||||
function authFromReq(req) {
|
||||
try {
|
||||
@@ -216,6 +217,100 @@ export function createAgendaConfig(pool) {
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Situações / regras da clínica (dono) — a "área de situações" da Secretária ─
|
||||
router.get('/clinica/:clinicaId/situacoes', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`SELECT id, texto, ativo, ordem FROM agenda_situacoes WHERE clinica_id = $1 ORDER BY ordem, created_at`, [req.params.clinicaId]);
|
||||
res.json({ situacoes: rows });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
// Substitui toda a lista. Body: { situacoes: [texto] }
|
||||
router.put('/clinica/:clinicaId/situacoes', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar as situações.' });
|
||||
const linhas = Array.isArray(req.body?.situacoes) ? req.body.situacoes : [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM agenda_situacoes WHERE clinica_id = $1', [clinicaId]);
|
||||
let i = 0;
|
||||
for (const s of linhas) {
|
||||
const t = String(s || '').trim(); if (!t) continue;
|
||||
await client.query(`INSERT INTO agenda_situacoes (id, clinica_id, texto, ativo, ordem) VALUES ($1,$2,$3,1,$4)`, [uid('sit'), clinicaId, t, i++]);
|
||||
}
|
||||
await client.query('COMMIT'); res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// ── Checklist do DONO + pendências (só o dono do workspace) ───────────────────
|
||||
// Conta dentistas/pacientes reais da clínica p/ conferir contra as respostas.
|
||||
async function contagens(clinicaId) {
|
||||
const q = async (sql) => { try { const { rows } = await pool.query(sql, [clinicaId]); return Number(rows[0]?.n || 0); } catch { return 0; } };
|
||||
const dentistas = await q(`SELECT COUNT(*) n FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0`);
|
||||
const pacientes = await q(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`);
|
||||
return { dentistas, pacientes };
|
||||
}
|
||||
async function respostasChecklist(clinicaId) {
|
||||
const map = {};
|
||||
try { const { rows } = await pool.query('SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1', [clinicaId]); for (const r of rows) map[r.chave] = r.resposta == null ? null : Number(r.resposta); } catch { /* tabela nova */ }
|
||||
return map;
|
||||
}
|
||||
|
||||
router.get('/clinica/:clinicaId/checklist', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa o checklist.' });
|
||||
try {
|
||||
const map = await respostasChecklist(clinicaId);
|
||||
res.json({ questoes: CHECKLIST_QUESTOES.map((q) => ({ ...q, resposta: map[q.chave] ?? null })) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
// Salva respostas. Body: { respostas: { chave: 1|0|null } }
|
||||
router.put('/clinica/:clinicaId/checklist', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar o checklist.' });
|
||||
const respostas = req.body?.respostas || {};
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const q of CHECKLIST_QUESTOES) {
|
||||
if (!(q.chave in respostas)) continue;
|
||||
const v = respostas[q.chave];
|
||||
const r = v === 1 || v === true ? 1 : v === 0 || v === false ? 0 : null;
|
||||
await client.query(
|
||||
`INSERT INTO agenda_checklist (clinica_id, chave, resposta, updated_at) VALUES ($1,$2,$3,now())
|
||||
ON CONFLICT (clinica_id, chave) DO UPDATE SET resposta = EXCLUDED.resposta, updated_at = now()`,
|
||||
[clinicaId, q.chave, r]);
|
||||
}
|
||||
await client.query('COMMIT'); res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// Área de pendências do DONO — engloba TUDO: pendências de config (checklist ×
|
||||
// banco) + a fila operacional (pedidos de horário + dúvidas aguardando humano).
|
||||
router.get('/clinica/:clinicaId/pendencias', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa as pendências.' });
|
||||
try {
|
||||
const counts = await contagens(clinicaId);
|
||||
const respostas = await respostasChecklist(clinicaId);
|
||||
const config = pendenciasDeConfig(respostas, counts);
|
||||
let horarios = 0, duvidas = 0;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT COALESCE(tipo,'horario') tipo, COUNT(*) n FROM agenda_pedidos WHERE clinica_id = $1 AND status = 'pendente' GROUP BY 1`, [clinicaId]);
|
||||
for (const r of rows) { if (r.tipo === 'duvida') duvidas = Number(r.n); else horarios = Number(r.n); }
|
||||
} catch { /* tabela nova */ }
|
||||
res.json({
|
||||
contagens: counts,
|
||||
config,
|
||||
operacionais: { horarios, duvidas, total: horarios + duvidas },
|
||||
total: config.length + horarios + duvidas,
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Pedidos pendentes (zona cinza) — a secretária humana confirma/recusa ──────
|
||||
async function ehMembro(clinicaId, userId) {
|
||||
if (await isDono(pool, clinicaId, userId)) return true;
|
||||
@@ -228,7 +323,8 @@ export function createAgendaConfig(pool) {
|
||||
const status = req.query.status ? String(req.query.status) : 'pendente';
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.id, to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio,
|
||||
`SELECT p.id, COALESCE(p.tipo,'horario') AS tipo, p.resumo,
|
||||
to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio,
|
||||
to_char(p.hora_fim,'HH24:MI') AS hora_fim, p.paciente_nome, p.paciente_telefone, p.procedimento,
|
||||
p.dentista_id, d.nome AS dentista_nome, p.status, p.created_at
|
||||
FROM agenda_pedidos p LEFT JOIN dentistas d ON d.id = p.dentista_id
|
||||
@@ -273,6 +369,17 @@ export function createAgendaConfig(pool) {
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// Resolve uma DÚVIDA (tipo='duvida') sem criar agendamento — a humana já
|
||||
// decidiu/atendeu (ou vai agendar manualmente uma avaliação).
|
||||
router.post('/clinica/:clinicaId/pedidos/:id/resolver', async (req, res) => {
|
||||
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
try {
|
||||
await pool.query(`UPDATE agenda_pedidos SET status='resolvido', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
|
||||
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/clinica/:clinicaId/pedidos/:id/recusar', async (req, res) => {
|
||||
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
try {
|
||||
|
||||
@@ -79,6 +79,36 @@ export async function ensureAgendaSchema(pool) {
|
||||
resolved_by varchar
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`);
|
||||
// Pedido também serve para DÚVIDAS (sem horário): tipo + resumo; data/hora nullable.
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS tipo varchar DEFAULT 'horario'`); // horario | duvida
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS resumo text`);
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN data DROP NOT NULL`).catch(() => {});
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN hora_inicio DROP NOT NULL`).catch(() => {});
|
||||
|
||||
// ── agenda_situacoes — "área de situações": regras da clínica que a Secretária
|
||||
// IA deve seguir (ex.: "plano X não cobre canal posterior — ofereça avaliação").
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_situacoes (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
texto text NOT NULL,
|
||||
ativo smallint DEFAULT 1,
|
||||
ordem integer DEFAULT 0,
|
||||
created_at timestamptz DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_situacoes_clinica ON agenda_situacoes (clinica_id)`);
|
||||
|
||||
// ── agenda_checklist — respostas Sim/Não do DONO sobre a clínica (ver
|
||||
// checklist-defs.js). Conferidas contra o banco → geram pendências de config
|
||||
// e deixam a Secretária cautelosa. resposta: 1=sim, 0=não, NULL=não respondido.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_checklist (
|
||||
clinica_id varchar NOT NULL,
|
||||
chave varchar NOT NULL,
|
||||
resposta smallint,
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (clinica_id, chave)
|
||||
)`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Não derruba o boot do satélite se o banco estiver indisponível no momento.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Checklist do DONO do workspace: perguntas Sim/Não sobre a clínica que a
|
||||
// Secretária IA usa para saber quando PODE agendar sozinha e quando deve ter
|
||||
// cautela. As respostas são conferidas contra o banco (dentistas/pacientes):
|
||||
// quando a declaração do dono não bate com os dados, geramos uma PENDÊNCIA
|
||||
// para ele configurar/cadastrar — e a IA fica cautelosa (encaminha ao humano).
|
||||
//
|
||||
// Fonte única: config (UI), consistência (pendências) e injeção no cérebro
|
||||
// (cautelas) consomem esta mesma definição para nunca divergirem.
|
||||
|
||||
export const CHECKLIST_QUESTOES = [
|
||||
{
|
||||
chave: 'multi_dentista',
|
||||
pergunta: 'A clínica tem mais de um dentista?',
|
||||
ajuda: 'Se sim, a Secretária não pode assumir um único dentista nem "clínico geral" por padrão — precisa saber quem atende cada paciente.',
|
||||
},
|
||||
{
|
||||
chave: 'pacientes_cadastrados',
|
||||
pergunta: 'Os pacientes já estão cadastrados no sistema (/pacientes)?',
|
||||
ajuda: 'Sem os pacientes cadastrados, a Secretária não reconhece quem já é paciente e encaminha os retornos para a secretária humana confirmar.',
|
||||
},
|
||||
{
|
||||
chave: 'dentista_fixo',
|
||||
pergunta: 'Cada paciente tem um dentista de referência (fixo)?',
|
||||
ajuda: 'Se sim, um paciente de retorno deve voltar para o MESMO dentista — a IA não deve agendá-lo no clínico geral.',
|
||||
},
|
||||
];
|
||||
|
||||
const sim = (r, k) => r[k] === 1;
|
||||
const nao = (r, k) => r[k] === 0;
|
||||
|
||||
// Pendências de CONFIG do dono: a declaração não bate com o banco.
|
||||
// counts = { dentistas, pacientes }.
|
||||
export function pendenciasDeConfig(respostas, counts) {
|
||||
const r = respostas || {};
|
||||
const dentistas = Number(counts?.dentistas || 0);
|
||||
const pacientes = Number(counts?.pacientes || 0);
|
||||
const P = [];
|
||||
|
||||
if (sim(r, 'multi_dentista') && dentistas <= 1)
|
||||
P.push({ chave: 'multi_dentista', severidade: 'alta', titulo: 'Cadastre os demais dentistas',
|
||||
detalhe: `Você indicou que a clínica tem mais de um dentista, mas há apenas ${dentistas} cadastrado. A Secretária não consegue direcionar cada paciente ao dentista certo — cadastre os outros dentistas.` });
|
||||
if (nao(r, 'multi_dentista') && dentistas > 1)
|
||||
P.push({ chave: 'multi_dentista', severidade: 'media', titulo: 'Confirme quantos dentistas atendem',
|
||||
detalhe: `Você indicou um único dentista, mas há ${dentistas} cadastrados no sistema. Ajuste a resposta ou os cadastros.` });
|
||||
|
||||
if (sim(r, 'pacientes_cadastrados') && pacientes === 0)
|
||||
P.push({ chave: 'pacientes_cadastrados', severidade: 'alta', titulo: 'Cadastre os pacientes',
|
||||
detalhe: 'Você indicou que os pacientes estão cadastrados, mas não há nenhum paciente no sistema. A Secretária não reconhece quem já é paciente.' });
|
||||
if (r['pacientes_cadastrados'] == null || nao(r, 'pacientes_cadastrados'))
|
||||
P.push({ chave: 'pacientes_cadastrados', severidade: 'media', titulo: 'Complete o cadastro de pacientes',
|
||||
detalhe: 'Enquanto os pacientes não estiverem cadastrados, a Secretária vai encaminhar todo retorno para a secretária humana confirmar (não agenda sozinha).' });
|
||||
|
||||
if (sim(r, 'dentista_fixo') && dentistas <= 1 && sim(r, 'multi_dentista'))
|
||||
P.push({ chave: 'dentista_fixo', severidade: 'media', titulo: 'Cadastre os dentistas de referência',
|
||||
detalhe: 'Você indicou que cada paciente tem um dentista fixo, mas o sistema ainda não tem os dentistas todos cadastrados para fazer esse vínculo.' });
|
||||
|
||||
return P;
|
||||
}
|
||||
|
||||
// Cautelas para injetar no cérebro da IA (texto direto no system prompt).
|
||||
export function cautelasParaIA(respostas, counts) {
|
||||
const r = respostas || {};
|
||||
const dentistas = Number(counts?.dentistas || 0);
|
||||
const pacientes = Number(counts?.pacientes || 0);
|
||||
const C = [];
|
||||
|
||||
const multi = sim(r, 'multi_dentista') || dentistas > 1;
|
||||
if (multi)
|
||||
C.push('A clínica tem MAIS DE UM dentista: NUNCA assuma um dentista nem "clínico geral" por padrão para quem já é paciente. Descubra/pergunte qual dentista atende. Sem certeza → encaminhe ao humano.');
|
||||
if (sim(r, 'multi_dentista') && dentistas <= 1)
|
||||
C.push('ATENÇÃO: o sistema ainda não tem todos os dentistas cadastrados. Para pacientes de retorno ou que pedem um dentista específico, encaminhe ao humano — NÃO agende no clínico geral.');
|
||||
if (sim(r, 'dentista_fixo'))
|
||||
C.push('Cada paciente tem um dentista fixo: um retorno deve voltar ao MESMO dentista. Se você não sabe qual é (falta cadastro), encaminhe ao humano.');
|
||||
if (pacientes === 0 || nao(r, 'pacientes_cadastrados'))
|
||||
C.push('O cadastro de pacientes está incompleto: você NÃO consegue confirmar histórico nem o dentista de quem diz já ser paciente. Nesses casos, encaminhe ao humano em vez de agendar.');
|
||||
|
||||
return C;
|
||||
}
|
||||
Reference in New Issue
Block a user