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

@@ -2,6 +2,7 @@ import { getSessionUser } from "@/lib/session";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s";
import { countPendingSkillActivationRequests } from "@/lib/db";
import { AdminPanel } from "@/components/admin/admin-panel";
export default async function AdminPage() {
@@ -19,6 +20,12 @@ export default async function AdminPage() {
}
const tenants = await listTenants();
// Phase 2.5: badge counter for the skill-activation admin queue.
// Cheap COUNT(*) on a partial-indexed status='pending' column —
// bounded by request volume and never expected to be high.
const pendingSkillCount = await countPendingSkillActivationRequests().catch(
() => 0
);
return (
<div>
@@ -33,6 +40,21 @@ export default async function AdminPage() {
than nav-shell entries — these are platform-team utilities,
not main navigation. */}
<div className="flex items-center gap-2">
<a
href="/admin/skills/pending"
className={`text-sm px-4 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
pendingSkillCount > 0
? "border-warning text-warning hover:bg-warning/10"
: "border-border text-text-secondary hover:text-text-primary hover:border-text-secondary"
}`}
>
<span>{t("skillsQueueTool")}</span>
{pendingSkillCount > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-warning text-surface-0 font-semibold">
{pendingSkillCount}
</span>
)}
</a>
<a
href="/admin/billing"
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"

View File

@@ -0,0 +1,59 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { listPendingSkillActivationRequests, getOrgBilling } from "@/lib/db";
import { getPackageDef } from "@/lib/packages";
import { BackLink } from "@/components/ui/back-link";
import { PendingSkillRequests } from "@/components/admin/skills/pending-skill-requests";
/**
* /admin/skills/pending — admin queue for manual-setup skill
* activation requests. Each row shows tenant, skill, requester
* info, and offers Approve / Reject actions.
*
* Server-renders the initial list. Approval/rejection trigger a
* client-side fetch + router.refresh() so the row disappears and
* the count updates without a hard reload.
*/
export default async function AdminPendingSkillRequestsPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminSkills");
const pending = await listPendingSkillActivationRequests();
// Hydrate display fields: skill name from catalog, org company name
// from billing. Skill name fallback to skillId for off-catalog
// entries (shouldn't happen but defensive). Company name is
// looked up lazily per row; dedup'd via a Map so we don't issue
// duplicate getOrgBilling calls for the same org.
const seenOrg = new Map<string, string | null>();
const rows = await Promise.all(
pending.map(async (r) => {
if (!seenOrg.has(r.zitadelOrgId)) {
const billing = await getOrgBilling(r.zitadelOrgId).catch(() => null);
seenOrg.set(r.zitadelOrgId, billing?.companyName ?? null);
}
const def = getPackageDef(r.skillId);
return {
...r,
skillName: def?.name ?? r.skillId,
companyName: seenOrg.get(r.zitadelOrgId) ?? null,
};
})
);
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<BackLink href="/admin" label={t("backToAdmin")} />
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
</div>
<PendingSkillRequests initialRows={rows} />
</main>
);
}

View File

