refactor(infra)!: schema sar no banco ERP substitui banco-por-workspace

Revoga o ADR 0006. O isolamento multi-tenant deixa de ser um banco por
workspace e passa a ser o schema `sar` dentro do proprio banco ERP do
cliente, com `id_empresa` discriminando o tenant em todas as tabelas.

Provisionamento:
- `provision-client.ts` substitui `provision-workspace.ts`. Nao cria mais
  banco: aplica schema + views + triggers num banco ERP ja existente.
  Trata o encoding LATIN1 dos bancos ERP via `toLatinSafe()`.
- `sar-erp-schema.sql` vira a fonte unica: absorve as views que estavam em
  `sarweb_views.sql` e passa a conter tambem `sar.chamados` /
  `sar.chamado_mensagens`, que so existiam via migration.

Correcoes necessarias para o provisionamento rodar ponta a ponta:
- `migrate deploy` abortava com P3005, porque o schema SQL roda antes do
  Prisma e o schema `sar` nunca esta vazio. O script agora detecta banco
  pre-existente e faz o baseline sozinho.
- `_prisma_migrations` ia parar no schema `public` do ERP do cliente; a URL
  de migracao agora leva `?schema=sar`.
- `sar.pedidos` nao tinha `end_entrega`, coluna que o schema.prisma ja
  declarava desde a feature de endereco de entrega, e que derrubava
  GET /orders com 500. Migration adicionada.
- `vw_sitpedido` se perdeu junto com `sarweb_views.sql` e e usada pela
  consulta de pedidos ERP. Restaurada.

Remove o seed de dados demo e os fallbacks de DATABASE_URL hardcoded: sem
a variavel configurada a API agora falha explicitamente, em vez de apontar
silenciosamente para um banco de desenvolvimento inexistente.

Verificado ponta a ponta contra um banco ERP real: schema aplicado, 13
tabelas + 20 views, migrations registradas, e segunda execucao idempotente.

BREAKING CHANGE: DATABASE_URL passa a ser obrigatoria. O comando de
provisionamento muda para `pnpm client:provision --host <host> --db <banco>`
(rename efetivado no commit de dependencias).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:11:35 +00:00
parent 32ff4b43fd
commit bf6e21ce47
10 changed files with 324 additions and 1467 deletions

