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(), agentsMd: 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(2_000).optional(), }); /** * GET /api/onboarding * Check the current onboarding state for the logged-in user's org. */ export async function GET() { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } // Check if there's already a running tenant for this org 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: "active", tenantName: myTenant.metadata.name, phase: myTenant.status?.phase ?? "Unknown", }); } // Check if there's a pending request const request = await getTenantRequestByOrgId(user.orgId); if (!request || request.status === "deleted") { return NextResponse.json({ state: "no_request" }); } return NextResponse.json({ state: request.status, request: { id: request.id, agentName: request.agentName, packages: request.packages, status: request.status, adminNotes: request.adminNotes, tenantName: request.tenantName, createdAt: request.createdAt, }, }); } /** * 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 }); } const body = await request.json(); const parsed = onboardingSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } // 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: "You already have a tenant provisioned.", tenantName: myTenant.metadata.name, }, { status: 409 } ); } 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, contactEmail: user.email, agentName: input.agentName, soulMd: input.soulMd, agentsMd: input.agentsMd, packages: input.packages ?? [], billingAddress: input.billingAddress, billingNotes: input.billingNotes, encryptedSecrets, }); // Notify admin about the new request try { await sendAdminNotificationEmail( tenantRequest.contactEmail, tenantRequest.contactName, tenantRequest.companyName ); } catch (e) { console.error("Failed to send admin notification:", e); } return NextResponse.json( { message: "Request submitted.", request: tenantRequest }, { status: 201 } ); }