Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m39s
All checks were successful
Build and Push / build (push) Successful in 1m39s
This commit is contained in:
155
src/app/api/admin/skills/pending/[id]/approve/route.ts
Normal file
155
src/app/api/admin/skills/pending/[id]/approve/route.ts
Normal 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);
|
||||
}
|
||||
101
src/app/api/admin/skills/pending/[id]/reject/route.ts
Normal file
101
src/app/api/admin/skills/pending/[id]/reject/route.ts
Normal 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);
|
||||
}
|
||||
22
src/app/api/admin/skills/pending/route.ts
Normal file
22
src/app/api/admin/skills/pending/route.ts
Normal 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);
|
||||
}
|
||||
23
src/app/api/skills/pricing/route.ts
Normal file
23
src/app/api/skills/pricing/route.ts
Normal 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);
|
||||
}
|
||||
54
src/app/api/skills/requests/[id]/withdraw/route.ts
Normal file
54
src/app/api/skills/requests/[id]/withdraw/route.ts
Normal 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);
|
||||
}
|
||||
40
src/app/api/skills/requests/route.ts
Normal file
40
src/app/api/skills/requests/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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") },
|
||||
|
||||
Reference in New Issue
Block a user