Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização

This commit is contained in:
Leonardo
2026-03-30 14:08:19 -03:00
parent 0658e2e9bf
commit d088a89fb7
112 changed files with 115867 additions and 5266 deletions

View File

@@ -0,0 +1,96 @@
# README — generate-dashboard.js
Script Node.js que lê o `schema.sql` do backup mais recente e gera um `dashboard.html` interativo com a visão completa do banco de dados do projeto.
---
## Como usar
Coloque o `generate-dashboard.js` na **raiz do projeto** (mesma pasta do `db.cjs`) e rode:
```bash
# Usa o backup mais recente automaticamente
node generate-dashboard.js
# Ou especifica uma data
node generate-dashboard.js 2026-03-27
```
O arquivo `dashboard.html` será gerado na raiz do projeto. Basta abrir no browser.
---
## Fluxo recomendado
Sempre que fizer alterações no banco, rode os dois comandos em sequência:
```bash
node db.cjs backup # gera o backup em database-novo/backups/YYYY-MM-DD/
node generate-dashboard.js # lê o backup mais recente e gera o dashboard.html
```
---
## O que o dashboard mostra
- **Visão geral** — cards com os 9 domínios do projeto, quantidade de tabelas e FKs por domínio
- **Tabelas** — todas as 86 tabelas com colunas, tipos, badges PK/FK
- **Foreign Keys** — cada FK aparece como link clicável que pula direto para a tabela destino
- **Views** — lista das 24 views do schema público
- **Busca** — busca em tempo real por nome de tabela ou nome de coluna
- **Sidebar** — navegação por domínio
---
## Estrutura de pastas esperada
O script espera essa estrutura para funcionar:
```
raiz-do-projeto/
├── db.cjs
├── db.config.json
├── generate-dashboard.js ← script
├── dashboard.html ← gerado aqui
└── database-novo/
└── backups/
└── 2026-03-27/
├── schema.sql ← lido pelo script
├── data.sql
└── full_dump.sql
```
---
## Tabelas novas não aparecem no domínio certo?
Quando você criar uma migration nova com uma tabela nova, ela aparecerá no dashboard na seção **"Outros"** e o script vai avisar no terminal:
```
⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):
- minha_tabela_nova
→ Edite DOMAIN_TABLES no script para mapeá-las.
```
Para corrigir, abra o `generate-dashboard.js` e adicione a tabela no domínio correto dentro do objeto `DOMAIN_TABLES` no topo do arquivo:
```js
const DOMAIN_TABLES = {
'Agenda': [
'agenda_eventos',
'agenda_configuracoes',
// ...
'minha_tabela_nova', // ← adiciona aqui
],
// ...
};
```
Depois rode `node generate-dashboard.js` novamente.
---
## Requisitos
- Node.js instalado (qualquer versão >= 14)
- Sem dependências externas — usa apenas módulos nativos (`fs`, `path`)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,457 @@
#!/usr/bin/env node
// =============================================================================
// AgenciaPsi — Dashboard Generator
// =============================================================================
// Uso:
// node generate-dashboard.js → usa backup mais recente
// node generate-dashboard.js 2026-03-27 → usa backup de data específica
//
// Lê de: ./database-novo/backups/YYYY-MM-DD/schema.sql
// Gera: ./dashboard.html (na mesma pasta do script)
// =============================================================================
const fs = require('fs');
const path = require('path');
const BACKUPS_DIR = path.join(__dirname, 'backups');
const OUTPUT_FILE = path.join(__dirname, 'dashboard.html');
// ---------------------------------------------------------------------------
// Cores por domínio
// ---------------------------------------------------------------------------
const DOMAIN_COLORS = {
'SaaS / Planos': '#4f8cff',
'Tenants / Multi-tenant': '#6ee7b7',
'Pacientes': '#f472b6',
'Agenda': '#fb923c',
'Financeiro': '#a78bfa',
'Serviços / Commitments': '#34d399',
'Notificações': '#38bdf8',
'Email / Comunicação': '#fbbf24',
'SaaS Admin': '#94a3b8',
};
// ---------------------------------------------------------------------------
// Mapeamento domínio → tabelas
// Adicione novas tabelas aqui quando criar migrations
// ---------------------------------------------------------------------------
const DOMAIN_TABLES = {
'SaaS / Planos': [
'plans','plan_features','plan_prices','plan_public','plan_public_bullets',
'features','modules','module_features','subscriptions','subscription_events',
'subscription_intents_personal','subscription_intents_tenant','subscription_intents_legacy',
'addon_products','addon_credits','addon_transactions',
'tenant_features','tenant_modules','entitlements_invalidation','tenant_feature_exceptions_log',
],
'Tenants / Multi-tenant': [
'tenants','tenant_members','tenant_invites','owner_users',
'profiles','company_profiles','billing_contracts','payment_settings','support_sessions',
],
'Pacientes': [
'patients','patient_groups','patient_group_patient','patient_tags',
'patient_patient_tag','patient_discounts','patient_invites','patient_intake_requests',
],
'Agenda': [
'agenda_eventos','agenda_configuracoes','agenda_bloqueios','agenda_excecoes',
'agenda_online_slots','agenda_regras_semanais','agenda_slots_bloqueados_semanais',
'agenda_slots_regras','agendador_configuracoes','agendador_solicitacoes',
'feriados','recurrence_rules','recurrence_exceptions','recurrence_rule_services',
],
'Financeiro': [
'financial_records','financial_categories','financial_exceptions',
'therapist_payouts','therapist_payout_records',
'insurance_plans','insurance_plan_services','professional_pricing',
],
'Serviços / Commitments': [
'services','determined_commitments','determined_commitment_fields',
'commitment_services','commitment_time_logs',
],
'Notificações': [
'notifications','notification_channels','notification_templates',
'notification_queue','notification_logs','notification_preferences',
'notification_schedules','twilio_subaccount_usage',
],
'Email / Comunicação': [
'email_templates_global','email_templates_tenant','email_layout_config',
'global_notices','notice_dismissals','login_carousel_slides',
],
'SaaS Admin': [
'saas_admins','saas_docs','saas_doc_votos','saas_faq','saas_faq_itens',
'user_settings','dev_user_credentials','_db_migrations',
],
};
// ---------------------------------------------------------------------------
// 1. Resolve qual schema.sql usar
// ---------------------------------------------------------------------------
function resolveSchema() {
const arg = process.argv[2];
if (!fs.existsSync(BACKUPS_DIR)) {
console.error(`✖ Pasta não encontrada: ${BACKUPS_DIR}`);
console.error(` Certifique-se que o script está na raiz do projeto.`);
process.exit(1);
}
const available = fs.readdirSync(BACKUPS_DIR)
.filter(f => /^\d{4}-\d{2}-\d{2}$/.test(f))
.sort()
.reverse();
if (available.length === 0) {
console.error('✖ Nenhum backup encontrado em database-novo/backups/');
console.error(' Rode primeiro: node db.cjs backup');
process.exit(1);
}
const date = (arg && /^\d{4}-\d{2}-\d{2}$/.test(arg)) ? arg : available[0];
if (!available.includes(date)) {
console.error(`✖ Backup não encontrado para: ${date}`);
console.error(` Disponíveis: ${available.join(', ')}`);
process.exit(1);
}
const schemaPath = path.join(BACKUPS_DIR, date, 'schema.sql');
if (!fs.existsSync(schemaPath)) {
console.error(`✖ schema.sql não encontrado em database-novo/backups/${date}/`);
process.exit(1);
}
return { schemaPath, date, available };
}
// ---------------------------------------------------------------------------
// 2. Parse do schema.sql — extrai tabelas, colunas e FKs
// ---------------------------------------------------------------------------
function parseSchema(content) {
const tables = {};
// Tabelas public.*
const tableRe = /CREATE TABLE (public\.\S+)\s*\(([\s\S]*?)\);/gm;
let m;
while ((m = tableRe.exec(content)) !== null) {
const name = m[1].replace('public.', '');
const body = m[2];
const columns = [];
for (let line of body.split('\n')) {
line = line.trim().replace(/,$/, '');
if (!line || line.startsWith('--')) continue;
if (/^(CONSTRAINT|PRIMARY KEY|UNIQUE|CHECK|FOREIGN KEY|EXCLUDE)/i.test(line)) continue;
const col = line.match(
/^(\w+)\s+([\w\[\]"()\s,]+?)(?:\s+DEFAULT\s+|\s+NOT NULL|\s+NULL|\s+GENERATED|\s+REFERENCES\s|$)/
);
if (col) {
columns.push({
name: col[1],
type: col[2].trim().split('(')[0].trim(),
pk: col[1] === 'id',
});
}
}
tables[name] = { columns, fks: [] };
}
// FKs via ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY
const fkRe = /ALTER TABLE ONLY public\.(\w+)\s+ADD CONSTRAINT \S+ FOREIGN KEY \((\w+)\) REFERENCES public\.(\w+)\((\w+)\)/gm;
while ((m = fkRe.exec(content)) !== null) {
const [, fromTable, fromCol, toTable, toCol] = m;
if (tables[fromTable]) {
tables[fromTable].fks.push({ from_col: fromCol, to_table: toTable, to_col: toCol });
}
}
// Views
const viewRe = /CREATE(?:\s+OR REPLACE)?\s+VIEW\s+public\.(\S+)\s+AS/gm;
const views = [];
while ((m = viewRe.exec(content)) !== null) views.push(m[1]);
return { tables, views };
}
// ---------------------------------------------------------------------------
// 3. Monta os domínios
// Tabelas novas que ainda não estão mapeadas vão para "Outros"
// ---------------------------------------------------------------------------
function buildDomains(tables) {
const mapped = new Set(Object.values(DOMAIN_TABLES).flat());
const others = Object.keys(tables).filter(t => !mapped.has(t));
const domains = {};
for (const [domain, list] of Object.entries(DOMAIN_TABLES)) {
const present = list.filter(t => tables[t]);
if (present.length > 0) domains[domain] = present;
}
if (others.length > 0) {
domains['Outros'] = others;
DOMAIN_COLORS['Outros'] = '#6b7280';
}
return domains;
}
// ---------------------------------------------------------------------------
// 4. Gera o HTML final (standalone, sem dependências externas de JS)
// ---------------------------------------------------------------------------
function generateHTML(tables, views, domains, date, available) {
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
const totalCols = Object.values(tables).reduce((a, t) => a + t.columns.length, 0);
const generated = new Date().toLocaleString('pt-BR');
// Serializa dados para embutir no HTML
const jsonData = JSON.stringify({ tables, views, domains });
const jsonColors = JSON.stringify(DOMAIN_COLORS);
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AgenciaPsi DB · ${date}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
:root{--bg:#0b0d12;--bg2:#111520;--bg3:#181e2d;--border:#1e2740;--border2:#263050;--text:#e2e8f8;--text2:#7d8fb3;--text3:#4a5a80;--accent:#4f8cff;--accent2:#6ee7b7;--pk:#fbbf24;--fk:#f472b6}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:'Space Grotesk',sans-serif;min-height:100vh;overflow-x:hidden}
.topbar{position:sticky;top:0;z-index:100;background:rgba(11,13,18,.94);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 28px;height:56px;display:flex;align-items:center;gap:20px}
.brand{font-weight:700;font-size:15px;letter-spacing:-.3px}.brand span{color:var(--accent)}
.gen{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.pills{display:flex;gap:10px;margin-left:auto}
.pill{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:4px 12px}
.pill strong{color:var(--text);font-size:13px}
.search{background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:6px 12px;color:var(--text);font-family:'Space Grotesk',sans-serif;font-size:13px;outline:none;width:200px;transition:border-color .2s,width .2s}
.search:focus{border-color:var(--accent);width:280px}
.search::placeholder{color:var(--text3)}
.layout{display:flex;height:calc(100vh - 56px)}
.sidebar{width:240px;flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px 0}
.sidebar::-webkit-scrollbar{width:4px}.sidebar::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
.sb-h{font-size:10px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--text3);padding:8px 20px 4px}
.sb-i{display:flex;align-items:center;gap:10px;padding:7px 20px;cursor:pointer;font-size:13px;color:var(--text2);border-left:2px solid transparent;transition:all .15s;user-select:none}
.sb-i:hover{color:var(--text);background:var(--bg3)}
.sb-i.active{color:var(--text);border-left-color:var(--accent);background:rgba(79,140,255,.08)}
.sb-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.sb-c{margin-left:auto;font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.main{flex:1;overflow-y:auto}
.main::-webkit-scrollbar{width:5px}.main::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
.overview{padding:32px 36px;border-bottom:1px solid var(--border)}
.ov-t{font-size:22px;font-weight:700;margin-bottom:6px}
.ov-s{font-size:14px;color:var(--text2);margin-bottom:28px}
.dgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
.dc{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:16px 18px;cursor:pointer;transition:all .2s;position:relative;overflow:hidden}
.dc::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--c)}
.dc:hover{border-color:var(--border2);transform:translateY(-1px)}
.dc-n{font-size:14px;font-weight:600;margin-bottom:6px}
.dc-m{font-size:12px;color:var(--text2);font-family:'IBM Plex Mono',monospace}
.dc-m span{font-weight:600}
.section{padding:28px 36px}
.sec-h{display:flex;align-items:center;gap:14px;margin-bottom:20px}
.sec-t{font-size:18px;font-weight:700}
.sec-b{font-size:11px;font-family:'IBM Plex Mono',monospace;background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:3px 10px;color:var(--text2)}
.tgrid{display:flex;flex-direction:column;gap:10px}
.tc{background:var(--bg3);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:border-color .15s}
.tc:hover{border-color:var(--border2)}.tc.hl{border-color:var(--accent)}
.tc-h{display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;user-select:none}
.tc-n{font-family:'IBM Plex Mono',monospace;font-size:13px;font-weight:600}
.tc-m{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.tc-f{font-size:11px;color:var(--fk);font-family:'IBM Plex Mono',monospace;margin-left:4px}
.tc-tg{margin-left:auto;color:var(--text3);font-size:11px;transition:transform .2s}
.tc-tg.open{transform:rotate(180deg)}
.tc-b{display:none;border-top:1px solid var(--border)}.tc-b.open{display:block}
.cols{padding:6px 0}
.cr{display:flex;align-items:center;gap:10px;padding:5px 16px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2)}
.cr:hover{background:rgba(255,255,255,.02)}
.bdg{font-size:9px;font-weight:700;letter-spacing:.5px;padding:1px 5px;border-radius:3px;width:26px;text-align:center;flex-shrink:0}
.bdg.pk{background:rgba(251,191,36,.15);color:var(--pk)}.bdg.fk{background:rgba(244,114,182,.15);color:var(--fk)}.bdg.x{background:transparent}
.cn{color:var(--text)}.ct{color:var(--text3);margin-left:auto;font-size:11px}
.fksec{border-top:1px solid var(--border);padding:10px 16px}
.fkt{font-size:10px;font-weight:600;letter-spacing:1px;color:var(--text3);text-transform:uppercase;margin-bottom:8px}
.fkr{display:flex;align-items:center;gap:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2);padding:3px 0}
.fka{color:var(--fk)}.fkl{color:var(--accent);cursor:pointer}.fkl:hover{text-decoration:underline}
.vsec{padding:0 36px 32px}
.vgrid{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
.vc{background:rgba(110,231,183,.08);border:1px solid rgba(110,231,183,.2);border-radius:6px;padding:5px 12px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--accent2)}
.empty{padding:40px;text-align:center;color:var(--text3);font-size:14px}
mark{background:rgba(79,140,255,.3);color:#fff;border-radius:2px}
</style>
</head>
<body>
<div class="topbar">
<div class="brand">Agência<span>Psi</span> DB</div>
<span class="gen">${date} · ${generated}</span>
<input class="search" id="si" placeholder="Buscar tabela ou coluna..." oninput="search(this.value)">
<div class="pills">
<div class="pill"><strong>${Object.keys(tables).length}</strong> tabelas</div>
<div class="pill"><strong>${totalFKs}</strong> FKs</div>
<div class="pill"><strong>${views.length}</strong> views</div>
<div class="pill"><strong>${totalCols}</strong> colunas</div>
</div>
</div>
<div class="layout">
<nav class="sidebar" id="sb"></nav>
<main class="main" id="mn"></main>
</div>
<script>
const D=${jsonData};
const C=${jsonColors};
const T2D={};
Object.entries(D.domains).forEach(([d,ts])=>ts.forEach(t=>T2D[t]=d));
let dom=null,q='';
function gc(d){return C[d]||'#6b7280';}
function buildSB(){
let h=\`<div class="sb-h">Visão Geral</div>
<div class="sb-i \${!dom?'active':''}" onclick="sel(null)">
<div class="sb-dot" style="background:#4f8cff"></div>Todos
<span class="sb-c">\${Object.keys(D.tables).length}</span>
</div>
<div class="sb-h" style="margin-top:8px">Domínios</div>\`;
for(const[d,ts]of Object.entries(D.domains)){
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="sel(\`+JSON.stringify(d)+\`)">
<div class="sb-dot" style="background:\${gc(d)}"></div>\${d}
<span class="sb-c">\${ts.length}</span>
</div>\`;
}
document.getElementById('sb').innerHTML=h;
}
function buildMN(){
const mn=document.getElementById('mn');
let h='';
if(q){
const matches=Object.entries(D.tables).filter(([n,t])=>n.includes(q)||t.columns.some(c=>c.name.includes(q)));
h+=\`<div class="section"><div class="sec-h"><div class="sec-t">"\${q}"</div><div class="sec-b">\${matches.length} tabelas</div></div><div class="tgrid">\`;
h+=matches.length?matches.map(([n,t])=>card(n,t,q)).join(''):'<div class="empty">Nenhum resultado.</div>';
h+='</div></div>';
} else {
const ds=dom?{[dom]:D.domains[dom]}:D.domains;
if(!dom){
h+=\`<div class="overview"><div class="ov-t">AgenciaPsi — Banco de Dados</div>
<div class="ov-s">Schema público · \${Object.keys(D.tables).length} tabelas · \${Object.values(D.tables).reduce((a,t)=>a+t.fks.length,0)} FKs · \${D.views.length} views</div>
<div class="dgrid">\`;
for(const[d,ts]of Object.entries(D.domains)){
const fks=ts.reduce((a,t)=>a+(D.tables[t]?.fks?.length||0),0);
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="sel(\`+JSON.stringify(d)+\`)">
<div class="dc-n">\${d}</div>
<div class="dc-m"><span style="color:\${gc(d)}">\${ts.length}</span> tabelas · \${fks} FKs</div>
</div>\`;
}
h+='</div></div>';
}
for(const[d,ts]of Object.entries(ds)){
h+=\`<div class="section"><div class="sec-h">
<div class="sec-t" style="color:\${gc(d)}">\${d}</div>
<div class="sec-b">\${ts.length} tabelas</div>
</div><div class="tgrid">\`;
ts.forEach(n=>{if(D.tables[n])h+=card(n,D.tables[n],'');});
h+='</div></div>';
}
if(!dom){
h+=\`<div class="vsec"><div class="sec-h">
<div class="sec-t" style="color:#6ee7b7">Views</div>
<div class="sec-b">\${D.views.length}</div>
</div><div class="vgrid">\${D.views.map(v=>\`<div class="vc">\${v}</div>\`).join('')}</div></div>\`;
}
}
mn.innerHTML=h;
}
function card(name,t,hl){
const fkCols=new Set(t.fks.map(f=>f.from_col));
const c=gc(T2D[name]);
const cols=t.columns.map(col=>{
let n=col.name;
if(hl&&n.includes(hl))n=n.replace(new RegExp(\`(\${hl})\`,'gi'),'<mark>$1</mark>');
const b=col.pk?'pk':fkCols.has(col.name)?'fk':'x';
const l=col.pk?'PK':fkCols.has(col.name)?'FK':'';
return \`<div class="cr"><span class="bdg \${b}">\${l}</span><span class="cn">\${n}</span><span class="ct">\${col.type}</span></div>\`;
}).join('');
const fks=t.fks.length?\`<div class="fksec"><div class="fkt">Foreign Keys</div>\${
t.fks.map(f=>\`<div class="fkr"><span>\${f.from_col}</span><span class="fka">→</span><span class="fkl" onclick="jump('\${f.to_table}')">\${f.to_table}.\${f.to_col}</span></div>\`).join('')
}</div>\`:'';
return \`<div class="tc \${hl&&name.includes(hl)?'hl':''}" id="tc-\${name}">
<div class="tc-h" onclick="tog('\${name}')">
<div style="width:8px;height:8px;border-radius:50%;background:\${c};flex-shrink:0"></div>
<div class="tc-n">\${name}</div>
<span class="tc-m">\${t.columns.length} cols</span>
\${t.fks.length?\`<span class="tc-f">\${t.fks.length} FK</span>\`:''}
<span class="tc-tg" id="tg-\${name}">▼</span>
</div>
<div class="tc-b" id="bd-\${name}"><div class="cols">\${cols}</div>\${fks}</div>
</div>\`;
}
function tog(n){
document.getElementById('bd-'+n)?.classList.toggle('open');
document.getElementById('tg-'+n)?.classList.toggle('open');
}
function sel(d){
dom=d;q='';document.getElementById('si').value='';
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
}
function jump(name){
dom=T2D[name]||null;q='';document.getElementById('si').value='';
buildSB();buildMN();
setTimeout(()=>{
const el=document.getElementById('tc-'+name);
if(!el)return;
el.scrollIntoView({behavior:'smooth',block:'center'});
const bd=document.getElementById('bd-'+name);
const tg=document.getElementById('tg-'+name);
if(bd&&!bd.classList.contains('open')){bd.classList.add('open');tg?.classList.add('open');}
el.style.borderColor='#4f8cff';
setTimeout(()=>el.style.borderColor='',2000);
},80);
}
let st;
function search(v){
clearTimeout(st);q=v.trim();
st=setTimeout(()=>{dom=null;buildSB();buildMN();},200);
}
buildSB();buildMN();
</script>
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// 5. Execução
// ---------------------------------------------------------------------------
console.log('\n═══ AgenciaPsi — Dashboard Generator ═══\n');
const { schemaPath, date, available } = resolveSchema();
console.log(` → Schema: ${schemaPath}`);
if (available.length > 1) console.log(` → Outros backups: ${available.slice(1).join(', ')}`);
const content = fs.readFileSync(schemaPath, 'utf8');
console.log(` → Lendo schema... (${(content.length / 1024).toFixed(0)} KB)`);
const { tables, views } = parseSchema(content);
const domains = buildDomains(tables);
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
console.log(`${Object.keys(tables).length} tabelas · ${totalFKs} FKs · ${views.length} views`);
// Avisa sobre tabelas novas não mapeadas
if (domains['Outros']) {
console.log(`\n ⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):`);
domains['Outros'].forEach(t => console.log(` - ${t}`));
console.log(` → Edite DOMAIN_TABLES no script para mapeá-las.\n`);
}
const html = generateHTML(tables, views, domains, date, available);
fs.writeFileSync(OUTPUT_FILE, html, 'utf8');
console.log(`\n✔ Gerado: ${OUTPUT_FILE}`);
console.log(` Tamanho: ${(fs.statSync(OUTPUT_FILE).size / 1024).toFixed(0)} KB`);
console.log(` Abra no browser: file://${OUTPUT_FILE}\n`);

View File

@@ -0,0 +1,57 @@
-- ============================================================
-- Migration 002 — SetupWizard1: campos Negócio e Atendimento
-- ============================================================
-- Tabela: tenants (Step 2 — Negócio)
-- Tabela: agenda_configuracoes (Step 3 — Atendimento)
-- ============================================================
-- ----------------------------------------------------------
-- tenants: dados do negócio
-- ----------------------------------------------------------
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS business_type text,
ADD COLUMN IF NOT EXISTS logo_url text,
ADD COLUMN IF NOT EXISTS address text,
ADD COLUMN IF NOT EXISTS phone text,
ADD COLUMN IF NOT EXISTS contact_email text,
ADD COLUMN IF NOT EXISTS site_url text,
ADD COLUMN IF NOT EXISTS social_instagram text;
-- Valores aceitos: consultorio | clinica | instituto | grupo
ALTER TABLE public.tenants
ADD CONSTRAINT tenants_business_type_check
CHECK (business_type IS NULL OR business_type = ANY (ARRAY[
'consultorio'::text,
'clinica'::text,
'instituto'::text,
'grupo'::text
]));
-- ----------------------------------------------------------
-- agenda_configuracoes: modo de atendimento
-- ----------------------------------------------------------
ALTER TABLE public.agenda_configuracoes
ADD COLUMN IF NOT EXISTS atendimento_mode text DEFAULT 'particular'::text;
ALTER TABLE public.agenda_configuracoes
ADD CONSTRAINT agenda_configuracoes_atendimento_mode_check
CHECK (atendimento_mode IS NULL OR atendimento_mode = ANY (ARRAY[
'particular'::text,
'convenio'::text,
'ambos'::text
]));
-- ----------------------------------------------------------
-- Comments
-- ----------------------------------------------------------
COMMENT ON COLUMN public.tenants.business_type IS 'Tipo de negócio: consultorio, clinica, instituto, grupo';
COMMENT ON COLUMN public.tenants.logo_url IS 'URL da logo do negócio (Storage bucket)';
COMMENT ON COLUMN public.tenants.address IS 'Endereço do negócio (texto livre)';
COMMENT ON COLUMN public.tenants.phone IS 'Telefone/WhatsApp do negócio';
COMMENT ON COLUMN public.tenants.contact_email IS 'E-mail público de contato do negócio';
COMMENT ON COLUMN public.tenants.site_url IS 'Site do negócio';
COMMENT ON COLUMN public.tenants.social_instagram IS 'Instagram do negócio (sem @)';
COMMENT ON COLUMN public.agenda_configuracoes.atendimento_mode IS 'Modo de atendimento: particular | convenio | ambos';

View File

@@ -0,0 +1,33 @@
-- ============================================================
-- Migration 003 — Tenants: campos de endereço detalhado
-- ============================================================
-- Substitui o campo address (texto livre) por campos estruturados
-- preenchidos via consulta de CEP (ViaCEP)
-- ============================================================
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS cep text,
ADD COLUMN IF NOT EXISTS logradouro text,
ADD COLUMN IF NOT EXISTS numero text,
ADD COLUMN IF NOT EXISTS complemento text,
ADD COLUMN IF NOT EXISTS bairro text,
ADD COLUMN IF NOT EXISTS cidade text,
ADD COLUMN IF NOT EXISTS estado text;
-- Migra dados existentes do campo address para logradouro
UPDATE public.tenants
SET logradouro = address
WHERE address IS NOT NULL
AND logradouro IS NULL;
-- ----------------------------------------------------------
-- Comments
-- ----------------------------------------------------------
COMMENT ON COLUMN public.tenants.cep IS 'CEP do endereço do negócio';
COMMENT ON COLUMN public.tenants.logradouro IS 'Logradouro (rua, avenida, etc.)';
COMMENT ON COLUMN public.tenants.numero IS 'Número do endereço';
COMMENT ON COLUMN public.tenants.complemento IS 'Complemento (sala, andar, etc.)';
COMMENT ON COLUMN public.tenants.bairro IS 'Bairro';
COMMENT ON COLUMN public.tenants.cidade IS 'Cidade';
COMMENT ON COLUMN public.tenants.estado IS 'UF (2 letras)';

View File

@@ -0,0 +1,147 @@
-- ==========================================================================
-- Agência PSI — Migração: tabela `medicos`
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026 · São Carlos/SP — Brasil
--
-- Propósito:
-- Armazena médicos e profissionais de referência (psiquiatras, neurologistas,
-- clínicos gerais, etc.) que encaminham pacientes ou fazem parte da rede de
-- suporte clínico do terapeuta.
--
-- Usado em:
-- - PatientsCadastroPage: campo "Encaminhado por" (FK medico_id)
-- - CadastroRapidoMedico.vue: cadastro rápido dentro do formulário
-- - MedicosCadastroPage.vue: página completa de gestão de médicos
--
-- Relacionamentos:
-- medicos.owner_id → auth.users(id)
-- medicos.tenant_id → tenants(id)
-- patients.medico_encaminhador_id → medicos(id) (opcional, ver abaixo)
--
-- RLS: owner_id = auth.uid() — cada profissional vê apenas seus médicos.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela principal
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.medicos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto de acesso
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Identidade profissional
nome text NOT NULL,
crm text, -- Ex: "123456/SP"
especialidade text, -- Ex: "Psiquiatria"
-- Contatos — telefone_pessoal é sensível (exibido com ícone de olho)
telefone_profissional text, -- Consultório / clínica
telefone_pessoal text, -- WhatsApp / pessoal
email text,
-- Local de atuação
clinica text, -- Nome da clínica/hospital
cidade text,
estado text DEFAULT 'SP',
-- Notas internas do terapeuta
observacoes text,
-- Controle
ativo boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT medicos_pkey PRIMARY KEY (id),
-- CRM único por owner (mesmo terapeuta não cadastra o mesmo CRM duas vezes)
CONSTRAINT medicos_crm_owner_unique UNIQUE NULLS NOT DISTINCT (owner_id, crm)
);
-- --------------------------------------------------------------------------
-- 2. Índices de performance
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS medicos_owner_idx
ON public.medicos USING btree (owner_id);
CREATE INDEX IF NOT EXISTS medicos_tenant_idx
ON public.medicos USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS medicos_nome_idx
ON public.medicos USING btree (nome);
CREATE INDEX IF NOT EXISTS medicos_especialidade_idx
ON public.medicos USING btree (especialidade);
-- Busca textual por nome e especialidade
CREATE INDEX IF NOT EXISTS medicos_nome_trgm_idx
ON public.medicos USING gin (nome gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger de updated_at
-- --------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.set_medicos_updated_at()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_medicos_updated_at
BEFORE UPDATE ON public.medicos
FOR EACH ROW
EXECUTE FUNCTION public.set_medicos_updated_at();
-- --------------------------------------------------------------------------
-- 4. Row Level Security
-- --------------------------------------------------------------------------
ALTER TABLE public.medicos ENABLE ROW LEVEL SECURITY;
-- Owner tem acesso total aos seus próprios médicos
CREATE POLICY "medicos: owner full access"
ON public.medicos
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- --------------------------------------------------------------------------
-- 5. Comentários de documentação
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.medicos IS 'Médicos e profissionais de referência cadastrados pelo terapeuta.';
COMMENT ON COLUMN public.medicos.owner_id IS 'Terapeuta dono do cadastro (auth.uid()).';
COMMENT ON COLUMN public.medicos.tenant_id IS 'Tenant do terapeuta.';
COMMENT ON COLUMN public.medicos.nome IS 'Nome completo do médico/profissional.';
COMMENT ON COLUMN public.medicos.crm IS 'CRM com UF. Ex: 123456/SP. Único por owner_id.';
COMMENT ON COLUMN public.medicos.especialidade IS 'Especialidade médica. Ex: Psiquiatria, Neurologia.';
COMMENT ON COLUMN public.medicos.telefone_profissional IS 'Telefone do consultório ou clínica.';
COMMENT ON COLUMN public.medicos.telefone_pessoal IS 'Telefone pessoal / WhatsApp. Campo sensível.';
COMMENT ON COLUMN public.medicos.email IS 'E-mail profissional.';
COMMENT ON COLUMN public.medicos.clinica IS 'Nome da clínica ou hospital onde atua.';
COMMENT ON COLUMN public.medicos.cidade IS 'Cidade de atuação.';
COMMENT ON COLUMN public.medicos.estado IS 'UF de atuação. Default SP.';
COMMENT ON COLUMN public.medicos.observacoes IS 'Notas internas do terapeuta sobre o médico.';
COMMENT ON COLUMN public.medicos.ativo IS 'Soft delete: false oculta da listagem.';
-- --------------------------------------------------------------------------
-- 6. Coluna FK opcional em patients
-- (Conecta "Encaminhado por" ao cadastro de médico)
-- Execute apenas se quiser a FK estruturada; caso contrário,
-- o campo encaminhado_por (text) no PatientsCadastroPage já funciona.
-- --------------------------------------------------------------------------
-- ALTER TABLE public.patients
-- ADD COLUMN IF NOT EXISTS medico_encaminhador_id uuid
-- REFERENCES public.medicos(id) ON DELETE SET NULL;
-- CREATE INDEX IF NOT EXISTS patients_medico_encaminhador_idx
-- ON public.patients USING btree (medico_encaminhador_id);
-- COMMENT ON COLUMN public.patients.medico_encaminhador_id
-- IS 'FK para medicos.id — quem encaminhou o paciente.';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,119 @@
-- ==========================================================================
-- Agência PSI — Migração: novos campos em `patients`
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000002_patients_new_columns.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- Adiciona as colunas identificadas na engenharia reversa da tela de detalhe
-- (PatientsDetailPage) que ainda não existiam na tabela `patients`.
--
-- Também ajusta os CHECK constraints de `status` e `patient_scope` para
-- aceitar os valores usados no novo formulário de cadastro.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Colunas novas
-- --------------------------------------------------------------------------
-- Identidade
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS pronomes text,
ADD COLUMN IF NOT EXISTS nome_social text,
ADD COLUMN IF NOT EXISTS etnia text;
-- Contato
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS canal_preferido text,
ADD COLUMN IF NOT EXISTS horario_contato text;
-- Clínico / convênio
-- convenio: nome de exibição (badge azul no header)
-- convenio_id: FK para insurance_plans (opcional — permite vincular ao cadastro)
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS convenio text,
ADD COLUMN IF NOT EXISTS convenio_id uuid REFERENCES public.insurance_plans(id) ON DELETE SET NULL;
-- Origem
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS metodo_pagamento_preferido text,
ADD COLUMN IF NOT EXISTS motivo_saida text;
-- --------------------------------------------------------------------------
-- 2. Ajuste do CHECK constraint de `status`
-- Valores originais: Ativo | Inativo | Alta | Encaminhado | Arquivado
-- Valores novos: + Em espera
-- --------------------------------------------------------------------------
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_status_check;
ALTER TABLE public.patients
ADD CONSTRAINT patients_status_check CHECK (
status = ANY (ARRAY[
'Ativo'::text,
'Em espera'::text,
'Inativo'::text,
'Alta'::text,
'Encaminhado'::text,
'Arquivado'::text
])
);
-- --------------------------------------------------------------------------
-- 3. Ajuste do CHECK constraint de `patient_scope`
-- Valores originais: clinic | therapist (valores técnicos internos)
-- Valores novos: + Clínica | Particular | Online | Híbrido
-- Estratégia: remover o constraint restritivo e deixar livre (text),
-- pois o controle já é feito no frontend via Select com opções fixas.
-- --------------------------------------------------------------------------
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
-- Também remove a constraint de consistência que dependia do scope antigo
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
-- --------------------------------------------------------------------------
-- 4. Índices de performance
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS patients_convenio_id_idx
ON public.patients USING btree (convenio_id);
CREATE INDEX IF NOT EXISTS patients_pronomes_idx
ON public.patients USING btree (pronomes);
CREATE INDEX IF NOT EXISTS patients_etnia_idx
ON public.patients USING btree (etnia);
-- --------------------------------------------------------------------------
-- 5. Comentários
-- --------------------------------------------------------------------------
COMMENT ON COLUMN public.patients.pronomes
IS 'Pronomes de tratamento. Ex: ela/dela, ele/dele. Exibido no header do perfil.';
COMMENT ON COLUMN public.patients.nome_social
IS 'Nome social / como prefere ser chamado(a) no atendimento.';
COMMENT ON COLUMN public.patients.etnia
IS 'Etnia / raça autodeclarada. Exibida no card "Dados pessoais".';
COMMENT ON COLUMN public.patients.canal_preferido
IS 'Canal preferido de contato. Ex: WhatsApp, Telefone, E-mail.';
COMMENT ON COLUMN public.patients.horario_contato
IS 'Horário preferido para contato. Ex: 08h18h.';
COMMENT ON COLUMN public.patients.convenio
IS 'Nome do convênio para exibição (badge azul no header). Derivado de convenio_id.';
COMMENT ON COLUMN public.patients.convenio_id
IS 'FK para insurance_plans.id. Vincula o paciente ao convênio cadastrado.';
COMMENT ON COLUMN public.patients.metodo_pagamento_preferido
IS 'Método de pagamento preferido. Ex: PIX, Cartão crédito. Exibido no card Origem.';
COMMENT ON COLUMN public.patients.motivo_saida
IS 'Motivo de encerramento do acompanhamento. Exibido no card Origem quando preenchido.';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,70 @@
-- ==========================================================================
-- Agência PSI — Migração: remove check constraints dos novos campos
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000003_patients_drop_check_constraints.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- O banco tinha CHECK constraints nos novos campos que foram adicionados
-- pela migration anterior (ou que já existiam no schema ao vivo).
-- O frontend já controla os valores via Select com opções fixas,
-- então os constraints são desnecessários e serão removidos.
-- ==========================================================================
-- canal_preferido
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_canal_preferido_check;
-- horario_contato
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_horario_contato_check;
-- pronomes
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_pronomes_check;
-- nome_social
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_nome_social_check;
-- etnia
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_etnia_check;
-- convenio
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_convenio_check;
-- metodo_pagamento_preferido
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_metodo_pagamento_preferido_check;
-- motivo_saida
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_motivo_saida_check;
-- status (já ajustado na migration anterior, mas garante)
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_status_check;
ALTER TABLE public.patients
ADD CONSTRAINT patients_status_check CHECK (
status = ANY (ARRAY[
'Ativo'::text,
'Em espera'::text,
'Inativo'::text,
'Alta'::text,
'Encaminhado'::text,
'Arquivado'::text
])
);
-- patient_scope (já ajustado na migration anterior, mas garante)
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,56 @@
-- ==========================================================================
-- Agência PSI — Migração: tabela `patient_support_contacts`
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000004_create_patient_support_contacts.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- Contatos da rede de suporte do paciente.
-- Alimenta o card "Contatos & rede de suporte" na tela de detalhe.
-- is_primario = true → badge vermelho "emergência" no perfil.
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.patient_support_contacts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
nome text,
relacao text, -- Ex: mãe, psiquiatra, cônjuge
tipo text, -- emergencia | familiar | profissional_saude | amigo | outro
telefone text,
email text,
is_primario boolean DEFAULT false NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT patient_support_contacts_pkey PRIMARY KEY (id)
);
-- Índices
CREATE INDEX IF NOT EXISTS psc_patient_idx ON public.patient_support_contacts USING btree (patient_id);
CREATE INDEX IF NOT EXISTS psc_owner_idx ON public.patient_support_contacts USING btree (owner_id);
-- Trigger updated_at
CREATE TRIGGER trg_psc_updated_at
BEFORE UPDATE ON public.patient_support_contacts
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
-- RLS
ALTER TABLE public.patient_support_contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "psc: owner full access"
ON public.patient_support_contacts
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- Comentários
COMMENT ON TABLE public.patient_support_contacts IS 'Rede de suporte do paciente. Exibida no card "Contatos & rede de suporte" do perfil.';
COMMENT ON COLUMN public.patient_support_contacts.is_primario IS 'true = badge vermelho "emergência" no perfil do paciente.';
COMMENT ON COLUMN public.patient_support_contacts.tipo IS 'emergencia | familiar | profissional_saude | amigo | outro';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,454 @@
-- ==========================================================================
-- Agencia PSI — Migracao: tabelas de Documentos & Arquivos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Proposito:
-- Modulo completo de documentos do paciente.
-- Tabelas: documents, document_access_logs, document_signatures,
-- document_share_links.
--
-- Relacionamentos:
-- documents.patient_id → patients(id)
-- documents.owner_id → auth.users(id)
-- documents.tenant_id → tenants(id)
-- documents.agenda_evento_id → agenda_eventos(id) (opcional)
-- document_access_logs.documento_id → documents(id)
-- document_signatures.documento_id → documents(id)
-- document_share_links.documento_id → documents(id)
--
-- RLS: owner_id = auth.uid() para documents, signatures e share_links.
-- access_logs: somente INSERT (imutavel) + SELECT por tenant.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela principal: documents
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.documents (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto de acesso
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Vinculo com paciente
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
-- Arquivo no Storage
bucket_path text NOT NULL,
storage_bucket text NOT NULL DEFAULT 'documents',
nome_original text NOT NULL,
mime_type text,
tamanho_bytes bigint,
-- Classificacao
tipo_documento text NOT NULL DEFAULT 'outro',
-- laudo | receita | exame | termo_assinado | relatorio_externo
-- identidade | convenio | declaracao | atestado | recibo | outro
categoria text,
descricao text,
tags text[] DEFAULT '{}',
-- Vinculo opcional com sessao/nota
agenda_evento_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
session_note_id uuid,
-- Visibilidade & controle de acesso
visibilidade text NOT NULL DEFAULT 'privado',
-- privado | compartilhado_supervisor | compartilhado_portal
compartilhado_portal boolean DEFAULT false NOT NULL,
compartilhado_supervisor boolean DEFAULT false NOT NULL,
compartilhado_em timestamptz,
expira_compartilhamento timestamptz,
-- Upload pelo paciente (portal)
enviado_pelo_paciente boolean DEFAULT false NOT NULL,
status_revisao text DEFAULT 'aprovado',
-- pendente | aprovado | rejeitado
revisado_por uuid,
revisado_em timestamptz,
-- Quem fez upload
uploaded_by uuid NOT NULL,
uploaded_at timestamptz DEFAULT now() NOT NULL,
-- Soft delete com retencao (LGPD / CFP)
deleted_at timestamptz,
deleted_by uuid,
retencao_ate timestamptz,
-- Controle
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT documents_pkey PRIMARY KEY (id),
-- Validacoes
CONSTRAINT documents_tipo_check CHECK (
tipo_documento = ANY (ARRAY[
'laudo', 'receita', 'exame', 'termo_assinado', 'relatorio_externo',
'identidade', 'convenio', 'declaracao', 'atestado', 'recibo', 'outro'
])
),
CONSTRAINT documents_visibilidade_check CHECK (
visibilidade = ANY (ARRAY['privado', 'compartilhado_supervisor', 'compartilhado_portal'])
),
CONSTRAINT documents_status_revisao_check CHECK (
status_revisao = ANY (ARRAY['pendente', 'aprovado', 'rejeitado'])
)
);
-- --------------------------------------------------------------------------
-- 2. Indices — documents
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS docs_patient_idx
ON public.documents USING btree (patient_id);
CREATE INDEX IF NOT EXISTS docs_owner_idx
ON public.documents USING btree (owner_id);
CREATE INDEX IF NOT EXISTS docs_tenant_idx
ON public.documents USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS docs_tipo_idx
ON public.documents USING btree (patient_id, tipo_documento);
CREATE INDEX IF NOT EXISTS docs_tags_idx
ON public.documents USING gin (tags);
CREATE INDEX IF NOT EXISTS docs_uploaded_at_idx
ON public.documents USING btree (patient_id, uploaded_at DESC);
-- Excluir soft-deleted da listagem padrao
CREATE INDEX IF NOT EXISTS docs_active_idx
ON public.documents USING btree (patient_id, uploaded_at DESC)
WHERE deleted_at IS NULL;
-- Busca textual no nome do arquivo
CREATE INDEX IF NOT EXISTS docs_nome_trgm_idx
ON public.documents USING gin (nome_original gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger updated_at — documents
-- --------------------------------------------------------------------------
CREATE TRIGGER trg_documents_updated_at
BEFORE UPDATE ON public.documents
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- --------------------------------------------------------------------------
-- 4. Trigger: registrar na patient_timeline ao adicionar documento
-- --------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.fn_documents_timeline_insert()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
NEW.patient_id,
NEW.tenant_id,
'documento_adicionado',
'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'),
'blue',
'documento',
NEW.id,
NEW.uploaded_by,
NEW.uploaded_at
);
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_documents_timeline_insert
AFTER INSERT ON public.documents
FOR EACH ROW
EXECUTE FUNCTION public.fn_documents_timeline_insert();
-- --------------------------------------------------------------------------
-- 5. RLS — documents
-- --------------------------------------------------------------------------
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "documents: owner full access"
ON public.documents
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- --------------------------------------------------------------------------
-- 6. Comentarios — documents
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.documents IS 'Documentos e arquivos vinculados a pacientes. Armazenados no Supabase Storage.';
COMMENT ON COLUMN public.documents.owner_id IS 'Terapeuta dono do documento (auth.uid()).';
COMMENT ON COLUMN public.documents.tenant_id IS 'Tenant do terapeuta.';
COMMENT ON COLUMN public.documents.patient_id IS 'Paciente ao qual o documento pertence.';
COMMENT ON COLUMN public.documents.bucket_path IS 'Caminho do arquivo no Supabase Storage bucket.';
COMMENT ON COLUMN public.documents.storage_bucket IS 'Nome do bucket no Storage. Default: documents.';
COMMENT ON COLUMN public.documents.nome_original IS 'Nome original do arquivo enviado.';
COMMENT ON COLUMN public.documents.mime_type IS 'MIME type do arquivo. Ex: application/pdf, image/jpeg.';
COMMENT ON COLUMN public.documents.tamanho_bytes IS 'Tamanho do arquivo em bytes.';
COMMENT ON COLUMN public.documents.tipo_documento IS 'Tipo: laudo|receita|exame|termo_assinado|relatorio_externo|identidade|convenio|declaracao|atestado|recibo|outro.';
COMMENT ON COLUMN public.documents.categoria IS 'Categoria livre para organizacao adicional.';
COMMENT ON COLUMN public.documents.tags IS 'Tags livres para busca e filtro. Array de text.';
COMMENT ON COLUMN public.documents.visibilidade IS 'privado|compartilhado_supervisor|compartilhado_portal.';
COMMENT ON COLUMN public.documents.compartilhado_portal IS 'true = visivel para o paciente no portal.';
COMMENT ON COLUMN public.documents.compartilhado_supervisor IS 'true = visivel para o supervisor.';
COMMENT ON COLUMN public.documents.enviado_pelo_paciente IS 'true = upload feito pelo paciente via portal.';
COMMENT ON COLUMN public.documents.status_revisao IS 'pendente|aprovado|rejeitado — para uploads do paciente.';
COMMENT ON COLUMN public.documents.deleted_at IS 'Soft delete: data da exclusao. NULL = ativo.';
COMMENT ON COLUMN public.documents.retencao_ate IS 'LGPD/CFP: arquivo retido ate esta data mesmo apos soft delete.';
-- ==========================================================================
-- 7. Tabela: document_access_logs (imutavel — auditoria)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_access_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Acao realizada
acao text NOT NULL,
-- visualizou | baixou | imprimiu | compartilhou | assinou
user_id uuid,
ip inet,
user_agent text,
acessado_em timestamptz DEFAULT now() NOT NULL,
CONSTRAINT document_access_logs_pkey PRIMARY KEY (id),
CONSTRAINT dal_acao_check CHECK (
acao = ANY (ARRAY['visualizou', 'baixou', 'imprimiu', 'compartilhou', 'assinou'])
)
);
-- Indices
CREATE INDEX IF NOT EXISTS dal_documento_idx
ON public.document_access_logs USING btree (documento_id, acessado_em DESC);
CREATE INDEX IF NOT EXISTS dal_tenant_idx
ON public.document_access_logs USING btree (tenant_id, acessado_em DESC);
CREATE INDEX IF NOT EXISTS dal_user_idx
ON public.document_access_logs USING btree (user_id, acessado_em DESC);
-- RLS — somente INSERT (imutavel) + SELECT
ALTER TABLE public.document_access_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dal: tenant members can insert"
ON public.document_access_logs
FOR INSERT
WITH CHECK (true);
CREATE POLICY "dal: tenant members can select"
ON public.document_access_logs
FOR SELECT
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- Comentarios
COMMENT ON TABLE public.document_access_logs IS 'Log imutavel de acessos a documentos. Conformidade CFP e LGPD. Sem UPDATE/DELETE.';
COMMENT ON COLUMN public.document_access_logs.acao IS 'visualizou|baixou|imprimiu|compartilhou|assinou.';
-- ==========================================================================
-- 8. Tabela: document_signatures (assinatura eletronica)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_signatures (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Signatario
signatario_tipo text NOT NULL,
-- paciente | responsavel_legal | terapeuta
signatario_id uuid,
signatario_nome text,
signatario_email text,
-- Ordem e status
ordem smallint DEFAULT 1 NOT NULL,
status text NOT NULL DEFAULT 'pendente',
-- pendente | enviado | assinado | recusado | expirado
-- Dados da assinatura (preenchidos ao assinar)
ip inet,
user_agent text,
assinado_em timestamptz,
hash_documento text,
-- Controle
criado_em timestamptz DEFAULT now(),
atualizado_em timestamptz DEFAULT now(),
CONSTRAINT document_signatures_pkey PRIMARY KEY (id),
CONSTRAINT ds_signatario_tipo_check CHECK (
signatario_tipo = ANY (ARRAY['paciente', 'responsavel_legal', 'terapeuta'])
),
CONSTRAINT ds_status_check CHECK (
status = ANY (ARRAY['pendente', 'enviado', 'assinado', 'recusado', 'expirado'])
)
);
-- Indices
CREATE INDEX IF NOT EXISTS ds_documento_idx
ON public.document_signatures USING btree (documento_id, ordem);
CREATE INDEX IF NOT EXISTS ds_tenant_idx
ON public.document_signatures USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS ds_status_idx
ON public.document_signatures USING btree (documento_id, status);
-- Trigger updated_at
CREATE TRIGGER trg_ds_updated_at
BEFORE UPDATE ON public.document_signatures
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- Trigger: ao assinar, registrar na patient_timeline
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_patient_id uuid;
v_tenant_id uuid;
v_doc_nome text;
BEGIN
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
SELECT d.patient_id, d.tenant_id, d.nome_original
INTO v_patient_id, v_tenant_id, v_doc_nome
FROM public.documents d
WHERE d.id = NEW.documento_id;
IF v_patient_id IS NOT NULL THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
v_patient_id,
v_tenant_id,
'documento_assinado',
'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo),
'green',
'documento',
NEW.documento_id,
NEW.signatario_id,
NEW.assinado_em
);
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_ds_timeline
AFTER UPDATE ON public.document_signatures
FOR EACH ROW
EXECUTE FUNCTION public.fn_document_signature_timeline();
-- RLS
ALTER TABLE public.document_signatures ENABLE ROW LEVEL SECURITY;
CREATE POLICY "ds: tenant members access"
ON public.document_signatures
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
))
WITH CHECK (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- Comentarios
COMMENT ON TABLE public.document_signatures IS 'Assinaturas eletronicas de documentos. Cada signatario tem seu registro.';
COMMENT ON COLUMN public.document_signatures.signatario_tipo IS 'paciente|responsavel_legal|terapeuta.';
COMMENT ON COLUMN public.document_signatures.status IS 'pendente|enviado|assinado|recusado|expirado.';
COMMENT ON COLUMN public.document_signatures.hash_documento IS 'Hash SHA-256 do documento no momento da assinatura. Garante integridade.';
COMMENT ON COLUMN public.document_signatures.ip IS 'IP do signatario no momento da assinatura.';
-- ==========================================================================
-- 9. Tabela: document_share_links (links temporarios)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_share_links (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Token unico para o link
token text NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
-- Limites
expira_em timestamptz NOT NULL,
usos_max smallint DEFAULT 5 NOT NULL,
usos smallint DEFAULT 0 NOT NULL,
-- Quem criou
criado_por uuid NOT NULL,
criado_em timestamptz DEFAULT now(),
-- Controle
ativo boolean DEFAULT true NOT NULL,
CONSTRAINT document_share_links_pkey PRIMARY KEY (id),
CONSTRAINT dsl_token_unique UNIQUE (token)
);
-- Indices
CREATE INDEX IF NOT EXISTS dsl_documento_idx
ON public.document_share_links USING btree (documento_id);
CREATE INDEX IF NOT EXISTS dsl_token_idx
ON public.document_share_links USING btree (token)
WHERE ativo = true;
CREATE INDEX IF NOT EXISTS dsl_expira_idx
ON public.document_share_links USING btree (expira_em)
WHERE ativo = true;
-- RLS
ALTER TABLE public.document_share_links ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dsl: creator full access"
ON public.document_share_links
USING (criado_por = auth.uid())
WITH CHECK (criado_por = auth.uid());
-- Politica publica de leitura por token (para acesso externo sem login)
CREATE POLICY "dsl: public read by token"
ON public.document_share_links
FOR SELECT
USING (ativo = true AND expira_em > now() AND usos < usos_max);
-- Comentarios
COMMENT ON TABLE public.document_share_links IS 'Links temporarios assinados para compartilhar documento com profissional externo.';
COMMENT ON COLUMN public.document_share_links.token IS 'Token unico gerado automaticamente (32 bytes hex).';
COMMENT ON COLUMN public.document_share_links.expira_em IS 'Data/hora de expiracao do link.';
COMMENT ON COLUMN public.document_share_links.usos_max IS 'Numero maximo de acessos permitidos.';
COMMENT ON COLUMN public.document_share_links.usos IS 'Numero de vezes que o link ja foi acessado.';
-- ==========================================================================
-- FIM DA MIGRACAO 005
-- ==========================================================================

View File

@@ -0,0 +1,260 @@
-- ==========================================================================
-- Agencia PSI — Migracao: tabelas de Templates de Documentos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Proposito:
-- Templates de documentos (declaracao, atestado, recibo, relatorio etc.)
-- e registro de cada documento gerado (instancia PDF).
--
-- Tabelas: document_templates, document_generated.
--
-- Relacionamentos:
-- document_templates.tenant_id → tenants(id)
-- document_templates.owner_id → auth.users(id)
-- document_generated.template_id → document_templates(id)
-- document_generated.patient_id → patients(id)
-- document_generated.tenant_id → tenants(id)
--
-- Templates globais: is_global = true, tenant_id = NULL.
-- Templates do tenant: is_global = false, tenant_id preenchido.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela: document_templates
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.document_templates (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto
tenant_id uuid,
owner_id uuid,
-- Identificacao
nome_template text NOT NULL,
tipo text NOT NULL DEFAULT 'outro',
-- declaracao_comparecimento | atestado_psicologico
-- relatorio_acompanhamento | recibo_pagamento
-- termo_consentimento | encaminhamento | outro
descricao text,
-- Corpo do template
corpo_html text NOT NULL DEFAULT '',
cabecalho_html text,
rodape_html text,
-- Variaveis que o template utiliza
variaveis text[] DEFAULT '{}',
-- Ex: {paciente_nome, paciente_cpf, data_sessao, terapeuta_nome, ...}
-- Personalizacao visual
logo_url text,
-- Escopo
is_global boolean DEFAULT false NOT NULL,
-- true = template padrao do sistema (visivel para todos)
-- false = template criado pelo tenant/terapeuta
-- Controle
ativo boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT document_templates_pkey PRIMARY KEY (id),
CONSTRAINT dt_tipo_check CHECK (
tipo = ANY (ARRAY[
'declaracao_comparecimento', 'atestado_psicologico',
'relatorio_acompanhamento', 'recibo_pagamento',
'termo_consentimento', 'encaminhamento',
'contrato_servicos', 'tcle', 'autorizacao_menor',
'laudo_psicologico', 'parecer_psicologico',
'termo_sigilo', 'declaracao_inicio_tratamento',
'termo_alta', 'tcle_online', 'outro'
])
)
);
-- --------------------------------------------------------------------------
-- 2. Indices — document_templates
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS dt_tenant_idx
ON public.document_templates USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS dt_owner_idx
ON public.document_templates USING btree (owner_id);
CREATE INDEX IF NOT EXISTS dt_global_idx
ON public.document_templates USING btree (is_global)
WHERE is_global = true;
CREATE INDEX IF NOT EXISTS dt_tipo_idx
ON public.document_templates USING btree (tipo);
CREATE INDEX IF NOT EXISTS dt_nome_trgm_idx
ON public.document_templates USING gin (nome_template gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger updated_at
-- --------------------------------------------------------------------------
CREATE TRIGGER trg_dt_updated_at
BEFORE UPDATE ON public.document_templates
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- --------------------------------------------------------------------------
-- 4. RLS — document_templates
-- --------------------------------------------------------------------------
ALTER TABLE public.document_templates ENABLE ROW LEVEL SECURITY;
-- Templates globais: todos podem ler
CREATE POLICY "dt: global templates readable by all"
ON public.document_templates
FOR SELECT
USING (is_global = true);
-- Templates do tenant: membros do tenant podem ler
CREATE POLICY "dt: tenant members can select"
ON public.document_templates
FOR SELECT
USING (
is_global = false
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- Owner pode inserir/atualizar/deletar seus templates
CREATE POLICY "dt: owner can insert"
ON public.document_templates
FOR INSERT
WITH CHECK (owner_id = auth.uid() AND is_global = false);
CREATE POLICY "dt: owner can update"
ON public.document_templates
FOR UPDATE
USING (owner_id = auth.uid() AND is_global = false)
WITH CHECK (owner_id = auth.uid() AND is_global = false);
CREATE POLICY "dt: owner can delete"
ON public.document_templates
FOR DELETE
USING (owner_id = auth.uid() AND is_global = false);
-- SaaS admin pode gerenciar templates globais (usa funcao public.is_saas_admin())
CREATE POLICY "dt: saas admin can insert global"
ON public.document_templates
FOR INSERT
WITH CHECK (is_global = true AND public.is_saas_admin());
CREATE POLICY "dt: saas admin can update global"
ON public.document_templates
FOR UPDATE
USING (is_global = true AND public.is_saas_admin())
WITH CHECK (is_global = true AND public.is_saas_admin());
CREATE POLICY "dt: saas admin can delete global"
ON public.document_templates
FOR DELETE
USING (is_global = true AND public.is_saas_admin());
-- --------------------------------------------------------------------------
-- 5. Comentarios — document_templates
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.document_templates IS 'Templates de documentos para geracao automatica (declaracao, atestado, recibo etc.).';
COMMENT ON COLUMN public.document_templates.nome_template IS 'Nome do template. Ex: Declaracao de Comparecimento.';
COMMENT ON COLUMN public.document_templates.tipo IS 'declaracao_comparecimento|atestado_psicologico|relatorio_acompanhamento|recibo_pagamento|termo_consentimento|encaminhamento|outro.';
COMMENT ON COLUMN public.document_templates.corpo_html IS 'Corpo do template em HTML com variaveis {{nome_variavel}}.';
COMMENT ON COLUMN public.document_templates.cabecalho_html IS 'HTML do cabecalho (logo, nome da clinica etc.).';
COMMENT ON COLUMN public.document_templates.rodape_html IS 'HTML do rodape (CRP, endereco, contato etc.).';
COMMENT ON COLUMN public.document_templates.variaveis IS 'Array com nomes das variaveis usadas no template. Ex: {paciente_nome, data_sessao}.';
COMMENT ON COLUMN public.document_templates.is_global IS 'true = template padrao do sistema visivel para todos. false = template do tenant.';
COMMENT ON COLUMN public.document_templates.logo_url IS 'URL do logo personalizado para o cabecalho do documento.';
-- ==========================================================================
-- 6. Tabela: document_generated (cada PDF gerado)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_generated (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Origem
template_id uuid NOT NULL REFERENCES public.document_templates(id) ON DELETE RESTRICT,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Dados usados no preenchimento (snapshot — permite auditoria futura)
dados_preenchidos jsonb NOT NULL DEFAULT '{}',
-- PDF gerado
pdf_path text NOT NULL,
storage_bucket text NOT NULL DEFAULT 'generated-docs',
-- Vinculo opcional com documento pai (se o PDF gerado tambem for registrado em documents)
documento_id uuid REFERENCES public.documents(id) ON DELETE SET NULL,
-- Quem gerou
gerado_por uuid NOT NULL,
gerado_em timestamptz DEFAULT now() NOT NULL,
CONSTRAINT document_generated_pkey PRIMARY KEY (id)
);
-- --------------------------------------------------------------------------
-- 7. Indices — document_generated
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS dg_template_idx
ON public.document_generated USING btree (template_id);
CREATE INDEX IF NOT EXISTS dg_patient_idx
ON public.document_generated USING btree (patient_id, gerado_em DESC);
CREATE INDEX IF NOT EXISTS dg_tenant_idx
ON public.document_generated USING btree (tenant_id, gerado_em DESC);
CREATE INDEX IF NOT EXISTS dg_gerado_por_idx
ON public.document_generated USING btree (gerado_por, gerado_em DESC);
-- --------------------------------------------------------------------------
-- 8. RLS — document_generated
-- --------------------------------------------------------------------------
ALTER TABLE public.document_generated ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dg: generator full access"
ON public.document_generated
USING (gerado_por = auth.uid())
WITH CHECK (gerado_por = auth.uid());
-- Membros do tenant podem visualizar
CREATE POLICY "dg: tenant members can select"
ON public.document_generated
FOR SELECT
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- --------------------------------------------------------------------------
-- 9. Comentarios — document_generated
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.document_generated IS 'Registro de cada documento PDF gerado a partir de um template.';
COMMENT ON COLUMN public.document_generated.template_id IS 'Template usado para gerar o documento.';
COMMENT ON COLUMN public.document_generated.dados_preenchidos IS 'Snapshot JSON dos dados usados no preenchimento. Permite auditoria futura.';
COMMENT ON COLUMN public.document_generated.pdf_path IS 'Caminho do PDF gerado no Supabase Storage bucket.';
COMMENT ON COLUMN public.document_generated.documento_id IS 'FK opcional para documents — se o PDF gerado tambem foi registrado como documento do paciente.';
COMMENT ON COLUMN public.document_generated.gerado_por IS 'Usuario que gerou o documento (auth.uid()).';
-- ==========================================================================
-- FIM DA MIGRACAO 006
-- ==========================================================================

View File

@@ -0,0 +1,93 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Storage Buckets para Documentos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Cria os buckets no Supabase Storage para documentos de pacientes
-- e PDFs gerados pelo sistema.
-- ==========================================================================
-- Bucket: documents (uploads de terapeuta/paciente)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'documents',
'documents',
false,
52428800, -- 50 MB
ARRAY[
'application/pdf',
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain'
]
)
ON CONFLICT (id) DO NOTHING;
-- Bucket: generated-docs (PDFs gerados pelo sistema)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'generated-docs',
'generated-docs',
false,
20971520, -- 20 MB
ARRAY['application/pdf']
)
ON CONFLICT (id) DO NOTHING;
-- --------------------------------------------------------------------------
-- Storage RLS Policies — bucket: documents
-- --------------------------------------------------------------------------
-- Upload: usuario autenticado pode fazer upload no path do seu tenant
CREATE POLICY "documents: authenticated upload"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'documents');
-- Download: usuario autenticado pode ler arquivos do seu tenant
CREATE POLICY "documents: authenticated read"
ON storage.objects
FOR SELECT
TO authenticated
USING (bucket_id = 'documents');
-- Delete: usuario autenticado pode deletar seus arquivos
CREATE POLICY "documents: authenticated delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'documents');
-- --------------------------------------------------------------------------
-- Storage RLS Policies — bucket: generated-docs
-- --------------------------------------------------------------------------
CREATE POLICY "generated-docs: authenticated upload"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'generated-docs');
CREATE POLICY "generated-docs: authenticated read"
ON storage.objects
FOR SELECT
TO authenticated
USING (bucket_id = 'generated-docs');
CREATE POLICY "generated-docs: authenticated delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'generated-docs');
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================

