import { Pool } from "pg"; import type { BillingAddress, OrgBilling, SupportTicket, SupportTicketComment, SupportTicketCommentAuthorKind, SupportTicketCategory, SupportTicketStatus, 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); -- Note: the unique constraint on tenant_name is NOT created here. -- Pre-Bug-37 we had a non-partial UNIQUE on tenant_name, which is -- incompatible with resume requests (same tenant_name, different -- request_type). The new partial unique indexes are created -- further down in the migration block, after the request_type -- column has been added and backfilled. This bootstrap section -- only creates indexes that are safe regardless of request_type -- semantics. -- 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; -- Bug 13: customer-side dismissal of rejected requests. NULL means "still -- visible on the dashboard"; non-null means "customer clicked Dismiss". -- Pending/approved/active rows keep this NULL by definition — the field -- is only meaningful for rejected and cancelled rows. ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ; -- Feature 6: free-form customer note attached to the request. -- Currently surfaced only by resume requests (where the customer -- explains why they want reactivation), but the column is generic -- so future flows could reuse it. Distinct from billing_notes -- (provision-only, accounting-related) and admin_notes (admin's -- reason on reject/approve). Optional — nullable. ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS customer_notes TEXT; -- Bug 37a: resume requests use the same table as provision requests so -- the customer dashboard and admin queue share rendering. Discriminator -- is request_type. Default 'provision' on backfill keeps existing rows -- working without explicit migration. -- -- Resume rows have: -- request_type = 'resume' -- tenant_name = the existing tenant being requested for reactivation -- zitadel_org_id = the org owning that tenant -- zitadel_user_id = the requesting customer -- status = pending → approved/rejected (or cancelled by customer) -- most provision-only fields (packages, billing_address, etc.) are NULL ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS request_type TEXT NOT NULL DEFAULT 'provision'; -- Constrain to the known set so a future code change can't accidentally -- write a third type without first widening this constraint. DO $$ BEGIN ALTER TABLE tenant_requests ADD CONSTRAINT tenant_requests_request_type_check CHECK (request_type IN ('provision', 'resume')); EXCEPTION WHEN duplicate_object THEN NULL; END $$; -- Tenant_name uniqueness was originally meant for "one tenant CR per -- approved provision request". Resume requests reuse a tenant_name, -- so the uniqueness must now be scoped to provision rows only. DROP INDEX IF EXISTS uniq_tenant_requests_tenant_name; CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_tenant_name_provision ON tenant_requests(tenant_name) WHERE tenant_name IS NOT NULL AND request_type = 'provision'; -- Only one pending resume request per tenant at a time. Otherwise a -- customer could spam-create resume requests (the admin queue would -- bloat) or two admins might race on approving duplicates. CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_pending_resume ON tenant_requests(tenant_name) WHERE tenant_name IS NOT NULL AND request_type = 'resume' AND status = 'pending'; -- 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() ); -- --------------------------------------------------------------------------- -- Slice 6: per-tenant user assignments -- --------------------------------------------------------------------------- -- -- Each row grants ONE user visibility into ONE tenant within their own -- ZITADEL org. Used to narrow the customer 'user' role from "everything -- in the org" to "only the tenants I've been assigned to". Owners and -- platform users bypass this table entirely. -- -- Composite PK is (tenant_name, zitadel_user_id) — a user is either -- assigned to a tenant or not, no degree. -- -- The zitadel_org_id column is denormalised onto every row so cascade -- cleanups when a user leaves an org can be expressed as a single -- DELETE WHERE zitadel_org_id=$1 AND zitadel_user_id=$2 — without -- joining tenant_requests. The assigned_by column tracks which user -- (the owner usually) granted the assignment, for audit. -- -- Cascade on tenant deletion is enforced in application code (the -- admin delete handler calls removeAllAssignmentsForTenant) rather -- than via FK — there's no FK target, since K8s CRs aren't a Postgres -- table. CREATE TABLE IF NOT EXISTS tenant_user_assignments ( tenant_name TEXT NOT NULL, zitadel_org_id TEXT NOT NULL, zitadel_user_id TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), assigned_by TEXT NOT NULL, PRIMARY KEY (tenant_name, zitadel_user_id) ); CREATE INDEX IF NOT EXISTS idx_tua_user ON tenant_user_assignments(zitadel_user_id); CREATE INDEX IF NOT EXISTS idx_tua_org ON tenant_user_assignments(zitadel_org_id); -- Bug 35: org-scoped billing. One row per ZITADEL org; captured by -- the first tenant request inline, editable afterwards via -- /settings/billing. Subsequent tenant requests in the same org read -- this and skip the billing step entirely. -- -- vat_number is nullable: required at write time for company orgs -- (enforced by the API, not the schema, because "company-or-personal" -- isn't expressible as a column constraint). Notes is free-form -- accounting context — VAT exemption reasons, special invoicing -- arrangements, etc. -- -- We do NOT migrate data from tenant_requests.billing_address into -- this table automatically. Existing customers re-enter on next -- tenant or via settings — the data set is small (single-digit -- customers in pilot) and re-entering is the simplest path. CREATE TABLE IF NOT EXISTS org_billing ( zitadel_org_id TEXT PRIMARY KEY, company_name TEXT NOT NULL, street_address TEXT NOT NULL, postal_code TEXT NOT NULL, city TEXT NOT NULL, country TEXT NOT NULL, vat_number TEXT, billing_email TEXT NOT NULL, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Feature 5: lightweight customer support / feedback tickets. -- Scoped strictly per-user (zitadel_user_id), not per-org — -- coworkers in the same org cannot see each other's tickets. The -- index on (zitadel_user_id, status) is what most customer-side -- queries hit; the index on (status, updated_at DESC) is for the -- admin queue. -- -- contact_email / contact_name are frozen at creation time so the -- ticket retains a working "reply-to" identity even if the user -- later changes their email or display name in ZITADEL. CREATE TABLE IF NOT EXISTS support_tickets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), zitadel_org_id TEXT NOT NULL, zitadel_user_id TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL, category TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', contact_email TEXT NOT NULL, contact_name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- CHECK constraints added separately so re-running the migration -- against an existing table (without these constraints) works. -- IF NOT EXISTS isn't supported on ADD CONSTRAINT, hence the -- DO $$ wrapper. DO $$ BEGIN ALTER TABLE support_tickets ADD CONSTRAINT support_tickets_category_check CHECK (category IN ('bug','feature_request','question','billing','other')); EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN ALTER TABLE support_tickets ADD CONSTRAINT support_tickets_status_check CHECK (status IN ('open','in_progress','waiting_for_customer','resolved','reopened')); EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE INDEX IF NOT EXISTS idx_support_tickets_user ON support_tickets(zitadel_user_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status, updated_at DESC); -- Threaded comments. ON DELETE CASCADE so deleting a ticket -- cleans up its history; we don't currently expose ticket -- deletion in the UI but the cascade keeps options open. CREATE TABLE IF NOT EXISTS support_ticket_comments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE, author_user_id TEXT NOT NULL, author_name TEXT NOT NULL, author_kind TEXT NOT NULL, body TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DO $$ BEGIN ALTER TABLE support_ticket_comments ADD CONSTRAINT support_ticket_comments_author_kind_check CHECK (author_kind IN ('customer','admin')); EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE INDEX IF NOT EXISTS idx_support_ticket_comments_ticket ON support_ticket_comments(ticket_id, created_at); `; let migrated = false; export async function ensureSchema(): Promise { 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 { 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 { 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 & { encryptedSecrets?: Buffer; } ): Promise { await ensureSchema(); const result = await getPool().query( `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 { await ensureSchema(); const result = await getPool().query( "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 { await ensureSchema(); const result = await getPool().query( "SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 ORDER BY created_at DESC", [orgId] ); return result.rows.map(mapRow); } /** * As {@link listTenantRequestsByOrgId} but tuned for the customer's * dashboard view. * * Returns: * - All non-terminal rows (pending, approved, provisioning, active), * because the customer needs to see what's in flight. * - Terminal-failed rows (rejected, cancelled) that the customer * hasn't dismissed yet (Bug 13). Without this, a rejection that * happens while the customer isn't online would only be * communicated by email — easy to miss. * * Excludes: * - `deleted` rows (admin tore down the tenant — historical, not * actionable). * - Dismissed rejected/cancelled rows. */ export async function listActiveTenantRequestsByOrgId( orgId: string ): Promise { await ensureSchema(); const result = await getPool().query( `SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 AND status <> 'deleted' AND (status NOT IN ('rejected', 'cancelled') OR dismissed_at IS NULL) 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 { await ensureSchema(); const result = await getPool().query( `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 { await ensureSchema(); const result = status ? await getPool().query( "SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC", [status] ) : await getPool().query( "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 { 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( `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 { await ensureSchema(); await getPool().query( "UPDATE tenant_requests SET encrypted_secrets = NULL, updated_at = now() WHERE id = $1", [requestId] ); } /** * Set dismissed_at = now() on a request row. Used when a customer * clicks "Dismiss" on a rejected/cancelled card on their dashboard * (Bug 13). The row stays in the database for history/audit but * stops appearing in `listActiveTenantRequestsByOrgId`. * * Idempotent: dismissing an already-dismissed row is a no-op. * Caller is responsible for verifying the row belongs to the user's * org before calling. */ /** * Create a resume request (Bug 37a). Used when an owner of a suspended * tenant wants to reactivate it. Resume is admin-gated — the request * sits as `pending` until a platform admin approves or rejects it. * * Tenant-name uniqueness is enforced for `pending` resume rows by a * partial unique index, so a customer can't spam the queue with * duplicate resume requests for the same tenant. The DB throws a * unique-violation if they try; callers should catch that and translate * to a 409. * * Why this lives in tenant_requests instead of a separate table: * - the lifecycle is identical (pending → approved/rejected, plus * customer-side cancel and dismiss-after-terminal) * - the customer dashboard renders pending+resume cards from the * same `listActiveTenantRequestsByOrgId` query — adding a separate * table would mean two queries and union-merging in the UI * - the admin queue likewise treats them uniformly * The cost is a discriminator column (`request_type`) and most * provision-only fields being null on resume rows. That's a tradeoff * I think is worth it. */ export async function createResumeRequest(params: { tenantName: string; zitadelOrgId: string; zitadelUserId: string; contactName: string; contactEmail: string; // Provision-only fields default sensibly. company_name + agent_name // are NOT NULL in the original schema; we copy them from the existing // tenant request for traceability rather than storing dummy values. companyName: string; agentName: string; /** * Feature 6: optional free-form note from the customer explaining * why they want reactivation. Surfaced to admin in the queue and * forwarded to the platform notification email so the admin can * decide before opening the request. */ customerNotes?: string | null; }): Promise { await ensureSchema(); const result = await getPool().query( `INSERT INTO tenant_requests ( zitadel_org_id, zitadel_user_id, company_name, contact_name, contact_email, agent_name, tenant_name, request_type, status, customer_notes ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'resume', 'pending', $8) RETURNING *`, [ params.zitadelOrgId, params.zitadelUserId, params.companyName, params.contactName, params.contactEmail, params.agentName, params.tenantName, params.customerNotes ?? null, ] ); return mapRow(result.rows[0]); } /** * Get the most recent provision request for a tenant_name. Used by * Bug 37a's resume-request creation to populate company_name and * agent_name (NOT NULL columns) from the original provision row * rather than make up values. * * Returns null when no such row exists — should be impossible in * normal flow (resume requests are only created for already-existing * tenants whose CR was created via approving a provision request), * but the caller should guard against it for safety. */ export async function getTenantRequestByTenantName( tenantName: string ): Promise { await ensureSchema(); const result = await getPool().query( `SELECT * FROM tenant_requests WHERE tenant_name = $1 AND request_type = 'provision' ORDER BY created_at DESC LIMIT 1`, [tenantName] ); return result.rows.length > 0 ? mapRow(result.rows[0]) : null; } /** * Return the in-flight (pending) resume request for a given tenant, if * any. Used both to gate the customer's "Request reactivation" button * (don't allow a second when one's already pending) and by the admin * UI to navigate from the tenant detail page to the awaiting request. * * Returns null when no pending resume exists. Approved/rejected rows * are never returned — they're terminal. */ export async function getPendingResumeRequestForTenant( tenantName: string ): Promise { await ensureSchema(); const result = await getPool().query( `SELECT * FROM tenant_requests WHERE tenant_name = $1 AND request_type = 'resume' AND status = 'pending' LIMIT 1`, [tenantName] ); return result.rows.length > 0 ? mapRow(result.rows[0]) : null; } export async function dismissTenantRequest(id: string): Promise { await ensureSchema(); await getPool().query( `UPDATE tenant_requests SET dismissed_at = COALESCE(dismissed_at, now()), updated_at = now() WHERE id = $1`, [id] ); } /** * Update editable fields of a still-pending tenant request. Bug 6 — a * customer who notices a typo or wants to add a package after submitting * the wizard should be able to fix it without admin involvement. * * Only the customer-input fields are updateable. `status`, `tenant_name`, * `admin_notes`, `encrypted_secrets`, `is_personal`, `zitadel_*` and * timestamps are managed elsewhere and intentionally not here. * * The caller is responsible for: * - verifying the row belongs to the user's org * - verifying status === 'pending' (editing approved/provisioning rows * would race against the operator) * * Returns the updated row, or null if the id didn't match anything. */ export async function updateTenantRequestEditableFields( id: string, fields: { instanceName?: string | null; agentName?: string; soulMd?: string; agentsMd?: string | null; packages?: string[]; billingAddress?: BillingAddress; billingNotes?: string; encryptedSecrets?: Buffer | null; } ): Promise { await ensureSchema(); const sets: string[] = ["updated_at = now()"]; const values: any[] = [id]; let idx = 2; // Map JS field names to SQL columns. Each entry is gated on // `!== undefined` so passing only some fields just updates those. const colMap: Array<[keyof typeof fields, string]> = [ ["instanceName", "instance_name"], ["agentName", "agent_name"], ["soulMd", "soul_md"], ["agentsMd", "agents_md"], ["packages", "packages"], ["billingAddress", "billing_address"], ["billingNotes", "billing_notes"], ["encryptedSecrets", "encrypted_secrets"], ]; for (const [jsField, sqlCol] of colMap) { const v = fields[jsField]; if (v === undefined) continue; sets.push(`${sqlCol} = $${idx}`); values.push(v); idx++; } if (sets.length === 1) { // No editable fields supplied — return the row unchanged rather // than running a useless UPDATE that just bumps updated_at. const cur = await getTenantRequestById(id); return cur; } const result = await getPool().query( `UPDATE tenant_requests SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, values ); return result.rows[0] ? mapRow(result.rows[0]) : null; } /** * 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 { 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 { await ensureSchema(); await getPool().query("DELETE FROM tenant_requests WHERE id = $1", [id]); } /** * Reconcile the portal's tenant_requests table against actual cluster * state. Three passes, walking only rows with `tenant_name` set: * * 1. provisioning → active: when a tenant CR's phase reaches Ready * or Running, the portal flips the row to active so the * "provisioning…" card transitions into the running tenant view. * * 2. active/provisioning → deleted: when the corresponding CR no * longer exists in the cluster (404), or is mid-deletion (has * metadata.deletionTimestamp set), the row gets flipped to * `deleted`. The DB is otherwise blind to operator-initiated * deletions — when the 60-day TTL fires (Bug 37b) and the * operator deletes a suspended tenant, the portal would happily * keep showing the "Your assistant is ready!" card forever. * Without this reconciliation the dashboard drifts from reality. * * 3. pending resume → cancelled: when a pending resume request's * tenant is no longer suspended (admin resumed it directly, * tenant was deleted, or it was never suspended in the first * place), the request is moot. Flip to 'cancelled' so the * pending-resume unique index releases for any future genuine * resume request. We pick `cancelled` over `rejected` because * the customer didn't do anything wrong — circumstances just * changed. * * Errors are tolerated per-row: a transient API hiccup on one tenant * shouldn't fail the whole sweep. Skipped rows get retried next call. * * 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 { await ensureSchema(); // Active+provisioning rows: status reflects "the tenant should // exist and be running". // Pending resume rows: status reflects "the tenant is suspended, // awaiting reactivation". // Both need cluster-side validation; we fetch them in one query // and dispatch on (status, request_type). const result = await getPool().query( `SELECT * FROM tenant_requests WHERE tenant_name IS NOT NULL AND ( status IN ('provisioning', 'active') OR (status = 'pending' AND request_type = 'resume') )` ); for (const row of result.rows) { const mapped = mapRow(row); if (!mapped.tenantName) continue; let tenant: Awaited> = null; try { tenant = await getTenant(mapped.tenantName); } catch { // Transient API error — skip this row, retry on next sweep. continue; } // Pending resume request: validity hinges on tenant being suspended. if ( mapped.status === "pending" && mapped.requestType === "resume" ) { // Tenant doesn't exist or is being deleted: cancel the resume // request (it can never be fulfilled). Don't fall through to // the "deleted" branch below — that would also flip the // provision row, which is the right thing for a CR-level // deletion but we want this resume row specifically resolved // here. if (!tenant || tenant.metadata.deletionTimestamp) { await updateTenantRequestStatus(mapped.id, "cancelled"); continue; } // Tenant is no longer suspended: the request is moot. // Cancel it (the customer didn't do anything wrong; the // condition the request was about no longer applies). if (!tenant.spec.suspend) { await updateTenantRequestStatus(mapped.id, "cancelled"); continue; } // Tenant still suspended, request still relevant. Leave as-is. continue; } // Active or provisioning row: CR gone, or mid-deletion. Flip the // row to 'deleted'. `markTenantRequestDeletedByTenantName` flips // every row with this tenant_name (provision + any resume rows), // which is the right thing for a CR-level deletion. if (!tenant || tenant.metadata.deletionTimestamp) { await markTenantRequestDeletedByTenantName(mapped.tenantName); continue; } // CR exists and is healthy. Promote provisioning → active when // the operator reports the tenant has reached steady state. // Keep `active` rows on `active` regardless of phase — a // temporarily-Reconfiguring tenant is still active from the // portal's billing/visibility perspective. if ( mapped.status === "provisioning" && (tenant.status?.phase === "Ready" || tenant.status?.phase === "Running") ) { await updateTenantRequestStatus(mapped.id, "active"); } } } // --------------------------------------------------------------------------- // 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, customerNotes: row.customer_notes ?? null, status: row.status as TenantRequestStatus, adminNotes: row.admin_notes, tenantName: row.tenant_name, encryptedSecrets: row.encrypted_secrets ?? null, isPersonal: row.is_personal ?? false, dismissedAt: row.dismissed_at?.toISOString?.() ?? row.dismissed_at ?? null, requestType: (row.request_type ?? "provision") as | "provision" | "resume", createdAt: row.created_at?.toISOString?.() ?? row.created_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, }; } // --------------------------------------------------------------------------- // Bug 35: org-scoped billing // --------------------------------------------------------------------------- function rowToOrgBilling(row: any): OrgBilling { return { zitadelOrgId: row.zitadel_org_id, companyName: row.company_name, streetAddress: row.street_address, postalCode: row.postal_code, city: row.city, country: row.country, vatNumber: row.vat_number ?? null, billingEmail: row.billing_email, notes: row.notes ?? null, createdAt: row.created_at?.toISOString?.() ?? row.created_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, }; } /** * Fetch org billing if it exists. Returns null when the org has never * captured billing — that's the signal the wizard uses to know * whether to render the inline billing step on the first tenant * request. */ export async function getOrgBilling( zitadelOrgId: string ): Promise { await ensureSchema(); const result = await getPool().query( "SELECT * FROM org_billing WHERE zitadel_org_id = $1", [zitadelOrgId] ); return result.rows.length > 0 ? rowToOrgBilling(result.rows[0]) : null; } /** * Insert or update org billing. Single function for both because the * UI flow makes the "first time vs editing" distinction in a single * settings page that doesn't need to know which one it's doing. * * VAT-required-for-companies isn't enforced here — that's an API * concern (the API knows whether the caller is a company org). * Keeping the DB layer dumb. */ export async function upsertOrgBilling( data: Omit ): Promise { await ensureSchema(); const result = await getPool().query( `INSERT INTO org_billing ( zitadel_org_id, company_name, street_address, postal_code, city, country, vat_number, billing_email, notes ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (zitadel_org_id) DO UPDATE SET company_name = EXCLUDED.company_name, street_address = EXCLUDED.street_address, postal_code = EXCLUDED.postal_code, city = EXCLUDED.city, country = EXCLUDED.country, vat_number = EXCLUDED.vat_number, billing_email = EXCLUDED.billing_email, notes = EXCLUDED.notes, updated_at = now() RETURNING *`, [ data.zitadelOrgId, data.companyName, data.streetAddress, data.postalCode, data.city, data.country, data.vatNumber ?? null, data.billingEmail, data.notes ?? null, ] ); return rowToOrgBilling(result.rows[0]); } // --------------------------------------------------------------------------- // Slice 6: tenant ↔ user assignments // --------------------------------------------------------------------------- /** * One assignment grants one user visibility into one tenant. Returned * shape is the camelCase mirror of the Postgres row. */ export interface TenantUserAssignment { tenantName: string; zitadelOrgId: string; zitadelUserId: string; assignedAt: string; assignedBy: string; } function mapAssignmentRow(row: any): TenantUserAssignment { return { tenantName: row.tenant_name, zitadelOrgId: row.zitadel_org_id, zitadelUserId: row.zitadel_user_id, assignedAt: row.assigned_at?.toISOString?.() ?? row.assigned_at, assignedBy: row.assigned_by, }; } /** * Returns the set of tenant CR names assigned to the given user. * * Hot path on every read for `user`-role customers, so it's intentionally * a single indexed lookup. The returned array is small (a handful of * tenants per user); callers usually wrap it in a Set. * * Note: this does NOT cross-check the org id — assignments are per-user, * and a user's org context comes from their JWT. If a user's * authorization is revoked at the ZITADEL level, their JWT ceases to * carry the customer role and they can't reach the dashboard at all; * the orphan rows are cleaned up the next time their org membership * is re-evaluated (Slice 7's removeAllAssignmentsForUser). */ export async function listTenantAssignmentsForUser( userId: string ): Promise { await ensureSchema(); const result = await getPool().query<{ tenant_name: string }>( "SELECT tenant_name FROM tenant_user_assignments WHERE zitadel_user_id = $1", [userId] ); return result.rows.map((r) => r.tenant_name); } /** * Returns all assignments for a single tenant. Used by the team UI * (Slice 7) to render "who has access to this instance". Includes * `assignedBy` and `assignedAt` for audit display. */ export async function listAssignmentsForTenant( tenantName: string ): Promise { await ensureSchema(); const result = await getPool().query( "SELECT * FROM tenant_user_assignments WHERE tenant_name = $1 ORDER BY assigned_at DESC", [tenantName] ); return result.rows.map(mapAssignmentRow); } /** * Grant a user access to a tenant. Idempotent — a duplicate INSERT * is silently ignored via ON CONFLICT, and the existing * `assigned_at`/`assigned_by` are preserved (we don't update them on * re-assign). * * Caller is responsible for verifying: * - The actor (`assignedBy`) holds owner/platform role in `orgId`. * - The target user (`userId`) is actually a member of the same * ZITADEL org. We don't validate this here — the team UI fetches * the org's user list from ZITADEL and selects from it. * - The tenant CR exists and is labelled with the same `orgId`. */ export async function addTenantAssignment(params: { tenantName: string; orgId: string; userId: string; assignedBy: string; }): Promise { await ensureSchema(); await getPool().query( `INSERT INTO tenant_user_assignments (tenant_name, zitadel_org_id, zitadel_user_id, assigned_by) VALUES ($1, $2, $3, $4) ON CONFLICT (tenant_name, zitadel_user_id) DO NOTHING`, [params.tenantName, params.orgId, params.userId, params.assignedBy] ); } /** * Revoke a user's access to a tenant. No-op if the row doesn't exist. */ export async function removeTenantAssignment( tenantName: string, userId: string ): Promise { await ensureSchema(); await getPool().query( "DELETE FROM tenant_user_assignments WHERE tenant_name = $1 AND zitadel_user_id = $2", [tenantName, userId] ); } /** * Cascade cleanup: drop ALL assignments for a tenant when the tenant * itself is deleted. Called from the admin delete handler. * * Without this, an orphan row would stick around forever — a future * tenant with the same name (won't happen given Slice 1's UUID-suffix * naming, but defense in depth) would inherit the old assignments. */ export async function removeAllAssignmentsForTenant( tenantName: string ): Promise { await ensureSchema(); await getPool().query( "DELETE FROM tenant_user_assignments WHERE tenant_name = $1", [tenantName] ); } /** * Cascade cleanup: drop ALL assignments for a user within a specific * org. Used by Slice 7's "remove member" flow when an owner kicks a * user out of the org. Scoped by `orgId` so a user with assignments in * org A doesn't lose them when removed from org B (multi-org users * exist when a person registers personally and is also invited to a * company). */ export async function removeAllAssignmentsForUser( orgId: string, userId: string ): Promise { await ensureSchema(); await getPool().query( "DELETE FROM tenant_user_assignments WHERE zitadel_org_id = $1 AND zitadel_user_id = $2", [orgId, userId] ); } // --------------------------------------------------------------------------- // Feature 5: support tickets // --------------------------------------------------------------------------- function rowToSupportTicket(row: any): SupportTicket { return { id: row.id, zitadelOrgId: row.zitadel_org_id, zitadelUserId: row.zitadel_user_id, title: row.title, description: row.description, category: row.category as SupportTicketCategory, status: row.status as SupportTicketStatus, contactEmail: row.contact_email, contactName: row.contact_name, createdAt: row.created_at?.toISOString?.() ?? row.created_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, }; } function rowToSupportTicketComment(row: any): SupportTicketComment { return { id: row.id, ticketId: row.ticket_id, authorUserId: row.author_user_id, authorName: row.author_name, authorKind: row.author_kind as SupportTicketCommentAuthorKind, body: row.body, createdAt: row.created_at?.toISOString?.() ?? row.created_at, }; } /** * Create a new support ticket. The contact_name/contact_email are * snapshotted from the session at creation time — see SupportTicket * doc for why. */ export async function createSupportTicket(params: { zitadelOrgId: string; zitadelUserId: string; title: string; description: string; category: SupportTicketCategory; contactName: string; contactEmail: string; }): Promise { await ensureSchema(); const result = await getPool().query( `INSERT INTO support_tickets ( zitadel_org_id, zitadel_user_id, title, description, category, contact_name, contact_email ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [ params.zitadelOrgId, params.zitadelUserId, params.title, params.description, params.category, params.contactName, params.contactEmail, ] ); return rowToSupportTicket(result.rows[0]); } /** Tickets created by a single user, newest activity first. */ export async function listSupportTicketsForUser( zitadelUserId: string ): Promise { await ensureSchema(); const result = await getPool().query( `SELECT * FROM support_tickets WHERE zitadel_user_id = $1 ORDER BY updated_at DESC`, [zitadelUserId] ); return result.rows.map(rowToSupportTicket); } /** * Admin queue. Returns every ticket across all users/orgs, newest * activity first. Pending tickets (open/reopened) bubble to the top * by virtue of recent activity, but the API doesn't sort by status — * the admin UI handles filtering and bucketing. */ export async function listAllSupportTickets(): Promise { await ensureSchema(); const result = await getPool().query( `SELECT * FROM support_tickets ORDER BY updated_at DESC` ); return result.rows.map(rowToSupportTicket); } export async function getSupportTicketById( id: string ): Promise { await ensureSchema(); const result = await getPool().query( "SELECT * FROM support_tickets WHERE id = $1", [id] ); return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null; } export async function listCommentsForTicket( ticketId: string ): Promise { await ensureSchema(); const result = await getPool().query( `SELECT * FROM support_ticket_comments WHERE ticket_id = $1 ORDER BY created_at`, [ticketId] ); return result.rows.map(rowToSupportTicketComment); } /** * Insert a comment. Bumps the parent ticket's `updated_at` so the * activity sort orders work — done in a transaction so the two are * atomic from any concurrent reader's perspective. * * Caller is responsible for status auto-bumping (e.g. customer * replying to a `waiting_for_customer` ticket → `in_progress`); the * DB layer just writes what it's told. */ export async function createSupportTicketComment(params: { ticketId: string; authorUserId: string; authorName: string; authorKind: SupportTicketCommentAuthorKind; body: string; }): Promise { await ensureSchema(); const client = await getPool().connect(); try { await client.query("BEGIN"); const inserted = await client.query( `INSERT INTO support_ticket_comments ( ticket_id, author_user_id, author_name, author_kind, body ) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [ params.ticketId, params.authorUserId, params.authorName, params.authorKind, params.body, ] ); await client.query( "UPDATE support_tickets SET updated_at = now() WHERE id = $1", [params.ticketId] ); await client.query("COMMIT"); return rowToSupportTicketComment(inserted.rows[0]); } catch (e) { await client.query("ROLLBACK"); throw e; } finally { client.release(); } } /** * Update mutable fields on a ticket. Only category and status are * mutable; title/description are frozen post-creation. Returns the * updated row so callers can email the right contact_email * afterwards. */ export async function updateSupportTicket( id: string, changes: { status?: SupportTicketStatus; category?: SupportTicketCategory } ): Promise { await ensureSchema(); const sets: string[] = ["updated_at = now()"]; const values: any[] = [id]; let idx = 2; if (changes.status !== undefined) { sets.push(`status = $${idx}`); values.push(changes.status); idx++; } if (changes.category !== undefined) { sets.push(`category = $${idx}`); values.push(changes.category); idx++; } // No-op early exit. Without an actual change we still want // updated_at refreshed if the caller asked for one, but if they // passed neither field there's nothing to do. if (sets.length === 1) return getSupportTicketById(id); const result = await getPool().query( `UPDATE support_tickets SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, values ); return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null; }