import { NextResponse } from "next/server"; import { getSessionUser } from "@/lib/session"; import { createTenantRequest, getTenantRequestByOrgId, deleteTenantRequest, } from "@/lib/db"; import { getTenant, listTenants } from "@/lib/k8s"; import { sendAdminNotificationEmail } from "@/lib/email"; import { encryptSecrets } from "@/lib/crypto"; import type { OnboardingInput } from "@/types"; import { z } from "zod"; const onboardingSchema = z.object({ agentName: z.string().min(1).max(50), soulMd: z.string().max(10_000).optional(), packages: z.array(z.string()).optional(), packageSecrets: z .record(z.string(), z.record(z.string(), z.string())) .optional(), billingAddress: z.object({ company: z.string().optional(), street: z.string().optional(), city: z.string().optional(), postalCode: z.string().optional(), country: z.string().optional(), }), billingNotes: z.string().max(2000).optional(), }); /** * GET /api/onboarding * Returns the current onboarding status for the logged-in user's org. * Used by the wizard/provisioning UI to poll state. */ export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); // Check if tenant already exists const allTenants = await listTenants(); const myTenant = allTenants.find( (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId ); if (myTenant) { return NextResponse.json({ state: "provisioned", tenant: { name: myTenant.metadata.name, phase: myTenant.status?.phase ?? "Pending", message: myTenant.status?.message, conditions: myTenant.status?.conditions, }, }); } // Check if there's a pending request const request = await getTenantRequestByOrgId(user.orgId); if (!request || request.status === "deleted") { return NextResponse.json({ state: "no_request" }); } // If approved and tenant_name set, check provisioning status if ( request.status === "provisioning" && request.tenantName ) { const tenant = await getTenant(request.tenantName); if (tenant) { return NextResponse.json({ state: "provisioning", request, tenant: { name: tenant.metadata.name, phase: tenant.status?.phase ?? "Pending", message: tenant.status?.message, conditions: tenant.status?.conditions, }, }); } } return NextResponse.json({ state: request.status, request, }); } /** * POST /api/onboarding * Submit the onboarding wizard. Creates a tenant_request with status "pending". * The actual PiecedTenant CR is NOT created yet — admin approval required. * * If packageSecrets are provided (for packages requiring credentials like * Telegram, Discord, Email), they are encrypted with AES-256-GCM and stored * as a BYTEA blob. They are decrypted only during admin approval to write * to OpenBao. */ export async function POST(request: Request) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); // Check for existing request const existing = await getTenantRequestByOrgId(user.orgId); if (existing && existing.status !== "deleted") { return NextResponse.json( { error: "Onboarding request already submitted.", request: existing }, { status: 409 } ); } // If previous request was deleted, remove it so a fresh one can be created if (existing && existing.status === "deleted") { await deleteTenantRequest(existing.id); } // Check for existing tenant const allTenants = await listTenants(); const myTenant = allTenants.find( (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId ); if (myTenant) { return NextResponse.json( { error: "Tenant already exists." }, { status: 409 } ); } const body = await request.json(); const parsed = onboardingSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Validation failed", details: parsed.error.flatten() }, { status: 400 } ); } const input: OnboardingInput & { packageSecrets?: Record> } = parsed.data; // Encrypt package secrets if provided let encryptedSecrets: Buffer | undefined; if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) { try { encryptedSecrets = await encryptSecrets(input.packageSecrets); } catch (e: any) { console.error("Failed to encrypt package secrets:", e); return NextResponse.json( { error: "Failed to secure credentials. Please try again." }, { status: 500 } ); } } const tenantRequest = await createTenantRequest({ zitadelOrgId: user.orgId, zitadelUserId: user.id, companyName: user.orgName, contactName: user.name || user.email, contactEmail: user.email, agentName: input.agentName, soulMd: input.soulMd, packages: input.packages ?? [], billingAddress: input.billingAddress, billingNotes: input.billingNotes, encryptedSecrets, }); // Notify admin about the new request await sendAdminNotificationEmail( user.orgName, user.name || user.email, user.email ); return NextResponse.json( { message: "Onboarding request submitted.", request: tenantRequest }, { status: 201 } ); }