View File

@@ -0,0 +1,661 @@
-- =============================================================================
-- MIGRATION: patients — melhorias completas
-- Gerado em: 2025-03
-- Estratégia: cirúrgico — só adiciona, nunca destrói o que existe
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. ALTERAÇÕES NA TABELA patients
-- Novos campos adicionados sem tocar nos existentes
-- -----------------------------------------------------------------------------
ALTER TABLE public.patients
-- Identidade & pronomes
ADD COLUMN IF NOT EXISTS nome_social text,
ADD COLUMN IF NOT EXISTS pronomes text,
-- Dados socioeconômicos (opcionais, clínicamente relevantes)
ADD COLUMN IF NOT EXISTS etnia text,
ADD COLUMN IF NOT EXISTS religiao text,
ADD COLUMN IF NOT EXISTS faixa_renda text,
-- Preferências de comunicação (alimenta lembretes automáticos)
ADD COLUMN IF NOT EXISTS canal_preferido text DEFAULT 'whatsapp',
ADD COLUMN IF NOT EXISTS horario_contato_inicio time DEFAULT '08:00',
ADD COLUMN IF NOT EXISTS horario_contato_fim time DEFAULT '20:00',
ADD COLUMN IF NOT EXISTS idioma text DEFAULT 'pt-BR',
-- Origem estruturada (permite filtros e relatórios)
ADD COLUMN IF NOT EXISTS origem text,
-- Financeiro
ADD COLUMN IF NOT EXISTS metodo_pagamento_preferido text,
-- Ciclo de vida
ADD COLUMN IF NOT EXISTS motivo_saida text,
ADD COLUMN IF NOT EXISTS data_saida date,
ADD COLUMN IF NOT EXISTS encaminhado_para text,
-- Risco clínico (flag de atenção visível no topo do cadastro)
ADD COLUMN IF NOT EXISTS risco_elevado boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS risco_nota text,
ADD COLUMN IF NOT EXISTS risco_sinalizado_em timestamp with time zone,
ADD COLUMN IF NOT EXISTS risco_sinalizado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL;
-- Constraints de validação para novos campos enum-like
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_canal_preferido_check,
ADD CONSTRAINT patients_canal_preferido_check
CHECK (canal_preferido IS NULL OR canal_preferido = ANY (
ARRAY['whatsapp','email','sms','telefone']
));
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_metodo_pagamento_check,
ADD CONSTRAINT patients_metodo_pagamento_check
CHECK (metodo_pagamento_preferido IS NULL OR metodo_pagamento_preferido = ANY (
ARRAY['pix','cartao','dinheiro','deposito','convenio']
));
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_faixa_renda_check,
ADD CONSTRAINT patients_faixa_renda_check
CHECK (faixa_renda IS NULL OR faixa_renda = ANY (
ARRAY['ate_1sm','1_3sm','3_6sm','6_10sm','acima_10sm','nao_informado']
));
-- Constraint: risco_elevado = true exige nota e sinalizante
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_risco_consistency_check,
ADD CONSTRAINT patients_risco_consistency_check
CHECK (
(risco_elevado = false)
OR (risco_elevado = true AND risco_nota IS NOT NULL AND risco_sinalizado_por IS NOT NULL)
);
-- Comments
COMMENT ON COLUMN public.patients.nome_social IS 'Nome social preferido — exibido no lugar do nome completo quando preenchido';
COMMENT ON COLUMN public.patients.pronomes IS 'Pronomes preferidos: ele/dele, ela/dela, eles/deles, etc.';
COMMENT ON COLUMN public.patients.etnia IS 'Autodeclaração étnico-racial (opcional)';
COMMENT ON COLUMN public.patients.religiao IS 'Religião ou espiritualidade (opcional, relevante clinicamente)';
COMMENT ON COLUMN public.patients.faixa_renda IS 'Faixa de renda em salários mínimos — usado para precificação solidária';
COMMENT ON COLUMN public.patients.canal_preferido IS 'Canal de comunicação preferido para lembretes e notificações';
COMMENT ON COLUMN public.patients.horario_contato_inicio IS 'Início da janela de horário preferida para contato';
COMMENT ON COLUMN public.patients.horario_contato_fim IS 'Fim da janela de horário preferida para contato';
COMMENT ON COLUMN public.patients.origem IS 'Como o paciente chegou: indicacao, agendador, redes_sociais, encaminhamento, outro';
COMMENT ON COLUMN public.patients.metodo_pagamento_preferido IS 'Método de pagamento habitual — sugerido ao criar cobrança';
COMMENT ON COLUMN public.patients.motivo_saida IS 'Motivo da alta, inativação ou encaminhamento';
COMMENT ON COLUMN public.patients.data_saida IS 'Data em que o paciente foi desligado/encaminhado';
COMMENT ON COLUMN public.patients.encaminhado_para IS 'Nome ou serviço para onde o paciente foi encaminhado';
COMMENT ON COLUMN public.patients.risco_elevado IS 'Flag de atenção clínica — exibe alerta no topo do cadastro e prontuário';
COMMENT ON COLUMN public.patients.risco_nota IS 'Descrição do risco (obrigatória quando risco_elevado = true)';
COMMENT ON COLUMN public.patients.risco_sinalizado_em IS 'Timestamp em que o risco foi sinalizado';
COMMENT ON COLUMN public.patients.risco_sinalizado_por IS 'Usuário que sinalizou o risco';
-- Índices úteis para filtros frequentes
CREATE INDEX IF NOT EXISTS idx_patients_risco_elevado
ON public.patients (tenant_id, risco_elevado)
WHERE risco_elevado = true;
CREATE INDEX IF NOT EXISTS idx_patients_status_tenant
ON public.patients (tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_patients_origem
ON public.patients (tenant_id, origem)
WHERE origem IS NOT NULL;
-- -----------------------------------------------------------------------------
-- 2. TABELA patient_contacts
-- Substitui os campos soltos nome_parente/telefone_parente na tabela principal
-- Os campos antigos ficam intactos (retrocompatibilidade)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.patient_contacts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
-- Identificação
nome text NOT NULL,
tipo text NOT NULL, -- emergencia | responsavel_legal | profissional_saude | outro
relacao text, -- mãe, pai, psiquiatra, médico, cônjuge...
-- Contato
telefone text,
email text,
cpf text,
-- Profissional de saúde
especialidade text, -- preenchido quando tipo = profissional_saude
registro_profissional text, -- CRM, CRP, etc.
-- Flags
is_primario boolean DEFAULT false NOT NULL, -- contato principal de emergência
ativo boolean DEFAULT true NOT NULL,
-- Auditoria
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT patient_contacts_pkey PRIMARY KEY (id),
CONSTRAINT patient_contacts_tipo_check CHECK (tipo = ANY (
ARRAY['emergencia','responsavel_legal','profissional_saude','outro']
))
);
COMMENT ON TABLE public.patient_contacts IS 'Contatos vinculados ao paciente: emergência, responsável legal, outros profissionais de saúde';
COMMENT ON COLUMN public.patient_contacts.tipo IS 'Categoria do contato: emergencia | responsavel_legal | profissional_saude | outro';
COMMENT ON COLUMN public.patient_contacts.is_primario IS 'Contato de emergência principal — exibido em destaque no cadastro';
-- Garante no máximo 1 contato primário por paciente
CREATE UNIQUE INDEX IF NOT EXISTS uq_patient_contacts_primario
ON public.patient_contacts (patient_id)
WHERE is_primario = true AND ativo = true;
CREATE INDEX IF NOT EXISTS idx_patient_contacts_patient
ON public.patient_contacts (patient_id);
CREATE INDEX IF NOT EXISTS idx_patient_contacts_tenant
ON public.patient_contacts (tenant_id);
-- updated_at automático
CREATE TRIGGER trg_patient_contacts_updated_at
BEFORE UPDATE ON public.patient_contacts
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
-- RLS — mesmas regras de patients
ALTER TABLE public.patient_contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY patient_contacts_select ON public.patient_contacts
FOR SELECT USING (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.view')
);
CREATE POLICY patient_contacts_write ON public.patient_contacts
USING (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.edit')
)
WITH CHECK (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.edit')
);
-- -----------------------------------------------------------------------------
-- 3. TABELA patient_status_history
-- Trilha de auditoria de todas as mudanças de status do paciente
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.patient_status_history (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
status_anterior text, -- NULL na primeira inserção
status_novo text NOT NULL,
motivo text,
encaminhado_para text, -- preenchido quando status = Encaminhado
data_saida date, -- preenchido quando Alta/Encaminhado/Arquivado
alterado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL,
alterado_em timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT patient_status_history_pkey PRIMARY KEY (id),
CONSTRAINT psh_status_novo_check CHECK (status_novo = ANY (
ARRAY['Ativo','Inativo','Alta','Encaminhado','Arquivado']
))
);
COMMENT ON TABLE public.patient_status_history IS 'Histórico imutável de todas as mudanças de status do paciente — não editar, apenas inserir';
CREATE INDEX IF NOT EXISTS idx_psh_patient
ON public.patient_status_history (patient_id, alterado_em DESC);
CREATE INDEX IF NOT EXISTS idx_psh_tenant
ON public.patient_status_history (tenant_id, alterado_em DESC);
-- RLS
ALTER TABLE public.patient_status_history ENABLE ROW LEVEL SECURITY;
CREATE POLICY psh_select ON public.patient_status_history
FOR SELECT USING (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.view')
);
CREATE POLICY psh_insert ON public.patient_status_history
FOR INSERT WITH CHECK (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.edit')
);
-- Trigger: registra automaticamente no histórico quando status muda em patients
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_history()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
INSERT INTO public.patient_status_history (
patient_id, tenant_id,
status_anterior, status_novo,
motivo, encaminhado_para, data_saida,
alterado_por, alterado_em
) VALUES (
NEW.id, NEW.tenant_id,
CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.status END,
NEW.status,
NEW.motivo_saida,
NEW.encaminhado_para,
NEW.data_saida,
auth.uid(),
now()
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_patient_status_history ON public.patients;
CREATE TRIGGER trg_patient_status_history
AFTER INSERT OR UPDATE OF status ON public.patients
FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_history();
-- -----------------------------------------------------------------------------
-- 4. TABELA patient_timeline
-- Feed cronológico automático de eventos relevantes do paciente
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.patient_timeline (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
-- Tipo do evento
evento_tipo text NOT NULL,
-- Exemplos: primeira_sessao | sessao_realizada | sessao_cancelada | falta |
-- status_alterado | risco_sinalizado | documento_assinado |
-- escala_respondida | pagamento_vencido | pagamento_recebido |
-- tarefa_combinada | contato_adicionado | prontuario_editado
titulo text NOT NULL, -- Ex: "Sessão realizada"
descricao text, -- Ex: "Sessão 47 · presencial · 50min"
icone_cor text DEFAULT 'gray', -- green | blue | amber | red | gray
link_ref_tipo text, -- agenda_evento | financial_record | documento | escala
link_ref_id uuid, -- FK genérico — sem constraint formal (polimórfico)
gerado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL,
ocorrido_em timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT patient_timeline_pkey PRIMARY KEY (id),
CONSTRAINT pt_evento_tipo_check CHECK (evento_tipo = ANY (ARRAY[
'primeira_sessao','sessao_realizada','sessao_cancelada','falta',
'status_alterado','risco_sinalizado','risco_removido',
'documento_assinado','documento_adicionado',
'escala_respondida','escala_enviada',
'pagamento_vencido','pagamento_recebido',
'tarefa_combinada','contato_adicionado',
'prontuario_editado','nota_adicionada','manual'
])),
CONSTRAINT pt_icone_cor_check CHECK (icone_cor = ANY (
ARRAY['green','blue','amber','red','gray','purple']
))
);
COMMENT ON TABLE public.patient_timeline IS 'Feed cronológico de eventos do paciente — alimentado por triggers e inserções manuais';
COMMENT ON COLUMN public.patient_timeline.link_ref_tipo IS 'Tipo da entidade referenciada (polimórfico): agenda_evento | financial_record | documento | escala';
COMMENT ON COLUMN public.patient_timeline.link_ref_id IS 'ID da entidade referenciada — sem FK formal para suportar múltiplos tipos';
CREATE INDEX IF NOT EXISTS idx_pt_patient_ocorrido
ON public.patient_timeline (patient_id, ocorrido_em DESC);
CREATE INDEX IF NOT EXISTS idx_pt_tenant
ON public.patient_timeline (tenant_id, ocorrido_em DESC);
CREATE INDEX IF NOT EXISTS idx_pt_evento_tipo
ON public.patient_timeline (patient_id, evento_tipo);
-- RLS
ALTER TABLE public.patient_timeline ENABLE ROW LEVEL SECURITY;
CREATE POLICY pt_select ON public.patient_timeline
FOR SELECT USING (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.view')
);
CREATE POLICY pt_insert ON public.patient_timeline
FOR INSERT WITH CHECK (
public.is_clinic_tenant(tenant_id)
AND public.is_tenant_member(tenant_id)
AND public.tenant_has_feature(tenant_id, 'patients.edit')
);
-- Trigger: registra na timeline quando risco é sinalizado/removido
CREATE OR REPLACE FUNCTION public.trg_fn_patient_risco_timeline()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF OLD.risco_elevado IS DISTINCT FROM NEW.risco_elevado THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id,
evento_tipo, titulo, descricao, icone_cor,
gerado_por, ocorrido_em
) VALUES (
NEW.id, NEW.tenant_id,
CASE WHEN NEW.risco_elevado THEN 'risco_sinalizado' ELSE 'risco_removido' END,
CASE WHEN NEW.risco_elevado THEN 'Risco elevado sinalizado' ELSE 'Sinalização de risco removida' END,
NEW.risco_nota,
CASE WHEN NEW.risco_elevado THEN 'red' ELSE 'green' END,
auth.uid(),
now()
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_patient_risco_timeline ON public.patients;
CREATE TRIGGER trg_patient_risco_timeline
AFTER UPDATE OF risco_elevado ON public.patients
FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_risco_timeline();
-- Trigger: registra na timeline quando status muda
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_timeline()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id,
evento_tipo, titulo, descricao, icone_cor,
gerado_por, ocorrido_em
) VALUES (
NEW.id, NEW.tenant_id,
'status_alterado',
'Status alterado para ' || NEW.status,
CASE
WHEN TG_OP = 'INSERT' THEN 'Paciente cadastrado'
ELSE 'De ' || OLD.status || '' || NEW.status ||
CASE WHEN NEW.motivo_saida IS NOT NULL THEN ' · ' || NEW.motivo_saida ELSE '' END
END,
CASE NEW.status
WHEN 'Ativo' THEN 'green'
WHEN 'Alta' THEN 'blue'
WHEN 'Inativo' THEN 'gray'
WHEN 'Encaminhado' THEN 'amber'
WHEN 'Arquivado' THEN 'gray'
ELSE 'gray'
END,
auth.uid(),
now()
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_patient_status_timeline ON public.patients;
CREATE TRIGGER trg_patient_status_timeline
AFTER INSERT OR UPDATE OF status ON public.patients
FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_timeline();
-- -----------------------------------------------------------------------------
-- 5. VIEW v_patient_engajamento
-- Score calculado em tempo real — sem armazenar, sem inconsistência
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW public.v_patient_engajamento
WITH (security_invoker = on)
AS
WITH sessoes AS (
SELECT
ae.patient_id,
ae.tenant_id,
COUNT(*) FILTER (WHERE ae.status = 'realizado') AS total_realizadas,
COUNT(*) FILTER (WHERE ae.status IN ('realizado','cancelado','faltou')) AS total_marcadas,
COUNT(*) FILTER (WHERE ae.status = 'faltou') AS total_faltas,
MAX(ae.inicio_em) FILTER (WHERE ae.status = 'realizado') AS ultima_sessao_em,
MIN(ae.inicio_em) FILTER (WHERE ae.status = 'realizado') AS primeira_sessao_em,
COUNT(*) FILTER (WHERE ae.status = 'realizado'
AND ae.inicio_em >= now() - interval '30 days') AS sessoes_ultimo_mes
FROM public.agenda_eventos ae
WHERE ae.patient_id IS NOT NULL
GROUP BY ae.patient_id, ae.tenant_id
),
financeiro AS (
SELECT
fr.patient_id,
fr.tenant_id,
COALESCE(SUM(fr.final_amount) FILTER (WHERE fr.status = 'paid'), 0) AS total_pago,
COALESCE(AVG(fr.final_amount) FILTER (WHERE fr.status = 'paid'), 0) AS ticket_medio,
COUNT(*) FILTER (WHERE fr.status IN ('pending','overdue')
AND fr.due_date < now()) AS cobr_vencidas,
COUNT(*) FILTER (WHERE fr.status IN ('pending','overdue')) AS cobr_pendentes,
COUNT(*) FILTER (WHERE fr.type = 'receita' AND fr.status = 'paid') AS cobr_pagas
FROM public.financial_records fr
WHERE fr.patient_id IS NOT NULL
AND fr.deleted_at IS NULL
GROUP BY fr.patient_id, fr.tenant_id
)
SELECT
p.id AS patient_id,
p.tenant_id,
p.nome_completo,
p.status,
p.risco_elevado,
-- Sessões
COALESCE(s.total_realizadas, 0) AS total_sessoes,
COALESCE(s.sessoes_ultimo_mes, 0) AS sessoes_ultimo_mes,
s.primeira_sessao_em,
s.ultima_sessao_em,
EXTRACT(DAY FROM now() - s.ultima_sessao_em)::int AS dias_sem_sessao,
-- Taxa de comparecimento (%)
CASE
WHEN COALESCE(s.total_marcadas, 0) = 0 THEN NULL
ELSE ROUND((s.total_realizadas::numeric / s.total_marcadas) * 100, 1)
END AS taxa_comparecimento,
-- Financeiro
COALESCE(f.total_pago, 0) AS ltv_total,
ROUND(COALESCE(f.ticket_medio, 0), 2) AS ticket_medio,
COALESCE(f.cobr_vencidas, 0) AS cobr_vencidas,
COALESCE(f.cobr_pagas, 0) AS cobr_pagas,
-- Taxa de pagamentos em dia (%)
CASE
WHEN COALESCE(f.cobr_pagas + f.cobr_vencidas, 0) = 0 THEN NULL
ELSE ROUND(
f.cobr_pagas::numeric / (f.cobr_pagas + f.cobr_vencidas) * 100, 1
)
END AS taxa_pagamentos_dia,
-- Score de engajamento composto (0-100)
-- Pesos: comparecimento 50%, pagamentos 30%, recência 20%
ROUND(
LEAST(100,
COALESCE(
(
-- Comparecimento (50 pts)
CASE WHEN COALESCE(s.total_marcadas, 0) = 0 THEN 50
ELSE LEAST(50, (s.total_realizadas::numeric / s.total_marcadas) * 50)
END
+
-- Pagamentos em dia (30 pts)
CASE WHEN COALESCE(f.cobr_pagas + f.cobr_vencidas, 0) = 0 THEN 30
ELSE LEAST(30, f.cobr_pagas::numeric / (f.cobr_pagas + f.cobr_vencidas) * 30)
END
+
-- Recência (20 pts — penaliza quem está há muito tempo sem sessão)
CASE
WHEN s.ultima_sessao_em IS NULL THEN 0
WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 14 THEN 20
WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 30 THEN 15
WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 60 THEN 8
ELSE 0
END
), 0
)
)
, 0) AS engajamento_score,
-- Duração do tratamento
CASE
WHEN s.primeira_sessao_em IS NULL THEN NULL
ELSE EXTRACT(DAY FROM now() - s.primeira_sessao_em)::int
END AS duracao_tratamento_dias
FROM public.patients p
LEFT JOIN sessoes s ON s.patient_id = p.id AND s.tenant_id = p.tenant_id
LEFT JOIN financeiro f ON f.patient_id = p.id AND f.tenant_id = p.tenant_id;
COMMENT ON VIEW public.v_patient_engajamento IS
'Score de engajamento e métricas consolidadas por paciente. Calculado em tempo real via RLS (security_invoker=on).';
-- -----------------------------------------------------------------------------
-- 6. VIEW v_patients_risco
-- Lista rápida de pacientes que precisam de atenção imediata
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW public.v_patients_risco
WITH (security_invoker = on)
AS
SELECT
p.id,
p.tenant_id,
p.nome_completo,
p.status,
p.risco_elevado,
p.risco_nota,
p.risco_sinalizado_em,
e.dias_sem_sessao,
e.engajamento_score,
e.taxa_comparecimento,
-- Motivo do alerta
CASE
WHEN p.risco_elevado THEN 'risco_sinalizado'
WHEN COALESCE(e.dias_sem_sessao, 999) > 30
AND p.status = 'Ativo' THEN 'sem_sessao_30d'
WHEN COALESCE(e.taxa_comparecimento, 100) < 60 THEN 'baixo_comparecimento'
WHEN COALESCE(e.cobr_vencidas, 0) > 0 THEN 'cobranca_vencida'
ELSE 'ok'
END AS alerta_tipo
FROM public.patients p
JOIN public.v_patient_engajamento e ON e.patient_id = p.id
WHERE p.status = 'Ativo'
AND (
p.risco_elevado = true
OR COALESCE(e.dias_sem_sessao, 999) > 30
OR COALESCE(e.taxa_comparecimento, 100) < 60
OR COALESCE(e.cobr_vencidas, 0) > 0
);
COMMENT ON VIEW public.v_patients_risco IS
'Pacientes ativos que precisam de atenção: risco clínico, sem sessão há 30+ dias, baixo comparecimento ou cobrança vencida';
-- -----------------------------------------------------------------------------
-- 7. Migração de dados: popular patient_contacts com os dados já existentes
-- Roda só uma vez — protegido por WHERE NOT EXISTS
-- -----------------------------------------------------------------------------
INSERT INTO public.patient_contacts (
patient_id, tenant_id,
nome, tipo, relacao,
telefone, is_primario, ativo
)
SELECT
p.id,
p.tenant_id,
p.nome_parente,
'emergencia',
p.grau_parentesco,
p.telefone_parente,
true,
true
FROM public.patients p
WHERE p.nome_parente IS NOT NULL
AND p.telefone_parente IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM public.patient_contacts pc
WHERE pc.patient_id = p.id AND pc.tipo = 'emergencia'
);
-- Migra responsável legal quando diferente do parente de emergência
INSERT INTO public.patient_contacts (
patient_id, tenant_id,
nome, tipo, relacao,
telefone, cpf,
is_primario, ativo
)
SELECT
p.id,
p.tenant_id,
p.nome_responsavel,
'responsavel_legal',
'Responsável legal',
p.telefone_responsavel,
p.cpf_responsavel,
false,
true
FROM public.patients p
WHERE p.nome_responsavel IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM public.patient_contacts pc
WHERE pc.patient_id = p.id AND pc.tipo = 'responsavel_legal'
);
-- -----------------------------------------------------------------------------
-- 8. Seed do histórico de status para pacientes já existentes
-- Cria a primeira entrada de histórico com o status atual
-- -----------------------------------------------------------------------------
INSERT INTO public.patient_status_history (
patient_id, tenant_id,
status_anterior, status_novo,
motivo, alterado_em
)
SELECT
p.id,
p.tenant_id,
NULL,
p.status,
'Status inicial — migração de dados',
COALESCE(p.created_at, now())
FROM public.patients p
WHERE NOT EXISTS (
SELECT 1 FROM public.patient_status_history psh
WHERE psh.patient_id = p.id
);
-- =============================================================================
-- FIM DO MIGRATION
-- Resumo do que foi feito:
-- 1. ALTER TABLE patients — 16 novos campos (pronomes, risco, origem, etc.)
-- 2. CREATE TABLE patient_contacts — múltiplos contatos por paciente
-- 3. CREATE TABLE patient_status_history — trilha imutável de mudanças de status
-- 4. CREATE TABLE patient_timeline — feed cronológico de eventos
-- 5. Triggers automáticos — status history, timeline de risco e status
-- 6. VIEW v_patient_engajamento — score 0-100 + métricas calculadas em tempo real
-- 7. VIEW v_patients_risco — lista de pacientes que precisam de atenção
-- 8. Migração de dados — popula patient_contacts e status_history com dados existentes
-- =============================================================================

