# 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 `*` com `tenant_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//services/ ├── _tenantGuards.js # SHARED entre repositories do feature ├── Selects.js # SELECT canônico + helpers de flatten ├── Repository.js # CRUD escopo terapeuta (owner_id = uid) └── 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`: ```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. ```js /** * Select canônico de com joins. * * FKs explícitas (obrigatórias quando há múltiplas colunas apontando pra mesma tabela): * - __fkey */ export const _SELECT = ` id, owner_id, tenant_id, ..., patients!__fkey ( id, nome_completo, avatar_url, status ) `.trim(); ``` E o **flatten helper** ao lado: ```js /** * Achata o aninhamento de patients dentro da row. * Mantém ambas formas (flat + nested) pra compat com call sites variados. */ export function flattenRow(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 ```js // ✅ 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: ```js 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 ```js 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 ```js // ✅ 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 ```js // 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) ```js export async function create(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('') .insert([insertPayload]) .select(_SELECT) .single(); if (error) throw error; return flattenRow(data); } ``` **Sempre:** - `tenant_id` injetado do store (não aceita do payload) - `owner_id` injetado do uid logado (ignora do payload — clinic-scoped variant pode aceitar explícito) - `.select(...)` + `.single()` retorna o registro completo ### Update ```js export async function update(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('') .update(safePatch) .eq('id', id) .eq('tenant_id', tid) // ← defesa em profundidade — RLS reforça no banco .select(_SELECT) .single(); if (error) throw error; return flattenRow(data); } ``` ### Delete ```js export async function delete(id, { tenantId } = {}) { if (!id) throw new Error('ID inválido.'); const tid = resolveTenantId(tenantId); const { error } = await supabase .from('') .delete() .eq('id', id) .eq('tenant_id', tid); if (error) throw error; return true; } ``` ### List (range query) ```js export async function list({ startISO, endISO, ownerId, tenantId } = {}) { assertIsoRange(startISO, endISO); const uid = ownerId || (await getUid()); const tid = resolveTenantId(tenantId); const { data, error } = await supabase .from('') .select(_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(flattenRow); } ``` ### Clinic-scoped variant (admin/secretaria) Diferenças em relação ao owner-scoped: - `tenantId` **obrigatório explícito** (sem default via store — admin pode operar em qualquer tenant onde tem permissão) - `ownerIds` é array (multi-terapeuta no mosaico) → `sanitizeOwnerIds` antes do `.in(...)` - Permite definir `owner_id` no create (admin cria pra qualquer terapeuta do tenant) - Sem `excludeMirror` automático — depende do uso Referência: `src/features/agenda/services/agendaClinicRepository.js` --- ## 7. Anti-patterns (NÃO fazer) ### ❌ Inline SELECT espalhado ```js // ❌ 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 `Selects.js`. ### ❌ `useTenantStore()` em vários arquivos ```js // ❌ 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 ```js // ❌ 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)` ```js // ❌ 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 ```js // ❌ if (error) { console.error(error); return null; } ``` ✅ `throw error`. Composable decide o que fazer. ### ❌ Range fechado ```js // ❌ — `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 `, validar: - [ ] `services/_tenantGuards.js` existe (ou inline se 1 repo só) - [ ] `services/Selects.js` existe e exporta `_SELECT` - [ ] `services/Repository.js` é pure functions (sem classe/state) - [ ] `resolveTenantId(tenantIdArg)` local — não `useTenantStore()` espalhado - [ ] Toda operação injeta `tenant_id` no insert/update - [ ] Create owner-scoped injeta `owner_id` do uid logado (ignora do payload) - [ ] Update/delete filtram `.eq('id').eq('tenant_id', tid)` — defesa em profundidade - [ ] FKs explícitas nos joins (`!`) - [ ] 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 - [ ] `flattenRow` definido se há joins aninhados Divergências viram items em `dev_auditoria_items` com: - `categoria`: `padronizacao` - `tag`: `padronizacao:` - `severidade`: alta se viola segurança (tenant leak), média se viola convenção, baixa se cosmético - `arquivo`: path do arquivo - `solucao`: 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_items` com tag `padronizacao:` - Decisões macro: `development/02-auditoria/PADRONIZACAO.md`