/* |-------------------------------------------------------------------------- | Agência PSI |-------------------------------------------------------------------------- | Criado e desenvolvido por Leonardo Nohama | | Tecnologia aplicada à escuta. | Estrutura para o cuidado. | | Arquivo: src/composables/useConversations.js | Data: 2026 | Local: São Carlos/SP — Brasil |-------------------------------------------------------------------------- | © 2026 — Todos os direitos reservados |-------------------------------------------------------------------------- */ import { ref, computed, onUnmounted } from 'vue'; import { supabase } from '@/lib/supabase/client'; import { useTenantStore } from '@/stores/tenantStore'; // Metadata canonica das colunas do kanban — fonte unica consumida pelo // SFC parent (kanban grid central) e pelo MelissaConversasSidebar // (resumo "Por status"). Antes, parent + sidebar tinham copias locais // de KANBAN_COLUMNS e o composable separadamente tinha KANBAN_ORDER. export const KANBAN_COLUMNS = Object.freeze([ Object.freeze({ key: 'urgent', label: 'Urgente', icon: 'pi pi-exclamation-triangle', color: 'red' }), Object.freeze({ key: 'awaiting_us', label: 'Aguardando resposta', icon: 'pi pi-inbox', color: 'amber' }), Object.freeze({ key: 'awaiting_patient', label: 'Aguardando paciente', icon: 'pi pi-hourglass', color: 'blue' }), Object.freeze({ key: 'resolved', label: 'Resolvido', icon: 'pi pi-check', color: 'emerald' }) ]); const KANBAN_ORDER = KANBAN_COLUMNS.map((c) => c.key); function sanitizeSearch(raw) { if (typeof raw !== 'string') return ''; return raw.trim().slice(0, 120).toLowerCase(); } export function useConversations() { const tenantStore = useTenantStore(); const threads = ref([]); const loading = ref(false); const error = ref(null); const filters = ref({ search: '', channel: null, // null = todos unreadOnly: false, assigned: null // null = todas | 'me' | 'unassigned' | }); const currentUserId = ref(null); supabase.auth.getUser().then(({ data }) => { currentUserId.value = data?.user?.id ?? null; }); let realtimeChannel = null; // Debounce do refetch disparado por realtime — sem isso, clínica ativa // recebendo 10 mensagens/min faz 10 SELECT 500 na lista de threads por // minuto. 300ms agrupa rajadas (digita-rápido, mensagens em sequência) // sem fazer o user esperar visualmente. A nova mensagem em si vai pro // threadMessages direto (acima); load() é só pra atualizar contadores // e preview do thread na lista — pode esperar. let _loadDebounceTimer = null; function _scheduleLoad() { if (_loadDebounceTimer) clearTimeout(_loadDebounceTimer); _loadDebounceTimer = setTimeout(() => { _loadDebounceTimer = null; load(); }, 300); } async function load() { const tenantId = tenantStore.activeTenantId; if (!tenantId) { threads.value = []; error.value = new Error('Tenant ativo inválido'); return; } loading.value = true; error.value = null; try { const { data, error: qErr } = await supabase .from('conversation_threads') .select('*') .eq('tenant_id', tenantId) .order('last_message_at', { ascending: false }) .limit(500); if (qErr) throw qErr; threads.value = data ?? []; } catch (err) { error.value = err; threads.value = []; } finally { loading.value = false; } } function subscribeRealtime() { const tenantId = tenantStore.activeTenantId; if (!tenantId) return; if (realtimeChannel) { supabase.removeChannel(realtimeChannel); } realtimeChannel = supabase .channel(`conv_msg_tenant_${tenantId}`) .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` }, (payload) => { // refetch da lista (view agrega tudo) — debounced _scheduleLoad(); // se o drawer esta aberto numa thread desta msg, appenda const newMsg = payload.new; if (currentThread.value && messageBelongsToThread(newMsg, currentThread.value)) { const alreadyThere = threadMessages.value.some((m) => m.id === newMsg.id); if (!alreadyThere) threadMessages.value.push(newMsg); } } ) .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` }, (payload) => { _scheduleLoad(); const updated = payload.new; if (currentThread.value && messageBelongsToThread(updated, currentThread.value)) { const idx = threadMessages.value.findIndex((m) => m.id === updated.id); if (idx >= 0) threadMessages.value[idx] = updated; } } ) .subscribe(); } function unsubscribeRealtime() { if (realtimeChannel) { supabase.removeChannel(realtimeChannel); realtimeChannel = null; } // Cancela refetch agendado — se desmontar entre o trigger e o // debounce dispara, callback rodaria em ref morta. if (_loadDebounceTimer) { clearTimeout(_loadDebounceTimer); _loadDebounceTimer = null; } } onUnmounted(() => unsubscribeRealtime()); const filteredThreads = computed(() => { const q = sanitizeSearch(filters.value.search); const assignFilter = filters.value.assigned; const uid = currentUserId.value; return threads.value.filter((t) => { if (filters.value.channel && t.channel !== filters.value.channel) return false; if (filters.value.unreadOnly && (t.unread_count || 0) === 0) return false; if (assignFilter === 'me') { if (!uid || t.assigned_to !== uid) return false; } else if (assignFilter === 'unassigned') { if (t.assigned_to) return false; } else if (assignFilter && typeof assignFilter === 'string') { if (t.assigned_to !== assignFilter) return false; } if (q) { const name = (t.patient_name || '').toLowerCase(); const num = (t.contact_number || '').toLowerCase(); const body = (t.last_message_body || '').toLowerCase(); if (!name.includes(q) && !num.includes(q) && !body.includes(q)) return false; } return true; }); }); const byKanban = computed(() => { const map = { urgent: [], awaiting_us: [], awaiting_patient: [], resolved: [] }; for (const t of filteredThreads.value) { const k = KANBAN_ORDER.includes(t.kanban_status) ? t.kanban_status : 'awaiting_us'; map[k].push(t); } return map; }); const summary = computed(() => ({ total: threads.value.length, urgent: byKanban.value.urgent.length, awaiting_us: byKanban.value.awaiting_us.length, awaiting_patient: byKanban.value.awaiting_patient.length, resolved: byKanban.value.resolved.length, unreadTotal: threads.value.reduce((s, t) => s + (t.unread_count || 0), 0) })); // Mensagens de uma thread especifica (drawer) const threadMessages = ref([]); const threadLoading = ref(false); const currentThread = ref(null); function messageBelongsToThread(msg, thread) { if (!thread || !msg) return false; if (thread.patient_id) return msg.patient_id === thread.patient_id; // thread anônima if (msg.patient_id) return false; return ( msg.from_number === thread.contact_number || msg.to_number === thread.contact_number ); } async function loadThreadMessages(thread) { currentThread.value = thread; if (!thread) { threadMessages.value = []; return; } threadLoading.value = true; try { let q = supabase .from('conversation_messages') .select('*') .eq('tenant_id', tenantStore.activeTenantId) .order('created_at', { ascending: true }) .limit(500); if (thread.patient_id) { q = q.eq('patient_id', thread.patient_id); } else { // anônimo — filtra por from_number ou to_number q = q.or(`from_number.eq.${thread.contact_number},to_number.eq.${thread.contact_number}`).is('patient_id', null); } const { data, error: qErr } = await q; if (qErr) throw qErr; threadMessages.value = data ?? []; } finally { threadLoading.value = false; } } async function markThreadRead(thread) { if (!thread) return; // Marca unread do inbound como lido const nowIso = new Date().toISOString(); const tenantId = tenantStore.activeTenantId; let q = supabase .from('conversation_messages') .update({ read_at: nowIso }) .eq('tenant_id', tenantId) .eq('direction', 'inbound') .is('read_at', null); if (thread.patient_id) q = q.eq('patient_id', thread.patient_id); else q = q.eq('from_number', thread.contact_number).is('patient_id', null); await q; load(); } async function setKanbanStatus(thread, newStatus) { if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(newStatus)) return; const tenantId = tenantStore.activeTenantId; const patch = { kanban_status: newStatus }; if (newStatus === 'resolved') patch.resolved_at = new Date().toISOString(); let q = supabase.from('conversation_messages').update(patch).eq('tenant_id', tenantId); if (thread.patient_id) q = q.eq('patient_id', thread.patient_id); else q = q.eq('from_number', thread.contact_number).is('patient_id', null); await q; load(); } return { threads, filteredThreads, byKanban, summary, filters, loading, error, load, subscribeRealtime, unsubscribeRealtime, threadMessages, threadLoading, loadThreadMessages, markThreadRead, setKanbanStatus, // Exposto pra evitar que cada consumidor chame supabase.auth.getUser() // por conta propria — antes, MelissaConversas + composable faziam 2 // round-trips ao auth no mesmo mount. currentUserId }; }