Multitenantperorg enabling
All checks were successful
Build and Push / build (push) Successful in 1m21s
All checks were successful
Build and Push / build (push) Successful in 1m21s
This commit is contained in:
104
src/lib/db.ts
104
src/lib/db.ts
@@ -22,12 +22,27 @@ function getPool(): 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 UNIQUE,
|
||||
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',
|
||||
@@ -46,10 +61,18 @@ const MIGRATION_SQL = `
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_status ON tenant_requests(zitadel_org_id, status);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_tenant_name
|
||||
ON tenant_requests(tenant_name)
|
||||
WHERE tenant_name IS NOT NULL;
|
||||
|
||||
-- Idempotent column adds for existing databases
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS encrypted_secrets BYTEA;
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS agents_md TEXT;
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS instance_name TEXT;
|
||||
|
||||
-- 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 (
|
||||
@@ -131,15 +154,16 @@ export async function createTenantRequest(
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<TenantRequest>(
|
||||
`INSERT INTO tenant_requests
|
||||
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
|
||||
contact_email, agent_name, soul_md, agents_md, packages, billing_address,
|
||||
billing_notes, encrypted_secrets)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
(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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
params.zitadelUserId,
|
||||
params.companyName,
|
||||
params.instanceName ?? null,
|
||||
params.contactName,
|
||||
params.contactEmail,
|
||||
params.agentName,
|
||||
@@ -165,12 +189,67 @@ export async function getTenantRequestById(
|
||||
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function getTenantRequestByOrgId(
|
||||
/**
|
||||
* Slice 3: returns ALL requests for an org, most recent first.
|
||||
*
|
||||
* Replaces the pre-Slice-3 `getTenantRequestByOrgId` which returned the
|
||||
* single most recent row. Callers that previously assumed one-row-per-org
|
||||
* must now iterate or pick by status. The intent is explicit at every
|
||||
* call site, which is the point of the rename.
|
||||
*
|
||||
* Includes rows in every status (pending, approved, provisioning, active,
|
||||
* rejected, deleted). For "active or in-flight only" filtering, see
|
||||
* {@link listActiveTenantRequestsByOrgId}.
|
||||
*/
|
||||
export async function listTenantRequestsByOrgId(
|
||||
orgId: string
|
||||
): Promise<TenantRequest[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<TenantRequest>(
|
||||
"SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 ORDER BY created_at DESC",
|
||||
[orgId]
|
||||
);
|
||||
return result.rows.map(mapRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link listTenantRequestsByOrgId} but excludes terminal-failed states
|
||||
* (rejected, deleted). Useful for the dashboard which wants to show
|
||||
* pending/approved/provisioning/active tenants and pending requests, not
|
||||
* historical rejections.
|
||||
*/
|
||||
export async function listActiveTenantRequestsByOrgId(
|
||||
orgId: string
|
||||
): Promise<TenantRequest[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<TenantRequest>(
|
||||
`SELECT * FROM tenant_requests
|
||||
WHERE zitadel_org_id = $1
|
||||
AND status NOT IN ('deleted', 'rejected')
|
||||
ORDER BY created_at DESC`,
|
||||
[orgId]
|
||||
);
|
||||
return result.rows.map(mapRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent approved-or-active request for an org. Used to
|
||||
* seed billing/contact defaults when a customer creates an additional
|
||||
* instance — saves them re-typing data already on file.
|
||||
*
|
||||
* Returns null if the org has never had an approved instance (e.g. first
|
||||
* registration is still pending).
|
||||
*/
|
||||
export async function getMostRecentApprovedRequestForOrg(
|
||||
orgId: string
|
||||
): Promise<TenantRequest | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<TenantRequest>(
|
||||
"SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 ORDER BY created_at DESC LIMIT 1",
|
||||
`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;
|
||||
@@ -250,8 +329,10 @@ export async function checkDuplicateDomain(email: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a tenant request as "deleted" when the associated tenant CR is deleted.
|
||||
* This allows the customer to re-submit the onboarding wizard.
|
||||
* 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
|
||||
@@ -275,6 +356,10 @@ export async function deleteTenantRequest(id: string): Promise<void> {
|
||||
/**
|
||||
* Sync provisioning statuses: for all requests with status "provisioning",
|
||||
* check if the PiecedTenant CR has reached "Ready" and update to "active".
|
||||
*
|
||||
* 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();
|
||||
@@ -310,6 +395,7 @@ function mapRow(row: any): TenantRequest {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user