1339 lines
48 KiB
TypeScript
1339 lines
48 KiB
TypeScript
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<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 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<TenantRequest[]> {
|
|
await ensureSchema();
|
|
const result = await getPool().query<TenantRequest>(
|
|
`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<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]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<TenantRequest> {
|
|
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<TenantRequest | null> {
|
|
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<TenantRequest | null> {
|
|
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<void> {
|
|
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<TenantRequest | null> {
|
|
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<TenantRequest>(
|
|
`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<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]);
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<TenantRequest>(
|
|
`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<ReturnType<typeof getTenant>> = 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<OrgBilling | null> {
|
|
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<OrgBilling, "createdAt" | "updatedAt">
|
|
): Promise<OrgBilling> {
|
|
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<string[]> {
|
|
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<TenantUserAssignment[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<SupportTicket> {
|
|
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<SupportTicket[]> {
|
|
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<SupportTicket[]> {
|
|
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<SupportTicket | null> {
|
|
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<SupportTicketComment[]> {
|
|
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<SupportTicketComment> {
|
|
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<SupportTicket | null> {
|
|
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;
|
|
}
|