a7f6bcbe66
- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
.eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
(singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados
Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
(gerenciam defaults do sistema / views cross-tenant)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
268 lines
10 KiB
JavaScript
268 lines
10 KiB
JavaScript
/*
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Arquivo: src/composables/useContactEmails.js
|
|
| Data: 2026-04-21
|
|
|
|
|
| Gerenciamento polimórfico de emails (patients, medicos, etc).
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
import { ref } from 'vue';
|
|
import { supabase } from '@/lib/supabase/client';
|
|
import { tenantDb } from '@/lib/supabase/tenantClient';
|
|
import { useTenantStore } from '@/stores/tenantStore';
|
|
|
|
const EMAIL_RE = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
|
|
|
|
function normalizeEmail(raw) {
|
|
return String(raw || '').trim().toLowerCase();
|
|
}
|
|
|
|
// Mesma estratégia do useContactPhones: emails sem entidade ficam em
|
|
// memória com id 'pending_*' até flushPending gravar tudo em lote.
|
|
const PENDING_PREFIX = 'pending_';
|
|
function isPending(id) { return typeof id === 'string' && id.startsWith(PENDING_PREFIX); }
|
|
function genPendingId() {
|
|
return `${PENDING_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
export function useContactEmails() {
|
|
const tenantStore = useTenantStore();
|
|
|
|
const types = ref([]);
|
|
const emails = ref([]);
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
|
|
async function loadTypes() {
|
|
try {
|
|
const { data } = await tenantDb().from('contact_email_types')
|
|
.select('id, name, slug, icon, is_system, position')
|
|
.order('position', { ascending: true })
|
|
.order('name', { ascending: true });
|
|
types.value = data || [];
|
|
} catch (e) {
|
|
console.error('[useContactEmails] loadTypes:', e?.message);
|
|
types.value = [];
|
|
}
|
|
}
|
|
|
|
async function loadEmails(entityType, entityId) {
|
|
if (!entityType || !entityId) {
|
|
emails.value = [];
|
|
return;
|
|
}
|
|
loading.value = true;
|
|
try {
|
|
const { data, error } = await tenantDb().from('contact_emails')
|
|
.select('id, contact_email_type_id, email, is_primary, notes, position, created_at')
|
|
.eq('entity_type', entityType)
|
|
.eq('entity_id', entityId)
|
|
.order('is_primary', { ascending: false })
|
|
.order('position', { ascending: true });
|
|
if (error) throw error;
|
|
emails.value = data || [];
|
|
} catch (e) {
|
|
console.error('[useContactEmails] loadEmails:', e?.message);
|
|
emails.value = [];
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
|
|
const q = tenantDb().from('contact_emails')
|
|
.update({ is_primary: false })
|
|
.eq('entity_type', entityType)
|
|
.eq('entity_id', entityId)
|
|
.eq('is_primary', true);
|
|
if (exceptId) q.neq('id', exceptId);
|
|
await q;
|
|
}
|
|
|
|
async function addEmail(entityType, entityId, { contact_email_type_id, email, is_primary = false, notes = null }) {
|
|
const clean = normalizeEmail(email);
|
|
if (!contact_email_type_id) return { ok: false, error: 'Tipo obrigatório' };
|
|
if (!clean || !EMAIL_RE.test(clean)) return { ok: false, error: 'Email inválido' };
|
|
|
|
// Modo pendente: entidade ainda não existe — mantém em memória até flushPending.
|
|
if (!entityType || !entityId) {
|
|
const wasFirst = emails.value.length === 0;
|
|
if (is_primary || wasFirst) {
|
|
emails.value.forEach((e) => { e.is_primary = false; });
|
|
is_primary = true;
|
|
}
|
|
const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0);
|
|
const tempEmail = {
|
|
id: genPendingId(),
|
|
contact_email_type_id,
|
|
email: clean,
|
|
is_primary,
|
|
notes,
|
|
position: maxPos + 10,
|
|
created_at: new Date().toISOString()
|
|
};
|
|
emails.value = [...emails.value, tempEmail];
|
|
return { ok: true, email: tempEmail };
|
|
}
|
|
|
|
const tenantId = tenantStore.activeTenantId;
|
|
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
|
|
|
saving.value = true;
|
|
try {
|
|
if (is_primary) {
|
|
await unsetOtherPrimaries(entityType, entityId);
|
|
} else if (emails.value.length === 0) {
|
|
is_primary = true;
|
|
}
|
|
|
|
const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0);
|
|
const { data, error } = await tenantDb().from('contact_emails')
|
|
.insert({
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
contact_email_type_id,
|
|
email: clean,
|
|
is_primary,
|
|
notes,
|
|
position: maxPos + 10
|
|
})
|
|
.select()
|
|
.single();
|
|
if (error) throw error;
|
|
await loadEmails(entityType, entityId);
|
|
return { ok: true, email: data };
|
|
} catch (e) {
|
|
return { ok: false, error: e?.message || 'add_failed' };
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function updateEmail(entityType, entityId, id, patch) {
|
|
const sanitized = { ...patch };
|
|
if (sanitized.email !== undefined) {
|
|
sanitized.email = normalizeEmail(sanitized.email);
|
|
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
|
|
return { ok: false, error: 'Email inválido' };
|
|
}
|
|
}
|
|
|
|
// Pending: muta no array local, sem DB
|
|
if (isPending(id) || !entityType || !entityId) {
|
|
const idx = emails.value.findIndex((e) => e.id === id);
|
|
if (idx === -1) return { ok: false, error: 'not_found' };
|
|
if (sanitized.is_primary === true) {
|
|
emails.value.forEach((e, i) => { if (i !== idx) e.is_primary = false; });
|
|
}
|
|
emails.value[idx] = { ...emails.value[idx], ...sanitized };
|
|
emails.value = [...emails.value];
|
|
return { ok: true };
|
|
}
|
|
|
|
saving.value = true;
|
|
try {
|
|
if (sanitized.is_primary === true) {
|
|
await unsetOtherPrimaries(entityType, entityId, id);
|
|
}
|
|
const { error } = await tenantDb().from('contact_emails').update(sanitized).eq('id', id);
|
|
if (error) throw error;
|
|
await loadEmails(entityType, entityId);
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, error: e?.message || 'update_failed' };
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function removeEmail(entityType, entityId, id) {
|
|
// Pending: tira do array + promove próximo a primary se necessário
|
|
if (isPending(id) || !entityType || !entityId) {
|
|
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
|
emails.value = emails.value.filter((e) => e.id !== id);
|
|
if (wasPrimary && emails.value.length > 0) {
|
|
const remaining = [...emails.value].sort((a, b) => (a.position || 0) - (b.position || 0));
|
|
emails.value = emails.value.map((e) =>
|
|
e.id === remaining[0].id ? { ...e, is_primary: true } : e
|
|
);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
saving.value = true;
|
|
try {
|
|
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
|
const { error } = await tenantDb().from('contact_emails').delete().eq('id', id);
|
|
if (error) throw error;
|
|
if (wasPrimary) {
|
|
const remaining = emails.value.filter((e) => e.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
|
|
if (remaining.length > 0) {
|
|
await tenantDb().from('contact_emails').update({ is_primary: true }).eq('id', remaining[0].id);
|
|
}
|
|
}
|
|
await loadEmails(entityType, entityId);
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, error: e?.message || 'remove_failed' };
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
// Grava em lote os emails que estavam em modo pendente.
|
|
async function flushPending(entityType, entityId) {
|
|
if (!entityType || !entityId) return { ok: false, error: 'missing_entity' };
|
|
const tenantId = tenantStore.activeTenantId;
|
|
if (!tenantId) return { ok: false, error: 'invalid_context' };
|
|
const pendingItems = emails.value.filter((e) => isPending(e.id));
|
|
if (pendingItems.length === 0) return { ok: true, count: 0 };
|
|
saving.value = true;
|
|
try {
|
|
const rows = pendingItems.map((e) => ({
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
contact_email_type_id: e.contact_email_type_id,
|
|
email: normalizeEmail(e.email),
|
|
is_primary: !!e.is_primary,
|
|
notes: e.notes || null,
|
|
position: e.position
|
|
}));
|
|
const { error } = await tenantDb().from('contact_emails').insert(rows);
|
|
if (error) throw error;
|
|
await loadEmails(entityType, entityId);
|
|
return { ok: true, count: rows.length };
|
|
} catch (e) {
|
|
return { ok: false, error: e?.message || 'flush_failed' };
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function setPrimary(entityType, entityId, id) {
|
|
return updateEmail(entityType, entityId, id, { is_primary: true });
|
|
}
|
|
|
|
function typeBySlug(slug) { return types.value.find((t) => t.slug === slug); }
|
|
function typeById(id) { return types.value.find((t) => t.id === id); }
|
|
|
|
return {
|
|
types,
|
|
emails,
|
|
loading,
|
|
saving,
|
|
loadTypes,
|
|
loadEmails,
|
|
addEmail,
|
|
updateEmail,
|
|
removeEmail,
|
|
setPrimary,
|
|
flushPending,
|
|
typeBySlug,
|
|
typeById
|
|
};
|
|
}
|