Pre-MVP: 3 blueprints canonicos (repository, composable, quick-create overlay), AUDIT_BASELINE com 51 divergencias em 6 modulos, estrategia PADRONIZACAO de 4 fases, DESIGN_BILLING_ORCHESTRATOR. Schema clinical notes pronto pra Fase B (4 migrations + seed templates). AgendaEvent Dialog.vue.bak deletado (lixo de refator anterior). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
Repository Blueprint
Stack: Supabase JS client + Vue 3 (Pinia stores) Canônico:
src/features/agenda/services/(validado em C1-C13 + análise sênior 2026-05-20) Aplicável: todo módulo com acesso a tabela*comtenant_id
1. Princípio
Camada thin entre Supabase e composables. Funções puras + tenant guards + SELECT canônico. Sem classes, sem state, sem singletons. Idempotente, testável, descartável.
Composable orquestra estado e cache. Repository só fala com o banco.
2. Estrutura de arquivos
src/features/<modulo>/services/
├── _tenantGuards.js # SHARED entre repositories do feature
├── <feature>Selects.js # SELECT canônico + helpers de flatten
├── <feature>Repository.js # CRUD escopo terapeuta (owner_id = uid)
└── <feature>ClinicRepository.js # CRUD escopo clínica (se aplicável)
Regra do _tenantGuards.js: se o feature tem 2+ repositories (terapeuta + clínica), os guards saem pra arquivo compartilhado. Se só tem 1, pode ficar no topo do próprio repo.
3. Tenant guards canônicos
Copiar literal de src/features/agenda/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/tenant antes de operar.');
}
}
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;
}
export function assertIsoRange(startISO, endISO) {
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).');
}
export function sanitizeOwnerIds(ownerIds) {
return (ownerIds || []).filter((id) => typeof id === 'string' && id && id !== 'null' && id !== 'undefined');
}
Por quê string 'null'/'undefined'? Vindo de URL params/localStorage stringificado, esses casos aparecem como string literal. Defesa em profundidade.
4. SELECT canônico
Extrair pra constante exportada. Inline SELECT em 3 lugares = divergência sutil (FKs explícitas em uns, não em outros) = bug.
/**
* Select canônico de <tabela> com joins.
*
* FKs explícitas (obrigatórias quando há múltiplas colunas apontando pra mesma tabela):
* - <tabela>_<col>_fkey
*/
export const <FEATURE>_SELECT = `
id, owner_id, tenant_id, ...,
patients!<tabela>_<col>_fkey (
id, nome_completo, avatar_url, status
)
`.trim();
E o flatten helper ao lado:
/**
* Achata o aninhamento de patients dentro da row.
* Mantém ambas formas (flat + nested) pra compat com call sites variados.
*/
export function flatten<Feature>Row(r) {
if (!r) return r;
const patient = r.patients || null;
return {
...r,
paciente_nome: patient?.nome_completo || r.paciente_nome || '',
paciente_avatar: patient?.avatar_url || r.paciente_avatar || '',
paciente_status: patient?.status || r.paciente_status || ''
};
}
5. Convenções de assinatura
Funções puras exportadas
// ✅ certo
export async function listMyEvents({ startISO, endISO, ownerId, tenantId } = {}) { ... }
// ❌ errado — classe com state
class AgendaRepository {
async list() { ... }
}
Args nomeados (destructure)
Posicionais quebram com refator. Default = {} evita TypeError se chamarem sem args.
tenantId opcional → resolve via store
Helper local no repository:
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId } from './_tenantGuards';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId;
assertTenantId(tenantId);
return tenantId;
}
Por que opcional? Composable pode passar tenantId explícito (testes, multi-tenant ops). Default chega via store.
Errors throw, nunca silent
const { data, error } = await supabase.from('...').select(...);
if (error) throw error; // ✅
// ❌ if (error) return null;
// ❌ if (error) console.error(error);
Composable decide se faz try/catch + toast.
Ranges half-open
// ✅ certo — half-open
.gte('inicio_em', startISO).lt('inicio_em', endISO)
// ❌ errado — fechado, gera off-by-one no último ms
.gte('inicio_em', startISO).lte('inicio_em', endISO)
Strip campos legados antes de insert/update
// eslint-disable-next-line no-unused-vars
const { paciente_id: _dropped, ...safePayload } = payload;
Quando há migração de coluna em andamento ou campo virtual no UI.
6. Operações CRUD — pattern
Create (owner-scoped)
export async function create<Feature>(payload) {
if (!payload) throw new Error('Payload vazio.');
const uid = await getUid();
const tid = resolveTenantId();
const { paciente_id: _dropped, ...rest } = payload;
const insertPayload = { ...rest, tenant_id: tid, owner_id: uid };
const { data, error } = await supabase
.from('<tabela>')
.insert([insertPayload])
.select(<FEATURE>_SELECT)
.single();
if (error) throw error;
return flatten<Feature>Row(data);
}
Sempre:
tenant_idinjetado do store (não aceita do payload)owner_idinjetado do uid logado (ignora do payload — clinic-scoped variant pode aceitar explícito).select(...)+.single()retorna o registro completo
Update
export async function update<Feature>(id, patch, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
if (!patch) throw new Error('Patch vazio.');
const tid = resolveTenantId(tenantId);
const { paciente_id: _dropped, ...safePatch } = patch;
const { data, error } = await supabase
.from('<tabela>')
.update(safePatch)
.eq('id', id)
.eq('tenant_id', tid) // ← defesa em profundidade — RLS reforça no banco
.select(<FEATURE>_SELECT)
.single();
if (error) throw error;
return flatten<Feature>Row(data);
}
Delete
export async function delete<Feature>(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const { error } = await supabase
.from('<tabela>')
.delete()
.eq('id', id)
.eq('tenant_id', tid);
if (error) throw error;
return true;
}
List (range query)
export async function list<Feature>({ startISO, endISO, ownerId, tenantId } = {}) {
assertIsoRange(startISO, endISO);
const uid = ownerId || (await getUid());
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('<tabela>')
.select(<FEATURE>_SELECT)
.eq('tenant_id', tid)
.eq('owner_id', uid)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true });
if (error) throw error;
return (data || []).map(flatten<Feature>Row);
}
Clinic-scoped variant (admin/secretaria)
Diferenças em relação ao owner-scoped:
tenantIdobrigatório explícito (sem default via store — admin pode operar em qualquer tenant onde tem permissão)ownerIdsé array (multi-terapeuta no mosaico) →sanitizeOwnerIdsantes do.in(...)- Permite definir
owner_idno create (admin cria pra qualquer terapeuta do tenant) - Sem
excludeMirrorautomático — depende do uso
Referência: src/features/agenda/services/agendaClinicRepository.js
7. Anti-patterns (NÃO fazer)
❌ Inline SELECT espalhado
// ❌ em useFoo.js
const { data } = await supabase.from('events').select('id, owner_id, patient_id, ...');
// ❌ em fooRepository.js
const { data } = await supabase.from('events').select('id, owner_id, ...'); // ← divergente
✅ Extrair pra <feature>Selects.js.
❌ useTenantStore() em vários arquivos
// ❌ em 5 arquivos diferentes
const tenantStore = useTenantStore();
const tid = tenantStore.activeTenantId;
if (!tid) throw new Error('...');
✅ resolveTenantId(tenantIdArg) no topo do repo.
❌ Aceitar owner_id do payload em create owner-scoped
// ❌ permite usuário criar evento "de outro terapeuta"
await supabase.from('events').insert({ ...payload, tenant_id: tid });
✅ Sempre injetar owner_id do uid logado (sobrescreve qualquer valor do payload).
❌ delete() sem .eq('tenant_id', tid)
// ❌ RLS deveria pegar, mas defesa em profundidade
await supabase.from('events').delete().eq('id', id);
✅ Sempre filtra .eq('tenant_id', tid) mesmo com RLS ativo.
❌ Return null em erro
// ❌
if (error) {
console.error(error);
return null;
}
✅ throw error. Composable decide o que fazer.
❌ Range fechado
// ❌ — `2026-05-20` no `endISO` faz aparecer o dia inteiro do 20
.gte('inicio_em', startISO).lte('inicio_em', endISO)
✅ Half-open: .gte(...).lt(...). Caller passa endISO como o início do próximo bucket.
❌ paciente_id (ou outro campo legado) chegando ao banco
A migração já dropou colunas legadas. Strip no safePayload evita 400 silencioso.
8. Checklist de auditoria por módulo
Quando rodar /audit-module <nome>, validar:
services/_tenantGuards.jsexiste (ou inline se 1 repo só)services/<feature>Selects.jsexiste e exporta<FEATURE>_SELECTservices/<feature>Repository.jsé pure functions (sem classe/state)resolveTenantId(tenantIdArg)local — nãouseTenantStore()espalhado- Toda operação injeta
tenant_idno insert/update - Create owner-scoped injeta
owner_iddo uid logado (ignora do payload) - Update/delete filtram
.eq('id').eq('tenant_id', tid)— defesa em profundidade - FKs explícitas nos joins (
<tabela>!<fk_name>) - Errors
throw, nunca silent - Ranges half-open (
gte + lt) - Strip de campos legados em insert/update
- Clinic-scoped variant (se existe) sem default via store, tenantId obrigatório
flatten<Feature>Rowdefinido se há joins aninhados
Divergências viram items em dev_auditoria_items com:
categoria:padronizacaotag:padronizacao:<modulo>severidade: alta se viola segurança (tenant leak), média se viola convenção, baixa se cosméticoarquivo: path do arquivosolucao: referência ao item do checklist
9. Referências
- Canônico:
src/features/agenda/services/ - Variant clinic:
src/features/agenda/services/agendaClinicRepository.js - Tracker:
dev_auditoria_itemscom tagpadronizacao:<modulo> - Decisões macro:
development/02-auditoria/PADRONIZACAO.md