This commit is contained in:
144
src/lib/db.ts
144
src/lib/db.ts
@@ -78,6 +78,42 @@ const MIGRATION_SQL = `
|
||||
-- is only meaningful for rejected and cancelled rows.
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ;
|
||||
|
||||
-- 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;
|
||||
|
||||
@@ -381,6 +417,111 @@ export async function clearEncryptedSecrets(requestId: string): Promise<void> {
|
||||
* 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;
|
||||
}): 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
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'resume', 'pending')
|
||||
RETURNING *`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
params.zitadelUserId,
|
||||
params.companyName,
|
||||
params.contactName,
|
||||
params.contactEmail,
|
||||
params.agentName,
|
||||
params.tenantName,
|
||||
]
|
||||
);
|
||||
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(
|
||||
@@ -555,6 +696,9 @@ function mapRow(row: any): TenantRequest {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -130,3 +130,46 @@ export async function patchTenantSpec(
|
||||
}
|
||||
return res.json() as Promise<PiecedTenant>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear an annotation on a PiecedTenant CR.
|
||||
*
|
||||
* Pass `value=null` to remove the annotation. K8s merge-patch removes
|
||||
* a key when its value is null in the patch — that's exactly the
|
||||
* semantic we want.
|
||||
*
|
||||
* Used by the resume-request flow (Bug 37a): the portal sets
|
||||
* `pieced.ch/resume-request-pending` when a customer creates a
|
||||
* resume request, and clears it when the request transitions to a
|
||||
* terminal state. The operator reads this annotation to pause its
|
||||
* 60-day deletion timer while a resume request is in flight.
|
||||
*
|
||||
* Annotations are namespaced informally — we use `pieced.ch/...` for
|
||||
* everything we own, mirroring the labels.
|
||||
*/
|
||||
export async function setTenantAnnotation(
|
||||
name: string,
|
||||
key: string,
|
||||
value: string | null
|
||||
): Promise<PiecedTenant> {
|
||||
const url = `${getBaseUrl()}/apis/${API_VERSION}/${PLURAL}/${name}`;
|
||||
const res = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/merge-patch+json",
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: { annotations: { [key]: value } },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(`K8s annotate /${name}: ${res.status} ${text}`);
|
||||
(err as any).statusCode = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json() as Promise<PiecedTenant>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user