Files
pieced-portal/src/lib/db.ts
admin 3521a0ff4f
All checks were successful
Build and Push / build (push) Successful in 1m30s
Personal accounts
2026-04-26 22:26:33 +02:00

420 lines
14 KiB
TypeScript

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<void> {
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<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(
params: Omit<TenantRequest, "id" | "status" | "createdAt" | "updatedAt"> & {
encryptedSecrets?: Buffer;
}
): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
`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<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
"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<TenantRequest[]> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
"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<TenantRequest[]> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
`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<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
`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<TenantRequest[]> {
await ensureSchema();
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;
}
): Promise<TenantRequest> {
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<TenantRequest>(
`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<void> {
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<void> {
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<void> {
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<void> {
await ensureSchema();
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 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,
};
}