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>
698 lines
27 KiB
JavaScript
698 lines
27 KiB
JavaScript
/*
|
|
|--------------------------------------------------------------------------
|
|
| 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
|
|
};
|
|
}
|