diff --git a/src/features/conversations/services/_tenantGuards.js b/src/features/conversations/services/_tenantGuards.js new file mode 100644 index 0000000..a115880 --- /dev/null +++ b/src/features/conversations/services/_tenantGuards.js @@ -0,0 +1,22 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/conversations/services/_tenantGuards.js +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; + +export function assertTenantId(tenantId) { + if (!tenantId || tenantId === 'null' || tenantId === 'undefined') { + throw new Error('Tenant ativo inválido. Selecione a clínica antes de acessar conversas.'); + } +} + +export async function getUid() { + const { data, error } = await supabase.auth.getUser(); + if (error) throw error; + const uid = data?.user?.id; + if (!uid) throw new Error('Usuário não autenticado.'); + return uid; +} diff --git a/src/features/conversations/services/conversationsRepository.js b/src/features/conversations/services/conversationsRepository.js new file mode 100644 index 0000000..44968de --- /dev/null +++ b/src/features/conversations/services/conversationsRepository.js @@ -0,0 +1,115 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/conversations/services/conversationsRepository.js +| +| Repository de conversas (threads + messages). Foundation pra Módulo 6. +| +| ⚠️ src/composables/useConversations.js (existente) AINDA tem supabase direto. +| Migração pra usar este repository fica pra sessão dedicada (composable é fat +| e mistura listing + threading + realtime — audit baseline pediu split em +| useConversationsList + useConversationThreadDetail). +| +| Channel send (WhatsApp Evolution/Twilio, SMS Twilio) NÃO está aqui — é +| operação cross-canal que precisa de factory dedicada (ver audit alta: +| conversationDrawerStore lógica de envio sem abstração). +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +import { assertTenantId } from './_tenantGuards'; +import { + CONVERSATION_THREAD_SELECT, + CONVERSATION_MESSAGE_SELECT, + CONVERSATION_MESSAGE_SELECT_BRIEF +} from './conversationsSelects'; + +function resolveTenantId(tenantIdArg) { + const tenantStore = useTenantStore(); + const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId; + assertTenantId(tenantId); + return tenantId; +} + +// ─── Threads ───────────────────────────────────────────────────────────── + +/** + * Lista threads do tenant. Limit 500 (default UI usage), ordem desc por + * last_message_at. + */ +export async function listThreads({ tenantId, limit = 500 } = {}) { + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('conversation_threads').select(CONVERSATION_THREAD_SELECT).eq('tenant_id', tid).order('last_message_at', { ascending: false }).limit(limit); + + if (error) throw error; + return data || []; +} + +/** + * Lê thread por id. + */ +export async function getThreadById(threadId, { tenantId } = {}) { + if (!threadId) throw new Error('threadId obrigatório.'); + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('conversation_threads').select(CONVERSATION_THREAD_SELECT).eq('id', threadId).eq('tenant_id', tid).maybeSingle(); + + if (error) throw error; + return data || null; +} + +/** + * Atualiza thread (assigned_to, kanban_status, etc). + */ +export async function updateThread(threadId, patch, { tenantId } = {}) { + if (!threadId) throw new Error('threadId obrigatório.'); + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('conversation_threads').update({ ...patch, updated_at: new Date().toISOString() }).eq('id', threadId).eq('tenant_id', tid).select(CONVERSATION_THREAD_SELECT).single(); + + if (error) throw error; + return data; +} + +// ─── Messages ──────────────────────────────────────────────────────────── + +/** + * Lista mensagens de uma thread. Limit 500 desc por created_at. + */ +export async function listMessagesByThread(threadId, { tenantId, limit = 500 } = {}) { + if (!threadId) return []; + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT).eq('tenant_id', tid).eq('thread_id', threadId).order('created_at', { ascending: true }).limit(limit); + + if (error) throw error; + return data || []; +} + +/** + * Lista mensagens por paciente (brief — sem attachments pesados). + * Usado em prontuário (PatientConversationsTab). + */ +export async function listMessagesByPatient(patientId, { tenantId, limit = 200 } = {}) { + if (!patientId) return []; + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT_BRIEF).eq('tenant_id', tid).eq('patient_id', patientId).order('created_at', { ascending: false }).limit(limit); + + if (error) throw error; + return data || []; +} + +/** + * Atualiza kanban_status de uma mensagem (in-app workflow). + */ +export async function updateMessageKanban(messageId, kanbanStatus, { tenantId } = {}) { + if (!messageId) throw new Error('messageId obrigatório.'); + const tid = resolveTenantId(tenantId); + + const { error } = await supabase.from('conversation_messages').update({ kanban_status: kanbanStatus, updated_at: new Date().toISOString() }).eq('id', messageId).eq('tenant_id', tid); + + if (error) throw error; +} diff --git a/src/features/conversations/services/conversationsSelects.js b/src/features/conversations/services/conversationsSelects.js new file mode 100644 index 0000000..75f52aa --- /dev/null +++ b/src/features/conversations/services/conversationsSelects.js @@ -0,0 +1,35 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/conversations/services/conversationsSelects.js +| +| SELECTs canônicos de conversation_threads e conversation_messages. +| Threads usa `*` porque a UI usa muitos campos derivados (last_message_at, +| unread_count, assigned_to, contact_number, patient_name, etc). +|-------------------------------------------------------------------------- +*/ + +/** Thread — UI usa praticamente todos os campos. */ +export const CONVERSATION_THREAD_SELECT = '*'; + +/** Mensagem — campos canônicos pra timeline. */ +export const CONVERSATION_MESSAGE_SELECT = ` + id, tenant_id, thread_id, patient_id, body, direction, channel, + created_at, sent_at, delivered_at, read_at, + kanban_status, status, contact_number, attachments +` + .replace(/\s+/g, ' ') + .trim(); + +/** Mensagem brief — listings em prontuário (sem campos pesados). */ +export const CONVERSATION_MESSAGE_SELECT_BRIEF = ` + id, body, direction, created_at, channel, kanban_status +` + .replace(/\s+/g, ' ') + .trim(); + +/** + * Canais identificados no sistema (memória audit baseline M6). + */ +export const CHANNEL_TYPES = Object.freeze(['whatsapp', 'sms', 'email', 'in_app']); diff --git a/src/features/notices/noticeService.js b/src/features/notices/noticeService.js index 704f7cd..482ca1d 100644 --- a/src/features/notices/noticeService.js +++ b/src/features/notices/noticeService.js @@ -17,6 +17,7 @@ // Serviço central de acesso ao Supabase para Global Notices import { supabase } from '@/lib/supabase/client'; +import { GLOBAL_NOTICE_SELECT, NOTICE_DISMISSAL_SELECT } from './noticesSelects'; // ── Leitura ──────────────────────────────────────────────────── @@ -28,7 +29,7 @@ import { supabase } from '@/lib/supabase/client'; export async function fetchActiveNotices() { const now = new Date().toISOString(); - const { data, error } = await supabase.from('global_notices').select('*').eq('is_active', true).or(`starts_at.is.null,starts_at.lte.${now}`).or(`ends_at.is.null,ends_at.gte.${now}`).order('priority', { ascending: false }); + const { data, error } = await supabase.from('global_notices').select(GLOBAL_NOTICE_SELECT).eq('is_active', true).or(`starts_at.is.null,starts_at.lte.${now}`).or(`ends_at.is.null,ends_at.gte.${now}`).order('priority', { ascending: false }); if (error) throw error; return data || []; @@ -38,7 +39,7 @@ export async function fetchActiveNotices() { * Busca todos os notices (sem filtro de ativo) — para o painel admin. */ export async function fetchAllNotices() { - const { data, error } = await supabase.from('global_notices').select('*').order('priority', { ascending: false }).order('created_at', { ascending: false }); + const { data, error } = await supabase.from('global_notices').select(GLOBAL_NOTICE_SELECT).order('priority', { ascending: false }).order('created_at', { ascending: false }); if (error) throw error; return data || []; @@ -77,7 +78,7 @@ export async function loadUserDismissals() { } = await supabase.auth.getUser(); if (!user?.id) return []; - const { data } = await supabase.from('notice_dismissals').select('notice_id, version').eq('user_id', user.id); + const { data } = await supabase.from('notice_dismissals').select(NOTICE_DISMISSAL_SELECT).eq('user_id', user.id); return data || []; } diff --git a/src/features/notices/noticesSelects.js b/src/features/notices/noticesSelects.js new file mode 100644 index 0000000..3200753 --- /dev/null +++ b/src/features/notices/noticesSelects.js @@ -0,0 +1,17 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/notices/noticesSelects.js +| +| SELECTs canônicos de notices. Extraídos de noticeService.js (audit alta). +| global_notices tem muitos campos usados pela UI — usa `*` por simplicidade. +| notice_dismissals tem só 2 colunas relevantes. +|-------------------------------------------------------------------------- +*/ + +/** SELECT completo de global_notices. */ +export const GLOBAL_NOTICE_SELECT = '*'; + +/** SELECT mínimo de notice_dismissals (pra checar se user já dismissou). */ +export const NOTICE_DISMISSAL_SELECT = 'notice_id, version';