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`
|
||||
@@ -0,0 +1,165 @@
|
||||
-- ============================================================================
|
||||
-- Cria tabelas do prontuário clínico
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Núcleo do prontuário: notas clínicas (anamnese, evolução, plano), com
|
||||
-- versionamento (audit trail) e templates (SOAP/DAP/BIRP/livre).
|
||||
--
|
||||
-- Decisões (sessão de modelagem 2026-05-20):
|
||||
-- • Tabela única `clinical_notes` discriminada por `note_type` (não 1 tabela
|
||||
-- por tipo). Templates customizáveis exigem flexibilidade.
|
||||
-- • `content_text` (livre) + `content_structured` (jsonb) coexistem na mesma
|
||||
-- row — UI prioriza conforme template; busca/edit rápido sempre tem text.
|
||||
-- • Versionamento via snapshot completo (não diff) em `clinical_note_versions`
|
||||
-- — restore trivial e audit visualization friendly. Trigger de versionamento
|
||||
-- criado em migration separada.
|
||||
-- • Instrumentos de avaliação (GAD-7, PHQ-9, etc) ficam pra Fase 2.
|
||||
-- • RLS: owner-only (terapeuta responsável). Sem clinic-wide read — CFP exige
|
||||
-- sigilo entre profissionais. Policies em migration separada.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 1. clinical_notes — núcleo do prontuário
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.clinical_notes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL, -- terapeuta responsável
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE RESTRICT,
|
||||
session_event_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
|
||||
note_type text NOT NULL,
|
||||
template_id uuid, -- FK adicionada após criar templates
|
||||
title text,
|
||||
content_text text,
|
||||
content_structured jsonb,
|
||||
pinned boolean DEFAULT false NOT NULL,
|
||||
is_draft boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_by uuid NOT NULL,
|
||||
updated_by uuid,
|
||||
deleted_at timestamp with time zone,
|
||||
deleted_by uuid,
|
||||
CONSTRAINT clinical_notes_note_type_check CHECK (note_type IN (
|
||||
'anamnese',
|
||||
'evolucao_sessao',
|
||||
'plano_terapeutico',
|
||||
'observacao_livre',
|
||||
'resumo_caso'
|
||||
)),
|
||||
CONSTRAINT clinical_notes_content_present_check CHECK (
|
||||
content_text IS NOT NULL OR content_structured IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.clinical_notes IS
|
||||
'Notas clínicas do prontuário (anamnese, evolução de sessão, plano, observações). Owner-only via RLS — CFP exige sigilo.';
|
||||
COMMENT ON COLUMN public.clinical_notes.session_event_id IS
|
||||
'Sessão associada (quando aplicável). Anamnese/plano/resumo podem ter NULL.';
|
||||
COMMENT ON COLUMN public.clinical_notes.content_text IS
|
||||
'Conteúdo em texto livre (sempre disponível pra busca/edit rápido).';
|
||||
COMMENT ON COLUMN public.clinical_notes.content_structured IS
|
||||
'Conteúdo em formato estruturado quando há template ativo (jsonb dos campos preenchidos).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_notes_patient_recent
|
||||
ON public.clinical_notes (tenant_id, patient_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_notes_owner
|
||||
ON public.clinical_notes (owner_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_notes_session
|
||||
ON public.clinical_notes (session_event_id)
|
||||
WHERE session_event_id IS NOT NULL AND deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_notes_type
|
||||
ON public.clinical_notes (tenant_id, patient_id, note_type)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_notes_pinned
|
||||
ON public.clinical_notes (tenant_id, patient_id)
|
||||
WHERE pinned = true AND deleted_at IS NULL;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 2. clinical_note_versions — audit trail (snapshot completo)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.clinical_note_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
note_id uuid NOT NULL REFERENCES public.clinical_notes(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL,
|
||||
version_number integer NOT NULL,
|
||||
title text,
|
||||
content_text text,
|
||||
content_structured jsonb,
|
||||
change_reason text, -- 'criacao' | 'edicao' | livre
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_by uuid NOT NULL,
|
||||
CONSTRAINT clinical_note_versions_unique UNIQUE (note_id, version_number)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.clinical_note_versions IS
|
||||
'Snapshot completo de cada versão de clinical_notes. Criado via trigger AFTER INSERT OR UPDATE.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_recent
|
||||
ON public.clinical_note_versions (note_id, version_number DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_audit
|
||||
ON public.clinical_note_versions (created_by, created_at DESC);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 3. clinical_note_templates — templates SOAP/DAP/BIRP/anamnese padrão
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.clinical_note_templates (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid, -- NULL = template global do sistema
|
||||
owner_id uuid, -- NULL = template do tenant inteiro
|
||||
key text NOT NULL, -- 'soap', 'dap', 'birp', 'anamnese_padrao', ...
|
||||
name text NOT NULL,
|
||||
note_type text NOT NULL,
|
||||
description text,
|
||||
structure jsonb NOT NULL, -- [{key, label, type, required, hint}]
|
||||
is_system boolean DEFAULT false NOT NULL,
|
||||
is_global boolean DEFAULT false NOT NULL,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT clinical_note_templates_note_type_check CHECK (note_type IN (
|
||||
'anamnese',
|
||||
'evolucao_sessao',
|
||||
'plano_terapeutico',
|
||||
'observacao_livre',
|
||||
'resumo_caso'
|
||||
)),
|
||||
CONSTRAINT clinical_note_templates_scope_check CHECK (
|
||||
-- Sistema: ambos NULL e is_system=true
|
||||
-- Tenant-wide: tenant_id presente, owner_id NULL
|
||||
-- Owner: ambos presentes
|
||||
(is_system = true AND tenant_id IS NULL AND owner_id IS NULL)
|
||||
OR (is_system = false AND tenant_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.clinical_note_templates IS
|
||||
'Templates de notas clínicas. Escopo: sistema (is_system, sem tenant), tenant-wide (tenant_id sem owner), owner (ambos).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_active
|
||||
ON public.clinical_note_templates (note_type)
|
||||
WHERE active = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_tenant
|
||||
ON public.clinical_note_templates (tenant_id, note_type)
|
||||
WHERE tenant_id IS NOT NULL AND active = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_owner
|
||||
ON public.clinical_note_templates (owner_id, note_type)
|
||||
WHERE owner_id IS NOT NULL AND active = true;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 4. FK de clinical_notes.template_id (criada agora que templates existe)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.clinical_notes
|
||||
ADD CONSTRAINT clinical_notes_template_fkey
|
||||
FOREIGN KEY (template_id)
|
||||
REFERENCES public.clinical_note_templates(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,111 @@
|
||||
-- ============================================================================
|
||||
-- RLS policies do prontuário clínico
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Padrão MAIS RESTRITIVO que agenda — CFP exige sigilo profissional entre
|
||||
-- terapeutas do mesmo tenant. Default: APENAS o owner (terapeuta responsável)
|
||||
-- lê e escreve. Sem clinic-wide read.
|
||||
--
|
||||
-- Compartilhamento com supervisor / outro terapeuta vai requerer policy
|
||||
-- específica baseada em tabela `clinical_note_shares` (Fase 2).
|
||||
--
|
||||
-- Templates seguem regra mais aberta:
|
||||
-- • Sistema (is_system): todos authenticated leem
|
||||
-- • Tenant-wide (tenant_id): membros do tenant leem; tenant_admin edita
|
||||
-- • Owner: só o owner lê/edita
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- clinical_notes — owner only
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.clinical_notes ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY clinical_notes_owner_select
|
||||
ON public.clinical_notes FOR SELECT TO authenticated
|
||||
USING (owner_id = auth.uid() AND deleted_at IS NULL);
|
||||
|
||||
CREATE POLICY clinical_notes_owner_insert
|
||||
ON public.clinical_notes FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND public.is_tenant_member(tenant_id)
|
||||
);
|
||||
|
||||
CREATE POLICY clinical_notes_owner_update
|
||||
ON public.clinical_notes FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- DELETE só por soft-delete (UPDATE deleted_at). Hard delete bloqueado em RLS.
|
||||
-- Backup/admin pode dropar via psql -U supabase_admin se preciso.
|
||||
CREATE POLICY clinical_notes_no_hard_delete
|
||||
ON public.clinical_notes FOR DELETE TO authenticated
|
||||
USING (false);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- clinical_note_versions — read-only pelo owner da nota
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.clinical_note_versions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY clinical_note_versions_owner_select
|
||||
ON public.clinical_note_versions FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.clinical_notes cn
|
||||
WHERE cn.id = clinical_note_versions.note_id
|
||||
AND cn.owner_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT só via trigger (SECURITY DEFINER). Sem policy de UPDATE/DELETE —
|
||||
-- versões são imutáveis. Trigger usa role bypass.
|
||||
CREATE POLICY clinical_note_versions_no_write
|
||||
ON public.clinical_note_versions FOR INSERT TO authenticated
|
||||
WITH CHECK (false);
|
||||
CREATE POLICY clinical_note_versions_no_update
|
||||
ON public.clinical_note_versions FOR UPDATE TO authenticated
|
||||
USING (false);
|
||||
CREATE POLICY clinical_note_versions_no_delete
|
||||
ON public.clinical_note_versions FOR DELETE TO authenticated
|
||||
USING (false);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- clinical_note_templates — escopo escalonado
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.clinical_note_templates ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: sistema (qualquer authenticated) + tenant-wide (membros) + owner (próprio)
|
||||
CREATE POLICY clinical_note_templates_select
|
||||
ON public.clinical_note_templates FOR SELECT TO authenticated
|
||||
USING (
|
||||
active = true
|
||||
AND (
|
||||
is_system = true
|
||||
OR (tenant_id IS NOT NULL AND public.is_tenant_member(tenant_id))
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT/UPDATE/DELETE: só owner ou tenant_admin do tenant
|
||||
-- Templates do sistema (is_system) nunca alteráveis via UI — só via seed/migration.
|
||||
CREATE POLICY clinical_note_templates_owner_write
|
||||
ON public.clinical_note_templates TO authenticated
|
||||
USING (
|
||||
is_system = false
|
||||
AND (
|
||||
owner_id = auth.uid()
|
||||
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
is_system = false
|
||||
AND (
|
||||
owner_id = auth.uid()
|
||||
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
|
||||
)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,117 @@
|
||||
-- ============================================================================
|
||||
-- Trigger de versionamento automático de clinical_notes
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- A cada INSERT ou UPDATE relevante em clinical_notes, cria snapshot completo
|
||||
-- em clinical_note_versions. Função é SECURITY DEFINER pra bypassar a RLS
|
||||
-- (que bloqueia INSERT direto em clinical_note_versions).
|
||||
--
|
||||
-- Versionamento dispara em:
|
||||
-- • INSERT — registra criação (version_number = 1)
|
||||
-- • UPDATE em content_text, content_structured ou title — registra edição
|
||||
--
|
||||
-- Mudanças em pinned/is_draft NÃO disparam versionamento (mudança de UI/state,
|
||||
-- não de conteúdo).
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_clinical_note_version()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
next_version integer;
|
||||
reason text;
|
||||
BEGIN
|
||||
SELECT COALESCE(MAX(version_number), 0) + 1
|
||||
INTO next_version
|
||||
FROM public.clinical_note_versions
|
||||
WHERE note_id = NEW.id;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
reason := 'criacao';
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
|
||||
reason := 'soft_delete';
|
||||
ELSIF NEW.deleted_at IS NULL AND OLD.deleted_at IS NOT NULL THEN
|
||||
reason := 'restore';
|
||||
ELSE
|
||||
reason := 'edicao';
|
||||
END IF;
|
||||
ELSE
|
||||
reason := 'desconhecido';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.clinical_note_versions (
|
||||
note_id,
|
||||
tenant_id,
|
||||
version_number,
|
||||
title,
|
||||
content_text,
|
||||
content_structured,
|
||||
change_reason,
|
||||
created_at,
|
||||
created_by
|
||||
) VALUES (
|
||||
NEW.id,
|
||||
NEW.tenant_id,
|
||||
next_version,
|
||||
NEW.title,
|
||||
NEW.content_text,
|
||||
NEW.content_structured,
|
||||
reason,
|
||||
now(),
|
||||
COALESCE(NEW.updated_by, NEW.created_by)
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.fn_clinical_note_version() IS
|
||||
'Snapshot completo de clinical_notes a cada INSERT/UPDATE relevante. SECURITY DEFINER bypassa RLS pra escrever em clinical_note_versions (que bloqueia INSERT direto).';
|
||||
|
||||
CREATE TRIGGER trg_clinical_notes_version_insert
|
||||
AFTER INSERT ON public.clinical_notes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_clinical_note_version();
|
||||
|
||||
CREATE TRIGGER trg_clinical_notes_version_update
|
||||
AFTER UPDATE OF content_text, content_structured, title, deleted_at
|
||||
ON public.clinical_notes
|
||||
FOR EACH ROW
|
||||
WHEN (
|
||||
OLD.content_text IS DISTINCT FROM NEW.content_text
|
||||
OR OLD.content_structured IS DISTINCT FROM NEW.content_structured
|
||||
OR OLD.title IS DISTINCT FROM NEW.title
|
||||
OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at
|
||||
)
|
||||
EXECUTE FUNCTION public.fn_clinical_note_version();
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- Trigger para updated_at automático
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_clinical_notes_updated_at()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_clinical_notes_updated_at
|
||||
BEFORE UPDATE ON public.clinical_notes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_clinical_note_templates_updated_at
|
||||
BEFORE UPDATE ON public.clinical_note_templates
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ============================================================================
|
||||
-- Liga documents a clinical_notes (preenche FK órfã)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- A coluna `documents.session_note_id` existia desde antes apontando pra uma
|
||||
-- tabela `session_notes` que nunca foi criada. Agora que `clinical_notes`
|
||||
-- existe e abrange anamnese/evolução/plano (não só sessão), renomeia pra
|
||||
-- `clinical_note_id` e adiciona FK constraint.
|
||||
--
|
||||
-- PRÉ-CHECK: a query abaixo deve retornar 0 antes de rodar esta migration.
|
||||
-- SELECT count(*) FROM public.documents WHERE session_note_id IS NOT NULL;
|
||||
-- Se houver dados, eles são órfãos (referenciam tabela inexistente) — limpar
|
||||
-- antes de adicionar a FK constraint, ou ela falha.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Limpa eventuais órfãos (FK nunca foi enforced, mas valor pode ter sido
|
||||
-- setado por código no front antes da migration). Defesa em profundidade.
|
||||
UPDATE public.documents
|
||||
SET session_note_id = NULL
|
||||
WHERE session_note_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.clinical_notes cn
|
||||
WHERE cn.id = documents.session_note_id
|
||||
);
|
||||
|
||||
-- 2. Rename
|
||||
ALTER TABLE public.documents
|
||||
RENAME COLUMN session_note_id TO clinical_note_id;
|
||||
|
||||
-- 3. FK constraint
|
||||
ALTER TABLE public.documents
|
||||
ADD CONSTRAINT documents_clinical_note_fkey
|
||||
FOREIGN KEY (clinical_note_id)
|
||||
REFERENCES public.clinical_notes(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
-- 4. Index pra reverse lookup (documentos de uma nota)
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_clinical_note
|
||||
ON public.documents (clinical_note_id)
|
||||
WHERE clinical_note_id IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
COMMENT ON COLUMN public.documents.clinical_note_id IS
|
||||
'Vínculo opcional a uma nota clínica (anexar PDF a anamnese/evolução). Renomeado de session_note_id em 2026-05-20.';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,151 @@
|
||||
-- ============================================================================
|
||||
-- Seed dos templates do sistema de prontuário clínico
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Templates is_system=true, sem tenant_id, sem owner_id.
|
||||
-- Cobrem os 4 tipos mais comuns de nota clínica em psicologia:
|
||||
-- • Anamnese padrão CFP-style
|
||||
-- • Evolução: SOAP / DAP / BIRP
|
||||
-- • Plano terapêutico padrão
|
||||
--
|
||||
-- structure jsonb segue schema:
|
||||
-- [
|
||||
-- { key, label, type, required?, hint?, options? },
|
||||
-- ...
|
||||
-- ]
|
||||
-- type: 'text' | 'textarea' | 'select' | 'date' | 'multiselect'
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 1. Anamnese padrão (CFP)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'anamnese_padrao',
|
||||
'Anamnese Padrão',
|
||||
'anamnese',
|
||||
'Estrutura padrão de anamnese clínica em psicologia. Pode ser preenchida em 1-3 sessões iniciais.',
|
||||
'[
|
||||
{"key": "queixa_principal", "label": "Queixa principal", "type": "textarea", "required": true, "hint": "O que trouxe o paciente à terapia"},
|
||||
{"key": "historia_queixa", "label": "História da queixa", "type": "textarea", "hint": "Quando começou, evolução, fatores agravantes/atenuantes"},
|
||||
{"key": "historia_vida", "label": "História de vida", "type": "textarea", "hint": "Infância, adolescência, eventos marcantes"},
|
||||
{"key": "antecedentes_psicologicos", "label": "Antecedentes psicológicos", "type": "textarea", "hint": "Tratamentos anteriores, medicações, internações"},
|
||||
{"key": "antecedentes_medicos", "label": "Antecedentes médicos", "type": "textarea", "hint": "Doenças, cirurgias, medicações em uso"},
|
||||
{"key": "antecedentes_familiares", "label": "Antecedentes familiares", "type": "textarea", "hint": "Histórico familiar de transtornos psicológicos/psiquiátricos"},
|
||||
{"key": "vida_atual_relacionamentos", "label": "Relacionamentos atuais", "type": "textarea"},
|
||||
{"key": "vida_atual_trabalho_estudo", "label": "Trabalho / estudo atual", "type": "textarea"},
|
||||
{"key": "hipoteses_iniciais", "label": "Hipóteses iniciais", "type": "textarea", "hint": "Hipóteses do terapeuta — não compartilhar com paciente"},
|
||||
{"key": "plano_inicial", "label": "Plano terapêutico inicial", "type": "textarea"}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 2. Evolução SOAP (Subjective, Objective, Assessment, Plan)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'soap',
|
||||
'Evolução SOAP',
|
||||
'evolucao_sessao',
|
||||
'Padrão internacional: Subjetivo (relato do paciente), Objetivo (observações), Avaliação (análise), Plano (próximos passos).',
|
||||
'[
|
||||
{"key": "subjetivo", "label": "S — Subjetivo", "type": "textarea", "required": true, "hint": "O que o paciente relatou; humor; queixas verbalizadas"},
|
||||
{"key": "objetivo", "label": "O — Objetivo", "type": "textarea", "hint": "Observações do terapeuta: comportamento, afeto, aparência, postura"},
|
||||
{"key": "avaliacao", "label": "A — Avaliação", "type": "textarea", "required": true, "hint": "Análise clínica, hipóteses, evolução"},
|
||||
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true, "hint": "Intervenções planejadas, tarefas, foco da próxima sessão"}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 3. Evolução DAP (Data, Assessment, Plan)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'dap',
|
||||
'Evolução DAP',
|
||||
'evolucao_sessao',
|
||||
'Mais conciso que SOAP: Dados (relato + observações), Avaliação, Plano.',
|
||||
'[
|
||||
{"key": "dados", "label": "D — Dados", "type": "textarea", "required": true, "hint": "Relato + observações em texto único"},
|
||||
{"key": "avaliacao", "label": "A — Avaliação", "type": "textarea", "required": true},
|
||||
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 4. Evolução BIRP (Behavior, Intervention, Response, Plan)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'birp',
|
||||
'Evolução BIRP',
|
||||
'evolucao_sessao',
|
||||
'Foco em intervenção: Comportamento observado, Intervenção aplicada, Resposta do paciente, Plano.',
|
||||
'[
|
||||
{"key": "behavior", "label": "B — Comportamento", "type": "textarea", "required": true, "hint": "Comportamento/queixa observada na sessão"},
|
||||
{"key": "intervention", "label": "I — Intervenção", "type": "textarea", "required": true, "hint": "Técnicas ou abordagens aplicadas pelo terapeuta"},
|
||||
{"key": "response", "label": "R — Resposta", "type": "textarea", "required": true, "hint": "Como o paciente respondeu à intervenção"},
|
||||
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 5. Evolução livre (CFP-style — texto único)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'evolucao_livre',
|
||||
'Evolução Livre',
|
||||
'evolucao_sessao',
|
||||
'Texto único, sem estrutura — pra quem prefere prosa contínua estilo CFP tradicional.',
|
||||
'[
|
||||
{"key": "evolucao", "label": "Evolução", "type": "textarea", "required": true, "hint": "Texto único descrevendo a sessão"}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 6. Plano terapêutico padrão
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'plano_terapeutico_padrao',
|
||||
'Plano Terapêutico Padrão',
|
||||
'plano_terapeutico',
|
||||
'Estrutura básica de plano: objetivos, estratégia, recursos, prazo estimado.',
|
||||
'[
|
||||
{"key": "objetivos_gerais", "label": "Objetivos gerais", "type": "textarea", "required": true, "hint": "O que o paciente quer alcançar"},
|
||||
{"key": "objetivos_especificos", "label": "Objetivos específicos / metas", "type": "textarea", "hint": "Metas mensuráveis"},
|
||||
{"key": "estrategia_terapeutica", "label": "Estratégia terapêutica", "type": "textarea", "required": true, "hint": "Abordagem teórica, técnicas previstas"},
|
||||
{"key": "recursos_indicados", "label": "Recursos / intervenções indicadas", "type": "textarea"},
|
||||
{"key": "duracao_estimada", "label": "Duração estimada", "type": "text", "hint": "Ex: 6 meses, indeterminado"},
|
||||
{"key": "criterios_alta", "label": "Critérios de alta", "type": "textarea"},
|
||||
{"key": "encaminhamentos", "label": "Encaminhamentos paralelos", "type": "textarea", "hint": "Psiquiatria, médico, outras especialidades"}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,302 @@
|
||||
# Audit Baseline — 6 Módulos vs Blueprints
|
||||
|
||||
> **Data:** 2026-05-20
|
||||
> **Método:** 6 agentes Explore em paralelo, cada um auditou 1 módulo contra os 3 blueprints (repository, composable, quick-create overlay)
|
||||
> **Saída:** mapa exato do trabalho da Fase 1 da Padronização Sweep
|
||||
|
||||
---
|
||||
|
||||
## Sumário Executivo
|
||||
|
||||
| # | Módulo | Estado | Alta | Média | Baixa | Bloqueador |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | Home / Components | Parcial | 3 | 2 | 2 | — |
|
||||
| 2 | Pacientes | Parcial | 4 | 6 | 2 | — |
|
||||
| 3 | Prontuário | **Embrionário** | 3 | 3 | 0 | Schema clínico ausente |
|
||||
| 4 | Financeiro | **Órfão** | 6 | 3 | 1 | Overlap com agenda + double-billing risk |
|
||||
| 5 | Multi-tenant | Parcial | 2 | 3 | 2 | **Convites/membership inexistem** |
|
||||
| 6 | Notificações | **Embrionário** | 5 | 3 | 1 | 3 canais fragmentados, SMS envio só stub |
|
||||
|
||||
**Totais:** 23 alta · 20 média · 8 baixa = **51 divergências catalogadas** + 4 gaps estruturais.
|
||||
|
||||
---
|
||||
|
||||
## Surpresas (descobertas que mudam o plano)
|
||||
|
||||
### 🚨 1. Convites/membership de tenant — gap apenas no front (CORRIGIDO 2026-05-20)
|
||||
|
||||
Agente Multi-tenant disse: **não existe** repository/composable pra `sendInvite(tenantId, email)`, `acceptInvite(inviteId)`, `listTenantMembers(tenantId)`. **Correção:** a tabela `public.tenant_invites` JÁ EXISTE no schema (`tenants_multi_tenant.sql:100`) com campos completos (id, tenant_id, email, role CHECK ['therapist','secretary'], token, invited_by, expires_at default 7d, accepted_at/by, revoked_at/by). Falta APENAS UI + composables/services no front.
|
||||
|
||||
Recomendação: criar `features/tenantship/` com services + composables + página `/admin/members` usando a tabela existente. Sem migration de schema necessária. Reduz escopo de 0.5.D.
|
||||
|
||||
### 🚨 2. Lógica de billing duplicada agenda ↔ financeiro — risco de double-billing
|
||||
|
||||
`useAgendaFinanceiro.gerarCobrancaManual()` (composables raiz) e `useFinancialRecords.createRecord()` (composables raiz) **chamam a mesma RPC** `create_financial_record_for_session`. Sem coordenação = race condition silenciosa.
|
||||
|
||||
`useAgendaFinanceiro.handleStatusChange()` ainda relê `financial_records` direto via `.select('id').eq('agenda_evento_id', ...)` — query que deveria viver só no useFinancialRecords.
|
||||
|
||||
Recomendação: consolidar em 1 composable orquestrador, ou separar responsabilidades com coordenação explícita via fila.
|
||||
|
||||
### 🚨 3. Quick-create overlay — promotion criteria atingida ANTES da hora
|
||||
|
||||
Blueprint documentei como "agenda-only, promover quando aparecer 2º caso de uso". Agente Home descobriu **3 quick-create candidatos JÁ em produção**, fora da agenda:
|
||||
|
||||
- `src/components/CadastroRapidoMedico.vue` (supabase direto)
|
||||
- `src/components/CadastroRapidoConvenio.vue` (supabase direto)
|
||||
- `src/components/ComponentCadastroRapido.vue` (genérico, supabase direto)
|
||||
|
||||
São o 2º, 3º e 4º casos. **Promover agora** muda o blueprint de "agenda-only" pra universal, e dá fix em 3 componentes ao mesmo tempo.
|
||||
|
||||
### 🚨 4. Prontuário sem schema clínico
|
||||
|
||||
Agente Prontuário: o "Prontuário" hoje é shell de abas que reusa `usePatientSessions`. Schema vazio pra anamnese, evolução clínica, plano terapêutico. **Não dá pra padronizar antes de modelar.**
|
||||
|
||||
Recomendação: adicionar etapa "modelagem schema clínico" como pré-requisito pro módulo 3 da Fase 1.
|
||||
|
||||
### 🟡 5. `error = ref(null)` vs `ref('')` confirmado como divergência sistêmica
|
||||
|
||||
Aparece em pacientes, financeiro, alguns lugares de notificações. Confirma a canonicalização do composable blueprint (`''` default). Fix mecânico, fácil de aplicar.
|
||||
|
||||
### 🟡 6. Setup Wizard tem 8 queries supabase inline
|
||||
|
||||
Já estava no `project_graphify_findings_20260504` ("Setup Wizard cohesion 0.05"). Agora quantificado: 8 queries (linhas 419, 429, 446, 595, 626, 656, 681, 706). Fix: criar `setupRepository.js` + `useSetupWizard.js`.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting patterns (não específicos a 1 módulo)
|
||||
|
||||
| Pattern | Onde aparece | Severidade | Fix |
|
||||
|---|---|---|---|
|
||||
| `error = ref(null)` | Pacientes, Financeiro, partes de Notificações | Média | Mecânico: `ref('')` |
|
||||
| `supabase.from(...)` em composable | Pacientes (4 composables), Financeiro (2), Notificações (3+), Multi-tenant (1) | Alta | Extrair pra repository |
|
||||
| SELECT inline em vez de constante | Pacientes (3), Financeiro, Notificações | Média | Extrair pra `<feature>Selects.js` |
|
||||
| UPDATE/DELETE sem `.eq('tenant_id', tid)` | Pacientes (2), Financeiro (3) | Alta | Defesa em profundidade |
|
||||
| `getUid()` / `useTenantStore()` duplicados | Múltiplos composables | Baixa | Helper compartilhado |
|
||||
| State em variável módulo (vaza entre instâncias) | `usePatientFinancial._lastPatientId` | Alta | Mover DENTRO da `function use*()` |
|
||||
| `_tenantGuards.js` ausente em todo módulo não-agenda | Todos | Média | Replicar pattern |
|
||||
|
||||
---
|
||||
|
||||
## Detalhamento por Módulo
|
||||
|
||||
### 1. Home / Components base
|
||||
|
||||
**Estado:** Parcial. Falta camada de repository. 3 quick-creates espalhados fazem supabase direto.
|
||||
|
||||
**Arquivos-chave:**
|
||||
- `src/views/pages/HomeCards.vue` — roteador de perfis + RPC de auditoria interna
|
||||
- `src/layout/melissa/MelissaLayout.vue` — orquestrador (~90 imports, monolítico)
|
||||
- `src/components/CadastroRapidoMedico.vue` — quick-create supabase direto
|
||||
- `src/components/CadastroRapidoConvenio.vue` — quick-create supabase direto
|
||||
- `src/components/ComponentCadastroRapido.vue` — quick-create genérico supabase direto
|
||||
|
||||
**Divergências:**
|
||||
|
||||
| Sev | Tipo | Local | Problema | Fix |
|
||||
|---|---|---|---|---|
|
||||
| ~~Alta~~ ✅ | quick-create | `CadastroRapidoMedico.vue:150` | ~~`supabase.from()` direto sem repository~~ | **RESOLVIDO 2026-05-20 (M1.1):** `features/medicos/services/medicosRepository.js` criado + componente refatorado pra usar `useMedicos` composable |
|
||||
| ~~Alta~~ ✅ | quick-create | `CadastroRapidoConvenio.vue:98` | ~~`supabase.from()` inline~~ | **RESOLVIDO 2026-05-20 (M1.2):** `features/insurance/services/insurancePlansRepository.js` criado + componente usa `useInsurancePlans` composable. Bônus: agenda `InsurancePlanQuickCreateDialog.vue` também migrado. |
|
||||
| ~~Alta~~ ✅ | quick-create | `ComponentCadastroRapido.vue:263` | ~~`insert()` sem validação `tenant_id`+`owner_id`~~ | **RESOLVIDO 2026-05-20 (M1.3):** componente usa `usePatients.create()`; tenant resolvido via `getMyActiveMember()` (helper novo em tenantship); repository injeta `owner_id = auth.uid()` sempre, ignora payload. |
|
||||
| ~~Média~~ ✅ | composable | `CadastroRapidoMedico.vue:49-58` | ~~`getTenantId()` via fallback query em vez de store~~ | **RESOLVIDO 2026-05-20 (M1.1):** removido — repository usa `resolveTenantId()` canônico |
|
||||
| ~~Média~~ ✅ | composable | `CadastroRapidoConvenio.vue:94-100` | ~~`loadPlans()` sem `.eq('tenant_id', tid)`~~ | **RESOLVIDO 2026-05-20 (M1.2):** repository agora filtra por tenant_id + owner_id |
|
||||
| ~~Baixa~~ ✅ | outro | `HomeCards.vue:23-33` | ~~`TEST_ACCOUNTS` hardcoded~~ | **RESOLVIDO 2026-05-20 (M1.4):** extraído pra `src/config/devTestAccounts.js` |
|
||||
| Baixa | outro | `MelissaLayout.vue:1-150` | 90+ imports, monolítico | Refactor Fase 2 (M1.6 — sessão dedicada) |
|
||||
|
||||
### 2. Pacientes
|
||||
|
||||
**Estado:** Parcial. `patientsRepository` é o ÚNICO repo padronizado fora da agenda (8/10 conformidade). Composables têm violações de camada.
|
||||
|
||||
**Arquivos-chave:**
|
||||
- `src/features/patients/services/patientsRepository.js` — referência parcial
|
||||
- `src/features/patients/composables/usePatients.js` — thin wrapper (falha em error type)
|
||||
- `src/features/patients/composables/usePatientDetail.js` — supabase direto
|
||||
- `src/features/patients/composables/usePatientFinancial.js` — supabase direto + estado módulo
|
||||
- `src/features/patients/composables/usePatientSessions.js` — supabase direto
|
||||
- `src/components/ui/PatientCreatePopover.vue` — padrão OK (não é quick-create overlay)
|
||||
|
||||
**Divergências:**
|
||||
|
||||
| Sev | Tipo | Local | Problema | Fix |
|
||||
|---|---|---|---|---|
|
||||
| ~~Alta~~ ✅ | repo | `patientsRepository.js:64` | ~~`createPatient` aceita `owner_id` do payload~~ | **RESOLVIDO 2026-05-20 (spillover M1.3):** repository sempre injeta `owner_id = await getUid()`; strip `owner_id` do payload via destructure. |
|
||||
| ~~Alta~~ ✅ | composable | `usePatientDetail.js:13-40` | ~~supabase direto em 4 funções~~ | **RESOLVIDO 2026-05-20 (M2.2):** 4 funções migradas pra patientsRepository |
|
||||
| ~~Alta~~ ✅ | composable | `usePatientFinancial.js:21,156-164` | ~~`_lastPatientId` em variável módulo + supabase direto~~ | **RESOLVIDO 2026-05-20 (M2.3):** state movido DENTRO da function; mutations via repository |
|
||||
| ~~Alta~~ ✅ | composable | `usePatientSessions.js:33-44, 127-182` | ~~supabase direto em 2 mutations~~ | **RESOLVIDO 2026-05-20 (M2.4):** list+create+updateStatus via repository (com helper findSessionByRecurrence pra materialização) |
|
||||
| ~~Média~~ ✅ | composable | `usePatients.js:22` | ~~`error = ref(null)` viola canon~~ | **RESOLVIDO 2026-05-20 (spillover M1.3):** `error = ref('')` canon do composable-blueprint. |
|
||||
| ~~Média~~ ✅ | composable | `usePatientDetail.js:69` | ~~Funções internas retornam `null` silencioso em erro~~ | **RESOLVIDO 2026-05-20 (M2.2):** repository functions throw em vez de return null |
|
||||
| Média | composable | `usePatientFinancial.js:149-191, usePatientSessions.js:140-182` | Mutations retornam `{ok, data?, error?}` em vez de throw | Padrão preservado por compat com callers; fix posterior em sessão dedicada |
|
||||
| ~~Média~~ ✅ | composable | `usePatientRecurrences.js:34` | ~~`.select('*')` inline~~ | **RESOLVIDO 2026-05-20 (M2.1):** `PATIENT_RECURRENCE_RULES_SELECT` em patientsSelects.js |
|
||||
| ~~Média~~ ✅ | composable | `usePatientMessages.js:29, usePatientDocuments.js:30, usePatientSessions.js:38` | ~~SELECT inline sem constante~~ | **RESOLVIDO 2026-05-20 (M2.1):** 5 constantes em patientsSelects.js |
|
||||
| ~~Média~~ ✅ | composable | `usePatientFinancial.js:127-130, usePatientSessions.js:211-274` | ~~Mutações sem `.eq('tenant_id', tid)`~~ | **RESOLVIDO 2026-05-20 (M2.3+M2.4):** todas mutations no repository usam `.eq('tenant_id', tid)` |
|
||||
| ~~Baixa~~ ✅ | composable | `usePatients.js:45` | ~~`remove` não re-throw~~ | **RESOLVIDO 2026-05-20 (M2.6):** Tipo A canônico completo |
|
||||
| Baixa | composable | `usePatientSessions.js:67` | Filtro de virtual occurrences encapsulado no composable | Documentar como legado pré-refactor |
|
||||
|
||||
### 3. Prontuário/Evolução
|
||||
|
||||
**Estado:** **Embrionário.** Mal-existe. Aba "Prontuário evolutivo" é placeholder vazio.
|
||||
|
||||
**Arquivos-chave:**
|
||||
- `src/features/patients/prontuario/PatientProntuario.vue` (188 KB) — shell de abas
|
||||
- `src/features/patients/prontuario/PatientConversationsTab.vue` (11.8 KB) — timeline supabase direto
|
||||
- `usePatientSessions.js` reusado (não é prontuário-específico)
|
||||
|
||||
**Divergências:**
|
||||
|
||||
| Sev | Tipo | Local | Problema | Fix |
|
||||
|---|---|---|---|---|
|
||||
| Alta | composable | `PatientProntuario.vue:29` | supabase direto pra `conversation_messages`, `agenda_eventos`, `financial_records`, `documents`, `patient_groups`, `patient_tags` | Extrair `usePatientConversations`, `usePatientFinancial`, `usePatientDocuments` |
|
||||
| Alta | repo | (não existe) | Nenhum repository pras tabelas do prontuário | Criar `patientFinancialRepository.js`, `patientDocumentsRepository.js` |
|
||||
| Alta | composable | `PatientConversationsTab.vue:8` | Query direto a `conversation_messages` | Mover pra repository |
|
||||
| Média | gap | `PatientProntuario.vue:1950` | Aba "Prontuário" é placeholder vazio — schema clínico não modelado | **Decidir modelo: `patient_notes`? `clinic_sessions`? `patient_clinical_notes`?** |
|
||||
| Média | composable | `usePatientSessions.js:38` | Queries inline a `agenda_eventos`, `recurrence` | Mover pra `patientSessionsRepository.js` |
|
||||
| Média | repo | `PatientProntuario.vue:381-384` | `updateSessionStatus` mutação inline em componente | Mover pra repository |
|
||||
|
||||
**Gaps estruturais:**
|
||||
1. Repository layer ausente (3 repositories a criar)
|
||||
2. Composable layer incompleto (3 composables a criar)
|
||||
3. **Schema clínico inexistente** — anamnese, evolução, plano terapêutico não modelados
|
||||
4. PatientProntuario.vue é monolítico (188 KB) — refactor candidate
|
||||
|
||||
### 4. Financeiro
|
||||
|
||||
**Estado:** **Órfão.** Módulo existe mas sem camada repository; composables raiz fazem supabase direto.
|
||||
|
||||
**Arquivos-chave:**
|
||||
- `src/features/financeiro/pages/FinanceiroPage.vue` — supabase direto inline
|
||||
- `src/features/financeiro/pages/FinanceiroDashboardPage.vue` — RPC direto inline
|
||||
- `src/composables/useFinancialRecords.js` — composable raiz com supabase inline (sem repository)
|
||||
- `src/composables/useAgendaFinanceiro.js` — orquestrador agenda-financeiro com lógica duplicada
|
||||
|
||||
**Divergências:**
|
||||
|
||||
| Sev | Tipo | Local | Problema | Fix |
|
||||
|---|---|---|---|---|
|
||||
| Alta | repo | `useFinancialRecords.js:294` | UPDATE sem `.eq('tenant_id', tid)` | Defesa em profundidade |
|
||||
| Alta | repo | `useAgendaFinanceiro.js:194, 215` | UPDATE sem `.eq('tenant_id', tid)` em 2 pontos | Idem |
|
||||
| Alta | composable | `useFinancialRecords.js:58` | `error = ref(null)` | `ref('')` |
|
||||
| Alta | camada | `useFinancialRecords.js` (todo) | supabase direto viola blueprint | Extrair pra `financeiro/services/financialRecordsRepository.js` |
|
||||
| Alta | camada | `useAgendaFinanceiro.js:191, 205, 209` | supabase direto | Mover pra repository ou RPC wrapper |
|
||||
| Alta | overlap | `useAgendaFinanceiro.js:114-151` + `useFinancialRecords.js:157-189` | **Lógica duplicada de criação de cobrança** — ambos chamam mesma RPC | Consolidar em 1 composable orquestrador |
|
||||
| Média | convenção | `FinanceiroPage.vue:22-51` | supabase direto em componente | Mover pra composable |
|
||||
| Média | convenção | `FinanceiroDashboardPage.vue:68, 78, 144` | RPC inline em componente | Criar `useFinancialDashboard` |
|
||||
| Média | SELECT | `useFinancialRecords.js:40-51` | BASE_SELECT constante OK, mas sem `flatten<Feature>Row` | Adicionar helper se joins aninhados |
|
||||
| Baixa | cosmético | `FinanceiroPage.vue:27-40` | Formatadores BRL/Date duplicados na dashboard | Extrair pra `financeiro/utils/formatters.js` |
|
||||
|
||||
**Overlap crítico com agenda:**
|
||||
- `useAgendaFinanceiro.gerarCobrancaManual()` vs `useFinancialRecords.createRecord()` chamam mesma RPC — **risco double-billing em race condition**
|
||||
- `useAgendaFinanceiro.handleStatusChange()` relê `financial_records` (linhas 191, 205) — query que pertence a `useFinancialRecords`
|
||||
- Ambos importam `useTenantStore` + `getUid()` inline (duplicação)
|
||||
|
||||
### 5. Multi-tenant
|
||||
|
||||
**Estado:** Parcial. Stores OK. **Gap crítico: convites/membership inexistem.** SetupWizard e SaasTenantFeaturesPage com queries inline.
|
||||
|
||||
**Arquivos-chave:**
|
||||
- `src/stores/tenantStore.js` — Pinia store + memberships read-only via RPC
|
||||
- `src/stores/tenantFeaturesStore.js` — computed store + TTL cache + RPC
|
||||
- `src/stores/entitlementsStore.js` — view-based (`v_tenant_entitlements`, `v_user_entitlements`)
|
||||
- `src/features/setup/SetupWizardPage.vue` — 8 queries supabase inline
|
||||
- `src/views/pages/saas/SaasTenantFeaturesPage.vue` — 4 queries inline
|
||||
- `src/features/clinic/components/ModuleRow.vue` — dumb component OK
|
||||
|
||||
**Divergências:**
|
||||
|
||||
| Sev | Tipo | Local | Problema | Fix |
|
||||
|---|---|---|---|---|
|
||||
| ~~Alta~~ ✅ | composable | `SaasTenantFeaturesPage.vue:124-129` | ~~4 queries supabase direto~~ | **RESOLVIDO 2026-05-20 (M5 quick win):** extraído pra `src/services/tenantFeatureAdminService.js`. |
|
||||
| Alta | repository | `SetupWizardPage.vue:419, 429, 446, 595, 626, 656, 681, 706` | 8 supabase queries inline em página | Criar `setupRepository.js` + `useSetupWizard.js` |
|
||||
| Média | store | `tenantFeaturesStore.js:134` | `fetchForTenant` faz `from('tenant_features')` direto | Wrapper em `tenantFeaturesRepository.js` |
|
||||
| Média | store | `entitlementsStore.js:136, 177` | Queries em views direto | Aceitar como read-only com comentário |
|
||||
| Média | convention | `SaasTenantFeaturesPage.vue:33-53` | Error pattern inconsistente | Usar toast |
|
||||
| Baixa | naming | `tenantFeaturesStore.js:52` | `loadedForTenantId` vs `tenantId` ambíguo | Renomear |
|
||||
| Baixa | cosmetic | `SetupWizardPage.vue:60` | `isClinicRole` via string matching | Usar `useRoleGuard` |
|
||||
|
||||
**Gap crítico — convites/membership:**
|
||||
|
||||
Grep por `tenant_members`, `tenant_invite`, `convite`, `invitation` retornou **zero** em `features/`. Não existe:
|
||||
- Repository para `sendInvite(tenantId, email)`
|
||||
- Repository para `acceptInvite(inviteId)`
|
||||
- Repository para `listTenantMembers(tenantId)`
|
||||
- Composable wrapper
|
||||
- Página `/admin/members` pra gestão
|
||||
|
||||
**Recomendação:** criar `features/tenantship/` (ou `features/team/`) completo. Bloqueador de MVP.
|
||||
|
||||
### 6. Notificações
|
||||
|
||||
**Estado:** **Embrionário.** Fragmentado em 3+ canais (WhatsApp Evolution, WhatsApp Twilio, SMS Twilio, in-app, notices globais), sem padronização.
|
||||
|
||||
**Arquivos-chave:**
|
||||
- `src/features/notices/noticeService.js` — supabase direto sem repository
|
||||
- `src/features/conversations/CRMConversasPage.vue` — página complexa, lógica não extraída
|
||||
- `src/composables/useConversations.js` — query + business logic + supabase direto
|
||||
- `src/composables/useNotifications.js` — toast + realtime + polling
|
||||
- `src/stores/notificationStore.js` — in-app puro (OK)
|
||||
- `src/stores/conversationDrawerStore.js` — mistura send WhatsApp/SMS + templates
|
||||
- `src/stores/twilioWhatsappStore.js` — estado Twilio subcontas
|
||||
- `src/views/pages/notifications/SmsChannelSetupPage.vue` — credenciais via supabase
|
||||
- `src/views/pages/therapist/NotificationsHistoryPage.vue` — sync com store
|
||||
|
||||
**Divergências:**
|
||||
|
||||
| Sev | Tipo | Local | Problema | Fix |
|
||||
|---|---|---|---|---|
|
||||
| Alta | repo | `noticeService.js:28-44` | SELECT inline | Criar `notices/noticeSelects.js` |
|
||||
| Alta | composable | `useConversations.js:85-91` | supabase direto, sem repository | Criar `conversations/services/conversationsRepository.js` |
|
||||
| Alta | repo | `useConversations.js:229-244` | SELECT inline em `loadThreadMessages()` | Extrair pra repository |
|
||||
| Alta | gap | `conversationDrawerStore.js:339-346` | Edge function invoke direto sem fallback/retry | Criar `sendMessageService.js` com error handling |
|
||||
| Alta | canais | `conversationDrawerStore.js:327-377` | Lógica de envio WhatsApp (Evolution + Twilio) sem abstração | Factory por canal |
|
||||
| Média | error | `useNotifications.js:117-145` | Realtime/polling sem try/catch | Wrap |
|
||||
| Média | repo | `conversationDrawerStore.js:414-449` | `loadTemplates()` sem `.eq('tenant_id', ...)` no 2º select | Adicionar guard |
|
||||
| Média | naming | `SmsChannelSetupPage.vue:84-102` | Query sem SELECT canônico | Extrair |
|
||||
| Baixa | cosmético | `useConversations.js:165-184` | Channel filter hardcoded ['whatsapp','sms','email'] | Exportar `CHANNEL_TYPES` const |
|
||||
|
||||
**Canais identificados:**
|
||||
1. WhatsApp (Evolution API) — Parcial
|
||||
2. WhatsApp (Twilio) — Parcial
|
||||
3. SMS (Twilio) — **Stub** (só setup, sem envio)
|
||||
4. In-app (browser notifications) — Funcional
|
||||
5. Global Notices — Funcional
|
||||
|
||||
**Gaps estruturais:**
|
||||
- Repositórios inexistem (conversas, mensagens, canais)
|
||||
- `_tenantGuards.js` ausente
|
||||
- SELECT canônico fragmentado
|
||||
- Composables fat (`useConversations` faz 3 coisas)
|
||||
- SMS envio não implementado (só credenciais)
|
||||
|
||||
---
|
||||
|
||||
## Próximos passos
|
||||
|
||||
### Ajustes ao plano original
|
||||
|
||||
**Fase 0** — concluída. Audit baseline pronto.
|
||||
|
||||
**Fase 1** — sequenciamento revisado considerando as 4 surpresas:
|
||||
|
||||
| Ordem | Módulo | Pré-requisito | Observação |
|
||||
|---|---|---|---|
|
||||
| 1 | **Home/Components** | — | Inclui promover quick-create blueprint (3 candidates já existem) + criar `medicos/` e `insurance/` features |
|
||||
| 1.5 | **Quick-create blueprint promotion** | — | Mover blueprint de "agenda-only" pra universal; refatorar 3 CadastroRapido components em paralelo |
|
||||
| 2 | **Pacientes** | — | `patientsRepository` já parcial; fix 4 composables com supabase direto |
|
||||
| 3 | **Prontuário (parcial)** | **Decisão de schema clínico** | Sem schema, só dá pra criar repositories pras tabelas existentes (financial_records, documents) |
|
||||
| 4 | **Financeiro** | Decisão sobre overlap com agenda | Resolver double-billing risk ANTES de refactor |
|
||||
| 5 | **Multi-tenant + Convites** | — | Criar `tenantship/` feature inteiro (gap crítico) |
|
||||
| 6 | **Notificações** | — | Pesado: 3 canais, abstração por factory |
|
||||
|
||||
### Decisões pendentes (precisa de você)
|
||||
|
||||
1. **Quick-create blueprint:** promover pra universal agora ou manter agenda-only? (recomendo promover — promotion criteria atingida)
|
||||
2. **Schema clínico do prontuário:** modelar agora (bloqueador) ou empurrar pra Fase 1 estendida?
|
||||
3. **Overlap billing agenda↔financeiro:** consolidar em 1 composable OU separar com coordenação via fila? (recomendo consolidar)
|
||||
4. **Convites/membership:** criar feature `tenantship/` separada OU absorver em `clinic/`? (recomendo separada — semântica diferente)
|
||||
5. **`dev_auditoria_items` no banco:** popular agora os 51 itens via SQL OU UI uma a uma? (recomendo SQL batch insert — mais rápido pra começar Fase 1)
|
||||
|
||||
---
|
||||
|
||||
## Referências
|
||||
|
||||
- Blueprints: `blueprints/repository-blueprint.md`, `composable-blueprint.md`, `quick-create-overlay-blueprint.md`
|
||||
- Estratégia: `development/02-auditoria/PADRONIZACAO.md`
|
||||
- Memória: `project_padronizacao_sweep.md`, `project_graphify_findings_20260504.md`
|
||||
@@ -0,0 +1,480 @@
|
||||
# Design — useBillingOrchestrator
|
||||
|
||||
> **Data:** 2026-05-20
|
||||
> **Tipo:** Design doc (sem código). Implementação fica pra Módulo 4 (Financeiro) da Fase 1.
|
||||
> **Resolve:** decisão 7 do PADRONIZACAO.md — overlap billing agenda ↔ financeiro com risco de double-billing.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problema atual
|
||||
|
||||
### 1.1 Três caminhos pra criar cobrança
|
||||
|
||||
Cobrança de sessão hoje pode ser criada por **3 lugares diferentes**:
|
||||
|
||||
| # | Caminho | Arquivo | Quando |
|
||||
|---|---|---|---|
|
||||
| A | Botão "Gerar cobrança" manual | `useAgendaFinanceiro.gerarCobrancaManual()` (linha 114) | User clica explicitamente em sessão sem cobrança |
|
||||
| B | Mudança de status na agenda | `useAgendaFinanceiro.handleStatusChange()` (linha 163) | User troca status (agendado→faltou, etc) |
|
||||
| C | Decisões aplicadas no Melissa | `useMelissaAgenda._applyStatusDecisions()` (linha 1450) | User confirma transição de status no fluxo Melissa |
|
||||
|
||||
**Os 3 chamam a mesma RPC** `create_financial_record_for_session`. Sem coordenação central. Resultado: race condition silenciosa possível.
|
||||
|
||||
### 1.2 UPDATEs diretos espalhados
|
||||
|
||||
`handleStatusChange` também faz UPDATE/SELECT em `financial_records` direto (linhas 191, 194, 205, 208) — queries que **pertencem ao useFinancialRecords** mas são duplicadas aqui pra evitar import circular.
|
||||
|
||||
### 1.3 State em variável de módulo (vaza)
|
||||
|
||||
`useAgendaFinanceiro.js:38`:
|
||||
```js
|
||||
const _exceptionsCache = new Map(); // ← módulo-level, vaza entre instâncias
|
||||
```
|
||||
|
||||
Quando user troca de tenant, cache não invalida automaticamente. Memória `useAgendaFinanceiro.invalidateExceptionsCache()` precisa ser chamada manualmente em vários lugares.
|
||||
|
||||
### 1.4 Cenários de double-billing concretos
|
||||
|
||||
1. **Race manual + status:** user clica "Gerar cobrança" + muda status pra "faltou" em < 200ms. Path A insere registro pending; Path B detecta sessão sem `billed` (já que ainda não chegou) e cria outro registro pela exceção.
|
||||
2. **Realizado vindo de faltou paid:** sessão estava `faltou` com multa paid. User volta pra `agendado` → `realizado`. Path B/C podem regerar cobrança em cima da multa paid existente (memória `project_rpc_idempotency_cancelled` foi um fix relacionado mas não cobre todo o problema).
|
||||
3. **Pacote saldo + adicional:** sessão de pacote `billing_contract_id` setado bloqueia Path A (linha 116). Mas Path B/C podem **não checar** esse campo em alguma branch — risco de cobrança individual em sessão de pacote.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals & Non-goals
|
||||
|
||||
### Goals
|
||||
1. **Single entry point** pra qualquer mudança de billing relacionada a evento da agenda.
|
||||
2. **Idempotência garantida** — chamar 2× a mesma intenção produz o mesmo resultado.
|
||||
3. **State machine explícito** de transições de status com consequências financeiras claras.
|
||||
4. **Reverse transitions** tratadas (realizado→agendado, faltou→agendado, cancelado→agendado).
|
||||
5. **Orchestrador NUNCA toca supabase direto** — só via repository e composable de financeiro.
|
||||
6. **Cache de regras de exceção** vive na instância do composable, não em módulo.
|
||||
|
||||
### Non-goals (fora deste escopo)
|
||||
1. Implementação — só design. Código vem na Fase 1 Módulo 4.
|
||||
2. Refator de `useFinancialRecords` em si (extrair pra repository) — vai junto no Módulo 4.
|
||||
3. Gateway de pagamento (Asaas) — Fase 3 do ROADMAP.
|
||||
4. Repasse a terapeutas — `therapist_payouts` separado.
|
||||
5. UI/UX de confirmação de reverse transitions — já mapeado em memória `project_agenda_reverse_transitions`, implementação no Módulo 4.
|
||||
|
||||
---
|
||||
|
||||
## 3. State machine de transições
|
||||
|
||||
### 3.1 Status válidos
|
||||
|
||||
Enum `status_evento_agenda` (do schema): `agendado | realizado | faltou | cancelado | remarcar`
|
||||
|
||||
### 3.2 Matriz `from → to`
|
||||
|
||||
| | →agendado | →realizado | →faltou | →cancelado | →remarcar |
|
||||
|---|---|---|---|---|---|
|
||||
| **agendado→** | — | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD |
|
||||
| **realizado→** | ⚠️ REVERSE | — | ⚠️ REVERSE | ⚠️ REVERSE | ❌ inválida |
|
||||
| **faltou→** | ⚠️ REVERSE | ⚠️ CROSS | — | ⚠️ CROSS | ❌ inválida |
|
||||
| **cancelado→** | ⚠️ REVERSE | ⚠️ CROSS | ⚠️ CROSS | — | ❌ inválida |
|
||||
| **remarcar→** | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD | — |
|
||||
|
||||
### 3.3 Tabela de consequências financeiras
|
||||
|
||||
| Transição | Ação financeira default | Decisão do user (override) |
|
||||
|---|---|---|
|
||||
| `agendado→realizado` | Criar pending (se ainda não billed) com `amount = event.price` | Marcar como já recebido (forma de pagamento) |
|
||||
| `agendado→faltou` | Consultar `financial_exceptions[patient_no_show]` → criar multa OR cancelar existente | Consumir saldo de pacote (se aplicável) |
|
||||
| `agendado→cancelado` | Consultar `financial_exceptions[patient_cancellation]` + `min_hours_notice` → criar taxa de cancelamento tardio OR cancelar existente | — |
|
||||
| `realizado→agendado` | **REVERSE:** se record `paid` existe → confirm dialog (refund_paid). Se `pending` → soft-cancel. Se `paid+package`: refund + devolver saldo | Reverter manualmente sem auto |
|
||||
| `realizado→faltou` | **CROSS:** reverter realizado + aplicar regra de no-show. Se já paid → manter pago e converter em multa | — |
|
||||
| `faltou→agendado` | **REVERSE:** cancelar multa pending. Se multa paid → confirm dialog (refund) | — |
|
||||
| `cancelado→agendado` | **REVERSE:** cancelar taxa de cancelamento (se houver) | — |
|
||||
| `*→remarcar` | Manter cobrança existente, atualizar `due_date` quando reagendar | — |
|
||||
|
||||
### 3.4 Pacote (billing_contract_id presente)
|
||||
|
||||
**Sobre qualquer transição:** se `event.billing_contract_id` não-nulo, **não criar nem cancelar `financial_records` individual**. Em vez disso:
|
||||
- `agendado→realizado`: incrementa `billing_contracts.sessions_used`
|
||||
- `agendado→faltou` ou `agendado→cancelado` com `default_consume_on_miss=true`: incrementa `sessions_used`
|
||||
- `realizado→agendado`: decrementa `sessions_used` (refresh FRESH do DB antes, memória `project_agenda_reverse_transitions`)
|
||||
|
||||
Memória relevante: `project_cross_week_propagation` — bulk-load tem que rodar mesmo sem reais na view + query records cross-week por recurrence_id.
|
||||
|
||||
---
|
||||
|
||||
## 4. API shape
|
||||
|
||||
### 4.1 Signature do composable
|
||||
|
||||
```js
|
||||
export function useBillingOrchestrator() {
|
||||
// ─── State ──────────────────────────────────────────────────
|
||||
const loading = ref(false); // operação async em andamento
|
||||
const error = ref(''); // string vazia default (canon do composable-blueprint)
|
||||
|
||||
// ─── Public actions ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Orquestra mudança de status de um evento + consequências financeiras.
|
||||
* Single entry point — substitui os 3 caminhos atuais.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Object} params.event - row de agenda_eventos completa
|
||||
* @param {string} params.fromStatus
|
||||
* @param {string} params.toStatus
|
||||
* @param {Object} [params.decisions] - overrides do user (ver decisões pendentes seção 5)
|
||||
* @returns {Promise<{ok, actions: Array<BillingAction>, error?}>}
|
||||
*/
|
||||
async function applyStatusChange(params) { ... }
|
||||
|
||||
/**
|
||||
* Gera cobrança manual pra evento sem cobrança ainda. Idempotente.
|
||||
* Bloqueia se billing_contract_id presente.
|
||||
*/
|
||||
async function generateChargeForEvent(event, options = {}) { ... }
|
||||
|
||||
/**
|
||||
* Lista records financeiros vinculados ao evento.
|
||||
*/
|
||||
async function fetchRecordsForEvent(eventId) { ... }
|
||||
|
||||
/**
|
||||
* Cancela TODOS os records pending/overdue de um evento (soft).
|
||||
* Use APENAS em reverse transitions confirmadas pelo user.
|
||||
*/
|
||||
async function cancelRecordsForEvent(eventId, reason) { ... }
|
||||
|
||||
/**
|
||||
* Lê regra de exceção financeira com cache local (instância).
|
||||
*/
|
||||
async function getExceptionRule(tenantId, exceptionType) { ... }
|
||||
|
||||
function invalidateRules() { ... } // chama em troca de tenant
|
||||
|
||||
return {
|
||||
loading, error,
|
||||
applyStatusChange, generateChargeForEvent,
|
||||
fetchRecordsForEvent, cancelRecordsForEvent,
|
||||
getExceptionRule, invalidateRules
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Tipos relevantes
|
||||
|
||||
```js
|
||||
/** @typedef {Object} BillingAction
|
||||
* Resultado de uma operação. Compõe o array `actions` retornado por applyStatusChange.
|
||||
* @property {string} type - 'created' | 'updated' | 'cancelled' | 'paid' | 'package_consumed' | 'package_returned' | 'noop'
|
||||
* @property {string} [recordId]
|
||||
* @property {number} [amount]
|
||||
* @property {string} [reason]
|
||||
*/
|
||||
|
||||
/** @typedef {Object} BillingDecisions
|
||||
* Overrides explícitos do user. Quando ausente, orchestrator decide via regras.
|
||||
* @property {boolean} [consumePackageSession] - faltou/cancelado: consumir saldo de pacote
|
||||
* @property {'auto'|'always'|'never'} [applyNoShowFee] - aplicar multa em faltou
|
||||
* @property {'cancel_pending'|'refund_paid'|'manual'} [reverseCleanup] - reverse: como tratar records existentes
|
||||
*/
|
||||
```
|
||||
|
||||
### 4.3 Exemplo de uso (caller)
|
||||
|
||||
```js
|
||||
// Em AgendaEventDialog.vue (ou onde quer que aplique status change)
|
||||
import { useBillingOrchestrator } from '@/features/financeiro/composables/useBillingOrchestrator';
|
||||
|
||||
const billing = useBillingOrchestrator();
|
||||
|
||||
async function onStatusChange(novoStatus, decisoes) {
|
||||
const result = await billing.applyStatusChange({
|
||||
event: eventoAtual.value,
|
||||
fromStatus: eventoAtual.value.status,
|
||||
toStatus: novoStatus,
|
||||
decisions: decisoes // pode ser undefined — orchestrator usa regras default
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
toast.add({ severity: 'error', summary: 'Falha', detail: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// result.actions é narrativa do que aconteceu — use pra UI feedback
|
||||
for (const action of result.actions) {
|
||||
if (action.type === 'created') showCreatedToast(action.amount);
|
||||
else if (action.type === 'cancelled') showCancelledToast();
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Arquitetura interna
|
||||
|
||||
### 5.1 Dependências (camadas)
|
||||
|
||||
```
|
||||
useBillingOrchestrator
|
||||
│
|
||||
├──> useFinancialRecords (thin wrapper)
|
||||
│ │
|
||||
│ └──> financialRecordsRepository
|
||||
│ │
|
||||
│ └──> supabase (RPC + tabela)
|
||||
│
|
||||
├──> useBillingContracts (composable a criar — pacotes)
|
||||
│ │
|
||||
│ └──> billingContractsRepository
|
||||
│
|
||||
├──> useFinancialExceptions (composable — regras de exceção)
|
||||
│ │
|
||||
│ └──> financialExceptionsRepository
|
||||
│
|
||||
└──> useAgendaEvents (THIN — só pra propagação reativa, não pra writes)
|
||||
│
|
||||
└──> agendaRepository (já existe)
|
||||
```
|
||||
|
||||
**Regra absoluta:** orchestrator não importa `supabase` diretamente. Só dos composables/repositories acima.
|
||||
|
||||
### 5.2 Internals
|
||||
|
||||
```js
|
||||
// PRIVATE — não exportado
|
||||
const _rulesCache = new Map(); // ← agora DENTRO da function, vive com a instância
|
||||
|
||||
async function _resolveBillingState(eventId) {
|
||||
// Snapshot completo: records[], contract?, exceptionRule?
|
||||
// Pra decidir transição sem race conditions.
|
||||
const records = await financialRecords.fetchByEvent(eventId);
|
||||
const packageInfo = event.billing_contract_id
|
||||
? await billingContracts.fetch(event.billing_contract_id)
|
||||
: null;
|
||||
return { records, packageInfo };
|
||||
}
|
||||
|
||||
async function _runTransition(event, fromStatus, toStatus, decisions, state) {
|
||||
const key = `${fromStatus}→${toStatus}`;
|
||||
const handler = TRANSITION_HANDLERS[key];
|
||||
if (!handler) {
|
||||
throw new Error(`Transição inválida: ${key}`);
|
||||
}
|
||||
return handler({ event, decisions, state });
|
||||
}
|
||||
|
||||
const TRANSITION_HANDLERS = {
|
||||
'agendado→realizado': _handleRealizado,
|
||||
'agendado→faltou': _handleFaltou,
|
||||
// ... 1 handler por transição válida
|
||||
};
|
||||
|
||||
async function _handleRealizado({ event, decisions, state }) {
|
||||
if (event.billing_contract_id) {
|
||||
return _consumePackageSession(event);
|
||||
}
|
||||
|
||||
// Sessão avulsa — criar pending se não tem record ativo
|
||||
const hasActive = state.records.some(r => ['pending','overdue','paid'].includes(r.status));
|
||||
if (hasActive) {
|
||||
return [{ type: 'noop', reason: 'Record já existe' }];
|
||||
}
|
||||
const record = await financialRecords.create({
|
||||
patient_id: event.patient_id,
|
||||
agenda_evento_id: event.id,
|
||||
amount: event.price,
|
||||
due_date: _eventDateISO(event)
|
||||
});
|
||||
return [{ type: 'created', recordId: record.id, amount: event.price }];
|
||||
}
|
||||
|
||||
async function _handleFaltou({ event, decisions, state }) {
|
||||
if (event.billing_contract_id) {
|
||||
return decisions?.consumePackageSession ?? state.exceptionRule?.default_consume_on_miss
|
||||
? _consumePackageSession(event)
|
||||
: [{ type: 'noop' }];
|
||||
}
|
||||
|
||||
const rule = await getExceptionRule(event.tenant_id, 'patient_no_show');
|
||||
if (!rule || rule.charge_mode === 'none') {
|
||||
return _cancelExistingPending(state.records);
|
||||
}
|
||||
// ... lógica completa
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Idempotência — como o orchestrator garante
|
||||
|
||||
Antes de criar record:
|
||||
```js
|
||||
// 1. Snapshot da state ANTES da decisão (já feito em _resolveBillingState)
|
||||
// 2. Verificar se record ativo já existe pro evento+intenção
|
||||
const existingActive = state.records.find(r =>
|
||||
['pending', 'overdue', 'paid'].includes(r.status)
|
||||
);
|
||||
if (existingActive) {
|
||||
// Decidir: noop, update, ou criar segundo (raro — multa em cima de sessão paid)
|
||||
}
|
||||
|
||||
// 3. Locks otimistas via UPDATE com .eq('status', expectedStatus)
|
||||
// Se conflito, refresh + re-decide
|
||||
```
|
||||
|
||||
Antes de update/cancel:
|
||||
```js
|
||||
// Sempre filtra .eq('tenant_id', tid) defesa em profundidade
|
||||
// (corrige divergências do audit baseline)
|
||||
```
|
||||
|
||||
Para mudanças de pacote:
|
||||
```js
|
||||
// REFRESH FRESH do banco antes do UPDATE (memória project_agenda_reverse_transitions)
|
||||
const currentContract = await billingContracts.fetch(id);
|
||||
await billingContracts.update(id, {
|
||||
sessions_used: currentContract.sessions_used - 1
|
||||
});
|
||||
```
|
||||
|
||||
### 5.4 RPC `create_financial_record_for_session` — usar como single insert path
|
||||
|
||||
A RPC já existe e tem idempotência (memória `project_rpc_idempotency_cancelled` foi fix recente). Orchestrator usa ela como **única forma de criar record de sessão**. INSERT direto fica APENAS pra `createManualRecord` (lançamento avulso sem evento), que continua em `useFinancialRecords.createManualRecord`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Plano de migração (faseado, Módulo 4)
|
||||
|
||||
### Fase A — Foundation (preparar terreno)
|
||||
1. Criar `features/financeiro/services/` com:
|
||||
- `_tenantGuards.js` (copy do agenda)
|
||||
- `financialSelects.js` (extrair `BASE_SELECT` atual)
|
||||
- `financialRecordsRepository.js` (extrair queries do `useFinancialRecords`)
|
||||
- `financialExceptionsRepository.js` (novo — pra `financial_exceptions`)
|
||||
- `billingContractsRepository.js` (novo — pra `billing_contracts`)
|
||||
2. Adicionar `.eq('tenant_id', tid)` em todas operações (fix do audit alta sev).
|
||||
|
||||
### Fase B — Composables refatorados (sem mudar callers)
|
||||
1. Mover `useFinancialRecords.js` pra `features/financeiro/composables/`.
|
||||
2. Refatorar pra usar repository (thin wrapper). Aplicar canon `error = ref('')`.
|
||||
3. Criar `features/financeiro/composables/useFinancialExceptions.js`.
|
||||
4. Criar `features/financeiro/composables/useBillingContracts.js`.
|
||||
5. Criar `features/financeiro/composables/useBillingOrchestrator.js` com signature acima.
|
||||
6. Callers ainda usam `useFinancialRecords` direto + `useAgendaFinanceiro` — **nada quebra ainda**.
|
||||
|
||||
### Fase C — Migração de callers (1 por vez)
|
||||
1. Migrar `AgendaEventDialog.vue` pra `useBillingOrchestrator.applyStatusChange` em vez de `useAgendaFinanceiro.handleStatusChange`.
|
||||
2. Migrar `useMelissaAgenda._applyStatusDecisions` (linha 1450-1505) pra orchestrator.
|
||||
3. Migrar todos os callers de `useAgendaFinanceiro.gerarCobrancaManual` → `useBillingOrchestrator.generateChargeForEvent`.
|
||||
|
||||
### Fase D — Cleanup
|
||||
1. Deletar `src/composables/useAgendaFinanceiro.js` (callers todos migrados).
|
||||
2. Deletar `src/composables/useFinancialRecords.js` raiz (versão refatorada vive em `features/financeiro/composables/`).
|
||||
3. Remover `_exceptionsCache` módulo-level (já estava no novo composable).
|
||||
|
||||
### Sanity checks pós-migração
|
||||
- E2E Playwright: criar sessão → realizar com pagamento → mudar pra faltou → confirmar reverse → verificar contract.sessions_used. **NUNCA double-billing.**
|
||||
- Memory `project_agenda_billing_decisoes` — confirmar 5 decisões mantidas (#1 híbrido, #4 semi-auto no-show, #5 bloqueia edit cobrada, #7 credit note, #8 pagamento separado).
|
||||
|
||||
---
|
||||
|
||||
## 7. Decisões resolvidas (2026-05-20)
|
||||
|
||||
### 7.1 ✅ `applyStatusChange` faz APENAS financeiro
|
||||
|
||||
Signature final: `applyStatusChange({ event, fromStatus, toStatus, decisions })` retorna `{ ok, actions[], needsConfirmation?, error? }`. Caller é responsável por atualizar a agenda separadamente (`agendaRepository.update()`).
|
||||
|
||||
**Consequência:** orchestrator stateless quanto à agenda. Caller faz wrapping:
|
||||
```js
|
||||
await agendaEvents.update(event.id, { status: novoStatus });
|
||||
const billing = await billingOrchestrator.applyStatusChange({ ... });
|
||||
// Se billing.needsConfirmation, mostrar dialog. Se erro, considerar rollback do status.
|
||||
```
|
||||
|
||||
### 7.2 ✅ Reverse confirm via `needsConfirmation` no return
|
||||
|
||||
Quando orchestrator detecta reverse com record paid (realizado paid → agendado) ou pacote saldo consumido:
|
||||
|
||||
```js
|
||||
// Primeira chamada — sem decisions
|
||||
const r = await billingOrchestrator.applyStatusChange({ event, fromStatus: 'realizado', toStatus: 'agendado' });
|
||||
// r = { ok: false, needsConfirmation: true, options: [
|
||||
// { key: 'cancel_pending', label: 'Cancelar cobrança pendente', amount: 200 },
|
||||
// { key: 'refund_paid', label: 'Estornar pagamento', amount: 200 },
|
||||
// { key: 'manual', label: 'Resolver manualmente depois' }
|
||||
// ] }
|
||||
|
||||
// Caller mostra dialog → user escolhe → re-chama
|
||||
const r2 = await billingOrchestrator.applyStatusChange({
|
||||
event, fromStatus: 'realizado', toStatus: 'agendado',
|
||||
decisions: { reverseCleanup: 'refund_paid' }
|
||||
});
|
||||
// r2 = { ok: true, actions: [{ type: 'refunded', recordId, amount }] }
|
||||
```
|
||||
|
||||
### 7.3 ✅ Transação via RPC `apply_billing_status_transition`
|
||||
|
||||
Toda mudança financeira de transição roda em RPC dedicada. Tudo ou nada. RPC entra como migration durante Módulo 4. Composable orchestrator faz apenas:
|
||||
1. Resolve state atual (snapshot read-only)
|
||||
2. Calcula decisões (state machine no JS)
|
||||
3. Chama RPC com plano completo (`p_actions jsonb`)
|
||||
4. RPC executa em ordem dentro de uma transação SQL
|
||||
|
||||
Signature proposta da RPC:
|
||||
```sql
|
||||
CREATE FUNCTION public.apply_billing_status_transition(
|
||||
p_tenant_id uuid,
|
||||
p_event_id uuid,
|
||||
p_actions jsonb -- [{ kind: 'create_record', amount, due_date }, { kind: 'cancel_record', record_id }, ...]
|
||||
) RETURNS jsonb; -- { ok, applied: [...], failed?: { kind, reason } }
|
||||
```
|
||||
|
||||
### 7.4 ✅ Decisões #2/#3/#6 de billing — sessão dedicada antes do Módulo 4
|
||||
|
||||
Marcar sessão dedicada (~1h) pra fechar memória `project_agenda_billing_decisoes` antes da implementação. **Bloqueador parcial do Módulo 4** — orchestrator pode ser parcialmente implementado, mas a state machine só fica completa após resolver essas 3 decisões.
|
||||
|
||||
> ⚠️ **Pendência rastreada:** adicionar item em `dev_auditoria_items` ou agenda recorrente. Vai aparecer como **TODO inicial** na sessão de implementação do Módulo 4.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions (não-bloqueantes pro design)
|
||||
|
||||
1. **`patient_timeline` integration:** quando orchestrator cria/cancela record, devia emitir evento `pagamento_recebido` / `pagamento_vencido` em `patient_timeline`? Hoje o enum suporta, mas não vejo inserts. **Sugestão:** adicionar como trigger no banco (não no orchestrator) — fica resiliente a chamadas que escapam do orchestrator.
|
||||
|
||||
2. **Gateway webhook (Asaas):** quando Asaas dispara webhook "paid", quem processa? Edge function dedicada que chama `financialRecords.markAsPaid(id, 'pix')`, sem passar por orchestrator (orchestrator é só pra mudanças via agenda). Documentar como caminho separado válido.
|
||||
|
||||
3. **Repasse a terapeuta (`therapist_payouts`):** quando record fica `paid`, gera entrada em `therapist_payout_records`? Hoje não. Decisão: trigger no banco que escuta UPDATE em `financial_records.status` → cria payout. Fora do escopo do orchestrator.
|
||||
|
||||
4. **`patient_assessments` (Fase 2):** notas clínicas com escalas têm relação com billing? Improvável — assessments são clínicos, não-monetizados. Confirmar quando implementar.
|
||||
|
||||
---
|
||||
|
||||
## 9. Cross-references
|
||||
|
||||
- Memórias relevantes:
|
||||
- `project_rpc_idempotency_cancelled.md` — RPCs ignoram cancelled
|
||||
- `project_billing_contracts_no_updated_at.md` — gotcha de UPDATE silently failing
|
||||
- `project_agenda_billing_decisoes.md` — 5 decisões base
|
||||
- `project_agenda_reverse_transitions.md` — confirm dialogs pra reverter
|
||||
- `project_cross_week_propagation.md` — pacote upfront cross-week
|
||||
- `project_c12_antecipar_iterar.md` — antecipar pacote (Watch sync resolveu snapshot stale)
|
||||
- Audit baseline divergências Financeiro: ver `AUDIT_BASELINE.md` seção 4
|
||||
- Blueprints: `repository-blueprint.md` + `composable-blueprint.md`
|
||||
- ROADMAP: Fase 1 itens 1-4 (Monetização)
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist pra implementação (Módulo 4 da Fase 1)
|
||||
|
||||
- [ ] 5 repositories criados em `features/financeiro/services/`
|
||||
- [ ] 4 composables criados em `features/financeiro/composables/`
|
||||
- [ ] `useBillingOrchestrator` com signature acima
|
||||
- [ ] State machine completa (todas transições da matriz 3.2)
|
||||
- [ ] Cache de regras dentro da instância (não módulo-level)
|
||||
- [ ] Idempotência testada (chamada 2× = noop)
|
||||
- [ ] `.eq('tenant_id', tid)` em todas mutações (defesa em profundidade)
|
||||
- [ ] RPC `apply_billing_status_transition` (decisão 7.3 opção B)
|
||||
- [ ] AgendaEventDialog migrado pra orchestrator
|
||||
- [ ] useMelissaAgenda._applyStatusDecisions migrado
|
||||
- [ ] gerarCobrancaManual callers migrados
|
||||
- [ ] useAgendaFinanceiro.js deletado
|
||||
- [ ] useFinancialRecords.js raiz deletado
|
||||
- [ ] E2E test cobrindo reverse transition (realizado paid → agendado)
|
||||
- [ ] Confirm dialogs UI implementados (memória project_agenda_reverse_transitions)
|
||||
@@ -0,0 +1,161 @@
|
||||
# Padronização — Sweep estrutural pré-MVP
|
||||
|
||||
> **Iniciado:** 2026-05-20
|
||||
> **Deadline MVP:** 3+ meses (lançamento limpo, sem usuários)
|
||||
> **Escopo MVP:** Pacientes + Agenda + Billing + Prontuário + Financeiro avançado + Multi-tenant
|
||||
|
||||
---
|
||||
|
||||
## Diagnóstico
|
||||
|
||||
Sistema tem ~487 arquivos Vue, ~75-80% MVP-ready, padronização irregular. **Agenda passou por C1-C13 + análise sênior** — é o único módulo com profundidade arquitetural. Outros módulos têm divergências (composables sem `tenant_id` consistente, SELECTs inline, page layouts inconsistentes, dialogs que não seguem o blueprint).
|
||||
|
||||
Sem padronizar antes de escalar features novas (Fase 1 do ROADMAP), cada gap vira dívida composta.
|
||||
|
||||
---
|
||||
|
||||
## Estratégia em 1 frase
|
||||
|
||||
**Agenda é a referência madura.** Extrair seus padrões em blueprints → propagar módulo a módulo → tracking em `dev_auditoria_items` com tag `padronizacao:<modulo>`. **Não tocar agenda** até a Fase 4 (apenas pendências residuais).
|
||||
|
||||
---
|
||||
|
||||
## Fases
|
||||
|
||||
### Fase 0 — Fundação (3-5 dias)
|
||||
|
||||
Extrair os blueprints faltantes a partir da agenda:
|
||||
|
||||
- [x] `melissa-page-blueprint.md` — já existe
|
||||
- [x] `melissa-table-page-blueprint.md` — já existe
|
||||
- [x] `dialog-blueprint.md` — já existe
|
||||
- [x] `repository-blueprint.md` — entregável 1 (2026-05-20)
|
||||
- [x] `composable-blueprint.md` — entregável 2 (2026-05-20)
|
||||
- [x] `quick-create-overlay-blueprint.md` — entregável 3 (2026-05-20). Documentado como **agenda-only** com promotion criteria explícito.
|
||||
|
||||
E baseline:
|
||||
|
||||
- [x] Update graphify do código atual (`graphify update src/` rodou 2026-05-20)
|
||||
- [x] Audit baseline por módulo → ver `development/02-auditoria/AUDIT_BASELINE.md` (2026-05-20). 51 divergências catalogadas + 4 surpresas estruturais. **Pendente:** popular `dev_auditoria_items` no banco (decisão de batch insert vs UI).
|
||||
|
||||
### Fase 0.5 — Pré-Fase 1 setups (pós-audit baseline)
|
||||
|
||||
Decisões 6-9 introduzem 4 entregáveis novos antes da Fase 1:
|
||||
|
||||
- [x] **0.5.A** Atualizar `quick-create-overlay-blueprint.md` de "agenda-only" pra universal (2026-05-20) — header reescrito, seção 9 virou "Promotion History", path convention adicionada, checklist generalizado, memória `feedback_agenda_inline_quick_create` marcada como superseded
|
||||
- [x] **0.5.B** Schema clínico modelado (2026-05-20). 4 migrations + 1 seed escritos em `database-novo/migrations/20260520000001..4_clinical_notes_*.sql` e `database-novo/seeds/seed_040_clinical_note_templates.sql`. **Não executado** — aguardando review do user pra rodar `node db.cjs migrate`. Tabelas: `clinical_notes` + `clinical_note_versions` (audit trail via trigger snapshot) + `clinical_note_templates` (6 templates do sistema: anamnese, SOAP, DAP, BIRP, evolução livre, plano padrão). FK órfã `documents.session_note_id` renomeada pra `clinical_note_id` com constraint. RLS: owner-only (CFP sigilo).
|
||||
- [x] **0.5.C** Design doc `useBillingOrchestrator` (2026-05-20) — `development/02-auditoria/DESIGN_BILLING_ORCHESTRATOR.md`. State machine de 10 transições, dependências em 4 composables/5 repositories, plano de migração faseado em 4 fases (A/B/C/D), checklist de implementação. **4 decisões resolvidas:** (1) `applyStatusChange` só financeiro, agenda separada; (2) reverse confirm via `needsConfirmation` no return; (3) transação via RPC dedicada `apply_billing_status_transition`; (4) sessão dedicada pra decisões billing #2/#3/#6 antes do Módulo 4.
|
||||
- [x] **0.5.D** Scaffold da feature `tenantship/` (2026-05-20). 7 arquivos criados em `src/features/tenantship/`: `_tenantGuards.js`, `tenantInvitesSelects.js` + `Repository.js`, `tenantMembersSelects.js` + `Repository.js`, `useTenantInvites.js`, `useTenantMembers.js`. Funções funcionais (não-stub) usando tabela existente `tenant_invites` + view `v_tenant_members_with_profiles`. **2 pendências documentadas no código:** (1) `acceptInvite()` é stub PT-BR explicando que precisa de RPC `accept_tenant_invite(p_token uuid)` — migration a criar; (2) `sendInvite()` só insere row — envio de email/WhatsApp fica pra Módulo 6 (Notificações).
|
||||
|
||||
### Fase 1 — Padronização módulo a módulo (4-6 semanas) — **em andamento**
|
||||
|
||||
Ordem confirmada com usuário:
|
||||
|
||||
| # | Módulo | Por que aqui | Status |
|
||||
|---|---|---|---|
|
||||
| 1 | **Home/Dashboard + Components base** | Alta visibilidade. Inclui refactor dos 3 quick-creates promovidos (decisão 6). | ✅ **Concluído 2026-05-20** (exceto M1.6 — MelissaLayout decomposition, deferida) |
|
||||
| 2 | **Pacientes** | Núcleo de dados — alimenta agenda, prontuário, financeiro. | ✅ **Concluído 2026-05-20** (aguarda teste batch) |
|
||||
| 3 | **Prontuário/Evolução** | Schema clínico já modelado (decisão 7). | ✅ **Foundation 2026-05-20** — repositories + composables prontos. Ativa quando migrations 0.5.B rodarem. UI da aba "Prontuário evolutivo" fica pra sessão dedicada. |
|
||||
| 4 | **Financeiro/Billing** | `useBillingOrchestrator` desenhado (decisão 8). | ✅ **Foundation 2026-05-20** — 3 repositories + 4 composables novos. State machine completa + migração dos 3 callers BLOQUEADA pelas decisões #2/#3/#6 — fica pra sessão dedicada. Old `useAgendaFinanceiro.js` + `useFinancialRecords.js` continuam em paralelo. |
|
||||
| 5 | **Multi-tenant + Tenantship** | Inclui implementar feature `tenantship/` (decisão 9). | ✅ **Concluído 2026-05-20.** MembersPage em `src/views/pages/admin/MembersPage.vue` (CRUD de membros + convites) + rota `/admin/members` registrada em `routes.clinic.js`. Migration `20260520000005_accept_tenant_invite_rpc.sql` criada (RPC SECURITY DEFINER com lock FOR UPDATE anti-race). `tenantInvitesRepository.acceptInvite` agora chama RPC real (não mais stub). SaasTenantFeaturesPage refatorada — 4 queries inline + 1 RPC extraídos pra `src/services/tenantFeatureAdminService.js`. **SetupWizardPage (2648 linhas) deferido** — refator de arquivo monolítico precisa sessão dedicada. |
|
||||
| 6 | **Notificações (WhatsApp/Email)** | Depende de tudo acima. | ✅ **Foundation 2026-05-20** — `noticesSelects.js` criado + `noticeService.js` refatorado pra usar constantes. `features/conversations/services/` com repository + selects. Channel factory + refactor de `useConversations.js` (fat composable) deferidos — sessão dedicada. SMS send ainda stub. |
|
||||
|
||||
#### Módulo 1 — sub-entregáveis
|
||||
|
||||
- [x] **M1.1** Criar `features/medicos/` (`_tenantGuards.js`, `medicosSelects.js`, `medicosRepository.js`, `useMedicos.js`) + refatorar `CadastroRapidoMedico.vue` pra usar repository (2026-05-20). Caller único (PatientsCadastroPage) — props/emits preservados. Zero referências `supabase`/`useTenantStore`/`getOwnerId`/`getTenantId` legacy no componente. **Testado pelo user ✅**
|
||||
- [x] **M1.2** Criar `features/insurance/` (`_tenantGuards.js`, `insurancePlansSelects.js`, `insurancePlansRepository.js` com `findByName` pra uniqueness check, `useInsurancePlans.js`) + refatorar `CadastroRapidoConvenio.vue` + `InsurancePlanQuickCreateDialog.vue` (bônus da agenda) (2026-05-20). Repository agora faz duplicate check case-insensitive antes de criar — quick-create blueprint compliance. Aguardando teste.
|
||||
- [x] **M1.3** Refatorado `ComponentCadastroRapido.vue` pra usar `usePatients` composable + `getMyActiveMember()` (novo helper em `tenantship/services/tenantMembersRepository.js`) (2026-05-20). Path NÃO mudou (continua em `src/components/`) — move pra `features/patients/components/` fica pra cleanup de M2. Props deprecated mantidas pra backwards-compat dos 8 callers. **Spillover M2:** fix `patientsRepository.createPatient` (audit alta — sempre injeta owner_id do uid logado, ignora payload) + `usePatients.error: ref('')` (audit média — canon). Aguardando teste.
|
||||
- [x] **M1.4** Extraído `TEST_ACCOUNTS` de `HomeCards.vue` pra `src/config/devTestAccounts.js` (2026-05-20). HomeCards importa do novo arquivo; 6 usages funcionam idênticos.
|
||||
- [x] **M1.5** Deletado `AgendaEventDialog.vue.bak` (não estava no git, 155KB órfão no disco) (2026-05-20).
|
||||
- [ ] **M1.6** Decomposição parcial `MelissaLayout.vue` (sessão dedicada — pode ficar pra depois)
|
||||
|
||||
#### Módulo 2 — sub-entregáveis (2026-05-20, sem pausas de teste)
|
||||
|
||||
- [x] **M2.1** Criar `patientsSelects.js` (11 constantes — PATIENTS_SELECT_BASE + cross-feature: sessões, financial, documents, messages, recurrences, support_contacts, groups, tags). Estender `patientsRepository.js` com 15 funções novas + `resolveTenantId` helper local. Cross-feature reads ficam no patients até M4/M6 padronizarem (documentado nos comments).
|
||||
- [x] **M2.2** `usePatientDetail.js` refatorado — 4 funções internas (getPatientById, getPatientRelations, getGroupsByIds, getTagsByIds) movidas pra repository. Composable agora é thin wrapper.
|
||||
- [x] **M2.3** `usePatientFinancial.js` refatorado — `_lastPatientId` movido DENTRO da function (audit alta resolvida: state não vaza mais entre instâncias). 4 mutations (load/markPaid/markUnpaid/createRecord) delegam ao repository.
|
||||
- [x] **M2.4** `usePatientSessions.js` refatorado — list+create+updateStatus via repository. Pattern virtual→materialize preservado usando `findSessionByRecurrence` + `createPatientSession` com recurrence_id/date. Recurrence expansion (useRecurrence) intacta. `supabase.auth.getUser()` mantido como context resolution (não é data query).
|
||||
- [x] **M2.5** Quatro composables simples refatorados em paralelo: `usePatientMessages`, `usePatientDocuments`, `usePatientRecurrences`, `usePatientSupportContacts`. Cada um delega list+mutations ao repository.
|
||||
- [x] **M2.6** Cleanups: `usePatients.js` upgraded pra Tipo A canônico completo (load/getById/create/update/remove com loading/error/re-throw consistente).
|
||||
|
||||
**Resultado:** zero `supabase.from(...)` em qualquer composable de `features/patients/composables/`. Todos os 8 composables seguem Tipo A do blueprint. `_lastPatientId` não vaza mais.
|
||||
| 2 | **Pacientes** | Núcleo de dados — alimenta agenda, prontuário, financeiro. |
|
||||
| 3 | **Prontuário/Evolução** | Schema clínico já modelado (decisão 7). Migration já aplicada. |
|
||||
| 4 | **Financeiro/Billing** | `useBillingOrchestrator` já desenhado (decisão 8). |
|
||||
| 5 | **Multi-tenant + Tenantship** | Inclui implementar feature `tenantship/` (decisão 9). |
|
||||
| 6 | **Notificações (WhatsApp/Email)** | Depende de tudo acima. |
|
||||
|
||||
**Por módulo, sempre:**
|
||||
1. Ler estado atual
|
||||
2. Diff vs blueprints
|
||||
3. Listar divergências em `dev_auditoria_items` (tag `padronizacao:<modulo>` + severidade)
|
||||
4. Plano de fix
|
||||
5. Executar
|
||||
6. Testar (persistir HANDOFF+wiki+memória **antes** do teste manual)
|
||||
7. Commit
|
||||
8. Atualizar tracker
|
||||
9. Log no wiki
|
||||
|
||||
### Fase 2 — Hotspots Graphify (paralela)
|
||||
|
||||
Do `project_graphify_findings_20260504`:
|
||||
- [ ] `convertToPatient` duplicado
|
||||
- [ ] Supabase client triplo
|
||||
- [ ] 348 nós fracos
|
||||
- [ ] Setup Wizard cohesion 0.05
|
||||
|
||||
### Fase 3 — Gaps de MVP (Fase 1 do ROADMAP)
|
||||
|
||||
- [🟡] **Gateway Asaas (Fase A foundation 2026-05-21)** — Design doc + 2 migrations (tables + RLS) + client service + 3 Edge Function stubs (create-payment-record, cancel-payment, sync-payment). Schema: `asaas_customers`, `asaas_payments`, `asaas_webhook_events` + 5 colunas em `payment_settings`. Fase B (implementação real) depende de credenciais + decisão modelo negócio (A/B/C). Ver `development/02-auditoria/DESIGN_ASAAS_GATEWAY.md`.
|
||||
- [🟡] **Compliance CFP (#5/#8/#9 done · #6/#7 deferred · 2026-05-21)** —
|
||||
- #5 (registro profissional): migration `20260521000003_profiles_professional_registration.sql` — adiciona `professional_registration_type` (CHECK 8 conselhos) + `_number` + `_uf`.
|
||||
- #8 (nome social): JÁ INTEGRADO — `patients.nome_social` schema existia + UI em 7 arquivos.
|
||||
- #9 (especialidades): `20260521000004_specialties.sql` (tabela + profile_specialties M:N + RLS) + `seed_050_specialties.sql` (33 specialties) + `src/services/specialtiesService.js`.
|
||||
- **#6 consent forms DEFERRED**: schema `document_templates` existe; falta seed + UI editor + workflow.
|
||||
- **#7 assinatura DEFERRED**: schema `document_signatures` existe com status flow completo; falta portal UI pra paciente.
|
||||
- [ ] E2E Playwright crítico (#16)
|
||||
- [ ] Sentry (#18)
|
||||
|
||||
### Fase 4 — Agenda residual (por último)
|
||||
|
||||
- [ ] Popover snapshot stale → `ev.id` + computed
|
||||
- [ ] Reverse transition confirm dialogs (realizado paid, faltou multa, pacote saldo)
|
||||
- [ ] Replicação Rail (AgendaTerapeutaPage) + Clínica (AgendaClinicaPage)
|
||||
- [ ] C12 antecipar — iterar UX
|
||||
- [ ] Doc de ajuda completa
|
||||
|
||||
---
|
||||
|
||||
## Decisões tomadas
|
||||
|
||||
### Estratégicas iniciais (2026-05-20)
|
||||
1. **Ordem dos módulos** ✅ Confirmada acima
|
||||
2. **Tracker** ✅ Reusar `dev_auditoria_items` com tag `padronizacao:<modulo>`. Zero migration. View materializada agrupa por módulo se virar útil pra UI.
|
||||
3. **Cadência** ✅ Validação por entregável (cada blueprint vira sessão antes de propagar)
|
||||
4. **Agenda intocada** ✅ Até Fase 4
|
||||
5. **Skills novas** Só `/audit-module <nome>` — minimalista. Outras só se emergir necessidade.
|
||||
|
||||
### Pós-audit baseline (2026-05-20)
|
||||
6. **Quick-create blueprint** ✅ **Promover pra universal** (3 candidates já existem fora da agenda). Refatorar `CadastroRapidoMedico.vue` + `CadastroRapidoConvenio.vue` + `ComponentCadastroRapido.vue` em paralelo no módulo 1.
|
||||
7. **Schema clínico do prontuário** ✅ **Modelar agora** (sessão dedicada antes da Fase 1). Sai com migration pronta de `patient_notes`/`clinic_sessions`/anamnese/evolução/plano terapêutico.
|
||||
8. **Overlap billing agenda ↔ financeiro** ✅ **Consolidar em 1 composable orquestrador** (`useBillingOrchestrator`). Resolve risco double-billing antes do refactor de Financeiro.
|
||||
9. **Convites/membership** ✅ **Feature separada `tenantship/`** com services + composables + página `/admin/members`. Semântica clara: gestão de membership.
|
||||
|
||||
---
|
||||
|
||||
## Antipadrão a evitar
|
||||
|
||||
- ❌ Refatorar tudo de uma vez (= nunca lançar)
|
||||
- ❌ Tocar agenda durante a sweep (= regressão garantida)
|
||||
- ❌ Pular o tracking em `dev_auditoria_items` (= perder rastreabilidade)
|
||||
- ❌ Aplicar blueprint sem antes auditar divergências (= refazer trabalho)
|
||||
|
||||
---
|
||||
|
||||
## Referências
|
||||
|
||||
- Blueprints: `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\blueprints/`
|
||||
- Agenda canônica: `src/features/agenda/services/` + `src/features/agenda/composables/useAgendaEvents.js`
|
||||
- Tracker: tabela `dev_auditoria_items` (UI em `/saas/desenvolvimento`)
|
||||
- Memória: `project_padronizacao_sweep.md`
|
||||
- ROADMAP de features (não confundir): `development/04-roadmap/ROADMAP.md`
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user