@@ -3,7 +3,11 @@ import { getTranslations, getFormatter } from "next-intl/server";
import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { getPendingResumeRequestForTenant } from "@/lib/db";
import {
getPendingResumeRequestForTenant,
listSkillActivationRequestsForTenant,
listSkillPricing,
} from "@/lib/db";
import { StatusBadge } from "@/components/ui/status-badge";
import { WarningBadge } from "@/components/ui/warning-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display";
@@ -82,6 +86,17 @@ export default async function TenantDetailPage({
);
const channelUsers = tenant.spec.channelUsers || {};
// Phase 2.5: surface pending and most-recently-rejected skill
// activation requests so PackageCard can render the inline
// "Manual review pending" / "Activation rejected" states.
// Pricing drives the cost-disclosure dialog before enable.
// Both fetches are best-effort — an empty list is the safe
// fallback if the DB call fails (cards just show normal toggles).
const [activationRequests, skillPricing] = await Promise.all([
listSkillActivationRequestsForTenant(name).catch(() => []),
listSkillPricing().catch(() => []),
]);
// Bug 19 fix: every viewer (customer or admin) passes the tenant
// name to UsageDisplay. The /api/usage route resolves team+alias
// from the tenant CR's status and applies the visibility check, so
@@ -219,6 +234,8 @@ export default async function TenantDetailPage({
enabledPackages={enabledPackages}
conditions={tenant.status?.conditions}
canEdit={canEdit}
activationRequests={activationRequests}
skillPricing={skillPricing}
/>
</section>

View File

@@ -0,0 +1,155 @@
import { NextResponse } from "next/server";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import {
getSkillActivationRequestById,
recordSkillEvents,
updateSkillActivationRequestStatus,
} from "@/lib/db";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import { listOrgUsers } from "@/lib/zitadel";
import { sendSkillActivationApprovalEmail } from "@/lib/email";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/skills/pending/[id]/approve
*
* Atomic-ish approval. Ordering:
* 1. Load + sanity-check the request (must be pending).
* 2. Patch the tenant CR to include the skill in spec.packages.
* 3. Record the skill_event (kind=enabled) for billing.
* 4. Flip the request row to 'approved'.
* 5. Best-effort approval email to the requester.
*
* Step 2 is the irreversible one — if it succeeds but step 4 fails
* we end up with a skill enabled in K8s but a still-pending request
* row. That's a manual cleanup task; we log loudly so admin notices
* via the queue page (the request would reappear there).
*
* The request must be in 'pending' status. Approving an already-
* approved/rejected request returns 409.
*
* Body (optional): { adminNotes?: string }
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
let admin;
try {
await requirePlatformRole();
admin = await getSessionUser();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!admin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const body = await request.json().catch(() => ({}));
const adminNotes =
typeof body.adminNotes === "string" && body.adminNotes.length <= 1000
? body.adminNotes
: null;
// 1. Load + sanity-check.
const req = await getSkillActivationRequestById(id);
if (!req) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
if (req.status !== "pending") {
return NextResponse.json(
{ error: `Request is already ${req.status}` },
{ status: 409 }
);
}
// 2. Patch the tenant CR — add the skill if not already present.
// Defensive: if the tenant was deleted or the skill was somehow
// added by another path, we still proceed without duplicate.
let tenant;
try {
tenant = await getTenant(req.tenantName);
} catch (e) {
return NextResponse.json(
{ error: `Tenant ${req.tenantName} not found: ${safeError(e, "")}` },
{ status: 404 }
);
}
if (!tenant) {
return NextResponse.json(
{ error: `Tenant ${req.tenantName} not found` },
{ status: 404 }
);
}
const currentPackages = new Set<string>(tenant.spec.packages ?? []);
const alreadyEnabled = currentPackages.has(req.skillId);
if (!alreadyEnabled) {
currentPackages.add(req.skillId);
try {
await patchTenantSpec(req.tenantName, {
packages: [...currentPackages],
});
} catch (e) {
return NextResponse.json(
{ error: `Failed to enable skill on tenant: ${safeError(e, "")}` },
{ status: 500 }
);
}
}
// 3. Record skill event (only if we actually added it — re-adding
// would skew the day-count). Best-effort.
if (!alreadyEnabled) {
try {
await recordSkillEvents(req.tenantName, req.zitadelOrgId, [req.skillId], []);
} catch (e) {
console.error(
`Failed to record skill_event after approve (request ${id}):`,
e
);
}
}
// 4. Flip request to approved.
const updated = await updateSkillActivationRequestStatus(id, "approved", {
reviewedBy: admin.id,
adminNotes,
});
if (!updated) {
// Race: another admin tab flipped it between our read and now.
// The K8s patch already happened so we don't roll back; log so
// the human notices.
console.error(
`Request ${id} was no longer pending when we tried to mark approved; K8s patch already applied.`
);
return NextResponse.json(
{
error:
"Request status changed during approval; the skill may have been enabled. Check the queue.",
},
{ status: 409 }
);
}
// 5. Email the requester (best-effort). Look up their email via
// ZITADEL since we only stored the userId on the request.
try {
const orgUsers = await listOrgUsers(req.zitadelOrgId);
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
if (requester?.email) {
const def = getPackageDef(req.skillId);
await sendSkillActivationApprovalEmail({
to: requester.email,
contactName: requester.displayName || requester.email,
skillName: def?.name ?? req.skillId,
tenantName: req.tenantName,
});
}
} catch (e) {
console.error(`Failed to send approval email for request ${id}:`, e);
}
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import {
getSkillActivationRequestById,
updateSkillActivationRequestStatus,
} from "@/lib/db";
import { getPackageDef } from "@/lib/packages";
import { listOrgUsers } from "@/lib/zitadel";
import { sendSkillActivationRejectionEmail } from "@/lib/email";
/**
* POST /api/admin/skills/pending/[id]/reject
*
* Reject a pending activation request with a required reason that
* is shown to the customer (mirroring the tenant-request rejection
* flow). The skill is NOT added to the tenant spec — it was never
* there in the first place — so the customer's enable attempt is
* effectively cancelled. They can try again from their tenant
* settings after seeing the reason (a new pending row will be
* created by their next toggle).
*
* Body:
* {
* reason: string (1..1000 chars, required),
* adminNotes?: string (optional, not shown to customer)
* }
*/
const bodySchema = z.object({
reason: z.string().min(1).max(1000),
adminNotes: z.string().max(1000).optional(),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
let admin;
try {
await requirePlatformRole();
admin = await getSessionUser();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!admin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
const req = await getSkillActivationRequestById(id);
if (!req) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
if (req.status !== "pending") {
return NextResponse.json(
{ error: `Request is already ${req.status}` },
{ status: 409 }
);
}
const updated = await updateSkillActivationRequestStatus(id, "rejected", {
reviewedBy: admin.id,
rejectionReason: parsed.data.reason,
adminNotes: parsed.data.adminNotes ?? null,
});
if (!updated) {
return NextResponse.json(
{ error: "Request status changed during rejection." },
{ status: 409 }
);
}
// Email the requester with the reason — best-effort.
try {
const orgUsers = await listOrgUsers(req.zitadelOrgId);
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
if (requester?.email) {
const def = getPackageDef(req.skillId);
await sendSkillActivationRejectionEmail({
to: requester.email,
contactName: requester.displayName || requester.email,
skillName: def?.name ?? req.skillId,
tenantName: req.tenantName,
reason: parsed.data.reason,
});
}
} catch (e) {
console.error(`Failed to send rejection email for request ${id}:`, e);
}
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { listPendingSkillActivationRequests } from "@/lib/db";
/**
* GET /api/admin/skills/pending
*
* List all pending skill-activation requests across all tenants
* and orgs. Powers the admin queue at /admin/skills/pending.
*
* Platform-role only. Returns up to 500 rows oldest-first so the
* queue UI shows the oldest requests at the top (FIFO).
*/
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const rows = await listPendingSkillActivationRequests();
return NextResponse.json(rows);
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listSkillPricing } from "@/lib/db";
/**
* GET /api/skills/pricing
*
* Returns the platform-wide skill pricing (daily price + setup fee
* per skill) for display in the customer's cost-disclosure dialog
* before they enable a priced skill. Any logged-in user can read
* this — pricing isn't org-specific and is effectively public
* information for anyone who'd be considering activation.
*
* Empty array means no skill is currently priced.
*/
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const rows = await listSkillPricing();
return NextResponse.json(rows);
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import {
getSkillActivationRequestById,
updateSkillActivationRequestStatus,
} from "@/lib/db";
/**
* POST /api/skills/requests/[id]/withdraw
*
* The owner of a pending activation request can cancel it. This
* doesn't touch K8s (the skill was never enabled) — it just flips
* the row to 'withdrawn' so the user's UI clears the pending
* state and they can try a different skill or retry later.
*
* Authorization: only the original requester OR a platform admin
* can withdraw a request. We deliberately don't allow other org
* members to cancel each other's requests in v1 — the partial
* unique index would let one user repeatedly cancel another's
* pending request.
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const req = await getSkillActivationRequestById(id);
if (!req) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
if (!user.isPlatform && req.zitadelUserId !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (req.status !== "pending") {
return NextResponse.json(
{ error: `Request is already ${req.status}` },
{ status: 409 }
);
}
const updated = await updateSkillActivationRequestStatus(id, "withdrawn", {
reviewedBy: user.id,
});
if (!updated) {
return NextResponse.json(
{ error: "Request status changed during withdraw." },
{ status: 409 }
);
}
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listSkillActivationRequestsForTenant } from "@/lib/db";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant } from "@/lib/k8s";
/**
* GET /api/skills/requests?tenant=<name>
*
* Returns pending and most-recent-rejected skill activation
* requests for the named tenant. Used by the tenant settings page
* to render the "Manual review pending" or "Activation rejected"
* inline states on PackageCard.
*
* Authorization: the caller must be able to see the tenant (owner
* of its org, assigned user, or platform admin).
*/
export async function GET(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const tenantName = searchParams.get("tenant");
if (!tenantName) {
return NextResponse.json(
{ error: "Missing tenant parameter" },
{ status: 400 }
);
}
const tenant = await getTenant(tenantName).catch(() => null);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!canUserSeeTenant(user, tenant)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const requests = await listSkillActivationRequestsForTenant(tenantName);
return NextResponse.json(requests);
}

View File

@@ -3,7 +3,12 @@ import { getSessionUser, canMutate } from "@/lib/session";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import { recordSkillEvents } from "@/lib/db";
import {
createSkillActivationRequest,
getOrgBilling,
recordSkillEvents,
} from "@/lib/db";
import { sendSkillActivationAdminNotification } from "@/lib/email";
import { safeError } from "@/lib/errors";
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
@@ -69,6 +74,17 @@ export async function PATCH(
const specPatch: Record<string, any> = {};
// Track manual-setup gate activations created during this PATCH.
// We push to the K8s spec only the non-gated skills; the gated
// ones live in skill_activation_requests until admin approves
// and adds them via the admin endpoint. Platform admins bypass
// the gate (direct enable from /admin still applies immediately).
let gatedRequests: Array<{
skillId: string;
requestId: string;
skillName: string;
}> = [];
// ── Validate packages against catalog ──
if (body.packages !== undefined) {
if (!Array.isArray(body.packages) || body.packages.length > 10) {
@@ -85,7 +101,63 @@ export async function PATCH(
);
}
}
specPatch.packages = body.packages;
// Compute the to-be-added set against the existing spec.
const existingPackages = new Set<string>(existing.spec.packages ?? []);
const desiredPackages: string[] = body.packages;
const newlyAdded = desiredPackages.filter(
(p) => !existingPackages.has(p)
);
// Manual-setup gate. Customer adds get routed to the queue;
// platform admins go straight through.
if (!user.isPlatform && newlyAdded.length > 0) {
const orgIdForGate =
existing.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (!orgIdForGate) {
// Defensive: every customer-visible tenant should have the
// org label. Without it we can't attribute the request.
return NextResponse.json(
{ error: "Tenant missing org binding; contact support." },
{ status: 500 }
);
}
const gatedSet = new Set<string>();
for (const skillId of newlyAdded) {
const def = getPackageDef(skillId);
if (!def?.requiresManualSetup) continue;
gatedSet.add(skillId);
try {
const req = await createSkillActivationRequest({
tenantName: name,
zitadelOrgId: orgIdForGate,
zitadelUserId: user.id,
skillId,
});
gatedRequests.push({
skillId,
requestId: req.id,
skillName: def.name,
});
} catch (e: any) {
if (e?.code === "REQUEST_ALREADY_PENDING") {
// Idempotent: a pending row already exists; just keep
// the skill out of the K8s spec and surface it as
// gated without creating a duplicate.
gatedRequests.push({
skillId,
requestId: "",
skillName: def.name,
});
} else {
throw e;
}
}
}
// Strip gated skills from the desired spec — they must not
// reach K8s until approved.
specPatch.packages = desiredPackages.filter((p) => !gatedSet.has(p));
} else {
specPatch.packages = desiredPackages;
}
}
// ── Validate workspaceFiles ──
@@ -232,7 +304,49 @@ export async function PATCH(
}
}
return NextResponse.json(updated);
// Phase 2.5: notify admin of newly created activation requests.
// Best-effort — email failure must not poison the PATCH response.
// requestId === "" means an existing-pending row was reused, so
// skip the email in that case (admin already knows).
if (gatedRequests.length > 0) {
const orgIdForEmail =
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
const companyName = orgIdForEmail
? await getOrgBilling(orgIdForEmail)
.then((b) => b?.companyName ?? null)
.catch(() => null)
: null;
for (const g of gatedRequests) {
if (!g.requestId) continue;
try {
await sendSkillActivationAdminNotification({
tenantName: name,
skillId: g.skillId,
skillName: g.skillName,
requesterEmail: user.email,
requesterName: user.name,
companyName,
});
} catch (e) {
console.error(
`Failed to send admin notification for skill activation request:`,
e
);
}
}
}
return NextResponse.json({
...updated,
// Phase 2.5: tells the client which requested-to-enable skills
// didn't actually land in the spec because they're awaiting
// admin approval. UI uses this to render the "pending review"
// state on those skill cards.
pendingActivationRequests: gatedRequests.map((g) => ({
skillId: g.skillId,
skillName: g.skillName,
})),
});
} catch (e: any) {
return NextResponse.json(
{ error: safeError(e, "Failed to update tenant") },