Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m39s

This commit is contained in:
2026-05-24 17:25:08 +02:00
parent cd15b391ac
commit 49b085e59e
22 changed files with 1666 additions and 14 deletions

View File

@@ -530,6 +530,39 @@ const MIGRATION_SQL = `
);
CREATE INDEX IF NOT EXISTS idx_invoice_reminders_invoice
ON invoice_reminders(invoice_id, level);
-- Phase 2.5: queue for skills flagged with requiresManualSetup in
-- the package catalog. A user-side enable on a flagged skill
-- creates a pending row here instead of mutating tenant.spec.packages;
-- the operator never sees the skill until admin approves and adds
-- it to the spec. Disable is always direct — there's no gate on
-- turning a skill off, even one that previously required setup.
CREATE TABLE IF NOT EXISTS skill_activation_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_name TEXT NOT NULL,
zitadel_org_id TEXT NOT NULL,
zitadel_user_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')) DEFAULT 'pending',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reviewed_at TIMESTAMPTZ,
reviewed_by TEXT,
rejection_reason TEXT,
admin_notes TEXT
);
-- Only one in-flight request per (tenant, skill). Rejected and
-- approved rows don't block new requests; user can retry after a
-- rejection by toggling the skill again.
CREATE UNIQUE INDEX IF NOT EXISTS uniq_skill_act_one_pending
ON skill_activation_requests (tenant_name, skill_id)
WHERE status = 'pending';
-- Admin queue lookup — partial index keeps it tiny.
CREATE INDEX IF NOT EXISTS idx_skill_act_pending_status
ON skill_activation_requests (requested_at DESC)
WHERE status = 'pending';
-- Per-tenant lookup for the customer UI's pending+rejected display.
CREATE INDEX IF NOT EXISTS idx_skill_act_tenant
ON skill_activation_requests (tenant_name, requested_at DESC);
`;
let migrated = false;
@@ -2590,3 +2623,173 @@ export async function updateInvoicePdf(
[invoiceId, pdfBuffer, filename]
);
}
// ---------------------------------------------------------------------------
// Skill activation requests — Phase 2.5
// ---------------------------------------------------------------------------
import type {
SkillActivationRequest,
SkillActivationStatus,
} from "@/types";
function rowToSkillActivationRequest(row: any): SkillActivationRequest {
return {
id: row.id,
tenantName: row.tenant_name,
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
skillId: row.skill_id,
status: row.status as SkillActivationStatus,
requestedAt: row.requested_at?.toISOString?.() ?? row.requested_at,
reviewedAt: row.reviewed_at?.toISOString?.() ?? row.reviewed_at ?? null,
reviewedBy: row.reviewed_by ?? null,
rejectionReason: row.rejection_reason ?? null,
adminNotes: row.admin_notes ?? null,
};
}
/**
* Insert a pending activation request. Throws a tagged error if a
* pending row already exists for the (tenant, skill) — the partial
* unique index enforces this. The caller surfaces "request already
* pending" to the user rather than letting it 500.
*/
export async function createSkillActivationRequest(params: {
tenantName: string;
zitadelOrgId: string;
zitadelUserId: string;
skillId: string;
}): Promise<SkillActivationRequest> {
await ensureSchema();
try {
const result = await getPool().query(
`INSERT INTO skill_activation_requests
(tenant_name, zitadel_org_id, zitadel_user_id, skill_id)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[
params.tenantName,
params.zitadelOrgId,
params.zitadelUserId,
params.skillId,
]
);
return rowToSkillActivationRequest(result.rows[0]);
} catch (e: any) {
if (e?.code === "23505") {
const err = new Error(
`A pending activation request already exists for ${params.skillId} on ${params.tenantName}.`
);
(err as any).code = "REQUEST_ALREADY_PENDING";
throw err;
}
throw e;
}
}
export async function getSkillActivationRequestById(
id: string
): Promise<SkillActivationRequest | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM skill_activation_requests WHERE id = $1",
[id]
);
return result.rows.length > 0
? rowToSkillActivationRequest(result.rows[0])
: null;
}
/**
* All pending requests across all tenants — feeds the admin queue
* page. Capped to 500 rows for safety; unlikely to ever be hit but
* keeps the page bounded.
*/
export async function listPendingSkillActivationRequests(): Promise<
SkillActivationRequest[]
> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM skill_activation_requests
WHERE status = 'pending'
ORDER BY requested_at ASC
LIMIT 500`
);
return result.rows.map(rowToSkillActivationRequest);
}
export async function countPendingSkillActivationRequests(): Promise<number> {
await ensureSchema();
const result = await getPool().query(
"SELECT COUNT(*)::int AS c FROM skill_activation_requests WHERE status = 'pending'"
);
return result.rows[0]?.c ?? 0;
}
/**
* Requests visible to a customer for one tenant. Returns:
* - pending: rows the user might want to withdraw
* - rejected: the most recent rejection per skill, so the user
* sees why and can retry
* Approved and withdrawn rows are excluded (terminal states with
* no user-visible UI effect after the fact).
*/
export async function listSkillActivationRequestsForTenant(
tenantName: string
): Promise<SkillActivationRequest[]> {
await ensureSchema();
const result = await getPool().query(
`WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY skill_id, status
ORDER BY requested_at DESC
) AS rn
FROM skill_activation_requests
WHERE tenant_name = $1
AND status IN ('pending', 'rejected')
)
SELECT * FROM ranked WHERE rn = 1
ORDER BY requested_at DESC`,
[tenantName]
);
return result.rows.map(rowToSkillActivationRequest);
}
/**
* Transition a request's status. The caller is responsible for the
* side effects (K8s patch on approve, email send) — this function
* only touches the row.
*
* Returns null when the row doesn't exist or isn't in 'pending'
* status. That null is meaningful: it tells the caller the
* transition didn't happen (already approved/rejected by another
* admin tab, etc.) and downstream actions should be skipped.
*/
export async function updateSkillActivationRequestStatus(
id: string,
newStatus: Exclude<SkillActivationStatus, "pending">,
opts: {
reviewedBy: string;
rejectionReason?: string | null;
adminNotes?: string | null;
}
): Promise<SkillActivationRequest | null> {
await ensureSchema();
const result = await getPool().query(
`UPDATE skill_activation_requests
SET status = $2,
reviewed_at = now(),
reviewed_by = $3,
rejection_reason = $4,
admin_notes = COALESCE($5, admin_notes)
WHERE id = $1
AND status = 'pending'
RETURNING *`,
[id, newStatus, opts.reviewedBy, opts.rejectionReason ?? null, opts.adminNotes ?? null]
);
return result.rows.length > 0
? rowToSkillActivationRequest(result.rows[0])
: null;
}