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 { 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 { 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 { 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 & { encryptedSecrets?: Buffer; } ): Promise { await ensureSchema(); const result = await getPool().query( `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 { await ensureSchema(); const result = await getPool().query( "SELECT * FROM tenant_requests WHERE id = $1", [id] ); return result.rows[0] ? mapRow(result.rows[0]) : null; } export async function getTenantRequestByOrgId( orgId: string ): Promise { await ensureSchema(); const result = await getPool().query( "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 { await ensureSchema(); const result = status ? await getPool().query( "SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC", [status] ) : await getPool().query( "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 { 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( `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 { 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 { 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 { 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 { await ensureSchema(); const result = await getPool().query( "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, }; }