mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
O gatilho fn_seed_org_llm_defaults semeia provider e default_model da
Anthropic juntos, e tanto o bootstrap-owner.ts quanto o install.sh (que
não roda o bootstrap: tem o próprio SQL) trocavam só o provider. O par
{openai, claude-sonnet-5} fazia o padrão da organização montar
openai/claude-sonnet-5, e o clima mandava à OpenAI um id da Anthropic.
Raiz: os dois instaladores gravam o modelo do provedor escolhido. O
bootstrap usa a mesma régua do onboarding (escolherModeloNoCatalogo,
agora a leitura única do catálogo, também usada por
definirPadraoDeIaDaOrganizacao); o install.sh grava o curado do
provedor e só quando o modelo gravado não é desse provedor, para quem
re-roda o instalador não perder a escolha feita pela tela. Catálogo
vazio (OpenRouter antes do sync) mantém só o provider, como antes.
Leitura: para instalações que já têm o par quebrado, sem migration de
dados, o padrão da organização em gateway-binding troca o modelo pelo
do catálogo do provedor quando o gravado não pertence a ele. Par
coerente passa intacto; catálogo vazio ou ilegível mantém o gravado.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PWGNXXzCQURHMALcEMmkC
276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
/**
|
||
* Bootstrap do 1º dono de uma instância self-host do DeskcommCRM.
|
||
*
|
||
* O app NÃO tem tela de cadastro — este script cria, de forma idempotente:
|
||
* 1. o usuário dono (auth) com e-mail confirmado
|
||
* 2. a organização (tenant)
|
||
* 3. a associação do dono como `admin`
|
||
* 4. a linha em `platform_admins` (super-admin de plataforma)
|
||
*
|
||
* Depois disso o dono faz login e o onboarding do app cuida do resto
|
||
* (WhatsApp, IA, time). A verificação em duas etapas é OPCIONAL e se liga em
|
||
* Configurações › Segurança — ver `lib/auth/politica-mfa.ts`.
|
||
*
|
||
* Uso (o install.sh exporta as vars; localmente lê .env/.env.local):
|
||
* OWNER_EMAIL=dono@empresa.com OWNER_PASSWORD='senha-forte' \
|
||
* OWNER_ORG_NAME='Minha Empresa' npx tsx scripts/bootstrap-owner.ts
|
||
*/
|
||
|
||
import { createClient } from "@supabase/supabase-js";
|
||
import * as fs from "node:fs";
|
||
import * as path from "node:path";
|
||
|
||
import { escolherModeloNoCatalogo } from "@/lib/ai/agents/escolher-modelo";
|
||
|
||
/** Lê env do processo; completa com .env / .env.local se rodando localmente. */
|
||
function loadEnv(): Record<string, string> {
|
||
const out: Record<string, string> = { ...process.env } as Record<string, string>;
|
||
for (const file of [".env", ".env.local"]) {
|
||
const p = path.join(process.cwd(), file);
|
||
if (!fs.existsSync(p)) continue;
|
||
for (const line of fs.readFileSync(p, "utf8").split("\n")) {
|
||
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
||
if (m && !out[m[1]!]) out[m[1]!] = m[2]!.replace(/^"(.*)"$/, "$1");
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const env = loadEnv();
|
||
|
||
const SUPABASE_URL = env.NEXT_PUBLIC_SUPABASE_URL;
|
||
const SERVICE_ROLE = env.SUPABASE_SERVICE_ROLE_KEY;
|
||
const OWNER_EMAIL = env.OWNER_EMAIL;
|
||
const OWNER_PASSWORD = env.OWNER_PASSWORD;
|
||
const ORG_NAME = env.OWNER_ORG_NAME || "Minha Empresa";
|
||
/**
|
||
* O idioma que quem instalou escolheu, gravado na ORGANIZAÇÃO.
|
||
*
|
||
* Na organização, e não só no usuário dono, porque é ela que responde por quem
|
||
* ainda não existe: o segundo, o terceiro e o décimo convidado entram sem
|
||
* preferência própria e caem no idioma da empresa
|
||
* (`AuthUser.idioma`, resolvido em `lib/auth/server.ts`). Gravar apenas no dono
|
||
* faria uma instalação inteira em espanhol entregar o sistema em português para
|
||
* todo mundo que o dono convidasse.
|
||
*
|
||
* Fecha para o padrão diante de qualquer valor desconhecido: um `.env` com
|
||
* `APP_LOCALE=en` não pode derrubar a instalação nem escrever lixo no banco.
|
||
*/
|
||
const IDIOMAS_SERVIDOS = ["pt-BR", "es"] as const;
|
||
const APP_LOCALE = (IDIOMAS_SERVIDOS as readonly string[]).includes(
|
||
(env.APP_LOCALE ?? "").trim(),
|
||
)
|
||
? (env.APP_LOCALE as string).trim()
|
||
: "pt-BR";
|
||
|
||
if (!SUPABASE_URL || !SERVICE_ROLE) {
|
||
throw new Error("Faltam NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY.");
|
||
}
|
||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||
throw new Error("Faltam OWNER_EMAIL / OWNER_PASSWORD.");
|
||
}
|
||
|
||
/** slug seguro (o tipo da coluna é restrito): minúsculo, hífens, sem acento. */
|
||
function slugify(s: string): string {
|
||
return s
|
||
.normalize("NFD")
|
||
.replace(/[̀-ͯ]/g, "")
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.slice(0, 40) || "minha-empresa";
|
||
}
|
||
|
||
const admin = createClient(SUPABASE_URL, SERVICE_ROLE, {
|
||
auth: { autoRefreshToken: false, persistSession: false },
|
||
});
|
||
|
||
async function ensureOwnerUser(): Promise<string> {
|
||
const { data: list } = await admin.auth.admin.listUsers({ perPage: 200 });
|
||
const existing = list.users.find((u) => u.email === OWNER_EMAIL);
|
||
if (existing) {
|
||
await admin.auth.admin.updateUserById(existing.id, { password: OWNER_PASSWORD });
|
||
console.log(`[bootstrap] dono já existia, senha atualizada: ${existing.id}`);
|
||
return existing.id;
|
||
}
|
||
const { data, error } = await admin.auth.admin.createUser({
|
||
email: OWNER_EMAIL,
|
||
password: OWNER_PASSWORD,
|
||
email_confirm: true,
|
||
// O dono também nasce com a preferência: ele é o único que entra antes de
|
||
// existir organização resolvida na sessão, no primeiro login.
|
||
user_metadata: { full_name: "Dono", locale: APP_LOCALE },
|
||
});
|
||
if (error || !data?.user) throw new Error(`criar dono: ${error?.message}`);
|
||
console.log(`[bootstrap] dono criado: ${data.user.id}`);
|
||
return data.user.id;
|
||
}
|
||
|
||
async function ensureOrg(ownerId: string): Promise<string> {
|
||
const slug = slugify(ORG_NAME);
|
||
const { data: existing } = await admin
|
||
.from("organizations")
|
||
.select("id")
|
||
.eq("slug", slug)
|
||
.maybeSingle();
|
||
if (existing) {
|
||
console.log(`[bootstrap] org já existia: ${(existing as { id: string }).id}`);
|
||
return (existing as { id: string }).id;
|
||
}
|
||
const { data, error } = await admin
|
||
.from("organizations")
|
||
.insert({
|
||
slug,
|
||
display_name: ORG_NAME,
|
||
legal_name: ORG_NAME,
|
||
locale: APP_LOCALE,
|
||
created_by: ownerId,
|
||
} as never)
|
||
.select("id")
|
||
.single();
|
||
if (error || !data) throw new Error(`criar org: ${error?.message}`);
|
||
const orgId = (data as { id: string }).id;
|
||
console.log(`[bootstrap] org criada: ${orgId}`);
|
||
await aplicarProvedorEscolhido(orgId);
|
||
return orgId;
|
||
}
|
||
|
||
/**
|
||
* O provedor que a pessoa ESCOLHEU no instalador passa a valer no banco.
|
||
*
|
||
* `fn_seed_org_llm_defaults` (trigger de insert em `organizations`) semeia
|
||
* `settings.llm.provider = 'anthropic'`, fixo. Enquanto a Anthropic era a única
|
||
* chave que o `install.sh` pedia, isso estava certo. Desde que o instalador
|
||
* pergunta qual IA vai atender, a resposta era simplesmente ignorada pelo
|
||
* banco: quem escolhia OpenRouter instalava, cadastrava a chave, e todo caminho
|
||
* que passa pelo agent-engine resolvia `provider='anthropic'` — sem chave da
|
||
* Anthropic, `LlmNotConfiguredError` em tudo, com a mensagem mandando cadastrar
|
||
* justamente a chave que ele decidiu não usar.
|
||
*
|
||
* O par vai INTEIRO (`lib/ai/pontos/padrao-da-organizacao.ts`): o gatilho
|
||
* semeia `provider` e `default_model` da Anthropic juntos, e trocar só o
|
||
* provedor deixava `{openai, claude-sonnet-5}` — um id que a OpenAI não
|
||
* conhece, pedido por todo ponto que cai no padrão da empresa. O modelo sai do
|
||
* catálogo do provedor escolhido pela MESMA régua do onboarding
|
||
* (`escolherModeloNoCatalogo`), então nada aqui é inventado.
|
||
*
|
||
* Catálogo vazio (a OpenRouter chega com zero linhas até o cron de catálogo
|
||
* rodar) ou ilegível: grava só o `provider`, como antes. O par incoerente que
|
||
* sobra é resolvido na LEITURA por `lib/ai/gateway-binding.ts`, que troca o
|
||
* modelo pelo do catálogo assim que ele existir.
|
||
*/
|
||
async function aplicarProvedorEscolhido(orgId: string): Promise<void> {
|
||
const escolhido = (process.env.AI_PROVIDER ?? "").trim().toLowerCase();
|
||
if (escolhido === "" || escolhido === "anthropic") return;
|
||
|
||
const { data: org } = await admin
|
||
.from("organizations")
|
||
.select("settings")
|
||
.eq("id", orgId)
|
||
.maybeSingle();
|
||
const settings = ((org as { settings?: Record<string, unknown> } | null)?.settings ?? {}) as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
const llm = ((settings["llm"] as Record<string, unknown> | undefined) ?? {}) as Record<string, unknown>;
|
||
|
||
const escolha = await escolherModeloNoCatalogo(admin, escolhido);
|
||
const modelo = escolha?.escolhido ? escolha.modelId : null;
|
||
if (modelo === null) {
|
||
console.warn(
|
||
`[bootstrap] ${escolha === null ? "não consegui ler" : "ainda não há modelo no"} catálogo de "${escolhido}": ` +
|
||
`gravo só o provedor, e o modelo padrão se escolhe em Agente de IA → Provedores.`,
|
||
);
|
||
}
|
||
const novoLlm = modelo === null ? { ...llm, provider: escolhido } : { ...llm, provider: escolhido, default_model: modelo };
|
||
|
||
const { error } = await admin
|
||
.from("organizations")
|
||
.update({ settings: { ...settings, llm: novoLlm } } as never)
|
||
.eq("id", orgId);
|
||
if (error) {
|
||
// Não derruba a instalação: a org existe e o operador consegue trocar o
|
||
// provedor pela tela. Mas o aviso precisa aparecer, senão ele descobre
|
||
// pelo agente mudo.
|
||
console.warn(
|
||
`[bootstrap] não consegui gravar o provedor "${escolhido}" na organização: ${error.message}. ` +
|
||
`Ajuste em Agente de IA → Provedores depois de entrar.`,
|
||
);
|
||
return;
|
||
}
|
||
console.log(`[bootstrap] provedor de IA da organização: ${escolhido}${modelo === null ? "" : ` (modelo ${modelo})`}`);
|
||
}
|
||
|
||
async function ensureMembership(userId: string, orgId: string): Promise<void> {
|
||
const { data: existing } = await admin
|
||
.from("user_organizations")
|
||
.select("user_id")
|
||
.eq("user_id", userId)
|
||
.eq("organization_id", orgId)
|
||
.maybeSingle();
|
||
if (existing) {
|
||
await admin
|
||
.from("user_organizations")
|
||
.update({ role: "admin", revoked_at: null } as never)
|
||
.eq("user_id", userId)
|
||
.eq("organization_id", orgId);
|
||
console.log("[bootstrap] associação admin garantida");
|
||
return;
|
||
}
|
||
const { error } = await admin.from("user_organizations").insert({
|
||
user_id: userId,
|
||
organization_id: orgId,
|
||
role: "admin",
|
||
accepted_at: new Date().toISOString(),
|
||
} as never);
|
||
if (error) throw new Error(`associação: ${error.message}`);
|
||
console.log("[bootstrap] dono associado como admin");
|
||
}
|
||
|
||
async function ensurePlatformAdmin(userId: string): Promise<void> {
|
||
const { data: existing } = await admin
|
||
.from("platform_admins")
|
||
.select("user_id")
|
||
.eq("user_id", userId)
|
||
.is("revoked_at", null)
|
||
.maybeSingle();
|
||
if (existing) {
|
||
console.log("[bootstrap] super-admin já existia");
|
||
return;
|
||
}
|
||
// granted_by = o próprio dono (auto-concessão no bootstrap).
|
||
//
|
||
// ⚠️ `mfa_required: false` EXPLÍCITO, contra o default `true` da coluna. A
|
||
// coluna nunca era lida pelo gate — ele olhava só `is_platform_admin` —, então
|
||
// o default nunca teve efeito e ninguém percebeu. Agora ela decide, e deixá-la
|
||
// em `true` significaria o oposto do que se pediu: TODA instalação nova
|
||
// voltaria a receber o bloqueador de tela cheia logo depois do onboarding,
|
||
// porque o `install.sh` cria o dono como platform admin.
|
||
//
|
||
// Instalações que JÁ EXISTEM ficam como estão — mudar o default não reescreve
|
||
// linha, e ninguém tem a proteção desligada pelas nossas costas. Quem quiser
|
||
// exigir liga em Configurações › Segurança.
|
||
const { error } = await admin.from("platform_admins").insert({
|
||
user_id: userId,
|
||
granted_by: userId,
|
||
scope: "full",
|
||
mfa_required: false,
|
||
reason: "Bootstrap inicial do self-host (dono da instância)",
|
||
} as never);
|
||
if (error) throw new Error(`platform_admin: ${error.message}`);
|
||
console.log("[bootstrap] dono promovido a super-admin de plataforma");
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
const ownerId = await ensureOwnerUser();
|
||
const orgId = await ensureOrg(ownerId);
|
||
await ensureMembership(ownerId, orgId);
|
||
await ensurePlatformAdmin(ownerId);
|
||
console.log(`\n✅ Bootstrap completo.\n dono: ${OWNER_EMAIL}\n org: ${orgId}\n Faça login em ${env.NEXT_PUBLIC_APP_URL || "https://<seu-dominio>"} e conclua o onboarding.`);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error("❌ Bootstrap falhou:", err);
|
||
process.exit(1);
|
||
});
|