/** * Database client for the portal-db PostgreSQL database. * * Uses the `pg` package directly — no ORM overhead for a single table. * The tenant_requests table acts as the approval gate between customer * registration and actual PiecedTenant CR creation. * * Connection: via DATABASE_URL env var pointing to CloudNativePG cluster. */ import { Pool } from "pg"; import type { TenantRequest, TenantRequestStatus } from "@/types"; // Lazy-init: pool is created on first use, not at module import time. // This avoids "Invalid URL" errors during Next.js build when env vars // aren't available yet. let _pool: Pool | null = null; function getPool(): Pool { if (!_pool) { const url = process.env.DATABASE_URL; if (!url) throw new Error("DATABASE_URL is not set"); _pool = new Pool({ connectionString: url, max: 5, idleTimeoutMillis: 30_000, }); } return _pool; } // --------------------------------------------------------------------------- // Schema migration (idempotent) // --------------------------------------------------------------------------- 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, packages TEXT[] DEFAULT '{}', billing_address JSONB DEFAULT '{}', billing_notes TEXT, status TEXT NOT NULL DEFAULT 'pending', admin_notes TEXT, tenant_name TEXT, 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); `; let migrated = false; export async function ensureSchema(): Promise { if (migrated) return; await getPool().query(MIGRATION_SQL); migrated = true; } // --------------------------------------------------------------------------- // CRUD // --------------------------------------------------------------------------- export async function createTenantRequest( params: Omit ): 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, packages, billing_address, billing_notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, [ params.zitadelOrgId, params.zitadelUserId, params.companyName, params.contactName, params.contactEmail, params.agentName, params.soulMd, params.packages, JSON.stringify(params.billingAddress), params.billingNotes, ] ); return mapRow(result.rows[0]); } export async function getTenantRequestByOrgId( orgId: string ): Promise { await ensureSchema(); const result = await getPool().query( "SELECT * FROM tenant_requests WHERE zitadel_org_id = $1", [orgId] ); return result.rows[0] ? mapRow(result.rows[0]) : null; } 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 listTenantRequests( status?: TenantRequestStatus ): Promise { await ensureSchema(); const pool = getPool(); const query = status ? { text: "SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC", values: [status] } : { text: "SELECT * FROM tenant_requests ORDER BY created_at DESC", values: [] }; const result = await pool.query(query); return result.rows.map(mapRow); } export async function updateTenantRequestStatus( id: string, status: TenantRequestStatus, extra?: { adminNotes?: string | null; tenantName?: string; clearAdminNotes?: boolean } ): Promise { await ensureSchema(); // If clearAdminNotes is true, explicitly set admin_notes to NULL // Otherwise use COALESCE to preserve existing value when not provided const adminNotesExpr = extra?.clearAdminNotes ? "$2" : "COALESCE($2, admin_notes)"; const result = await getPool().query( `UPDATE tenant_requests SET status = $1, admin_notes = ${adminNotesExpr}, tenant_name = COALESCE($3, tenant_name), updated_at = now() WHERE id = $4 RETURNING *`, [status, extra?.adminNotes ?? null, extra?.tenantName ?? null, id] ); if (!result.rows[0]) throw new Error(`TenantRequest ${id} not found`); return mapRow(result.rows[0]); } /** * Sync provisioning statuses: for all requests with status "provisioning", * check if the PiecedTenant CR has reached "Ready" and update to "active". * Called from the admin requests list endpoint. */ export async function syncProvisioningStatuses( checkTenantPhase: (tenantName: string) => Promise ): Promise { await ensureSchema(); const pool = getPool(); const result = await pool.query( "SELECT id, tenant_name FROM tenant_requests WHERE status = 'provisioning' AND tenant_name IS NOT NULL" ); for (const row of result.rows) { try { const phase = await checkTenantPhase(row.tenant_name); if (phase === "Ready" || phase === "Running") { await pool.query( "UPDATE tenant_requests SET status = 'active', updated_at = now() WHERE id = $1", [row.id] ); } } catch (e) { console.error(`Failed to sync status for request ${row.id}:`, e); } } } // --------------------------------------------------------------------------- // Row mapping (snake_case → camelCase) // --------------------------------------------------------------------------- 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, packages: row.packages ?? [], billingAddress: typeof row.billing_address === "string" ? JSON.parse(row.billing_address) : row.billing_address ?? {}, billingNotes: row.billing_notes, status: row.status as TenantRequestStatus, adminNotes: row.admin_notes, tenantName: row.tenant_name, createdAt: row.created_at?.toISOString?.() ?? row.created_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, }; }