diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index 3f1b87b..4684412 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -123,12 +123,38 @@ async function findConflict(db, dentistaId, start, end, excludeId = null) { return rows[0] || null; } +// ── Fuso horário por clínica ───────────────────────────────────────────────────── +// Brasil não tem horário de verão → offset estável, dispensa lib de TZ. +const DOW_MAP = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; +async function getClinicaTZ(pool, clinicaId) { + try { const { rows } = await pool.query('SELECT timezone FROM clinicas WHERE id=$1', [clinicaId]); return rows[0]?.timezone || 'America/Sao_Paulo'; } + catch { return 'America/Sao_Paulo'; } +} +// "Agora" no fuso da clínica → { year, month, day, hour, minute, dow, hm }. +function partsNow(tz, base = new Date()) { + const f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, weekday: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); + const p = {}; for (const x of f.formatToParts(base)) p[x.type] = x.value; + const hour = p.hour === '24' ? 0 : +p.hour; + return { year: +p.year, month: +p.month, day: +p.day, hour, minute: +p.minute, dow: DOW_MAP[p.weekday], hm: hour * 60 + (+p.minute) }; +} +// Offset do fuso (min) num instante. +function tzOffsetMin(tz, date) { + const f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); + const p = {}; for (const x of f.formatToParts(date)) p[x.type] = x.value; + const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, (p.hour === '24' ? 0 : +p.hour), +p.minute, +p.second); + return Math.round((asUTC - date.getTime()) / 60000); +} +// Instante UTC de um wall-clock (y, mon, d, h, min) no fuso tz. +function zonedToUtc(y, mon, d, h, min, tz) { + const guess = Date.UTC(y, mon - 1, d, h, min, 0); + return new Date(guess - tzOffsetMin(tz, new Date(guess)) * 60000); +} + // ── 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(); +// clock = partsNow(tz) da clínica. Retorna o 1º número contactável AGORA ou null. +function contatoPermitidoAgora(numeros, clock) { + const dow = clock.dow, hm = clock.hm; for (const n of (numeros || [])) { const tel = String(n?.numero || '').replace(/\D/g, ''); if (tel.length < 10) continue; @@ -139,10 +165,9 @@ function contatoPermitidoAgora(numeros, now = new Date()) { } return null; } -// Próxima janela permitida (varre os próximos 7 dias) → { label:"segunda-feira às 08:00", ts:ISO } | null. -function proximaJanela(numeros, now = new Date()) { - const nowDow = now.getDay(); - const nowHm = now.getHours() * 60 + now.getMinutes(); +// Próxima janela permitida → { label, ts:ISO } | null. ts é o instante REAL no fuso tz. +function proximaJanela(numeros, clock, tz) { + const nowDow = clock.dow, nowHm = clock.hm; let best = null; // { offset, ini, dow, inicio } for (const n of (numeros || [])) { if (String(n?.numero || '').replace(/\D/g, '').length < 10) continue; @@ -158,9 +183,11 @@ function proximaJanela(numeros, now = new Date()) { } if (!best) return null; const quando = best.offset === 0 ? 'hoje' : best.offset === 1 ? 'amanhã' : DIAS_NOME[best.dow]; - const dt = new Date(now); dt.setDate(dt.getDate() + best.offset); - const [h, m] = String(best.inicio).split(':').map(Number); dt.setHours(h || 0, m || 0, 0, 0); - return { label: `${quando} às ${best.inicio}`, ts: dt.toISOString() }; + // data-alvo = hoje (da clínica) + offset dias, resolvida como instante no fuso tz. + const td = new Date(Date.UTC(clock.year, clock.month - 1, clock.day)); td.setUTCDate(td.getUTCDate() + best.offset); + const [h, m] = String(best.inicio).split(':').map(Number); + const ts = zonedToUtc(td.getUTCFullYear(), td.getUTCMonth() + 1, td.getUTCDate(), h || 0, m || 0, tz).toISOString(); + return { label: `${quando} às ${best.inicio}`, ts }; } export function createAgendaBridge(pool) { @@ -206,7 +233,13 @@ export function createAgendaBridge(pool) { // Folgas por data dos dentistas (férias/indisponibilidade). const folgasBy = await carregarFolgas(pool, clinicaId); - const start0 = new Date(from); start0.setHours(0, 0, 0, 0); + // "Hoje" e "agora" no fuso da CLÍNICA (não do container). Os slots são rótulos + // wall-clock (fmt); comparamos contra o "agora" da clínica expresso no mesmo frame. + const tz = await getClinicaTZ(pool, clinicaId); + const cn = partsNow(tz); + const start0 = req.query.date ? new Date(`${req.query.date}T00:00:00`) : new Date(cn.year, cn.month - 1, cn.day); + start0.setHours(0, 0, 0, 0); + const nowClinic = new Date(cn.year, cn.month - 1, cn.day, cn.hour, cn.minute, 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 @@ -216,7 +249,7 @@ export function createAgendaBridge(pool) { 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 now = nowClinic; const slots = []; for (let i = 0; i < days && slots.length < CANDIDATE_CAP; i++) { const day = new Date(start0); day.setDate(day.getDate() + i); @@ -464,8 +497,9 @@ export function createAgendaBridge(pool) { 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); - const prox = agora ? null : proximaJanela(numeros); + const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz); + const agora = contatoPermitidoAgora(numeros, clock); + const prox = agora ? null : proximaJanela(numeros, clock, tz); 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: prox ? prox.label : null, proxima_janela_ts: prox ? prox.ts : null }); @@ -505,8 +539,9 @@ export function createAgendaBridge(pool) { } if (!row) return res.json({ encontrado: false }); const numeros = Array.isArray(row.numeros) ? row.numeros : []; - const agora = contatoPermitidoAgora(numeros); - const prox = agora ? null : proximaJanela(numeros); + const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz); + const agora = contatoPermitidoAgora(numeros, clock); + const prox = agora ? null : proximaJanela(numeros, clock, tz); res.json({ encontrado: true, usuario_id: row.usuario_id, nome: row.nome, papel: row.role, telefone: agora ? agora.numero : null, contato_agora: agora, bloqueado_por_janela: !agora, proxima_janela: prox ? prox.label : null, proxima_janela_ts: prox ? prox.ts : null }); @@ -528,14 +563,14 @@ export function createAgendaBridge(pool) { LEFT JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id LEFT JOIN usuarios u ON u.id = pc.usuario_id WHERE pc.clinica_id = $1`, [clinicaId]); - const now = new Date(); + const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz); const contatos = []; for (const r of rows) { const nums = Array.isArray(r.numeros) ? r.numeros : []; for (const n of nums) { if (String(n.numero || '').replace(/\D/g, '').length < 10) continue; - const agora = contatoPermitidoAgora([n], now); - const prox = agora ? null : proximaJanela([n], now); + const agora = contatoPermitidoAgora([n], clock); + const prox = agora ? null : proximaJanela([n], clock, tz); contatos.push({ nome: r.nome || null, papel: r.role || null, descricao: n.descricao || null, situacoes: n.situacoes || null,