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:
@@ -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']);
|
||||||
@@ -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 || [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user