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>
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
# Composable Blueprint
|
||||
|
||||
> **Stack:** Vue 3 Composition API + Pinia (para state global) + Supabase via repository
|
||||
> **Canônicos:** `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js`
|
||||
> **Aplicável:** todo composable que orquestra estado reativo sobre uma repository
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípio
|
||||
|
||||
Composable é **wrapper fino** sobre a repository. Responsabilidade:
|
||||
- Manter **estado reativo** (data + loading + error)
|
||||
- Chamar a repository (delegação 1:1)
|
||||
- (Opcional) Cachear com stale-while-revalidate
|
||||
- (Opcional) Compor outros composables
|
||||
|
||||
**Não faz:**
|
||||
- Lógica de banco direta (vai no repository)
|
||||
- Lógica de UI (vai no componente)
|
||||
- Manipulação de DOM
|
||||
- I/O direto fora do repository
|
||||
|
||||
> Regra de ouro: **se o composable tem `from('...')` do Supabase, ele virou repository disfarçado — refatorar.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Estrutura de arquivos
|
||||
|
||||
```
|
||||
src/features/<modulo>/composables/
|
||||
├── use<Entity>.js # CRUD básico (thin wrapper)
|
||||
├── use<Entity>Clinic.js # variant clinic-scoped (se aplicável)
|
||||
├── use<Entity>Settings.js # config/preferences (com cache opt-in)
|
||||
├── use<Entity>Lifecycle.js # orquestrador de estados (se domain complexo)
|
||||
└── <entity>Helpers.js # funções puras auxiliares (não-composable)
|
||||
```
|
||||
|
||||
**Convenção de nome:** sempre `use<Entity>...`. Funções helpers de domínio NÃO usam prefixo `use` — não são composables.
|
||||
|
||||
---
|
||||
|
||||
## 3. State shape canônico
|
||||
|
||||
Todo composable expõe **no mínimo** este shape:
|
||||
|
||||
```js
|
||||
const rows = ref([]); // ou single ref dependendo do domínio
|
||||
const loading = ref(false); // boolean
|
||||
const error = ref(''); // string vazia, não null — facilita v-if
|
||||
```
|
||||
|
||||
**Decisões importantes:**
|
||||
|
||||
| Refs | Tipo | Inicial | Por quê |
|
||||
|---|---|---|---|
|
||||
| `loading` | `boolean` | `false` | Padrão V3 — UI binda `:disabled="loading"` direto |
|
||||
| `error` | `string` | `''` (vazio) | `v-if="error"` é falsy-friendly; sem null check |
|
||||
| `rows`/data | `Array` ou objeto | `[]` ou `null` | Reset pra `[]` em erro de load — UI fica previsível |
|
||||
|
||||
**Anti-pattern:** misturar `error = ref(null)` num composable e `error = ref('')` em outro. Canonize `''` no projeto inteiro.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tipos de composable (3 patterns)
|
||||
|
||||
### Tipo A — Thin wrapper (default) · referência: `useAgendaClinicEvents.js`
|
||||
|
||||
CRUD direto, sem cache, com loading/error em TODA operação:
|
||||
|
||||
```js
|
||||
import { ref } from 'vue';
|
||||
import { listX, createX, updateX, deleteX } from '@/features/<modulo>/services/<feature>Repository';
|
||||
|
||||
export function useX() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function loadRange({ startISO, endISO, ...scope } = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
rows.value = await listX({ startISO, endISO, ...scope });
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload, opts = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await createX(payload, opts);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar.';
|
||||
throw e; // ← re-throw: composable repassa o erro pro componente decidir
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id, patch, opts = {}) { /* idem */ }
|
||||
async function remove(id, opts = {}) { /* idem */ }
|
||||
|
||||
return { rows, loading, error, loadRange, create, update, remove };
|
||||
}
|
||||
```
|
||||
|
||||
**Por que re-throw nas mutações?** Componente precisa saber se o `await` falhou pra:
|
||||
- Mostrar toast
|
||||
- Não fechar modal
|
||||
- Não navegar
|
||||
- Manter form com dados
|
||||
|
||||
`error.value` é só pra estado reativo persistente. Mutação síncrona precisa de throw também.
|
||||
|
||||
### Tipo B — Thin wrapper "extra-leve" · referência: `useAgendaEvents.js`
|
||||
|
||||
Variant aceitável quando mutações **não precisam de loading**:
|
||||
|
||||
```js
|
||||
async function create(payload) {
|
||||
return createX(payload); // ← repassa erro nativamente; componente try/catch
|
||||
}
|
||||
```
|
||||
|
||||
**Quando usar:** UIs onde criar/editar tem feedback próprio (skeleton no item criado, optimistic UI, etc.). Default é o Tipo A.
|
||||
|
||||
### Tipo C — Cache com stale-while-revalidate · referência: `useAgendaSettings.js`
|
||||
|
||||
Para dados raros/pesados (settings, preferences, listas estáveis):
|
||||
|
||||
```js
|
||||
import { ref } from 'vue';
|
||||
import { getX } from '../services/<feature>Repository';
|
||||
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
|
||||
|
||||
export function useX(opts = {}) {
|
||||
const useCache = !!opts.cache;
|
||||
const cache = useCache ? useMelissaCacheStore() : null;
|
||||
|
||||
const data = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function _doFetch() {
|
||||
const result = await getX();
|
||||
data.value = result;
|
||||
if (cache) {
|
||||
const key = result?.owner_id || 'anon';
|
||||
cache.set('xKey', result, key);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (cache) {
|
||||
const cached = cache.get('xKey', undefined, MELISSA_CACHE_TTL.xKey);
|
||||
if (cached) {
|
||||
data.value = cached;
|
||||
_doFetch().catch((e) => console.warn('[useX] revalidate', e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await _doFetch();
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar.';
|
||||
data.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { data, loading, error, load };
|
||||
}
|
||||
```
|
||||
|
||||
**Decisões do Tipo C:**
|
||||
|
||||
- **`opts.cache` default `false`** — páginas de configuração que editam settings esperam mudança imediata após salvar, então cache opt-in.
|
||||
- **Cache key inclui scope** (`owner_id`/`tenant_id`) — invalida automaticamente em troca de usuário/tenant.
|
||||
- **TTL constants no store** — `MELISSA_CACHE_TTL.<feature>` (não hardcoded no composable).
|
||||
- **Stale-while-revalidate:** retorna cached SE existe + dispara fetch em background (sem await).
|
||||
- **Revalidate fail é warn**, não error — UI já tem dados válidos do cache.
|
||||
|
||||
---
|
||||
|
||||
## 5. Convenções de nomenclatura
|
||||
|
||||
### Funções
|
||||
|
||||
| Operação | Nome canônico | Variantes aceitas |
|
||||
|---|---|---|
|
||||
| Listar com filtro | `loadRange` / `loadMy<X>` | `load<Scope><Range>` |
|
||||
| Criar | `create` | `create<Scope>` (se houver ambiguidade) |
|
||||
| Atualizar | `update` | `update<Scope>` |
|
||||
| Remover | `remove` | `remove<Scope>` (nunca `delete` — palavra reservada) |
|
||||
| Recarregar | `refresh` | `reload` |
|
||||
| Limpar estado | `reset` / `clear` | — |
|
||||
|
||||
**Scope sufixo** quando o composable serve múltiplos contextos: `loadMyRange` (terapeuta) vs `loadClinicRange` (admin).
|
||||
|
||||
### State refs
|
||||
|
||||
- `rows` — coleção principal (array)
|
||||
- `record` — single (quando faz sentido)
|
||||
- `data` — genérico (settings, config)
|
||||
- `loading` — boolean único; se há múltiplos `loading` (load vs save), nomear: `loadingList`, `saving`
|
||||
- `error` — string única; mesmo princípio: `loadError`, `saveError` se precisar
|
||||
|
||||
---
|
||||
|
||||
## 6. Anatomia padrão de uma operação `load*`
|
||||
|
||||
```js
|
||||
async function loadXxx(args) {
|
||||
// 1. Validação leve (early return, não throw)
|
||||
if (!args?.required) return;
|
||||
|
||||
// 2. State flag
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
// 3. Delegate pra repository (UMA chamada — se múltiplas, Promise.all)
|
||||
const result = await listX(args);
|
||||
|
||||
// 4. Mutate state
|
||||
rows.value = result;
|
||||
} catch (e) {
|
||||
// 5. Erro humano + reset de data (UI fica previsível)
|
||||
error.value = e?.message || 'Mensagem PT-BR genérica.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
// 6. Sempre limpar loading
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Por que early-return em vez de throw na validação?** Composable é wrapper — chamadas inválidas (ex: `ownerId` ainda não chegou no mount) não devem quebrar UI. Throw fica pra repository.
|
||||
|
||||
---
|
||||
|
||||
## 7. Múltiplos fetches paralelos
|
||||
|
||||
Quando uma operação precisa de N queries:
|
||||
|
||||
```js
|
||||
async function _doFetch() {
|
||||
const [cfg, rules, profile] = await Promise.all([
|
||||
getMyAgendaSettings(),
|
||||
getMyWorkSchedule(),
|
||||
getMyProfile()
|
||||
]);
|
||||
settings.value = cfg;
|
||||
workRules.value = rules;
|
||||
profile.value = profile;
|
||||
}
|
||||
```
|
||||
|
||||
**Regras:**
|
||||
- `Promise.all` (não `Promise.allSettled`) — falha de qualquer query falha a operação inteira
|
||||
- Exception: quando uma query é opcional/best-effort → `Promise.allSettled` + processa por result
|
||||
- **Nunca** sequenciar fetches independentes (await + await + await)
|
||||
|
||||
---
|
||||
|
||||
## 8. Composição de composables
|
||||
|
||||
Composable pode usar outros composables, mas:
|
||||
|
||||
```js
|
||||
// ✅ certo — composição estrutural
|
||||
export function useAgendaEventLifecycle() {
|
||||
const events = useAgendaEvents();
|
||||
const billing = useAgendaFinanceiro();
|
||||
const settings = useAgendaSettings({ cache: true });
|
||||
|
||||
async function realizar(eventId) {
|
||||
// orquestra os 3
|
||||
}
|
||||
|
||||
return { ...events, realizar, ... };
|
||||
}
|
||||
|
||||
// ❌ errado — não compor pra economizar 1 linha
|
||||
export function useOnlyToWrapList() {
|
||||
const { rows, loadMyRange } = useAgendaEvents();
|
||||
return { rows, loadMyRange }; // ← isso é um re-export inútil
|
||||
}
|
||||
```
|
||||
|
||||
**Regra:** compõe quando há **orquestração**. Se é só forward, importa direto.
|
||||
|
||||
---
|
||||
|
||||
## 9. Anti-patterns (NÃO fazer)
|
||||
|
||||
### ❌ Composable que tem `supabase.from('...')` direto
|
||||
|
||||
```js
|
||||
// ❌ — violação de camadas
|
||||
export function useFoo() {
|
||||
async function load() {
|
||||
const { data } = await supabase.from('foo').select('*');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
✅ Move pra repository, composable só delega.
|
||||
|
||||
### ❌ `error` ora `null`, ora `''`, ora `Error`
|
||||
|
||||
Canonize `string` (default `''`). Errors do JS dão `e?.message || 'fallback PT-BR'`.
|
||||
|
||||
### ❌ Não resetar `rows` em erro de load
|
||||
|
||||
```js
|
||||
// ❌
|
||||
async function loadRange() {
|
||||
try { rows.value = await listX(); } catch (e) { error.value = e.message; }
|
||||
// rows.value mantém dados antigos = UI mostra coisa stale + alerta de erro
|
||||
}
|
||||
```
|
||||
|
||||
✅ Reset `rows.value = []` no catch — UI fica determinística.
|
||||
|
||||
### ❌ Não re-throw mutações
|
||||
|
||||
```js
|
||||
// ❌
|
||||
async function create(payload) {
|
||||
try { return await createX(payload); }
|
||||
catch (e) { error.value = e.message; }
|
||||
// componente faz `await create()` e nunca sabe que falhou
|
||||
}
|
||||
```
|
||||
|
||||
✅ Re-throw após setar `error.value`.
|
||||
|
||||
### ❌ `Promise.all` quando uma falha é aceitável
|
||||
|
||||
Quando uma das queries pode falhar sem invalidar as outras, usar `Promise.allSettled`. Comum em listings que enriquece com lookups opcionais.
|
||||
|
||||
### ❌ State global em variável módulo
|
||||
|
||||
```js
|
||||
// ❌ — vaza entre componentes que compartilham o composable
|
||||
const rows = ref([]);
|
||||
export function useFoo() {
|
||||
return { rows };
|
||||
}
|
||||
```
|
||||
|
||||
✅ State sempre DENTRO da `function useFoo()`. Se precisar global, use Pinia store.
|
||||
|
||||
### ❌ Composable que faz `watch` no próprio state pra "side effect"
|
||||
|
||||
```js
|
||||
// ❌
|
||||
const rows = ref([]);
|
||||
watch(rows, () => { /* save algo */ });
|
||||
```
|
||||
|
||||
✅ Mover `watch` pro componente — composable não decide quando salvar.
|
||||
|
||||
**Exceção:** watch pra sincronizar com prop externa do composable (`watchEffect(() => loadRange(props.range))`) é OK.
|
||||
|
||||
### ❌ Composable retornando objeto enorme
|
||||
|
||||
Se o `return` tem 20+ chaves, o composable está fazendo coisa demais. Quebrar em N composables menores ou extrair Pinia store.
|
||||
|
||||
---
|
||||
|
||||
## 10. Cache store (Tipo C complementar)
|
||||
|
||||
Quando criar um composable Tipo C, garantir que existe entry em:
|
||||
|
||||
- `src/stores/melissaCacheStore.js` — `MELISSA_CACHE_TTL.<feature>` constante (TTL em ms)
|
||||
- `.get(key, scope, ttl)` retorna valor ou null
|
||||
- `.set(key, value, scope)` salva com timestamp
|
||||
- Invalidação manual: `.invalidate('<feature>')`
|
||||
|
||||
**TTL guidelines:**
|
||||
|
||||
| Tipo de dado | TTL sugerido |
|
||||
|---|---|
|
||||
| Settings/preferences | 5 min |
|
||||
| Listas estáveis (specialties, plans) | 30 min |
|
||||
| Catálogo (services, pricing) | 10 min |
|
||||
| Multi-tenant lookups | 5 min |
|
||||
| Anything user-edited | NÃO cachear (Tipo A) |
|
||||
|
||||
---
|
||||
|
||||
## 11. Checklist de auditoria por módulo
|
||||
|
||||
Quando rodar `/audit-module <nome>`, validar cada composable:
|
||||
|
||||
- [ ] Não tem `supabase.from(...)` direto — só importa da repository
|
||||
- [ ] State shape: `rows`/`data`, `loading: boolean`, `error: string`
|
||||
- [ ] `error` é string, default `''`
|
||||
- [ ] Reset de data em erro de load (`rows.value = []`)
|
||||
- [ ] Mutações re-throw após setar error.value
|
||||
- [ ] Nomenclatura: `loadRange`/`load<Scope>`, `create`, `update`, `remove`
|
||||
- [ ] `remove` não `delete` (palavra reservada)
|
||||
- [ ] Validação leve usa early-return (não throw)
|
||||
- [ ] Múltiplos fetches em `Promise.all` (não sequencial)
|
||||
- [ ] State DENTRO da `function use*()` (não em variável de módulo)
|
||||
- [ ] Sem `watch` em própria state pra side effect (mover pro componente)
|
||||
- [ ] Helpers de domínio em arquivo separado sem prefixo `use`
|
||||
- [ ] Se cacheia (Tipo C): `opts.cache` opt-in, default `false`; TTL em `MELISSA_CACHE_TTL`; cache key inclui scope
|
||||
- [ ] Return statement com chaves explícitas (não `return { ...state, ...actions }` opaco)
|
||||
- [ ] Return ≤ 15 chaves (>15 = composable fazendo coisa demais)
|
||||
|
||||
Divergências viram items em `dev_auditoria_items` com:
|
||||
- `categoria`: `padronizacao`
|
||||
- `tag`: `padronizacao:<modulo>`
|
||||
- `severidade`: alta se camada quebrada (composable com `from()`); média se viola convenção (error null vs ''); baixa se cosmético (nome de função)
|
||||
|
||||
---
|
||||
|
||||
## 12. Exemplo completo (template)
|
||||
|
||||
```js
|
||||
/*
|
||||
| Arquivo: src/features/patients/composables/usePatients.js
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
listPatients,
|
||||
createPatient,
|
||||
updatePatient,
|
||||
deletePatient
|
||||
} from '@/features/patients/services/patientsRepository';
|
||||
|
||||
export function usePatients() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function loadRange({ search, status, tenantId } = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
rows.value = await listPatients({ search, status, tenantId });
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar pacientes.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await createPatient(payload);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar paciente.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id, patch) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await updatePatient(id, patch);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar paciente.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await deletePatient(id);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao remover paciente.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, loading, error, loadRange, create, update, remove };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Referências
|
||||
|
||||
- Canônicos: `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js`
|
||||
- Repository pareado: `blueprints/repository-blueprint.md`
|
||||
- Cache store: `src/stores/melissaCacheStore.js`
|
||||
- Tracker: `dev_auditoria_items` com tag `padronizacao:<modulo>`
|
||||
- Estratégia: `development/02-auditoria/PADRONIZACAO.md`
|
||||
@@ -0,0 +1,431 @@
|
||||
# Quick-Create Overlay Blueprint
|
||||
|
||||
> **Status:** Pattern **universal**. Promovido de agenda-only em 2026-05-20 após audit baseline (`development/02-auditoria/AUDIT_BASELINE.md`) identificar 3 candidates já em produção fora da agenda.
|
||||
> **Stack:** Vue 3 + PrimeVue Dialog
|
||||
> **Canônicos:**
|
||||
> - `src/features/agenda/components/ServiceQuickCreateDialog.vue` (referência completa)
|
||||
> - `src/features/agenda/components/InsurancePlanQuickCreateDialog.vue`
|
||||
> - `src/features/agenda/components/InsurancePlanServiceQuickCreateDialog.vue`
|
||||
> **Legacy a refatorar (supabase direto, sem repository):**
|
||||
> - `src/components/CadastroRapidoMedico.vue` → migrar pra `features/medicos/components/` (módulo 1 da Fase 1)
|
||||
> - `src/components/CadastroRapidoConvenio.vue` → migrar pra `features/insurance/components/`
|
||||
> - `src/components/ComponentCadastroRapido.vue` → migrar pra path apropriado conforme dono da entidade
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípio
|
||||
|
||||
**Problema:** usuário está num fluxo (ex: agendar uma sessão) e precisa de uma entidade dependente que ainda não existe (serviço, convênio, plano). Navegar pra outra página significa **perder o contexto** do form em progresso.
|
||||
|
||||
**Solução:** mini-dialog **por cima** do dialog/fluxo atual, com **campos mínimos** pra criar a entidade, e ao salvar **pré-seleciona** ela no select que disparou o quick-create.
|
||||
|
||||
**Regra absoluta:** criar dependência faltante em **qualquer fluxo** deve **abrir overlay POR CIMA, nunca navegar pra fora**. Aplicável em todo o sistema desde a promoção do blueprint (2026-05-20). Origem do pattern: agenda (memória `feedback_agenda_inline_quick_create`, agora generalizada).
|
||||
|
||||
---
|
||||
|
||||
## 2. Quando aplicar (vs alternativas)
|
||||
|
||||
| Situação | Solução |
|
||||
|---|---|
|
||||
| Fluxo crítico travado por dependência faltante (form em progresso) | **Quick-create overlay** ✅ |
|
||||
| Cadastro completo, com todos os campos | Página dedicada `/entity/new` ou Dialog full |
|
||||
| Apenas selecionar item existente | Select com busca; sem botão "+" |
|
||||
| Onboarding ou setup wizard | Não — fluxo é a página inteira, não um overlay |
|
||||
|
||||
**Anti-uso:** quick-create NÃO é "shortcut pra criar do menu lateral". É **fallback contextual** quando o form atual depende de algo que falta. O parent **precisa estar pronto pra receber o evento `created`** e usar o ID.
|
||||
|
||||
---
|
||||
|
||||
## 3. Estrutura do componente `<Entity>QuickCreateDialog.vue`
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
// CANÔNICO: importar da repository do feature dono da entidade.
|
||||
// LEGACY: 3 componentes em src/components/ usam supabase direto — refatorar quando módulo dono for tocado na Fase 1.
|
||||
import { createX } from '@/features/<feature>/services/<feature>Repository';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
ownerId: { type: String, default: '' },
|
||||
initialName: { type: String, default: '' } // pré-preenche do search atual do select
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue', 'created']);
|
||||
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const visible = ref(props.modelValue);
|
||||
watch(() => props.modelValue, (v) => { visible.value = v; });
|
||||
watch(visible, (v) => emit('update:modelValue', v));
|
||||
|
||||
const form = ref({ /* só campos MÍNIMOS obrigatórios + 1-2 opcionais úteis */ });
|
||||
const saving = ref(false);
|
||||
|
||||
// Resetar form toda vez que abre
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v) form.value = { /* defaults + initialName */ };
|
||||
});
|
||||
|
||||
const canSave = () => /* validação leve */;
|
||||
|
||||
async function onSave() {
|
||||
if (!canSave()) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
// Sanitize (trim + maxlength slice + nullif vazio) ANTES de chamar repository
|
||||
const payload = {
|
||||
name: form.value.name.trim().slice(0, 120),
|
||||
// ...resto sanitizado
|
||||
};
|
||||
|
||||
// Repository injeta owner_id (uid logado) + tenant_id (store) + faz uniqueness check
|
||||
// e throw em erro. Quick-create só decide o que mostrar ao usuário.
|
||||
const data = await createX(payload);
|
||||
|
||||
toast.add({ severity: 'success', summary: '<Entity> criado', life: 2200 });
|
||||
emit('created', data); // ← parent usa data.id pra pré-selecionar
|
||||
visible.value = false;
|
||||
} catch (e) {
|
||||
// Repository pode throw com message conhecido (ex: "Nome em uso") — mostra como warn ou error
|
||||
const isDup = /em uso|já existe|duplicate/i.test(e?.message || '');
|
||||
toast.add({
|
||||
severity: isDup ? 'warn' : 'error',
|
||||
summary: isDup ? 'Nome em uso' : 'Falha ao criar',
|
||||
detail: e?.message || 'Erro inesperado',
|
||||
life: 4000
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
LEGACY-NOTE (2026-05-20): os 3 quick-creates em src/components/ (CadastroRapidoMedico,
|
||||
CadastroRapidoConvenio, ComponentCadastroRapido) ainda usam supabase direto. Padrão acima
|
||||
é o CANÔNICO pós-promoção. Refator vai acontecer no módulo correspondente da Fase 1.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
header="Novo <entity>"
|
||||
class="w-[94vw] max-w-md"
|
||||
>
|
||||
<!-- Campos mínimos: 3-5 inputs, nada mais -->
|
||||
<div class="flex flex-col gap-3 pt-1"> ... </div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text :disabled="saving" @click="visible = false" />
|
||||
<Button label="Salvar" :loading="saving" :disabled="!canSave()" @click="onSave" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Contrato canônico de props/emits
|
||||
|
||||
### Props (sempre)
|
||||
|
||||
| Prop | Tipo | Default | Função |
|
||||
|---|---|---|---|
|
||||
| `modelValue` | `Boolean` | `false` | Visibilidade do dialog. Two-way via `v-model`. |
|
||||
| `ownerId` | `String` | `''` | Owner_id (terapeuta). Default: usuário logado. |
|
||||
| `initialName` | `String` | `''` | Pré-preenche o campo nome com o search atual do select (UX win). |
|
||||
|
||||
### Props (opcionais por entidade)
|
||||
|
||||
- `parentId` (`String`) — quando a entidade tem hierarquia (ex: `plan_id` em `plan_service`)
|
||||
- `defaultDurationMin` (`Number`) — quando faz sentido herdar valor do contexto
|
||||
- Outras herdadas do contexto, **nunca** mais que 3 props extras (senão vira form pesado, não quick-create)
|
||||
|
||||
### Emits
|
||||
|
||||
| Evento | Payload | Quando |
|
||||
|---|---|---|
|
||||
| `update:modelValue` | `Boolean` | `v-model` two-way |
|
||||
| `created` | `Object` (row inserida completa) | Após insert bem-sucedido |
|
||||
|
||||
**Nunca emitir** `cancelled`, `closed`, `error` — parent não precisa saber dessas distinções; `update:modelValue=false` cobre.
|
||||
|
||||
---
|
||||
|
||||
## 5. Integração no parent
|
||||
|
||||
### Slot do botão `+` ao lado do select
|
||||
|
||||
```vue
|
||||
<div class="flex gap-2 items-center">
|
||||
<Select v-model="selectedServiceId" :options="services" optionLabel="name" optionValue="id" class="flex-1" />
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
v-tooltip.top="'Cadastrar novo serviço'"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="openServiceQuickCreate"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Lock do dialog parent
|
||||
|
||||
Parent **precisa** travar seu próprio `dismissableMask` e `closeOnEscape` enquanto qualquer quick-create child está aberto, senão clicar fora fecha tudo:
|
||||
|
||||
```vue
|
||||
<Dialog
|
||||
v-model:visible="parentVisible"
|
||||
:dismissableMask="!anyChildDialogOpen"
|
||||
:closeOnEscape="!anyChildDialogOpen"
|
||||
...
|
||||
>
|
||||
```
|
||||
|
||||
```js
|
||||
const serviceQuickCreateOpen = ref(false);
|
||||
const insuranceQuickCreateOpen = ref(false);
|
||||
const anyChildDialogOpen = computed(() =>
|
||||
serviceQuickCreateOpen.value || insuranceQuickCreateOpen.value
|
||||
);
|
||||
```
|
||||
|
||||
### Renderização dos quick-creates DENTRO do parent
|
||||
|
||||
```vue
|
||||
<!-- DENTRO do template do parent dialog, antes do </Dialog> -->
|
||||
<ServiceQuickCreateDialog
|
||||
v-model="serviceQuickCreateOpen"
|
||||
:owner-id="ownerId"
|
||||
:initial-name="serviceSearchText"
|
||||
@created="onServiceCreated"
|
||||
/>
|
||||
```
|
||||
|
||||
### Handler `on<Entity>Created`
|
||||
|
||||
```js
|
||||
function onServiceCreated(row) {
|
||||
// 1. Inserir na lista local (sem re-fetch)
|
||||
services.value = [row, ...services.value];
|
||||
// 2. Pré-selecionar no select
|
||||
selectedServiceId.value = row.id;
|
||||
// 3. (Opcional) Focar o próximo campo
|
||||
nextTick(() => priceInputRef.value?.focus());
|
||||
}
|
||||
```
|
||||
|
||||
### Handler `openXQuickCreate`
|
||||
|
||||
```js
|
||||
function openServiceQuickCreate() {
|
||||
serviceSearchText.value = currentSearchInSelect.value; // capture pra initialName
|
||||
serviceQuickCreateOpen.value = true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Convenções de UX
|
||||
|
||||
### Campos mínimos absolutos
|
||||
|
||||
Quick-create **não é cadastro completo**. Inclui só:
|
||||
- 1 campo obrigatório principal (nome)
|
||||
- 1-2 campos obrigatórios secundários (preço, duração)
|
||||
- 1 campo opcional (descrição)
|
||||
|
||||
Resto (categorias, tags, configurações avançadas) edita depois em `/entity/:id`.
|
||||
|
||||
### Maxlength visível
|
||||
|
||||
```vue
|
||||
<InputText v-model="form.name" maxlength="120" />
|
||||
```
|
||||
|
||||
Slice no save: `.trim().slice(0, 120)` — defesa em profundidade.
|
||||
|
||||
### Botão "+" sempre `size="small"` `severity="secondary"`
|
||||
|
||||
Discrição visual — não compete com CTA do dialog parent.
|
||||
|
||||
### Toast em vez de inline error
|
||||
|
||||
Mini-dialog não tem espaço pra banner de erro. Toast no canto superior direito (padrão PrimeVue) basta.
|
||||
|
||||
### `autofocus` no primeiro input
|
||||
|
||||
```vue
|
||||
<InputText autofocus v-model="form.name" />
|
||||
```
|
||||
|
||||
Usuário já está em modo "digitar" — pular o clique no input.
|
||||
|
||||
### `:loading="saving"` no botão Salvar
|
||||
|
||||
Spinner + disabled simultâneo. PrimeVue já dá o efeito visual.
|
||||
|
||||
---
|
||||
|
||||
## 7. Anti-patterns (NÃO fazer)
|
||||
|
||||
### ❌ Navegar pra rota nova no botão "+"
|
||||
|
||||
```js
|
||||
// ❌ — destrói o form em progresso
|
||||
function openServiceQuickCreate() {
|
||||
router.push('/saas/services/new');
|
||||
}
|
||||
```
|
||||
|
||||
✅ Abre o overlay.
|
||||
|
||||
### ❌ Quick-create que pede 10 campos
|
||||
|
||||
Se a entidade exige cadastro complexo (campos condicionais, validações cruzadas, upload de arquivo), **não cabe num quick-create**. Use página dedicada e aceite que o usuário perde contexto. Ou crie um wizard.
|
||||
|
||||
### ❌ Sem `dups check` antes do insert
|
||||
|
||||
```js
|
||||
// ❌ — usuário clica 2x, cria duplicata silenciosa
|
||||
await supabase.from('services').insert(payload).select().single();
|
||||
```
|
||||
|
||||
✅ `ilike` por `name` antes; aborta com warn toast.
|
||||
|
||||
### ❌ Não emitir o objeto completo no `created`
|
||||
|
||||
```js
|
||||
// ❌
|
||||
emit('created', { id: data.id }); // parent precisa de mais que id
|
||||
|
||||
// ❌ pior ainda
|
||||
emit('created'); // parent não sabe o que foi criado
|
||||
```
|
||||
|
||||
✅ `emit('created', data)` — row completa do banco.
|
||||
|
||||
### ❌ Não capturar `initialName` do search atual
|
||||
|
||||
Quando usuário digita "Sessão 50min" no select e clica "+", o `initialName=` deve já vir preenchido. Senão usuário re-digita.
|
||||
|
||||
### ❌ Parent sem `anyChildDialogOpen` no lock
|
||||
|
||||
Sem o lock, clicar fora do quick-create child fecha o parent inteiro. Bug clássico.
|
||||
|
||||
### ❌ Re-fetch da lista após `created`
|
||||
|
||||
```js
|
||||
// ❌ — round-trip desnecessário; o evento já trouxe o row
|
||||
async function onServiceCreated() {
|
||||
await loadServices();
|
||||
}
|
||||
```
|
||||
|
||||
✅ Inserir o `row` recebido direto na lista local; só re-fetch se houver lógica de ordenação complexa.
|
||||
|
||||
### ❌ Múltiplos quick-creates abertos ao mesmo tempo
|
||||
|
||||
Permitir abrir um quick-create de plano de saúde enquanto outro de serviço está aberto = stack visual confuso. Force fechar o atual antes de abrir o próximo, OU mantenha o lock no `anyChildDialogOpen` que cobre.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sanitização (memória `feedback_sanitizacao`)
|
||||
|
||||
Toda entrada de quick-create:
|
||||
|
||||
```js
|
||||
const name = form.value.name?.trim().slice(0, 120) || null;
|
||||
const description = form.value.description?.trim().slice(0, 500) || null;
|
||||
const price = form.value.price != null ? Number(form.value.price) : null;
|
||||
```
|
||||
|
||||
Padrão: `trim()` → `slice(maxlength)` → `nullif vazio` → cast tipo.
|
||||
|
||||
Pro upload (não comum em quick-create, mas se houver): mime allowlist + size check antes de submitter.
|
||||
|
||||
---
|
||||
|
||||
## 9. Promotion History & Path Convention
|
||||
|
||||
### Histórico
|
||||
|
||||
- **2026-05-04** — Pattern nasceu em `features/agenda/` com 3 quick-creates (Service, InsurancePlan, InsurancePlanService). Documentado como **agenda-only** com promotion criteria explícito.
|
||||
- **2026-05-20** — Audit baseline identificou 3 candidates já em produção fora da agenda: `CadastroRapidoMedico.vue`, `CadastroRapidoConvenio.vue`, `ComponentCadastroRapido.vue` (todos `supabase` direto, em `src/components/`). Promotion criteria atingida 3×. **Blueprint promovido pra universal.**
|
||||
|
||||
### Path convention pós-promoção
|
||||
|
||||
| Caso | Path | Exemplo |
|
||||
|---|---|---|
|
||||
| Entidade pertence a 1 feature claro | `src/features/<feature>/components/<Entity>QuickCreateDialog.vue` | `features/medicos/components/MedicoQuickCreateDialog.vue` |
|
||||
| Entidade é cross-feature (raro) | `src/components/quick-create/<Entity>QuickCreateDialog.vue` | (nenhum hoje) |
|
||||
|
||||
**Anti-pattern:** quick-create morando em `src/components/` raiz sem subpasta — perde discoverability e mistura com componentes utilitários.
|
||||
|
||||
### Plano de migração dos 3 legacy
|
||||
|
||||
Cada refator acontece **quando o módulo dono for tocado na Fase 1**:
|
||||
|
||||
| Componente atual | Path destino | Quando | Fix obrigatório |
|
||||
|---|---|---|---|
|
||||
| `src/components/CadastroRapidoMedico.vue` | `src/features/medicos/components/MedicoQuickCreateDialog.vue` | Módulo 1 (Home/Components) — pode criar `features/medicos/` se ainda não existe | Migrar pra repository; usar `_tenantGuards` |
|
||||
| `src/components/CadastroRapidoConvenio.vue` | `src/features/insurance/components/InsurancePlanQuickCreateDialog.vue` (consolidar com o existente na agenda?) | Módulo 1 | Idem; **verificar se duplica `features/agenda/components/InsurancePlanQuickCreateDialog.vue`** |
|
||||
| `src/components/ComponentCadastroRapido.vue` | depende do que cria | Módulo 1 | Idem |
|
||||
|
||||
### Boilerplate DRY (futuro, não-prioritário)
|
||||
|
||||
Quando houver 5+ quick-creates seguindo o pattern, considerar:
|
||||
|
||||
- `useQuickCreateLock()` composable que encapsula `anyChildDialogOpen` (DRY entre parent dialogs com 2+ children)
|
||||
- `<BaseQuickCreateDialog>` wrapper component com slots `#fields`, `#footer-extra` e props padrão
|
||||
|
||||
**Não fazer agora** — 6 instâncias ainda é pouco pra inflar abstração. Pattern atual (cada quick-create standalone) é fácil de entender e copiar.
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist de auditoria
|
||||
|
||||
Aplica-se a **todo quick-create do sistema** pós-promoção (2026-05-20):
|
||||
|
||||
- [ ] Path correto (feature folder se entidade pertence a 1 feature; `src/components/quick-create/` se cross-feature)
|
||||
- [ ] Nome do arquivo: `<Entity>QuickCreateDialog.vue` (PascalCase)
|
||||
- [ ] Props canônicas: `modelValue`, `ownerId`, `initialName`
|
||||
- [ ] Emits canônicos: `update:modelValue`, `created`
|
||||
- [ ] `Dialog` com `modal`, `:draggable="false"`, `:closable="!saving"`
|
||||
- [ ] Form reset quando abre (`watch modelValue`)
|
||||
- [ ] Sanitização: `trim() + slice(maxlength) + nullif` ANTES de chamar repository
|
||||
- [ ] **Insert via repository** (não supabase direto) — repository injeta `owner_id`+`tenant_id` e faz uniqueness check
|
||||
- [ ] Toast feedback em success/warn/error (warn quando erro for "nome em uso", error caso contrário)
|
||||
- [ ] Emit `created` com row completo (não só id)
|
||||
- [ ] Parent: `anyChildDialogOpen` computed lock
|
||||
- [ ] Parent: `dismissableMask` e `closeOnEscape` bindados ao lock
|
||||
- [ ] Parent: handler `on<Entity>Created` insere row na lista local e pré-seleciona
|
||||
- [ ] Parent: `initialName` capturado do search atual do select
|
||||
- [ ] Botão "+": `size="small"` `severity="secondary"` `v-tooltip`
|
||||
- [ ] `autofocus` no primeiro input
|
||||
- [ ] `:loading="saving"` + `:disabled="!canSave()"` no Salvar
|
||||
- [ ] Máximo 3-5 inputs no form (senão não é quick-create — vira página dedicada)
|
||||
|
||||
Divergências viram items em `dev_auditoria_items` com:
|
||||
- `categoria`: `padronizacao`
|
||||
- `tag`: `padronizacao:<modulo>` (módulo dono da entidade)
|
||||
- `severidade`: **alta** se usa supabase direto em vez de repository, ou viola lock (vaza dismiss); **média** se viola contrato (emits/props); **baixa** se cosmético
|
||||
|
||||
---
|
||||
|
||||
## 11. Referências
|
||||
|
||||
- Canônicos: `src/features/agenda/components/ServiceQuickCreateDialog.vue`, `InsurancePlanQuickCreateDialog.vue`, `InsurancePlanServiceQuickCreateDialog.vue`
|
||||
- Parent integrador: `src/features/agenda/components/AgendaEventDialog.vue` (linhas ~3081-3107, ~3170, ~3274, ~3307)
|
||||
- Legacy a refatorar: `src/components/CadastroRapidoMedico.vue`, `CadastroRapidoConvenio.vue`, `ComponentCadastroRapido.vue`
|
||||
- Dialog base: `blueprints/dialog-blueprint.md`
|
||||
- Repository pareado: `blueprints/repository-blueprint.md`
|
||||
- Audit baseline: `development/02-auditoria/AUDIT_BASELINE.md` (3 candidates descobertos em 2026-05-20)
|
||||
- Memória: `feedback_agenda_inline_quick_create.md` (superseded — pattern agora universal), `feedback_sanitizacao.md`
|
||||
- Estratégia: `development/02-auditoria/PADRONIZACAO.md`
|
||||
@@ -0,0 +1,379 @@
|
||||
# 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`:
|
||||
|
||||
```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 <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:
|
||||
|
||||
```js
|
||||
/**
|
||||
* 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
|
||||
|
||||
```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<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
|
||||
|
||||
```js
|
||||
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
|
||||
|
||||
```js
|
||||
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)
|
||||
|
||||
```js
|
||||
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
|
||||
|
||||
```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 `<feature>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 <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`
|
||||
Reference in New Issue
Block a user