View File

@@ -60,7 +60,14 @@ VALUES
-- ── Branding / API / Auditoria ──
('f393178c-284d-422f-b096-8793f85428d5', 'custom_branding', 'Custom branding', '2026-03-01 09:59:15.432733+00', 'Personalização de marca', 'Marca Personalizada'),
('d6f54674-ea8b-484b-af0e-99127a510da2', 'api_access', 'API/Integrations access', '2026-03-01 09:59:15.432733+00', 'Integrações/API', 'Acesso à API'),
('a5593d96-dd95-46bb-bef0-bd379b56ad50', 'audit_log', 'Audit log capability', '2026-03-01 09:59:15.432733+00', 'Auditoria', 'Log de Auditoria')
('a5593d96-dd95-46bb-bef0-bd379b56ad50', 'audit_log', 'Audit log capability', '2026-03-01 09:59:15.432733+00', 'Auditoria', 'Log de Auditoria'),
-- ── Documentos ──
('b1a2c3d4-1111-4aaa-bbbb-000000000001', 'documents.upload', 'Upload e gestão de arquivos do paciente', '2026-03-29 00:00:00.000000+00', 'Upload de documentos', 'Upload de Documentos'),
('b1a2c3d4-1111-4aaa-bbbb-000000000002', 'documents.templates', 'Geração de documentos a partir de templates', '2026-03-29 00:00:00.000000+00', 'Templates de documentos', 'Templates de Documentos'),
('b1a2c3d4-1111-4aaa-bbbb-000000000003', 'documents.signatures', 'Assinatura eletrônica de documentos', '2026-03-29 00:00:00.000000+00', 'Assinaturas eletrônicas', 'Assinaturas Eletrônicas'),
('b1a2c3d4-1111-4aaa-bbbb-000000000004', 'documents.share_links', 'Links temporários para compartilhamento de documentos', '2026-03-29 00:00:00.000000+00', 'Links de compartilhamento', 'Links de Compartilhamento'),
('b1a2c3d4-1111-4aaa-bbbb-000000000005', 'documents.patient_portal', 'Acesso a documentos pelo portal do paciente', '2026-03-29 00:00:00.000000+00', 'Portal do paciente (documentos)', 'Portal do Paciente (Documentos)')
ON CONFLICT (id) DO UPDATE SET
key = EXCLUDED.key,
@@ -71,7 +78,7 @@ ON CONFLICT (id) DO UPDATE SET
DO $$
BEGIN
RAISE NOTICE 'seed_011_features: 26 features inseridas/atualizadas.';
RAISE NOTICE 'seed_011_features: 31 features inseridas/atualizadas.';
END;
$$;

