Files
agenciapsilmno/blueprints/repository-blueprint.md
Leonardo f94a4ae97f padronizacao: foundation Fase 0+0.5 — blueprints + auditoria + clinical_notes
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>
2026-05-21 04:19:45 -03:00

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 * 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/<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_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

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:

  • 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

// ❌ 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.js existe (ou inline se 1 repo só)
  • services/<feature>Selects.js existe e exporta <FEATURE>_SELECT
  • services/<feature>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 (<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>Row definido se há joins aninhados

Divergências viram items em dev_auditoria_items com:

  • categoria: padronizacao
  • tag: padronizacao:<modulo>
  • 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:<modulo>
  • Decisões macro: development/02-auditoria/PADRONIZACAO.md