feat(agenda-bridge): enforcement de janela ao contatar dentista

/dentista-contato passa a ler pessoa_comunicacao (via dentistas.usuario_id):
- Com perfil: só devolve número se a hora atual cair numa janela permitida
  (contato_agora); senão bloqueado_por_janela=true + proxima_janela ("sábado às 08:00").
- Sem perfil: back-compat — usa telefone legado e não bloqueia.
Helpers contatoPermitidoAgora/proximaJanela (TZ do container = America/Sao_Paulo).

Verificado E2E: fora da janela -> bloqueado; dentro -> libera nº; sem perfil -> ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-07 17:42:11 +02:00
parent d33ebd3f76
commit 727d0a245d
+56 -3
View File
@@ -123,6 +123,44 @@ async function findConflict(db, dentistaId, start, end, excludeId = null) {
return rows[0] || null;
}
// ── Comunicação interna: janelas de horário permitido p/ contatar um número ──────
const toMin = (t) => { const [h, m] = String(t ?? '').split(':').map(Number); return (h || 0) * 60 + (m || 0); };
// Retorna o 1º número contactável AGORA (dia/horário dentro de alguma janela) ou null.
function contatoPermitidoAgora(numeros, now = new Date()) {
const dow = now.getDay();
const hm = now.getHours() * 60 + now.getMinutes();
for (const n of (numeros || [])) {
const tel = String(n?.numero || '').replace(/\D/g, '');
if (tel.length < 10) continue;
for (const j of (n.janelas || [])) {
if (!Array.isArray(j.dias) || !j.dias.includes(dow)) continue;
if (hm >= toMin(j.inicio) && hm <= toMin(j.fim)) return { numero: tel, descricao: n.descricao || null, situacoes: n.situacoes || null };
}
}
return null;
}
// Próxima janela permitida (varre os próximos 7 dias) → "segunda-feira às 08:00" | null.
function proximaJanela(numeros, now = new Date()) {
const nowDow = now.getDay();
const nowHm = now.getHours() * 60 + now.getMinutes();
let best = null; // { offset, ini }
for (const n of (numeros || [])) {
if (String(n?.numero || '').replace(/\D/g, '').length < 10) continue;
for (const j of (n.janelas || [])) {
if (!Array.isArray(j.dias)) continue;
for (const d of j.dias) {
let offset = (d - nowDow + 7) % 7;
const ini = toMin(j.inicio);
if (offset === 0 && ini <= nowHm) offset = 7; // hoje já passou → próxima semana
if (!best || offset < best.offset || (offset === best.offset && ini < best.ini)) best = { offset, ini, dow: d, inicio: j.inicio };
}
}
}
if (!best) return null;
const quando = best.offset === 0 ? 'hoje' : best.offset === 1 ? 'amanhã' : DIAS_NOME[best.dow];
return `${quando} às ${best.inicio}`;
}
export function createAgendaBridge(pool) {
const router = express.Router();
@@ -408,11 +446,26 @@ export function createAgendaBridge(pool) {
if (req.query.dentista_id) { params.push(String(req.query.dentista_id)); where += ` AND id = $${params.length}`; }
else if (req.query.nome) { params.push(String(req.query.nome)); where += ` AND lower(nome) LIKE '%'||lower($${params.length})||'%'`; }
const { rows } = await pool.query(
`SELECT id, nome, telefone FROM dentistas WHERE ${where} AND ativo IS DISTINCT FROM 0 ORDER BY nome LIMIT 1`, params);
`SELECT id, nome, telefone, usuario_id FROM dentistas WHERE ${where} AND ativo IS DISTINCT FROM 0 ORDER BY nome LIMIT 1`, params);
if (!rows.length) return res.json({ encontrado: false });
const d = rows[0];
const tel = String(d.telefone || '').replace(/\D/g, '');
res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, telefone: tel || null });
const legacyTel = String(d.telefone || '').replace(/\D/g, '') || null;
// Perfil de comunicação interna (múltiplos números + janelas de horário permitido).
let numeros = [], temPerfil = false;
if (d.usuario_id) {
const { rows: pc } = await pool.query('SELECT numeros FROM pessoa_comunicacao WHERE usuario_id=$1 AND clinica_id=$2', [d.usuario_id, clinicaId]);
if (pc[0]) { temPerfil = true; numeros = Array.isArray(pc[0].numeros) ? pc[0].numeros : []; }
}
// Sem perfil → back-compat: usa o telefone legado e NÃO bloqueia (não quebra dentistas antigos).
if (!temPerfil) {
return res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, telefone: legacyTel, tem_perfil: false,
contato_agora: legacyTel ? { numero: legacyTel, descricao: null } : null, bloqueado_por_janela: false, proxima_janela: null });
}
// Com perfil → só permite contato dentro de uma janela; senão devolve a próxima.
const agora = contatoPermitidoAgora(numeros);
res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, tem_perfil: true,
telefone: agora ? agora.numero : null, contato_agora: agora,
bloqueado_por_janela: !agora, proxima_janela: agora ? null : proximaJanela(numeros) });
} catch (e) { res.status(500).json({ error: e.message }); }
});