All the MD files via Database

This commit is contained in:
2026-04-11 21:14:09 +02:00
parent c67259ebe0
commit fdb56490dd
14 changed files with 1004 additions and 240 deletions

View File

@@ -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,