420 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|