View File

@@ -52,7 +52,13 @@ INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
-- PRO exclusivo
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'f393178c-284d-422f-b096-8793f85428d5', true, NULL), -- custom_branding
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'd6f54674-ea8b-484b-af0e-99127a510da2', true, NULL), -- api_access
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', true, NULL); -- audit_log
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', true, NULL), -- audit_log
-- Documentos
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 5000}'), -- documents.upload
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', true, NULL), -- documents.templates
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', true, NULL), -- documents.signatures
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', true, NULL), -- documents.share_links
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', true, NULL); -- documents.patient_portal
-- ════════════════════════════════════════════════════════════════
@@ -88,7 +94,13 @@ INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
('01a5867f-0705-4714-ac97-a23470949157', '74fc1321-4d17-49c3-b72e-db3a7f4be451', false, NULL), -- rooms (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'f393178c-284d-422f-b096-8793f85428d5', false, NULL), -- custom_branding (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'd6f54674-ea8b-484b-af0e-99127a510da2', false, NULL), -- api_access (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', false, NULL); -- audit_log (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', false, NULL), -- audit_log (PRO)
-- Documentos
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 500}'), -- documents.upload (FREE)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', false, NULL), -- documents.templates (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', false, NULL), -- documents.signatures (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', false, NULL), -- documents.share_links (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', false, NULL); -- documents.patient_portal (PRO)
-- ════════════════════════════════════════════════════════════════
@@ -122,7 +134,13 @@ INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
-- PRO exclusivo
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'f393178c-284d-422f-b096-8793f85428d5', true, NULL), -- custom_branding
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'd6f54674-ea8b-484b-af0e-99127a510da2', true, NULL), -- api_access
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', true, NULL); -- audit_log
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', true, NULL), -- audit_log
-- Documentos
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 5000}'), -- documents.upload
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', true, NULL), -- documents.templates
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', true, NULL), -- documents.signatures
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', true, NULL), -- documents.share_links
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', true, NULL); -- documents.patient_portal
-- ════════════════════════════════════════════════════════════════
@@ -152,7 +170,13 @@ INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
-- PRO-only (desabilitado)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'f393178c-284d-422f-b096-8793f85428d5', false, NULL), -- custom_branding (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'd6f54674-ea8b-484b-af0e-99127a510da2', false, NULL), -- api_access (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', false, NULL); -- audit_log (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', false, NULL), -- audit_log (PRO)
-- Documentos
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 500}'), -- documents.upload (FREE)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', false, NULL), -- documents.templates (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', false, NULL), -- documents.signatures (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', false, NULL), -- documents.share_links (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', false, NULL); -- documents.patient_portal (PRO)
-- ════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,230 @@
-- ==========================================================================
-- Agencia PSI — Seed: Templates globais de documentos
-- ==========================================================================
-- 4 templates padrao do sistema (is_global = true).
-- Disponiveis para todos os tenants como base para personalizacao.
-- ==========================================================================
INSERT INTO public.document_templates (
id, tenant_id, owner_id, nome_template, tipo, descricao,
corpo_html, cabecalho_html, rodape_html,
variaveis, is_global, ativo
) VALUES
-- ────────────────────────────────────────────────────────────
-- 1. Declaração de Comparecimento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Declaração de Comparecimento',
'declaracao_comparecimento',
'Declara que o paciente compareceu à sessão na data e horário indicados.',
E'<h2 style="text-align:center; margin-bottom:30px;">DECLARAÇÃO DE COMPARECIMENTO</h2>\n\n<p>Declaro, para os devidos fins, que <strong>{{paciente_nome}}</strong>, portador(a) do CPF nº <strong>{{paciente_cpf}}</strong>, compareceu a esta clínica/consultório no dia <strong>{{data_sessao}}</strong>, no horário das <strong>{{hora_inicio}}</strong> às <strong>{{hora_fim}}</strong>, para atendimento psicológico.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','data_sessao','hora_inicio','hora_fim','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 2. Atestado Psicológico
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Atestado Psicológico',
'atestado_psicologico',
'Atesta acompanhamento psicológico do paciente conforme Resolução CFP.',
E'<h2 style="text-align:center; margin-bottom:30px;">ATESTADO PSICOLÓGICO</h2>\n\n<p>Atesto, para os devidos fins, que <strong>{{paciente_nome}}</strong>, nascido(a) em <strong>{{paciente_data_nascimento}}</strong>, encontra-se em acompanhamento psicológico neste consultório/clínica.</p>\n\n<p>O presente atestado é emitido com base no atendimento realizado, em conformidade com o Código de Ética Profissional do Psicólogo e as Resoluções do Conselho Federal de Psicologia.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 3. Relatório de Acompanhamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Relatório de Acompanhamento',
'relatorio_acompanhamento',
'Modelo base para relatório de acompanhamento psicológico.',
E'<h2 style="text-align:center; margin-bottom:30px;">RELATÓRIO DE ACOMPANHAMENTO PSICOLÓGICO</h2>\n\n<table style="width:100%; border-collapse:collapse; margin-bottom:20px;">\n <tr><td style="padding:4px 8px; font-weight:bold; width:180px;">Paciente:</td><td style="padding:4px 8px;">{{paciente_nome}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data de nascimento:</td><td style="padding:4px 8px;">{{paciente_data_nascimento}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">CPF:</td><td style="padding:4px 8px;">{{paciente_cpf}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Profissional:</td><td style="padding:4px 8px;">{{terapeuta_nome}} — CRP {{terapeuta_crp}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data do relatório:</td><td style="padding:4px 8px;">{{data_atual}}</td></tr>\n</table>\n\n<h3>1. Demanda inicial</h3>\n<p>[Descreva a queixa ou demanda que motivou o início do acompanhamento.]</p>\n\n<h3>2. Procedimentos utilizados</h3>\n<p>[Descreva os métodos, técnicas e instrumentos utilizados.]</p>\n\n<h3>3. Evolução observada</h3>\n<p>[Descreva a evolução do paciente ao longo do acompanhamento.]</p>\n\n<h3>4. Considerações finais</h3>\n<p>[Conclusões e recomendações.]</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este documento é de caráter sigiloso, conforme Art. 9º do Código de Ética do Psicólogo.\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','paciente_cpf','terapeuta_nome','terapeuta_crp','data_atual','data_atual_extenso','cidade_estado','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 4. Recibo de Pagamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Recibo de Pagamento',
'recibo_pagamento',
'Recibo para pagamento de sessão de atendimento psicológico.',
E'<h2 style="text-align:center; margin-bottom:30px;">RECIBO DE PAGAMENTO</h2>\n\n<p>Recebi de <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, a quantia de <strong>{{valor}}</strong> ({{valor_extenso}}), referente a sessão de atendimento psicológico realizada em <strong>{{data_sessao}}</strong>.</p>\n\n<p><strong>Forma de pagamento:</strong> {{forma_pagamento}}</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · CNPJ: {{clinica_cnpj}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','valor','valor_extenso','data_sessao','forma_pagamento','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_cnpj','clinica_telefone'],
true, true
)
,
-- ────────────────────────────────────────────────────────────
-- 5. Contrato de Prestação de Serviços Psicológicos
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Contrato de Prestação de Serviços',
'contrato_servicos',
'Contrato padrão entre profissional/clínica e paciente para prestação de serviços psicológicos.',
E'<h2 style="text-align:center; margin-bottom:30px;">CONTRATO DE PRESTAÇÃO DE SERVIÇOS PSICOLÓGICOS</h2>\n\n<p>Pelo presente instrumento particular, de um lado <strong>{{terapeuta_nome}}</strong>, Psicólogo(a), inscrito(a) no CRP sob o nº <strong>{{terapeuta_crp}}</strong>, CPF nº <strong>{{terapeuta_cpf}}</strong>, doravante denominado(a) <strong>CONTRATADO(A)</strong>, e de outro lado <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, doravante denominado(a) <strong>CONTRATANTE</strong>, têm entre si justo e contratado o seguinte:</p>\n\n<h3>CLÁUSULA 1ª — DO OBJETO</h3>\n<p>O presente contrato tem por objeto a prestação de serviços de atendimento psicológico clínico, na modalidade <strong>{{modalidade_atendimento}}</strong>, pelo(a) CONTRATADO(A) ao CONTRATANTE.</p>\n\n<h3>CLÁUSULA 2ª — DA PERIODICIDADE</h3>\n<p>As sessões ocorrerão com frequência <strong>{{frequencia_sessoes}}</strong>, com duração aproximada de <strong>{{duracao_sessao}}</strong> minutos, em dia e horário previamente acordados entre as partes.</p>\n\n<h3>CLÁUSULA 3ª — DOS HONORÁRIOS</h3>\n<p>O valor de cada sessão será de <strong>{{valor}}</strong> ({{valor_extenso}}), a ser pago <strong>{{forma_pagamento}}</strong>.</p>\n<p>Em caso de reajuste, o(a) CONTRATADO(A) comunicará o CONTRATANTE com antecedência mínima de 30 (trinta) dias.</p>\n\n<h3>CLÁUSULA 4ª — DO CANCELAMENTO E FALTAS</h3>\n<p>O cancelamento ou remarcação de sessões deverá ser comunicado com antecedência mínima de <strong>24 (vinte e quatro) horas</strong>. Faltas sem aviso prévio serão cobradas integralmente.</p>\n\n<h3>CLÁUSULA 5ª — DO SIGILO</h3>\n<p>O(A) CONTRATADO(A) compromete-se a manter sigilo absoluto sobre todas as informações obtidas durante o atendimento, conforme o Código de Ética Profissional do Psicólogo (Resolução CFP nº 010/2005) e a Lei Geral de Proteção de Dados (Lei nº 13.709/2018).</p>\n\n<h3>CLÁUSULA 6ª — DA RESCISÃO</h3>\n<p>O presente contrato poderá ser rescindido por qualquer das partes, a qualquer tempo, mediante comunicação prévia, sem ônus adicionais além das sessões já realizadas.</p>\n\n<h3>CLÁUSULA 7ª — DO FORO</h3>\n<p>Para dirimir quaisquer controvérsias oriundas deste contrato, as partes elegem o foro da Comarca de <strong>{{cidade_estado}}</strong>.</p>\n\n<p style="margin-top:30px;">E por estarem assim justas e contratadas, as partes assinam o presente instrumento em 2 (duas) vias de igual teor e forma.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · CNPJ: {{clinica_cnpj}} · {{clinica_endereco}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','terapeuta_cpf','modalidade_atendimento','frequencia_sessoes','duracao_sessao','valor','valor_extenso','forma_pagamento','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco','clinica_telefone','clinica_cnpj'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 6. Termo de Consentimento Livre e Esclarecido (TCLE)
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Consentimento Livre e Esclarecido',
'tcle',
'TCLE para início de acompanhamento psicológico, conforme exigências éticas do CFP.',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE CONSENTIMENTO LIVRE E ESCLARECIDO</h2>\n\n<p>Eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro que fui devidamente informado(a) e esclarecido(a) pelo(a) psicólogo(a) <strong>{{terapeuta_nome}}</strong>, CRP <strong>{{terapeuta_crp}}</strong>, sobre os seguintes pontos:</p>\n\n<h3>1. Natureza do serviço</h3>\n<p>O atendimento psicológico consiste em sessões de psicoterapia, com abordagem <strong>{{abordagem_terapeutica}}</strong>, visando o acolhimento e o tratamento das demandas apresentadas.</p>\n\n<h3>2. Sigilo profissional</h3>\n<p>Todas as informações compartilhadas durante as sessões são estritamente confidenciais, conforme o Código de Ética Profissional do Psicólogo (Resolução CFP nº 010/2005), podendo ser quebrado apenas nas hipóteses previstas em lei.</p>\n\n<h3>3. Registro de informações</h3>\n<p>O(A) profissional poderá realizar registros das sessões (prontuário psicológico) para fins de acompanhamento clínico, mantidos em sigilo conforme a LGPD (Lei nº 13.709/2018) e as normas do CFP.</p>\n\n<h3>4. Direitos do paciente</h3>\n<ul>\n <li>Receber informações sobre o processo terapêutico;</li>\n <li>Interromper o atendimento a qualquer momento;</li>\n <li>Solicitar encaminhamento a outro profissional;</li>\n <li>Ter acesso às informações registradas em seu prontuário.</li>\n</ul>\n\n<h3>5. Limitações</h3>\n<p>O acompanhamento psicológico não substitui tratamento médico ou psiquiátrico quando necessário. O(A) profissional poderá sugerir encaminhamentos complementares.</p>\n\n<p style="margin-top:30px;">Declaro que li e compreendi as informações acima e <strong>consinto livremente</strong> com o início do acompanhamento psicológico.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este termo é regido pela Resolução CFP nº 010/2005 e pela Lei nº 13.709/2018 (LGPD).\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','abordagem_terapeutica','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 7. Encaminhamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Encaminhamento',
'encaminhamento',
'Carta de encaminhamento do paciente para outro profissional ou serviço.',
E'<h2 style="text-align:center; margin-bottom:30px;">ENCAMINHAMENTO</h2>\n\n<p>Ao(À) profissional/serviço de <strong>{{especialidade_destino}}</strong>,</p>\n\n<p style="margin-top:20px;">Encaminho o(a) paciente <strong>{{paciente_nome}}</strong>, nascido(a) em <strong>{{paciente_data_nascimento}}</strong>, para avaliação e acompanhamento em <strong>{{especialidade_destino}}</strong>.</p>\n\n<p><strong>Motivo do encaminhamento:</strong></p>\n<p>{{motivo_encaminhamento}}</p>\n\n<p><strong>Informações relevantes:</strong></p>\n<p>{{informacoes_relevantes}}</p>\n\n<p style="margin-top:20px;">Coloco-me à disposição para troca de informações que se façam necessárias ao melhor atendimento do(a) paciente, respeitadas as normas de sigilo profissional.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}<br/>\n <span style="font-size:10pt; color:#666;">{{terapeuta_email}} · {{terapeuta_telefone}}</span>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','especialidade_destino','motivo_encaminhamento','informacoes_relevantes','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','terapeuta_email','terapeuta_telefone','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 8. Autorização para Atendimento de Menor
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Autorização para Atendimento de Menor',
'autorizacao_menor',
'Termo de autorização dos responsáveis legais para atendimento psicológico de criança ou adolescente.',
E'<h2 style="text-align:center; margin-bottom:30px;">AUTORIZAÇÃO PARA ATENDIMENTO PSICOLÓGICO DE MENOR</h2>\n\n<p>Eu, <strong>{{responsavel_nome}}</strong>, CPF nº <strong>{{responsavel_cpf}}</strong>, na qualidade de <strong>{{grau_parentesco}}</strong> e responsável legal do(a) menor <strong>{{paciente_nome}}</strong>, nascido(a) em <strong>{{paciente_data_nascimento}}</strong>, <strong>AUTORIZO</strong> a realização de atendimento psicológico pelo(a) profissional abaixo identificado(a).</p>\n\n<h3>Profissional responsável</h3>\n<p><strong>{{terapeuta_nome}}</strong><br/>\nPsicólogo(a) — CRP {{terapeuta_crp}}</p>\n\n<h3>Declarações</h3>\n<p>Declaro estar ciente de que:</p>\n<ul>\n <li>O atendimento seguirá as normas do Código de Ética Profissional do Psicólogo e do Estatuto da Criança e do Adolescente (ECA);</li>\n <li>As informações do atendimento são sigilosas, sendo compartilhadas com o responsável apenas o necessário para o bem-estar do(a) menor, conforme julgamento técnico do(a) profissional;</li>\n <li>Posso solicitar informações sobre a evolução do tratamento a qualquer momento;</li>\n <li>Posso revogar esta autorização a qualquer tempo, mediante comunicação por escrito.</li>\n</ul>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{responsavel_nome}}</strong><br/>\n Responsável legal<br/>\n CPF: {{responsavel_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','responsavel_nome','responsavel_cpf','grau_parentesco','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 9. Laudo Psicológico
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Laudo Psicológico',
'laudo_psicologico',
'Modelo de laudo psicológico conforme Resolução CFP nº 06/2019.',
E'<h2 style="text-align:center; margin-bottom:30px;">LAUDO PSICOLÓGICO</h2>\n\n<table style="width:100%; border-collapse:collapse; margin-bottom:20px;">\n <tr><td style="padding:4px 8px; font-weight:bold; width:200px;">Solicitante:</td><td style="padding:4px 8px;">{{solicitante}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Finalidade:</td><td style="padding:4px 8px;">{{finalidade}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Avaliado(a):</td><td style="padding:4px 8px;">{{paciente_nome}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data de nascimento:</td><td style="padding:4px 8px;">{{paciente_data_nascimento}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">CPF:</td><td style="padding:4px 8px;">{{paciente_cpf}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Profissional:</td><td style="padding:4px 8px;">{{terapeuta_nome}} — CRP {{terapeuta_crp}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data do laudo:</td><td style="padding:4px 8px;">{{data_atual}}</td></tr>\n</table>\n\n<h3>1. Descrição da demanda</h3>\n<p>[Apresente a demanda e o motivo da avaliação, conforme solicitação recebida.]</p>\n\n<h3>2. Procedimentos</h3>\n<p>[Descreva os recursos e instrumentos técnicos utilizados na avaliação: entrevistas, testes psicológicos (com nome, autor e parecer favorável do SATEPSI quando aplicável), observação, etc.]</p>\n\n<h3>3. Análise</h3>\n<p>[Apresente de forma clara e fundamentada os dados obtidos, integrando os resultados dos procedimentos realizados à luz da literatura científica da Psicologia. Não inclua informações que não tenham relação com a demanda.]</p>\n\n<h3>4. Conclusão</h3>\n<p>[Apresente o resultado da avaliação, indicando a resposta à demanda inicial. A conclusão deve ser coerente com a análise e os procedimentos utilizados.]</p>\n\n<p style="margin-top:40px; font-size:10pt; color:#666;"><em>Este laudo foi elaborado em conformidade com a Resolução CFP nº 06/2019, que institui regras para a elaboração de documentos escritos produzidos pelo psicólogo no exercício profissional.</em></p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este documento é de caráter sigiloso, conforme Art. 9º do Código de Ética do Psicólogo e Resolução CFP nº 06/2019.\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','paciente_cpf','solicitante','finalidade','terapeuta_nome','terapeuta_crp','data_atual','data_atual_extenso','cidade_estado','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 10. Parecer Psicológico
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Parecer Psicológico',
'parecer_psicologico',
'Manifestação técnica sobre questão específica no campo da Psicologia, conforme Resolução CFP nº 06/2019.',
E'<h2 style="text-align:center; margin-bottom:30px;">PARECER PSICOLÓGICO</h2>\n\n<table style="width:100%; border-collapse:collapse; margin-bottom:20px;">\n <tr><td style="padding:4px 8px; font-weight:bold; width:200px;">Parecer nº:</td><td style="padding:4px 8px;">{{numero_parecer}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Solicitante:</td><td style="padding:4px 8px;">{{solicitante}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Assunto:</td><td style="padding:4px 8px;">{{assunto}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Profissional:</td><td style="padding:4px 8px;">{{terapeuta_nome}} — CRP {{terapeuta_crp}}</td></tr>\n</table>\n\n<h3>1. Exposição de motivos</h3>\n<p>[Descreva a questão ou consulta que originou o pedido de parecer, indicando quem solicitou e com qual finalidade.]</p>\n\n<h3>2. Análise fundamentada</h3>\n<p>[Apresente a análise técnica, com base em referencial teórico-científico da Psicologia, normas do CFP e legislação pertinente. O parecer deve se restringir ao campo de conhecimento do psicólogo.]</p>\n\n<h3>3. Conclusão</h3>\n<p>[Apresente a resposta técnica à questão formulada, de forma objetiva e fundamentada.]</p>\n\n<p style="margin-top:30px; font-size:10pt; color:#666;"><em>Parecer elaborado em conformidade com a Resolução CFP nº 06/2019.</em></p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este documento é de caráter sigiloso, conforme Resolução CFP nº 06/2019.\n</div>',
ARRAY['numero_parecer','solicitante','assunto','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 11. Termo de Sigilo e Confidencialidade
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Sigilo e Confidencialidade',
'termo_sigilo',
'Termo reforçando o compromisso de sigilo entre profissional e paciente.',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE SIGILO E CONFIDENCIALIDADE</h2>\n\n<p>Eu, <strong>{{terapeuta_nome}}</strong>, Psicólogo(a), CRP <strong>{{terapeuta_crp}}</strong>, declaro ao(à) paciente <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, que:</p>\n\n<ol style="line-height:2;">\n <li>Todas as informações verbais, escritas ou de qualquer natureza, obtidas durante o processo terapêutico, serão mantidas em <strong>sigilo absoluto</strong>;</li>\n <li>O prontuário psicológico é de acesso exclusivo do profissional e do paciente, nos termos da Resolução CFP nº 001/2009;</li>\n <li>Os dados pessoais serão tratados conforme a Lei Geral de Proteção de Dados (Lei nº 13.709/2018), sendo utilizados exclusivamente para fins de acompanhamento clínico;</li>\n <li>A quebra de sigilo somente ocorrerá nas hipóteses previstas no Código de Ética do Psicólogo:\n <ul>\n <li>Situações de risco à vida do paciente ou de terceiros;</li>\n <li>Determinação judicial;</li>\n <li>Atendimento de menor — informações necessárias aos responsáveis;</li>\n </ul>\n </li>\n <li>Dados anonimizados poderão ser utilizados para fins de estudo ou pesquisa, mediante consentimento prévio específico.</li>\n</ol>\n\n<p style="margin-top:30px;">Este termo entra em vigor na data de sua assinatura e permanece válido mesmo após o encerramento do acompanhamento.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Documento regido pelo Código de Ética Profissional do Psicólogo e pela LGPD (Lei nº 13.709/2018).\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 12. Declaração de Início de Tratamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Declaração de Início de Tratamento',
'declaracao_inicio_tratamento',
'Declara que o paciente iniciou acompanhamento psicológico a partir de determinada data.',
E'<h2 style="text-align:center; margin-bottom:30px;">DECLARAÇÃO DE INÍCIO DE TRATAMENTO</h2>\n\n<p>Declaro, para os devidos fins, que <strong>{{paciente_nome}}</strong>, portador(a) do CPF nº <strong>{{paciente_cpf}}</strong>, encontra-se em acompanhamento psicológico neste consultório/clínica desde <strong>{{data_inicio_tratamento}}</strong>, com frequência <strong>{{frequencia_sessoes}}</strong>.</p>\n\n<p>O presente documento é expedido a pedido do(a) interessado(a) e não contém informações de caráter diagnóstico ou sigiloso.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','data_inicio_tratamento','frequencia_sessoes','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 13. Termo de Alta Terapêutica
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Alta Terapêutica',
'termo_alta',
'Documento formalizando o encerramento do acompanhamento psicológico.',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE ALTA TERAPÊUTICA</h2>\n\n<p>Comunico que o(a) paciente <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, que esteve em acompanhamento psicológico desde <strong>{{data_inicio_tratamento}}</strong>, recebe <strong>alta terapêutica</strong> nesta data.</p>\n\n<h3>Motivo da alta</h3>\n<p>{{motivo_alta}}</p>\n\n<h3>Orientações</h3>\n<p>{{orientacoes_pos_alta}}</p>\n\n<p style="margin-top:20px;">O(A) paciente foi informado(a) de que poderá retornar ao acompanhamento a qualquer momento, caso considere necessário.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n Ciente\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','data_inicio_tratamento','motivo_alta','orientacoes_pos_alta','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 14. Termo de Consentimento para Atendimento Online
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Consentimento para Atendimento Online',
'tcle_online',
'Consentimento específico para atendimento psicológico por meios tecnológicos (Resolução CFP nº 11/2018).',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE CONSENTIMENTO PARA ATENDIMENTO PSICOLÓGICO ONLINE</h2>\n\n<p>Eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro que fui informado(a) e concordo com a realização de atendimento psicológico na modalidade <strong>online</strong>, por meio de Tecnologias da Informação e Comunicação (TICs), conforme a Resolução CFP nº 11/2018.</p>\n\n<h3>1. Plataforma utilizada</h3>\n<p>As sessões serão realizadas por meio de <strong>{{plataforma_online}}</strong>, devendo ambas as partes garantir ambiente adequado, com privacidade e conexão estável.</p>\n\n<h3>2. Condições do atendimento</h3>\n<ul>\n <li>O(A) profissional está cadastrado(a) no e-Psi (plataforma do CFP) para prestação de serviços psicológicos online;</li>\n <li>Todas as regras de sigilo profissional aplicam-se igualmente ao atendimento online;</li>\n <li>A plataforma utilizada oferece criptografia e segurança dos dados;</li>\n <li>Não é permitida a gravação das sessões por nenhuma das partes, salvo acordo prévio por escrito.</li>\n</ul>\n\n<h3>3. Situações excepcionais</h3>\n<p>Em caso de instabilidade técnica que comprometa a sessão, o(a) profissional e o(a) paciente acordarão a melhor forma de dar continuidade (reagendar, trocar de plataforma, ou realizar sessão presencial).</p>\n\n<h3>4. Limitações</h3>\n<p>O(A) profissional reserva-se o direito de indicar atendimento presencial quando avaliar que a modalidade online não é adequada ao caso clínico.</p>\n\n<p style="margin-top:30px;">Declaro que compreendi as condições acima e <strong>consinto</strong> com a realização das sessões na modalidade online.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Atendimento online regulamentado pela Resolução CFP nº 11/2018. Profissional cadastrado(a) no e-Psi.\n</div>',
ARRAY['paciente_nome','paciente_cpf','plataforma_online','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
)
ON CONFLICT DO NOTHING;
-- ==========================================================================
-- FIM DO SEED — 14 templates globais
-- ==========================================================================