318 lines
9.7 KiB
TypeScript
318 lines
9.7 KiB
TypeScript
import { Pool } from "pg";
|
|
import type { TenantRequest, TenantRequestStatus } from "@/types";
|
|
import { listTenants, getTenant } from "./k8s";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Connection pool (singleton)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let pool: Pool | null = null;
|
|
|
|
function getPool(): Pool {
|
|
if (!pool) {
|
|
const connectionString =
|
|
process.env.DATABASE_URL ??
|
|
"postgresql://portal:portal@portal-db-rw.portal.svc:5432/portal";
|
|
pool = new Pool({ connectionString, max: 5 });
|
|
}
|
|
return pool;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schema migration (auto-run on first query)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MIGRATION_SQL = `
|
|
CREATE TABLE IF NOT EXISTS tenant_requests (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
zitadel_org_id TEXT NOT NULL UNIQUE,
|
|
zitadel_user_id TEXT NOT NULL,
|
|
company_name TEXT NOT NULL,
|
|
contact_name TEXT NOT NULL,
|
|
contact_email TEXT NOT NULL,
|
|
agent_name TEXT NOT NULL DEFAULT 'Assistant',
|
|
soul_md TEXT,
|
|
agents_md TEXT,
|
|
packages TEXT[] DEFAULT '{}',
|
|
billing_address JSONB DEFAULT '{}',
|
|
billing_notes TEXT,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
admin_notes TEXT,
|
|
tenant_name TEXT,
|
|
encrypted_secrets BYTEA,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
|
|
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id);
|
|
|
|
-- Idempotent column adds for existing databases
|
|
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS encrypted_secrets BYTEA;
|
|
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS agents_md TEXT;
|
|
|
|
-- Workspace templates: admin-editable default content for workspace files
|
|
CREATE TABLE IF NOT EXISTS workspace_templates (
|
|
file_key TEXT PRIMARY KEY,
|
|
content TEXT NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`;
|
|
|
|
let migrated = false;
|
|
|
|
export async function ensureSchema(): Promise<void> {
|
|
if (migrated) return;
|
|
await getPool().query(MIGRATION_SQL);
|
|
migrated = true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workspace templates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get a workspace template by file key (e.g. "SOUL.md", "AGENTS.md", "TOOLS.md").
|
|
* Returns null if no template is stored for this key.
|
|
*/
|
|
export async function getWorkspaceTemplate(
|
|
fileKey: string
|
|
): Promise<string | null> {
|
|
await ensureSchema();
|
|
const result = await getPool().query<{ content: string }>(
|
|
"SELECT content FROM workspace_templates WHERE file_key = $1",
|
|
[fileKey]
|
|
);
|
|
return result.rows[0]?.content ?? null;
|
|
}
|
|
|
|
/**
|
|
* Upsert a workspace template.
|
|
*/
|
|
export async function setWorkspaceTemplate(
|
|
fileKey: string,
|
|
content: string
|
|
): Promise<void> {
|
|
await ensureSchema();
|
|
await getPool().query(
|
|
`INSERT INTO workspace_templates (file_key, content, updated_at)
|
|
VALUES ($1, $2, now())
|
|
ON CONFLICT (file_key) DO UPDATE SET content = $2, updated_at = now()`,
|
|
[fileKey, content]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* List all workspace templates.
|
|
*/
|
|
export async function listWorkspaceTemplates(): Promise<
|
|
Array<{ fileKey: string; content: string; updatedAt: string }>
|
|
> {
|
|
await ensureSchema();
|
|
const result = await getPool().query(
|
|
"SELECT file_key, content, updated_at FROM workspace_templates ORDER BY file_key"
|
|
);
|
|
return result.rows.map((r: any) => ({
|
|
fileKey: r.file_key,
|
|
content: r.content,
|
|
updatedAt: r.updated_at?.toISOString?.() ?? r.updated_at,
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tenant requests CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function createTenantRequest(
|
|
params: Omit<TenantRequest, "id" | "status" | "createdAt" | "updatedAt"> & {
|
|
encryptedSecrets?: Buffer;
|
|
}
|
|
): Promise<TenantRequest> {
|
|
await ensureSchema();
|
|
const result = await getPool().query<TenantRequest>(
|
|
`INSERT INTO tenant_requests
|
|
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
|
|
contact_email, agent_name, soul_md, agents_md, packages, billing_address,
|
|
billing_notes, encrypted_secrets)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
RETURNING *`,
|
|
[
|
|
params.zitadelOrgId,
|
|
params.zitadelUserId,
|
|
params.companyName,
|
|
params.contactName,
|
|
params.contactEmail,
|
|
params.agentName,
|
|
params.soulMd,
|
|
params.agentsMd ?? null,
|
|
params.packages,
|
|
JSON.stringify(params.billingAddress),
|
|
params.billingNotes,
|
|
params.encryptedSecrets ?? null,
|
|
]
|
|
);
|
|
return mapRow(result.rows[0]);
|
|
}
|
|
|
|
export async function getTenantRequestById(
|
|
id: string
|
|
): Promise<TenantRequest | null> {
|
|
await ensureSchema();
|
|
const result = await getPool().query<TenantRequest>(
|
|
"SELECT * FROM tenant_requests WHERE id = $1",
|
|
[id]
|
|
);
|
|
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
}
|
|
|
|
export async function getTenantRequestByOrgId(
|
|
orgId: string
|
|
): Promise<TenantRequest | null> {
|
|
await ensureSchema();
|
|
const result = await getPool().query<TenantRequest>(
|
|
"SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 ORDER BY created_at DESC LIMIT 1",
|
|
[orgId]
|
|
);
|
|
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
}
|
|
|
|
export async function listTenantRequests(
|
|
status?: TenantRequestStatus
|
|
): Promise<TenantRequest[]> {
|
|
await ensureSchema();
|
|
const result = status
|
|
? await getPool().query<TenantRequest>(
|
|
"SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC",
|
|
[status]
|
|
)
|
|
: await getPool().query<TenantRequest>(
|
|
"SELECT * FROM tenant_requests ORDER BY created_at DESC"
|
|
);
|
|
return result.rows.map(mapRow);
|
|
}
|
|
|
|
export async function updateTenantRequestStatus(
|
|
id: string,
|
|
status: TenantRequestStatus,
|
|
extra?: {
|
|
adminNotes?: string | null;
|
|
tenantName?: string;
|
|
clearAdminNotes?: boolean;
|
|
}
|
|
): Promise<TenantRequest> {
|
|
await ensureSchema();
|
|
const sets = ["status = $2", "updated_at = now()"];
|
|
const values: any[] = [id, status];
|
|
let idx = 3;
|
|
|
|
if (extra?.adminNotes !== undefined) {
|
|
sets.push(`admin_notes = $${idx}`);
|
|
values.push(extra.adminNotes);
|
|
idx++;
|
|
}
|
|
if (extra?.clearAdminNotes) {
|
|
sets.push("admin_notes = NULL");
|
|
}
|
|
if (extra?.tenantName) {
|
|
sets.push(`tenant_name = $${idx}`);
|
|
values.push(extra.tenantName);
|
|
idx++;
|
|
}
|
|
|
|
const result = await getPool().query<TenantRequest>(
|
|
`UPDATE tenant_requests SET ${sets.join(", ")} WHERE id = $1 RETURNING *`,
|
|
values
|
|
);
|
|
return mapRow(result.rows[0]);
|
|
}
|
|
|
|
/**
|
|
* Clear the encrypted_secrets column after secrets have been written to OpenBao.
|
|
* Called during admin approval after successful vault writes.
|
|
*/
|
|
export async function clearEncryptedSecrets(requestId: string): Promise<void> {
|
|
await ensureSchema();
|
|
await getPool().query(
|
|
"UPDATE tenant_requests SET encrypted_secrets = NULL, updated_at = now() WHERE id = $1",
|
|
[requestId]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Mark a tenant request as "deleted" when the associated tenant CR is deleted.
|
|
* This allows the customer to re-submit the onboarding wizard.
|
|
*/
|
|
export async function markTenantRequestDeletedByTenantName(
|
|
tenantName: string
|
|
): Promise<void> {
|
|
await ensureSchema();
|
|
await getPool().query(
|
|
"UPDATE tenant_requests SET status = 'deleted', tenant_name = NULL, updated_at = now() WHERE tenant_name = $1",
|
|
[tenantName]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Delete a tenant request row entirely. Used when a customer re-submits
|
|
* after their previous tenant was deleted by admin.
|
|
*/
|
|
export async function deleteTenantRequest(id: string): Promise<void> {
|
|
await ensureSchema();
|
|
await getPool().query("DELETE FROM tenant_requests WHERE id = $1", [id]);
|
|
}
|
|
|
|
/**
|
|
* Sync provisioning statuses: for all requests with status "provisioning",
|
|
* check if the PiecedTenant CR has reached "Ready" and update to "active".
|
|
*/
|
|
export async function syncProvisioningStatuses(): Promise<void> {
|
|
await ensureSchema();
|
|
const result = await getPool().query<TenantRequest>(
|
|
"SELECT * FROM tenant_requests WHERE status = 'provisioning'"
|
|
);
|
|
|
|
for (const row of result.rows) {
|
|
const mapped = mapRow(row);
|
|
if (!mapped.tenantName) continue;
|
|
|
|
try {
|
|
const tenant = await getTenant(mapped.tenantName);
|
|
if (
|
|
tenant?.status?.phase === "Ready" ||
|
|
tenant?.status?.phase === "Running"
|
|
) {
|
|
await updateTenantRequestStatus(mapped.id, "active");
|
|
}
|
|
} catch {
|
|
// Tenant might not exist yet — skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Row mapper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function mapRow(row: any): TenantRequest {
|
|
return {
|
|
id: row.id,
|
|
zitadelOrgId: row.zitadel_org_id,
|
|
zitadelUserId: row.zitadel_user_id,
|
|
companyName: row.company_name,
|
|
contactName: row.contact_name,
|
|
contactEmail: row.contact_email,
|
|
agentName: row.agent_name,
|
|
soulMd: row.soul_md,
|
|
agentsMd: row.agents_md ?? null,
|
|
packages: row.packages ?? [],
|
|
billingAddress: row.billing_address ?? {},
|
|
billingNotes: row.billing_notes,
|
|
status: row.status as TenantRequestStatus,
|
|
adminNotes: row.admin_notes,
|
|
tenantName: row.tenant_name,
|
|
encryptedSecrets: row.encrypted_secrets ?? null,
|
|
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
|
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
|
};
|
|
}
|