7c20b518d4
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
301 lines
14 KiB
JavaScript
301 lines
14 KiB
JavaScript
/*
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/features/agenda/services/agendaMappers.js
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
// Suporta dois tipos de linha:
|
|
// 1. Evento real (agenda_eventos do banco) — is_occurrence = false/undefined
|
|
// 2. Ocorrência virtual (gerada por useRecurrence) — is_occurrence = true
|
|
//
|
|
// Em ambos os casos o shape de saída para o FullCalendar é idêntico.
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// mapAgendaEventosToCalendarEvents
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function mapAgendaEventosToCalendarEvents(rows) {
|
|
return (rows || []).map(_mapRow).filter(Boolean);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// mapAgendaEventosToClinicResourceEvents
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function mapAgendaEventosToClinicResourceEvents(rows) {
|
|
return (rows || [])
|
|
.map((r) => {
|
|
const ev = _mapRow(r);
|
|
if (!ev) return null;
|
|
ev.resourceId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null);
|
|
return ev;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// mapper interno
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function _mapRow(r) {
|
|
if (!r) return null;
|
|
|
|
const isOccurrence = !!r.is_occurrence;
|
|
const isRealSession = !isOccurrence;
|
|
|
|
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null);
|
|
|
|
// commitment / cores
|
|
const commitment = r.determined_commitments ?? r.commitment ?? null;
|
|
const baseBg = commitment?.bg_color ? `#${commitment.bg_color}` : null;
|
|
const baseTxt = commitment?.text_color ? `#${commitment.text_color}` : null;
|
|
const statusBg = _statusBgColor(r.status);
|
|
const bgColor = statusBg ?? baseBg ?? undefined;
|
|
const txtColor = baseTxt ?? (statusBg ? '#ffffff' : undefined);
|
|
|
|
// título
|
|
const nomeP = r.patients?.nome_completo ?? r.paciente_nome ?? r.patient_name ?? '';
|
|
const titleBase = r.titulo_custom || r.titulo || (nomeP ? nomeP : tituloFallback(r.tipo));
|
|
const icon = _statusIcon(r.status, isOccurrence, !!r.recurrence_id);
|
|
const title = `${icon}${titleBase}`;
|
|
|
|
// recorrência — nova + fallback legada
|
|
const recurrenceId = r.recurrence_id ?? null;
|
|
const originalDate = r.original_date ?? r.recurrence_date ?? null;
|
|
const exceptionType = r.exception_type ?? null;
|
|
|
|
return {
|
|
id: r.id ?? `occ::${recurrenceId}::${originalDate}`,
|
|
title,
|
|
start: r.inicio_em,
|
|
end: r.fim_em,
|
|
|
|
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
|
...(txtColor && { textColor: txtColor }),
|
|
|
|
extendedProps: {
|
|
// identidade
|
|
dbId: r.id ?? null,
|
|
isOccurrence,
|
|
isRealSession,
|
|
|
|
// owner
|
|
owner_id: ownerId,
|
|
terapeuta_id: normalizeId(r?.terapeuta_id ?? null),
|
|
|
|
// compromisso
|
|
tipo: r.tipo ?? null,
|
|
status: r.status ?? null,
|
|
determined_commitment_id: r.determined_commitment_id ?? null,
|
|
commitment_bg_color: bgColor ?? null,
|
|
commitment_text_color: txtColor ?? null,
|
|
|
|
// paciente
|
|
patient_id: r.patient_id ?? null,
|
|
paciente_id: r.patient_id ?? null, // alias para compatibilidade com dialog/form
|
|
paciente_nome: nomeP,
|
|
paciente_avatar: r.patients?.avatar_url ?? r.paciente_avatar ?? null,
|
|
paciente_status: r.patients?.status ?? r.paciente_status ?? null,
|
|
|
|
// campos
|
|
observacoes: r.observacoes ?? null,
|
|
titulo_custom: r.titulo_custom ?? null,
|
|
extra_fields: r.extra_fields ?? null,
|
|
modalidade: r.modalidade ?? null,
|
|
|
|
// privacidade (clínica)
|
|
visibility_scope: r.visibility_scope ?? null,
|
|
masked: !!r.masked,
|
|
|
|
// recorrência — NOVA arquitetura
|
|
recurrence_id: recurrenceId,
|
|
original_date: originalDate,
|
|
exception_type: exceptionType,
|
|
exception_id: r.exception_id ?? null,
|
|
exception_reason: r.exception_reason ?? null,
|
|
|
|
// recorrência — fallback LEGADA (não quebra enquanto migra)
|
|
serie_id: r.serie_id ?? recurrenceId ?? null,
|
|
serie_dia_semana: r.agenda_series?.dia_semana ?? r.serie_dia_semana ?? null,
|
|
serie_hora: r.agenda_series?.hora_inicio ?? r.serie_hora ?? null,
|
|
serie_duracao: r.agenda_series?.duracao_min ?? r.serie_duracao ?? null,
|
|
serie_status: r.agenda_series?.status ?? r.serie_status ?? null,
|
|
is_exception: r.is_exception ?? exceptionType != null,
|
|
|
|
// financeiro
|
|
price: r.price ?? null,
|
|
billed: r.billed ?? false,
|
|
billing_contract_id: r.billing_contract_id ?? null,
|
|
insurance_plan_id: r.insurance_plan_id ?? null,
|
|
insurance_guide_number: r.insurance_guide_number ?? null,
|
|
insurance_value: r.insurance_value != null ? Number(r.insurance_value) : null,
|
|
insurance_plan_service_id: r.insurance_plan_service_id ?? null,
|
|
|
|
// timestamps
|
|
inicio_em: r.inicio_em,
|
|
fim_em: r.fim_em,
|
|
tenant_id: r.tenant_id ?? null
|
|
}
|
|
};
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// buildNextSessions
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function buildNextSessions(rows, now = new Date()) {
|
|
const nowMs = now.getTime();
|
|
return (rows || [])
|
|
.filter((r) => new Date(r.fim_em).getTime() >= nowMs)
|
|
.slice(0, 6)
|
|
.map((r) => ({
|
|
id: r.id,
|
|
title: r.titulo || tituloFallback(r.tipo),
|
|
startISO: r.inicio_em,
|
|
endISO: r.fim_em,
|
|
tipo: r.tipo,
|
|
status: r.status,
|
|
pacienteId: r.patient_id || null
|
|
}));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// calcDefaultSlotDuration
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function calcDefaultSlotDuration(settings) {
|
|
const min = (settings?.usar_granularidade_custom && settings?.granularidade_min) || 0 || settings?.admin_slot_visual_minutos || 30;
|
|
return minutesToDuration(min);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// buildWeeklyBreakBackgroundEvents — código original preservado integralmente
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function buildWeeklyBreakBackgroundEvents(pausas, rangeStart, rangeEnd) {
|
|
if (!Array.isArray(pausas) || pausas.length === 0) return [];
|
|
|
|
const out = [];
|
|
const dayMs = 24 * 60 * 60 * 1000;
|
|
|
|
for (let ts = startOfDay(rangeStart).getTime(); ts < rangeEnd.getTime(); ts += dayMs) {
|
|
const d = new Date(ts);
|
|
const dow = d.getDay();
|
|
|
|
for (const p of pausas) {
|
|
const wd = normalizeWeekday(p?.weekday ?? p?.dia_semana);
|
|
if (wd === null || wd !== dow) continue;
|
|
|
|
const start = asTime(p?.start ?? p?.inicio ?? p?.from);
|
|
const end = asTime(p?.end ?? p?.fim ?? p?.to);
|
|
if (!start || !end) continue;
|
|
|
|
out.push({
|
|
id: `break-${ts}-${start}-${end}`,
|
|
start: combineDateTimeISO(d, start),
|
|
end: combineDateTimeISO(d, end),
|
|
display: 'background',
|
|
overlap: false,
|
|
extendedProps: { kind: 'break', label: p?.label ?? 'Pausa' }
|
|
});
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// minutesToDuration / tituloFallback
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function minutesToDuration(min) {
|
|
const h = Math.floor(min / 60);
|
|
const m = min % 60;
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
|
|
}
|
|
|
|
export function tituloFallback(tipo) {
|
|
const t = String(tipo || '').toLowerCase();
|
|
if (t.includes('sess')) return 'Sessão';
|
|
if (t.includes('block') || t.includes('bloq')) return 'Bloqueio';
|
|
if (t.includes('pessoal')) return 'Pessoal';
|
|
if (t.includes('clin')) return 'Clínica';
|
|
return 'Compromisso';
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// helpers de status
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function _statusBgColor(status) {
|
|
const map = {
|
|
agendado: '#3b82f6', // azul
|
|
realizado: '#22c55e', // verde
|
|
faltou: '#f97316', // laranja
|
|
cancelado: '#ef4444', // vermelho
|
|
remarcado: '#a855f7', // roxo
|
|
bloqueado: '#6b7280' // cinza
|
|
};
|
|
return map[status] ?? null;
|
|
}
|
|
|
|
function _statusIcon(status, isOccurrence, hasSerie) {
|
|
if (status === 'realizado') return '✓ ';
|
|
if (status === 'faltou') return '✗ ';
|
|
if (status === 'cancelado') return '∅ ';
|
|
if (status === 'remarcado') return '↺ ';
|
|
if (status === 'bloqueado') return '⊘ ';
|
|
if (hasSerie || isOccurrence) return '↻ ';
|
|
return '';
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// helpers internos — originais preservados
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function normalizeId(v) {
|
|
if (v === null || v === undefined) return null;
|
|
const s = String(v).trim();
|
|
return s ? s : null;
|
|
}
|
|
|
|
function normalizeWeekday(value) {
|
|
if (value === null || value === undefined) return null;
|
|
const n = Number(value);
|
|
if (Number.isNaN(n)) return null;
|
|
if (n >= 0 && n <= 6) return n;
|
|
if (n >= 1 && n <= 7) return n === 7 ? 0 : n;
|
|
return null;
|
|
}
|
|
|
|
function asTime(v) {
|
|
if (!v || typeof v !== 'string') return null;
|
|
const s = v.trim();
|
|
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`;
|
|
if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s;
|
|
return null;
|
|
}
|
|
|
|
function startOfDay(d) {
|
|
const x = new Date(d);
|
|
x.setHours(0, 0, 0, 0);
|
|
return x;
|
|
}
|
|
|
|
function combineDateTimeISO(date, timeHHMMSS) {
|
|
const yyyy = date.getFullYear();
|
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
|
const dd = String(date.getDate()).padStart(2, '0');
|
|
return `${yyyy}-${mm}-${dd}T${timeHHMMSS}`;
|
|
}
|