212 lines
6.7 KiB
TypeScript
212 lines
6.7 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
if (migrated) return;
|
|
await getPool().query(MIGRATION_SQL);
|
|
migrated = true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function createTenantRequest(
|
|
params: Omit<TenantRequest, "id" | "status" | "createdAt" | "updatedAt">
|
|
): 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, 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<TenantRequest | null> {
|
|
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<TenantRequest | null> {
|
|
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<TenantRequest[]> {
|
|
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<TenantRequest> {
|
|
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<string | null>
|
|
): Promise<void> {
|
|
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,
|
|
};
|
|
}
|