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) // --------------------------------------------------------------------------- // Notes on the Slice 3 changes // ---------------------------- // 1. Removed `UNIQUE` from `zitadel_org_id` in the CREATE TABLE for fresh // installs, AND emit a defensive `DROP CONSTRAINT IF EXISTS` for // existing installs whose schema was created pre-Slice-3. The // constraint was Postgres-autonamed; the name is deterministic. // 2. Added `instance_name TEXT` — the customer's human label per // instance (e.g. "Production", "Dev"). NULL is fine and means "use // the company name for display". // 3. Added a unique index on `tenant_name WHERE NOT NULL`. Multiple // rows in the table can have NULL tenant_name (pending/rejected // requests), but every approved row points to a distinct K8s CR. // 4. Added `(zitadel_org_id, status)` index for the list-by-org queries // introduced this slice. const MIGRATION_SQL = ` CREATE TABLE IF NOT EXISTS tenant_requests ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), zitadel_org_id TEXT NOT NULL, zitadel_user_id TEXT NOT NULL, company_name TEXT NOT NULL, instance_name TEXT, 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, is_personal BOOLEAN NOT NULL DEFAULT FALSE, 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); CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_status ON tenant_requests(zitadel_org_id, status); CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_tenant_name ON tenant_requests(tenant_name) WHERE tenant_name IS NOT NULL; -- 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; ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS instance_name TEXT; ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS is_personal BOOLEAN NOT NULL DEFAULT FALSE; -- Slice 3: drop the legacy 1-org-1-request constraint if it exists ALTER TABLE tenant_requests DROP CONSTRAINT IF EXISTS tenant_requests_zitadel_org_id_key; -- 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, instance_name, contact_name, contact_email, agent_name, soul_md, agents_md, packages, billing_address, billing_notes, encrypted_secrets, is_personal) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`, [ params.zitadelOrgId, params.zitadelUserId, params.companyName, params.instanceName ?? null, params.contactName, params.contactEmail, params.agentName, params.soulMd, params.agentsMd ?? null, params.packages, JSON.stringify(params.billingAddress), params.billingNotes, params.encryptedSecrets ?? null, params.isPersonal ?? false, ] ); 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; } /** * Slice 3: returns ALL requests for an org, most recent first. * * Replaces the pre-Slice-3 `getTenantRequestByOrgId` which returned the * single most recent row. Callers that previously assumed one-row-per-org * must now iterate or pick by status. The intent is explicit at every * call site, which is the point of the rename. * * Includes rows in every status (pending, approved, provisioning, active, * rejected, deleted). For "active or in-flight only" filtering, see * {@link listActiveTenantRequestsByOrgId}. */ export async function listTenantRequestsByOrgId( orgId: string ): Promise { await ensureSchema(); const result = await getPool().query( "SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 ORDER BY created_at DESC", [orgId] ); return result.rows.map(mapRow); } /** * As {@link listTenantRequestsByOrgId} but excludes terminal-failed states * (rejected, deleted). Useful for the dashboard which wants to show * pending/approved/provisioning/active tenants and pending requests, not * historical rejections. */ export async function listActiveTenantRequestsByOrgId( orgId: string ): Promise { await ensureSchema(); const result = await getPool().query( `SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 AND status NOT IN ('deleted', 'rejected') ORDER BY created_at DESC`, [orgId] ); return result.rows.map(mapRow); } /** * Returns the most recent approved-or-active request for an org. Used to * seed billing/contact defaults when a customer creates an additional * instance — saves them re-typing data already on file. * * Returns null if the org has never had an approved instance (e.g. first * registration is still pending). */ export async function getMostRecentApprovedRequestForOrg( orgId: string ): Promise { await ensureSchema(); const result = await getPool().query( `SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 AND status IN ('approved', 'provisioning', 'active') 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] ); } /** * Wrapper around domain-check.ts that injects the portal's connection pool. * Kept here so route handlers don't need direct access to the pool. */ export async function checkDuplicateDomain(email: string) { await ensureSchema(); // Lazy import to keep db.ts free of fetch/AbortSignal at module load time. const { checkRegistrationDomain } = await import("./domain-check"); return checkRegistrationDomain(getPool(), email); } /** * Mark a single tenant request as "deleted" when the associated tenant CR * is deleted. With multi-tenant per org this affects exactly one row, * since tenant_name is unique by index. The customer's other instances * are untouched. */ 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". * * Slice 3 note: with multi-tenant per org, this iterates each row * individually (keyed by its own tenant_name), so multiple in-flight * tenants in the same org are handled correctly. */ 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, instanceName: row.instance_name ?? null, 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, isPersonal: row.is_personal ?? false, createdAt: row.created_at?.toISOString?.() ?? row.created_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, }; }