M6: notices/conversations foundation — selects + repositories

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) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 04:20:42 -03:00
parent 0956e4facc
commit ee2967a075
5 changed files with 193 additions and 3 deletions
@@ -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;
}
@@ -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;
}
@@ -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']);
+4 -3
View File
@@ -17,6 +17,7 @@
// Serviço central de acesso ao Supabase para Global Notices // Serviço central de acesso ao Supabase para Global Notices
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { GLOBAL_NOTICE_SELECT, NOTICE_DISMISSAL_SELECT } from './noticesSelects';
// ── Leitura ──────────────────────────────────────────────────── // ── Leitura ────────────────────────────────────────────────────
@@ -28,7 +29,7 @@ import { supabase } from '@/lib/supabase/client';
export async function fetchActiveNotices() { export async function fetchActiveNotices() {
const now = new Date().toISOString(); 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; if (error) throw error;
return data || []; return data || [];
@@ -38,7 +39,7 @@ export async function fetchActiveNotices() {
* Busca todos os notices (sem filtro de ativo) — para o painel admin. * Busca todos os notices (sem filtro de ativo) — para o painel admin.
*/ */
export async function fetchAllNotices() { 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; if (error) throw error;
return data || []; return data || [];
@@ -77,7 +78,7 @@ export async function loadUserDismissals() {
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
if (!user?.id) return []; 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 || []; return data || [];
} }
+17
View File
@@ -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';