/* |-------------------------------------------------------------------------- | Agência PSI |-------------------------------------------------------------------------- | Criado e desenvolvido por Leonardo Nohama | | Tecnologia aplicada à escuta. | Estrutura para o cuidado. | | Arquivo: src/features/agenda/composables/useRecurrence.js | Data: 2026 | Local: São Carlos/SP — Brasil |-------------------------------------------------------------------------- | © 2026 — Todos os direitos reservados |-------------------------------------------------------------------------- */ /** * useRecurrence.js * src/features/agenda/composables/useRecurrence.js * * Coração da nova arquitetura de recorrência. * Gera ocorrências dinamicamente no frontend a partir das regras. * Nunca grava eventos futuros no banco — apenas regras + exceções. * * Fluxo: * 1. loadRules(ownerId, rangeStart, rangeEnd) → busca regras ativas * 2. loadExceptions(ruleIds, rangeStart, rangeEnd) → busca exceções no range * 3. expandRules(rules, exceptions, rangeStart, rangeEnd) → gera ocorrências * 4. mergeWithStoredSessions(occurrences, storedRows) → sessões reais sobrepõem */ import { ref } from 'vue'; import { supabase } from '@/lib/supabase/client'; import { useTenantStore } from '@/stores/tenantStore'; import { assertTenantId } from '@/features/agenda/services/_tenantGuards'; import { logRecurrence, logError, logPerf } from '@/support/supportLogger'; // ─── helpers de data ──────────────────────────────────────────────────────── /** 'YYYY-MM-DD' → Date (local, sem UTC shift) */ function parseDate(iso) { const [y, m, d] = String(iso).slice(0, 10).split('-').map(Number); return new Date(y, m - 1, d); } /** Date → 'YYYY-MM-DD' */ function toISO(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } /** 'HH:MM' ou 'HH:MM:SS' → { hours, minutes } */ function parseTime(t) { const [h, m] = String(t || '00:00') .split(':') .map(Number); return { hours: h || 0, minutes: m || 0 }; } /** Aplica HH:MM a um Date, retorna novo Date */ function applyTime(date, timeStr) { const d = new Date(date); const { hours, minutes } = parseTime(timeStr); d.setHours(hours, minutes, 0, 0); return d; } /** Avança cursor para o próximo dia-da-semana especificado */ function nextWeekday(fromDate, targetDow) { const d = new Date(fromDate); const diff = (targetDow - d.getDay() + 7) % 7; d.setDate(d.getDate() + (diff === 0 ? 0 : diff)); return d; } // ─── geradores de datas por tipo ───────────────────────────────────────────── /** * Gera array de datas (Date) para uma regra no intervalo [rangeStart, rangeEnd] * Respeita: start_date, end_date, max_occurrences, open_ended */ export function generateDates(rule, rangeStart, rangeEnd) { const ruleStart = parseDate(rule.start_date); const ruleEnd = rule.end_date ? parseDate(rule.end_date) : null; const effStart = ruleStart > rangeStart ? ruleStart : rangeStart; const effEnd = ruleEnd && ruleEnd < rangeEnd ? ruleEnd : rangeEnd; const interval = Number(rule.interval || 1); const weekdays = (rule.weekdays || []).map(Number); if (!weekdays.length) return []; const dates = []; if (rule.type === 'weekly' || rule.type === 'biweekly') { const dow = weekdays[0]; // primeira ocorrência da série (a partir do start_date da regra) const firstInSerie = nextWeekday(ruleStart, dow); // conta quantas ocorrências já existem ANTES do range atual // para saber o occurrenceCount global correto let globalCount = 0; const counter = new Date(firstInSerie); while (counter < effStart) { globalCount++; counter.setDate(counter.getDate() + 7 * interval); } // agora itera a partir do effStart gerando as do range const cur = new Date(counter); // está na primeira data >= effStart while (cur <= effEnd) { if (rule.max_occurrences && globalCount >= rule.max_occurrences) break; dates.push(new Date(cur)); globalCount++; cur.setDate(cur.getDate() + 7 * interval); } } else if (rule.type === 'custom_weekdays') { // múltiplos dias da semana, intervalo semanal // Conta ocorrências ANTES do range para respeitar max_occurrences globalmente let occurrenceCount = 0; const sortedDows = [...weekdays].sort(); // Início da semana de ruleStart const preStart = new Date(ruleStart); preStart.setDate(preStart.getDate() - preStart.getDay()); // Pré-conta ocorrências entre ruleStart e effStart (cursor separado) const preCur = new Date(preStart); while (preCur < effStart) { for (const dow of sortedDows) { const d = new Date(preCur); d.setDate(d.getDate() + dow); if (d >= ruleStart && d < effStart) occurrenceCount++; } preCur.setDate(preCur.getDate() + 7); } // Itera a partir da semana que contém effStart (cursor independente do preCur) const weekOfEffStart = new Date(effStart); weekOfEffStart.setDate(weekOfEffStart.getDate() - weekOfEffStart.getDay()); const cur = new Date(weekOfEffStart); while (cur <= effEnd) { for (const dow of sortedDows) { const d = new Date(cur); d.setDate(d.getDate() + dow); if (d >= effStart && d <= effEnd && d >= ruleStart) { if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break; dates.push(new Date(d)); occurrenceCount++; } } cur.setDate(cur.getDate() + 7); } } else if (rule.type === 'monthly') { // mesmo dia do mês // Conta ocorrências ANTES do range para respeitar max_occurrences globalmente let occurrenceCount = 0; const dayOfMonth = ruleStart.getDate(); // Pré-conta: de ruleStart até o mês anterior a effStart const preCur = new Date(ruleStart.getFullYear(), ruleStart.getMonth(), dayOfMonth); while (preCur < effStart) { if (preCur >= ruleStart) occurrenceCount++; preCur.setMonth(preCur.getMonth() + interval); } // Itera a partir do primeiro mês dentro do range const cur = new Date(preCur); while (cur <= effEnd) { if (cur >= ruleStart) { if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break; dates.push(new Date(cur)); occurrenceCount++; } cur.setMonth(cur.getMonth() + interval); } } else if (rule.type === 'yearly') { // Conta ocorrências ANTES do range para respeitar max_occurrences globalmente let occurrenceCount = 0; // Pré-conta: de ruleStart até o ano anterior a effStart const preCur = new Date(ruleStart); while (preCur < effStart) { occurrenceCount++; preCur.setFullYear(preCur.getFullYear() + interval); } // Itera a partir do primeiro ano dentro do range const cur = new Date(preCur); while (cur <= effEnd) { if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break; dates.push(new Date(cur)); occurrenceCount++; cur.setFullYear(cur.getFullYear() + interval); } } return dates; } // ─── expansão principal ────────────────────────────────────────────────────── // Cap defensivo: a agenda real sempre passa ranges mensais/semanais (≤42d). // Range muito grande com muitas regras = milhares de ocorrências no browser. // Não bloqueamos (relatórios legítimos podem precisar), só avisamos. const MAX_RANGE_DAYS = 730; // 2 anos /** * Expande regras em ocorrências, aplica exceções. * * @param {Array} rules - regras do banco * @param {Array} exceptions - exceções do banco (todas as das regras carregadas) * @param {Date} rangeStart * @param {Date} rangeEnd * @returns {Array} occurrences — objetos com shape compatível com FullCalendar */ export function expandRules(rules, exceptions, rangeStart, rangeEnd) { const rangeDays = Math.round((rangeEnd.getTime() - rangeStart.getTime()) / 86_400_000); if (rangeDays > MAX_RANGE_DAYS) { logError('useRecurrence', 'expandRules: range grande pode degradar UI', { rangeDays, maxRecommended: MAX_RANGE_DAYS, ruleCount: (rules || []).length }); } // índice de exceções por regra+data const exMap = new Map(); for (const ex of exceptions || []) { const key = `${ex.recurrence_id}::${ex.original_date}`; exMap.set(key, ex); } const occurrences = []; // Rastreia IDs de exceções consumidas no loop principal const handledExIds = new Set(); for (const rule of rules || []) { if (rule.status === 'cancelado') continue; const dates = generateDates(rule, rangeStart, rangeEnd); for (const date of dates) { const iso = toISO(date); const exKey = `${rule.id}::${iso}`; const exception = exMap.get(exKey); if (exception) handledExIds.add(exception.id); // ── exceção: cancela esta ocorrência ── if (exception?.type === 'cancel_session' || exception?.type === 'patient_missed' || exception?.type === 'therapist_canceled' || exception?.type === 'holiday_block') { // ainda inclui no calendário mas com status especial occurrences.push(buildOccurrence(rule, date, iso, exception)); continue; } // ── exceção: remarca esta ocorrência ── if (exception?.type === 'reschedule_session') { const newDate = exception.new_date ? parseDate(exception.new_date) : date; const newIso = exception.new_date || iso; occurrences.push(buildOccurrence(rule, newDate, newIso, exception)); continue; } // ── ocorrência normal ── occurrences.push(buildOccurrence(rule, date, iso, null)); } } // ── post-pass: remarcações inbound ────────────────────────────────────────── // Cobre exceções do tipo reschedule_session cujo original_date estava FORA do // range (não gerado pelo loop acima) mas cujo new_date cai DENTRO do range. // Essas exceções chegam aqui via loadExceptions query 2, mas nunca são // alcançadas no loop principal — sem este post-pass o slot ficaria vazio. const ruleMap = new Map((rules || []).map((r) => [r.id, r])); const startISO = toISO(rangeStart); const endISO = toISO(rangeEnd); for (const ex of exceptions || []) { if (handledExIds.has(ex.id)) continue; if (ex.type !== 'reschedule_session') continue; if (!ex.new_date) continue; if (ex.new_date < startISO || ex.new_date > endISO) continue; const rule = ruleMap.get(ex.recurrence_id); if (!rule || rule.status === 'cancelado') continue; const newDate = parseDate(ex.new_date); occurrences.push(buildOccurrence(rule, newDate, ex.original_date, ex)); } return occurrences; } /** * Constrói o objeto de ocorrência no formato que o calendário e o dialog esperam */ function buildOccurrence(rule, date, originalIso, exception) { const effectiveStartTime = exception?.new_start_time || rule.start_time; const effectiveEndTime = exception?.new_end_time || rule.end_time; const start = applyTime(date, effectiveStartTime); const end = applyTime(date, effectiveEndTime); const exType = exception?.type || null; return { // identificação id: `rec::${rule.id}::${originalIso}`, // id virtual recurrence_id: rule.id, original_date: originalIso, is_occurrence: true, // flag para diferenciar de eventos reais is_real_session: false, // dados da regra determined_commitment_id: rule.determined_commitment_id, patient_id: rule.patient_id, paciente_id: rule.patient_id, owner_id: rule.owner_id, therapist_id: rule.therapist_id, terapeuta_id: rule.therapist_id, tenant_id: rule.tenant_id, // nome do paciente — injetado pelo loadAndExpand via _patient paciente_nome: rule._patient?.nome_completo ?? null, paciente_avatar: rule._patient?.avatar_url ?? null, patient_name: rule._patient?.nome_completo ?? null, // tempo inicio_em: start.toISOString(), fim_em: end.toISOString(), // campos opcionais modalidade: exception?.modalidade || rule.modalidade || 'presencial', titulo_custom: exception?.titulo_custom || rule.titulo_custom || null, observacoes: exception?.observacoes || rule.observacoes || null, extra_fields: exception?.extra_fields || rule.extra_fields || null, price: rule.price ?? null, // convênio — herdado da regra de recorrência insurance_plan_id: rule.insurance_plan_id ?? null, insurance_guide_number: rule.insurance_guide_number ?? null, insurance_value: rule.insurance_value ?? null, insurance_plan_service_id: rule.insurance_plan_service_id ?? null, // estado da exceção exception_type: exType, exception_id: exception?.id || null, exception_reason: exception?.reason || null, // status derivado da exceção status: _statusFromException(exType), // para o FullCalendar tipo: 'sessao' }; } function _statusFromException(exType) { if (!exType) return 'agendado'; if (exType === 'cancel_session') return 'cancelado'; if (exType === 'patient_missed') return 'faltou'; if (exType === 'therapist_canceled') return 'cancelado'; if (exType === 'holiday_block') return 'bloqueado'; if (exType === 'reschedule_session') return 'remarcado'; return 'agendado'; } /** * Merge ocorrências geradas com sessões reais do banco. * Sessões reais (is_real_session=true) sobrepõem ocorrências geradas * para a mesma regra+data. * * @param {Array} occurrences - geradas por expandRules * @param {Array} storedRows - linhas de agenda_eventos com recurrence_id * @returns {Array} merged */ export function mergeWithStoredSessions(occurrences, storedRows) { // índice de sessões reais por recurrence_id + recurrence_date const realMap = new Map(); for (const row of storedRows || []) { if (!row.recurrence_id || !row.recurrence_date) continue; const key = `${row.recurrence_id}::${row.recurrence_date}`; realMap.set(key, { ...row, is_real_session: true, is_occurrence: false, original_date: row.original_date ?? row.recurrence_date ?? null }); } const result = []; for (const occ of occurrences) { const key = `${occ.recurrence_id}::${occ.original_date}`; if (realMap.has(key)) { result.push(realMap.get(key)); realMap.delete(key); // evita duplicata } else { result.push(occ); } } // adiciona sessões reais que não tiveram ocorrência correspondente // (ex: sessões avulsas ligadas a uma regra mas fora do range normal) for (const real of realMap.values()) { result.push(real); } return result; } // ─── composable principal ──────────────────────────────────────────────────── export function useRecurrence() { const rules = ref([]); const exceptions = ref([]); const loading = ref(false); const error = ref(null); const tenantStore = useTenantStore(); function currentTenantId() { const tid = tenantStore.activeTenantId; assertTenantId(tid); return tid; } /** * Carrega regras ativas para um owner no range dado. * @param {string} ownerId * @param {Date} rangeStart * @param {Date} rangeEnd * @param {string|null} tenantId — se fornecido, filtra também por tenant (multi-clínica) */ async function loadRules(ownerId, rangeStart, rangeEnd, tenantId = null) { if (!ownerId) { logRecurrence('loadRules: ownerId vazio, abortando'); return; } const endPerf = logPerf('useRecurrence', 'loadRules'); try { const startISO = toISO(rangeStart); const endISO = toISO(rangeEnd); logRecurrence('loadRules →', { ownerId, tenantId, startISO, endISO }); // Busca regras sem end_date (abertas) + regras com end_date >= rangeStart // Dois selects separados evitam problemas com .or() + .is.null no Supabase JS const baseQuery = () => { let q = supabase.from('recurrence_rules').select('*').eq('owner_id', ownerId).eq('status', 'ativo').lte('start_date', endISO).order('start_date', { ascending: true }); // Filtra por tenant quando disponível — defesa em profundidade if (tenantId && tenantId !== 'null' && tenantId !== 'undefined') { q = q.eq('tenant_id', tenantId); } return q; }; const [resOpen, resWithEnd] = await Promise.all([baseQuery().is('end_date', null), baseQuery().gte('end_date', startISO).not('end_date', 'is', null)]); if (resOpen.error) throw resOpen.error; if (resWithEnd.error) throw resWithEnd.error; // deduplica por id (improvável mas seguro) const merged = [...(resOpen.data || []), ...(resWithEnd.data || [])]; const seen = new Set(); rules.value = merged.filter((r) => { if (seen.has(r.id)) return false; seen.add(r.id); return true; }); logRecurrence('loadRules ← regras encontradas', { count: rules.value.length }); endPerf({ ruleCount: rules.value.length }); } catch (e) { logError('useRecurrence', 'loadRules ERRO', e); error.value = e?.message || 'Erro ao carregar regras'; rules.value = []; } } /** * Carrega exceções para as regras carregadas no range. * * Dois casos cobertos: * 1. original_date no range → cobre cancels, faltas, remarcações-para-fora e remarcações-normais * 2. reschedule_session com new_date no range (original fora do range) * → "remarcação inbound": sessão de outra semana/mês movida para cair neste range * * Ambos os resultados são mesclados e deduplicados por id. */ async function loadExceptions(rangeStart, rangeEnd) { const ids = rules.value.map((r) => r.id); if (!ids.length) { exceptions.value = []; return; } try { const startISO = toISO(rangeStart); const endISO = toISO(rangeEnd); // Query 1 — comportamento original: exceções cujo original_date está no range const q1 = supabase.from('recurrence_exceptions').select('*').in('recurrence_id', ids).gte('original_date', startISO).lte('original_date', endISO); // Query 2 — bug fix: remarcações cujo new_date cai neste range // (original_date pode estar antes ou depois do range) const q2 = supabase.from('recurrence_exceptions').select('*').in('recurrence_id', ids).eq('type', 'reschedule_session').not('new_date', 'is', null).gte('new_date', startISO).lte('new_date', endISO); const [res1, res2] = await Promise.all([q1, q2]); if (res1.error) throw res1.error; if (res2.error) throw res2.error; // Mescla e deduplica por id const merged = [...(res1.data || []), ...(res2.data || [])]; const seen = new Set(); exceptions.value = merged.filter((ex) => { if (seen.has(ex.id)) return false; seen.add(ex.id); return true; }); } catch (e) { logError('useRecurrence', 'loadExceptions ERRO', e); error.value = e?.message || 'Erro ao carregar exceções'; exceptions.value = []; } } /** * Carrega tudo e retorna ocorrências expandidas + merged com sessões reais. * @param {string} ownerId * @param {Date} rangeStart * @param {Date} rangeEnd * @param {Array} storedRows — eventos reais já carregados * @param {string|null} tenantId — filtra regras por tenant (multi-clínica) */ async function loadAndExpand(ownerId, rangeStart, rangeEnd, storedRows = [], tenantId = null) { loading.value = true; error.value = null; const endPerf = logPerf('useRecurrence', 'loadAndExpand'); logRecurrence('loadAndExpand START', { ownerId, tenantId, storedRows: storedRows.length }); try { await loadRules(ownerId, rangeStart, rangeEnd, tenantId); await loadExceptions(rangeStart, rangeEnd); // Busca nomes dos pacientes das regras carregadas const patientIds = [...new Set(rules.value.map((r) => r.patient_id).filter(Boolean))]; if (patientIds.length) { const { data: patients } = await supabase.from('patients').select('id, nome_completo, avatar_url').in('id', patientIds); // injeta nome diretamente na regra para o buildOccurrence usar const pMap = new Map((patients || []).map((p) => [p.id, p])); for (const rule of rules.value) { if (rule.patient_id && pMap.has(rule.patient_id)) { rule._patient = pMap.get(rule.patient_id); } } } const occurrences = expandRules(rules.value, exceptions.value, rangeStart, rangeEnd); logRecurrence('expandRules → ocorrências', { count: occurrences.length }); const merged = mergeWithStoredSessions(occurrences, storedRows); logRecurrence('merged final', { count: merged.length }); endPerf({ occurrences: occurrences.length, merged: merged.length }); return merged; } catch (e) { logError('useRecurrence', 'loadAndExpand ERRO', e); error.value = e?.message || 'Erro ao expandir recorrências'; return []; } finally { loading.value = false; } } // ── CRUD de regras ────────────────────────────────────────────────────────── /** * Cria uma nova regra de recorrência. * tenant_id é injetado do tenantStore se não vier no payload (defesa em profundidade). * @param {Object} rule - campos da tabela recurrence_rules * @returns {Object} regra criada */ async function createRule(rule) { const tenantId = currentTenantId(); logRecurrence('createRule →', { patient_id: rule?.patient_id, type: rule?.type }); const safeRule = { ...rule, tenant_id: rule?.tenant_id || tenantId }; const { data, error: err } = await supabase.from('recurrence_rules').insert([safeRule]).select('*').single(); if (err) { logError('useRecurrence', 'createRule ERRO', err); throw err; } logRecurrence('createRule ← criado', { id: data?.id }); return data; } /** * Atualiza a regra toda (editar todos). * Filtro adicional por tenant_id — defesa em profundidade (RLS cobre, mas reforçamos). */ async function updateRule(id, patch) { const tenantId = currentTenantId(); const { data, error: err } = await supabase .from('recurrence_rules') .update({ ...patch, updated_at: new Date().toISOString() }) .eq('id', id) .eq('tenant_id', tenantId) .select('*') .single(); if (err) throw err; return data; } /** * Cancela a série inteira (filtro por tenant_id — defesa em profundidade). */ async function cancelRule(id) { const tenantId = currentTenantId(); const { error: err } = await supabase .from('recurrence_rules') .update({ status: 'cancelado', updated_at: new Date().toISOString() }) .eq('id', id) .eq('tenant_id', tenantId); if (err) throw err; } /** * Divide a série a partir de uma data (este e os seguintes) * Retorna o id da nova regra criada */ async function splitRuleAt(id, fromDateISO) { const { data, error: err } = await supabase.rpc('split_recurrence_at', { p_recurrence_id: id, p_from_date: fromDateISO }); if (err) throw err; return data; // new rule id } /** * Cancela a série a partir de uma data */ async function cancelRuleFrom(id, fromDateISO) { const { error: err } = await supabase.rpc('cancel_recurrence_from', { p_recurrence_id: id, p_from_date: fromDateISO }); if (err) throw err; } // ── CRUD de exceções ──────────────────────────────────────────────────────── /** * Cria ou atualiza uma exceção para uma ocorrência específica. * tenant_id é injetado do tenantStore se não vier no payload. */ async function upsertException(ex) { const tenantId = currentTenantId(); const safeEx = { ...ex, tenant_id: ex?.tenant_id || tenantId }; const { data, error: err } = await supabase .from('recurrence_exceptions') .upsert([safeEx], { onConflict: 'recurrence_id,original_date' }) .select('*') .single(); if (err) throw err; return data; } /** * Remove uma exceção (restaura a ocorrência ao normal). * Filtro por tenant_id — defesa em profundidade. */ async function deleteException(recurrenceId, originalDate) { const tenantId = currentTenantId(); const { error: err } = await supabase .from('recurrence_exceptions') .delete() .eq('recurrence_id', recurrenceId) .eq('original_date', originalDate) .eq('tenant_id', tenantId); if (err) throw err; } return { rules, exceptions, loading, error, loadRules, loadExceptions, loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException, deleteException }; }