189
scripts/provision-client.ts Normal file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env tsx
/**
* SAR — Provisionamento de cliente
*
* Aplica o schema SAR (views + tabelas + triggers) no banco ERP do cliente.
* Não requer psql — usa a lib pg que já é dependência do projeto.
*
* Uso:
* pnpm client:provision --host <host> --db <banco> [opções]
*
* Opções:
* --host Host do PostgreSQL (obrigatório)
* --db Nome do banco ERP (obrigatório)
* --port Porta (default: 5432)
* --user Usuário (default: postgres)
* --password Senha (default: lê PG_PASSWORD do env)
* --role Role do app SAR — aplica GRANTs se informado (ex: sar_app)
*
* O que faz:
* 1. Executa scripts/sar-erp-schema.sql (schema sar + views + tabelas SAR)
* 2. Executa scripts/sar-triggers.sql (triggers de sync ERP + staging tables)
* 3. Aplica GRANTs se --role foi informado
* 4. Executa `prisma migrate deploy` (aplica migrations Prisma pendentes)
*/
import { readFileSync, readdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import pg from 'pg';
// ─── CLI args ─────────────────────────────────────────────────────────────────
function arg(flag: string): string | undefined {
const idx = process.argv.indexOf(flag);
return idx !== -1 ? process.argv[idx + 1] : undefined;
}
const host = arg('--host');
const db = arg('--db');
const port = parseInt(arg('--port') ?? '5432', 10);
const user = arg('--user') ?? 'postgres';
const password = arg('--password') ?? process.env['PG_PASSWORD'] ?? '';
const role = arg('--role');
if (!host || !db) {
console.error(
'Uso: pnpm client:provision --host <host> --db <banco> [--port 5432] [--user postgres] [--password ...] [--role <pg-role>]',
);
console.error(
'Exemplo: pnpm client:provision --host 192.168.0.43 --db libreplast --role sar_app',
);
process.exit(1);
}
// ─── Paths ────────────────────────────────────────────────────────────────────
const SCRIPTS_DIR = dirname(fileURLToPath(import.meta.url));
const MONOREPO_ROOT = resolve(SCRIPTS_DIR, '..');
const API_DIR = resolve(MONOREPO_ROOT, 'apps/api');
// Banco ERP usa LATIN1 — strip chars fora do range 0x00-0xFF (decorações nos comentários SQL)
function toLatinSafe(sql: string): string {
// O range LATIN1 começa em \x00 por definição — control chars intencionais.
// eslint-disable-next-line no-control-regex
return sql.replace(/[^\x00-\xFF]/g, (c) => {
if (c === '—' || c === '') return '--'; // em dash / en dash
return '-'; // box-drawing chars e outros
});
}
const SQL_SCHEMA = toLatinSafe(readFileSync(resolve(SCRIPTS_DIR, 'sar-erp-schema.sql'), 'utf8'));
const SQL_TRIGGERS = toLatinSafe(readFileSync(resolve(SCRIPTS_DIR, 'sar-triggers.sql'), 'utf8'));
const DATABASE_URL = `postgresql://${user}:${encodeURIComponent(password)}@${host}:${port}/${db}`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function runSql(client: pg.Client, label: string, sql: string): Promise<void> {
console.log(` Executando ${label}...`);
await client.query(sql);
console.log(`${label} aplicado`);
}
async function applyGrants(client: pg.Client, appRole: string): Promise<void> {
console.log(` Aplicando GRANTs para role "${appRole}"...`);
await client.query(`
GRANT USAGE ON SCHEMA sar TO ${appRole};
GRANT SELECT ON ALL TABLES IN SCHEMA sar TO ${appRole};
GRANT INSERT, UPDATE, DELETE ON
sar.pedidos,
sar.pedido_itens,
sar.historico_pedido,
sar.alcada_desconto,
sar.meta_representante,
sar.push_subscription,
sar.promocoes,
sar.clientes_novos,
sar.contatos_novos
TO ${appRole};
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA sar TO ${appRole};
`);
console.log(` ✓ GRANTs aplicados`);
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
console.log('\nSAR — Provisionamento de Cliente');
console.log('──────────────────────────────────');
console.log(`Host: ${host}:${port}`);
console.log(`Banco: ${db}`);
console.log(`User: ${user}`);
if (role) console.log(`Role: ${role} (GRANTs serão aplicados)`);
console.log('');
const client = new pg.Client({ host, port, database: db, user, password });
await client.connect();
let needsBaseline = false;
try {
console.log('[1/3] Schema SAR...');
await runSql(client, 'sar-erp-schema.sql', SQL_SCHEMA);
console.log('[2/3] Triggers e tabelas staging...');
await runSql(client, 'sar-triggers.sql', SQL_TRIGGERS);
if (role) {
console.log('[+] GRANTs...');
await applyGrants(client, role);
}
// O schema SQL acima já criou as tabelas, então o schema `sar` nunca está
// vazio quando o Prisma roda. Sem histórico de migrations o `migrate deploy`
// aborta com P3005 — é preciso marcar a migration baseline como aplicada.
const { rows } = await client.query(
`SELECT to_regclass('sar._prisma_migrations') IS NOT NULL AS existe`,
);
needsBaseline = !rows[0]?.existe;
} finally {
await client.end();
}
// _prisma_migrations precisa morar no schema sar, não no public do ERP.
const MIGRATE_URL = `${DATABASE_URL}?schema=sar`;
const prismaEnv = { ...process.env, DATABASE_URL: MIGRATE_URL };
console.log('[3/3] Migrations Prisma...');
if (needsBaseline) {
const baseline = readdirSync(resolve(API_DIR, 'prisma/migrations'), { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort()[0];
if (!baseline) throw new Error('nenhuma migration encontrada para baseline');
console.log(` Banco pré-existente: marcando "${baseline}" como aplicada (baseline)...`);
execSync(`pnpm exec prisma migrate resolve --applied ${baseline}`, {
cwd: API_DIR,
env: prismaEnv,
stdio: 'inherit',
});
}
execSync('pnpm exec prisma migrate deploy', {
cwd: API_DIR,
env: prismaEnv,
stdio: 'inherit',
});
console.log(' ✓ Migrations aplicadas');
console.log('');
console.log('════════════════════════════════════');
console.log('✓ Cliente provisionado com sucesso');
console.log('════════════════════════════════════');
console.log('');
console.log('Próximos passos:');
console.log(` 1. Configure DATABASE_URL=${DATABASE_URL}`);
console.log(` no ambiente de produção (Vault ou .env do cliente)`);
if (!role) {
console.log(' 2. Execute os GRANTs manualmente ou rode novamente com --role <pg-role>');
}
console.log('');
}
main().catch((e: unknown) => {
console.error('\nErro durante provisionamento:', e);
process.exit(1);
});