All the MD files via Database
This commit is contained in:
254
src/lib/db.ts
254
src/lib/db.ts
@@ -1,64 +1,62 @@
|
||||
/**
|
||||
* 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";
|
||||
import { listTenants, getTenant } from "./k8s";
|
||||
|
||||
// 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;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection pool (singleton)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
});
|
||||
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;
|
||||
return pool;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema migration (idempotent)
|
||||
// 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,
|
||||
packages TEXT[] DEFAULT '{}',
|
||||
billing_address JSONB DEFAULT '{}',
|
||||
billing_notes TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
admin_notes TEXT,
|
||||
tenant_name TEXT,
|
||||
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()
|
||||
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 add for existing databases
|
||||
-- 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;
|
||||
@@ -70,7 +68,59 @@ export async function ensureSchema(): Promise<void> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// 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(
|
||||
@@ -81,10 +131,10 @@ export async function createTenantRequest(
|
||||
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, encrypted_secrets)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
(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,
|
||||
@@ -94,6 +144,7 @@ export async function createTenantRequest(
|
||||
params.contactEmail,
|
||||
params.agentName,
|
||||
params.soulMd,
|
||||
params.agentsMd ?? null,
|
||||
params.packages,
|
||||
JSON.stringify(params.billingAddress),
|
||||
params.billingNotes,
|
||||
@@ -103,62 +154,75 @@ export async function createTenantRequest(
|
||||
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(
|
||||
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 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);
|
||||
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 }
|
||||
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 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)";
|
||||
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 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]
|
||||
const result = await getPool().query<TenantRequest>(
|
||||
`UPDATE tenant_requests SET ${sets.join(", ")} WHERE id = $1 RETURNING *`,
|
||||
values
|
||||
);
|
||||
if (!result.rows[0]) throw new Error(`TenantRequest ${id} not found`);
|
||||
return mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
@@ -200,34 +264,33 @@ export async function deleteTenantRequest(id: string): Promise<void> {
|
||||
/**
|
||||
* 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> {
|
||||
export async function syncProvisioningStatuses(): 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"
|
||||
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 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]
|
||||
);
|
||||
const tenant = await getTenant(mapped.tenantName);
|
||||
if (
|
||||
tenant?.status?.phase === "Ready" ||
|
||||
tenant?.status?.phase === "Running"
|
||||
) {
|
||||
await updateTenantRequestStatus(mapped.id, "active");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to sync status for request ${row.id}:`, e);
|
||||
} catch {
|
||||
// Tenant might not exist yet — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row mapping (snake_case → camelCase)
|
||||
// Row mapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapRow(row: any): TenantRequest {
|
||||
@@ -240,10 +303,9 @@ function mapRow(row: any): TenantRequest {
|
||||
contactEmail: row.contact_email,
|
||||
agentName: row.agent_name,
|
||||
soulMd: row.soul_md,
|
||||
agentsMd: row.agents_md ?? null,
|
||||
packages: row.packages ?? [],
|
||||
billingAddress: typeof row.billing_address === "string"
|
||||
? JSON.parse(row.billing_address)
|
||||
: row.billing_address ?? {},
|
||||
billingAddress: row.billing_address ?? {},
|
||||
billingNotes: row.billing_notes,
|
||||
status: row.status as TenantRequestStatus,
|
||||
adminNotes: row.admin_notes,
|
||||
|
||||
Reference in New Issue
Block a user