Files
agenciapsilmno/src/features/agenda/services/agendaMappers.js
T
Leonardo 7c20b518d4 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
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>
2026-04-19 15:42:46 -03:00

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}`;
}