From ee2967a0753e44db134778b819bbd5194aab5613 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 21 May 2026 04:20:42 -0300 Subject: [PATCH] =?UTF-8?q?M6:=20notices/conversations=20foundation=20?= =?UTF-8?q?=E2=80=94=20selects=20+=20repositories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modulo 6 da Fase 1. noticesSelects.js extrai os 2 selects do noticeService (GLOBAL_NOTICE_SELECT, NOTICE_DISMISSAL_SELECT) + noticeService passa a usa-los (zero select inline). Conversations ganha foundation: 3 services (_tenantGuards, conversationsSelects, conversationsRepository). Channel factory (WhatsApp/SMS/Email) e composables ficam pra sessao dedicada — escopo M6 era so destravar o supabase.from() inline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../conversations/services/_tenantGuards.js | 22 ++++ .../services/conversationsRepository.js | 115 ++++++++++++++++++ .../services/conversationsSelects.js | 35 ++++++ src/features/notices/noticeService.js | 7 +- src/features/notices/noticesSelects.js | 17 +++ 5 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/features/conversations/services/_tenantGuards.js create mode 100644 src/features/conversations/services/conversationsRepository.js create mode 100644 src/features/conversations/services/conversationsSelects.js create mode 100644 src/features/notices/noticesSelects.js 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';