Multitenantperorg enabling
All checks were successful
Build and Push / build (push) Successful in 1m21s
All checks were successful
Build and Push / build (push) Successful in 1m21s
This commit is contained in:
49
src/app/[locale]/dashboard/new/page.tsx
Normal file
49
src/app/[locale]/dashboard/new/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* /dashboard/new — wizard for creating an additional instance for an
|
||||
* existing customer. Reachable from the dashboard "+ Create new instance"
|
||||
* link.
|
||||
*
|
||||
* Slice 3: this page is the entry point for follow-up instances. The
|
||||
* first-instance case is still served inline on /dashboard. Both paths
|
||||
* mount the same <OnboardingFlow>; the API resolves the difference
|
||||
* server-side based on whether prior approved rows exist for the org.
|
||||
*
|
||||
* Platform admins are redirected to /dashboard — they shouldn't be
|
||||
* creating tenant instances under their own org.
|
||||
*/
|
||||
export default async function NewInstancePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (user.isPlatform) redirect("/dashboard");
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center gap-1.5 mb-4 text-xs font-medium text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<span>←</span> {t("title")}
|
||||
</Link>
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("createInstance")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("createInstanceDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<OnboardingFlow orgName={user.orgName} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,11 @@ import { getSessionUser } from "@/lib/session";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getTenantRequestByOrgId } from "@/lib/db";
|
||||
import { listActiveTenantRequestsByOrgId } from "@/lib/db";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import { ProvisioningStatus } from "@/components/onboarding/provisioning-status";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -20,7 +20,7 @@ export default async function DashboardPage() {
|
||||
|
||||
const allTenants = await listTenants();
|
||||
|
||||
// Platform users see overview of all tenants
|
||||
// Platform users see overview of all tenants — unchanged from pre-Slice-3.
|
||||
if (user.isPlatform) {
|
||||
const phaseCount = allTenants.reduce<Record<string, number>>((acc, t) => {
|
||||
const phase = t.status?.phase ?? "Pending";
|
||||
@@ -133,20 +133,24 @@ export default async function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Regular user: find their tenant
|
||||
const myTenant = allTenants.find(
|
||||
// ---------------------------------------------------------------------
|
||||
// Customer view (Slice 3 multi-tenant)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const orgTenants = allTenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
const orgRequests = await listActiveTenantRequestsByOrgId(user.orgId);
|
||||
|
||||
// No tenant → check for existing request, show onboarding flow
|
||||
if (!myTenant) {
|
||||
const existingRequest = await getTenantRequestByOrgId(user.orgId);
|
||||
// Treat "deleted" as no request — customer can re-onboard
|
||||
const initialState =
|
||||
!existingRequest || existingRequest.status === "deleted"
|
||||
? "no_request"
|
||||
: existingRequest.status;
|
||||
// Pending/in-flight requests that don't yet have a tenant CR. Once the
|
||||
// CR exists, the tenant card carries the live phase, so a separate
|
||||
// "request" card would just duplicate it.
|
||||
const inflightRequests = orgRequests.filter(
|
||||
(r) => !r.tenantName || !orgTenants.some((t) => t.metadata.name === r.tenantName)
|
||||
);
|
||||
|
||||
// First-time user: empty company. Show the onboarding wizard inline.
|
||||
if (orgTenants.length === 0 && inflightRequests.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
@@ -159,70 +163,107 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<OnboardingFlow
|
||||
orgName={user.orgName}
|
||||
initialState={initialState as any}
|
||||
/>
|
||||
<OnboardingFlow orgName={user.orgName} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tenantName = myTenant.metadata.name;
|
||||
|
||||
// Returning customer: list of tenants + in-flight requests, plus
|
||||
// a button to add another instance.
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("welcome", { name: user.name || user.email })}
|
||||
</p>
|
||||
<div className="mb-8 animate-in flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("welcome", { name: user.name || user.email })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/dashboard/new"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
<span>+</span> {t("createInstance")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Instance status card */}
|
||||
<div className="mb-6 animate-in animate-in-delay-1">
|
||||
<Card>
|
||||
<CardHeader>{t("instanceStatus")}</CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusBadge phase={myTenant.status?.phase ?? "Pending"} />
|
||||
{myTenant.spec.agentName && (
|
||||
<span className="text-sm text-text-secondary">
|
||||
{myTenant.spec.agentName}
|
||||
</span>
|
||||
)}
|
||||
{/* In-flight (pending/approved/provisioning/rejected) requests */}
|
||||
{inflightRequests.length > 0 && (
|
||||
<div className="mb-8 animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("inflightRequests")}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{inflightRequests.map((r) => (
|
||||
<ProvisioningStatus key={r.id} requestId={r.id} />
|
||||
))}
|
||||
</div>
|
||||
{myTenant.spec.packages && myTenant.spec.packages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{myTenant.spec.packages.map((pkg) => (
|
||||
<span
|
||||
key={pkg}
|
||||
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2.5 py-0.5"
|
||||
>
|
||||
{pkg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage — no teamId passed, backend resolves from session */}
|
||||
<div className="mb-6 animate-in animate-in-delay-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("usage")}
|
||||
</h2>
|
||||
<UsageDisplay />
|
||||
</div>
|
||||
{/* Active tenants */}
|
||||
{orgTenants.length > 0 && (
|
||||
<div className="animate-in animate-in-delay-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("instances")}
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{orgTenants.map((tenant) => (
|
||||
<Link
|
||||
key={tenant.metadata.name}
|
||||
href={`/tenants/${tenant.metadata.name}`}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full hover:border-accent/40 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-text-primary truncate">
|
||||
{tenant.spec.displayName || tenant.metadata.name}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-text-muted truncate mt-0.5">
|
||||
{tenant.metadata.name}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
|
||||
</div>
|
||||
|
||||
{/* Link to tenant detail */}
|
||||
<Link
|
||||
href={`/tenants/${tenantName}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-accent hover:text-accent-dim transition-colors animate-in animate-in-delay-3"
|
||||
>
|
||||
<span>→</span> {t("manage")}
|
||||
</Link>
|
||||
{tenant.spec.agentName && (
|
||||
<div className="text-xs text-text-secondary mb-2">
|
||||
{tenant.spec.agentName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tenant.spec.packages && tenant.spec.packages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{tenant.spec.packages.slice(0, 4).map((pkg) => (
|
||||
<span
|
||||
key={pkg}
|
||||
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2 py-0.5"
|
||||
>
|
||||
{pkg}
|
||||
</span>
|
||||
))}
|
||||
{tenant.spec.packages.length > 4 && (
|
||||
<span className="text-xs text-text-muted">
|
||||
+{tenant.spec.packages.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs font-medium text-accent group-hover:text-accent-dim transition-colors">
|
||||
{t("manage")} →
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,11 +100,19 @@ export async function POST(
|
||||
"TOOLS.md": toolsMd,
|
||||
};
|
||||
|
||||
// Step 4: Create the PiecedTenant CR
|
||||
// Step 4: Create the PiecedTenant CR.
|
||||
// displayName: prefer the customer-chosen instance name; fall back to
|
||||
// the company name. With multi-tenant per org, instanceName is what
|
||||
// distinguishes "Acme Production" from "Acme Dev" on the dashboard.
|
||||
const displayName =
|
||||
tenantRequest.instanceName && tenantRequest.instanceName.trim().length > 0
|
||||
? tenantRequest.instanceName.trim()
|
||||
: tenantRequest.companyName;
|
||||
|
||||
await createTenant(
|
||||
tenantName,
|
||||
{
|
||||
displayName: tenantRequest.companyName,
|
||||
displayName,
|
||||
agentName: tenantRequest.agentName,
|
||||
packages,
|
||||
workspaceFiles,
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
createTenantRequest,
|
||||
getTenantRequestByOrgId,
|
||||
deleteTenantRequest,
|
||||
getTenantRequestById,
|
||||
listTenantRequestsByOrgId,
|
||||
listActiveTenantRequestsByOrgId,
|
||||
getMostRecentApprovedRequestForOrg,
|
||||
} 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 type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
|
||||
import { z } from "zod";
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
instanceName: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(80)
|
||||
.optional()
|
||||
// Empty string from a form input → drop to undefined so the DB stores NULL
|
||||
.transform((v) => (v && v.length > 0 ? v : undefined)),
|
||||
agentName: z.string().min(1).max(50),
|
||||
soulMd: z.string().max(10_000).optional(),
|
||||
agentsMd: z.string().max(10_000).optional(),
|
||||
@@ -30,59 +39,116 @@ const onboardingSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/onboarding
|
||||
* Check the current onboarding state for the logged-in user's org.
|
||||
* Helper: shape a TenantRequest row for client consumption.
|
||||
* Hides server-only fields (encryptedSecrets, internal db ids).
|
||||
*/
|
||||
export async function GET() {
|
||||
function publicRequestShape(r: TenantRequest) {
|
||||
return {
|
||||
id: r.id,
|
||||
instanceName: r.instanceName,
|
||||
agentName: r.agentName,
|
||||
packages: r.packages,
|
||||
status: r.status,
|
||||
adminNotes: r.adminNotes,
|
||||
tenantName: r.tenantName,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function publicTenantShape(t: PiecedTenant) {
|
||||
return {
|
||||
name: t.metadata.name,
|
||||
displayName: t.spec.displayName,
|
||||
phase: t.status?.phase ?? "Pending",
|
||||
suspended: t.spec.suspend ?? false,
|
||||
packages: t.spec.packages ?? [],
|
||||
creationTimestamp: t.metadata.creationTimestamp,
|
||||
conditions: t.status?.conditions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/onboarding
|
||||
*
|
||||
* Two response shapes depending on the `?id=` query:
|
||||
*
|
||||
* - With `?id=<requestId>`: returns the single request's status plus
|
||||
* the linked tenant's phase if approved. Used by ProvisioningStatus
|
||||
* to poll a specific request. The id is validated against the
|
||||
* caller's orgId so admins-and-only-admins can read across orgs.
|
||||
*
|
||||
* - Without `id`: returns lists of all in-flight requests and active
|
||||
* tenants for the caller's org. Used by the dashboard to render the
|
||||
* multi-tenant view.
|
||||
*
|
||||
* Slice 3 note: this replaces the old single-state response shape
|
||||
* (`{ state: "...", request: {...} }`). Pre-Slice-3 callers will see
|
||||
* the new shape and need to be updated. The only known caller is
|
||||
* `<ProvisioningStatus>`, updated in lockstep.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
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
|
||||
);
|
||||
const requestedId = req.nextUrl.searchParams.get("id");
|
||||
|
||||
if (myTenant) {
|
||||
if (requestedId) {
|
||||
const tr = await getTenantRequestById(requestedId);
|
||||
if (!tr) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
// Customers may only read their own org's requests; platform
|
||||
// admins/operators may read any.
|
||||
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let tenant: PiecedTenant | null = null;
|
||||
if (tr.tenantName) {
|
||||
tenant = (await getTenant(tr.tenantName)) ?? null;
|
||||
}
|
||||
return NextResponse.json({
|
||||
state: "active",
|
||||
tenantName: myTenant.metadata.name,
|
||||
phase: myTenant.status?.phase ?? "Unknown",
|
||||
request: publicRequestShape(tr),
|
||||
tenant: tenant ? publicTenantShape(tenant) : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there's a pending request
|
||||
const request = await getTenantRequestByOrgId(user.orgId);
|
||||
// List view: requests + tenants for this org
|
||||
const [requests, allTenants] = await Promise.all([
|
||||
listActiveTenantRequestsByOrgId(user.orgId),
|
||||
listTenants(),
|
||||
]);
|
||||
|
||||
if (!request || request.status === "deleted") {
|
||||
return NextResponse.json({ state: "no_request" });
|
||||
}
|
||||
const orgTenants = allTenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
requests: requests.map(publicRequestShape),
|
||||
tenants: orgTenants.map(publicTenantShape),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Always creates a NEW tenant_request row, regardless of how many other
|
||||
* rows already exist for this org. The pre-Slice-3 409 ("you already
|
||||
* have a request") is gone — multi-tenant is the design now.
|
||||
*
|
||||
* For additional instances in an existing company, the customer's prior
|
||||
* approved row is used to seed billing/contact info, so the wizard
|
||||
* doesn't need to re-collect data already on file. The wizard *does*
|
||||
* still send a billingAddress payload (the field is required by the
|
||||
* schema), but in practice the client can pre-fill it from
|
||||
* `getMostRecentApprovedRequestForOrg`.
|
||||
*
|
||||
* Encrypted package secrets, if provided, are AES-256-GCM-sealed 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();
|
||||
@@ -99,40 +165,17 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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<string, Record<string, string>>;
|
||||
} = parsed.data;
|
||||
|
||||
// Look up an existing approved request for this org to inherit
|
||||
// company-level billing data. For brand-new orgs (first registration),
|
||||
// there is no prior row and we use the form-supplied billingAddress
|
||||
// verbatim. For follow-up requests, we ignore the form-supplied
|
||||
// company line in favour of the recorded company name.
|
||||
const prior = await getMostRecentApprovedRequestForOrg(user.orgId);
|
||||
|
||||
// Encrypt package secrets if provided
|
||||
let encryptedSecrets: Buffer | undefined;
|
||||
if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) {
|
||||
@@ -147,34 +190,55 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// For follow-up instances, prefer the on-file company name and contact
|
||||
// details; the user can't change those by re-typing them in the wizard.
|
||||
const companyName = prior?.companyName ?? user.orgName;
|
||||
const contactName = prior?.contactName ?? user.name;
|
||||
const contactEmail = prior?.contactEmail ?? user.email;
|
||||
const billingAddress = prior?.billingAddress ?? input.billingAddress;
|
||||
const billingNotes = input.billingNotes ?? prior?.billingNotes;
|
||||
|
||||
const tenantRequest = await createTenantRequest({
|
||||
zitadelOrgId: user.orgId,
|
||||
zitadelUserId: user.id,
|
||||
companyName: user.orgName,
|
||||
contactName: user.name,
|
||||
contactEmail: user.email,
|
||||
companyName,
|
||||
instanceName: input.instanceName,
|
||||
contactName,
|
||||
contactEmail,
|
||||
agentName: input.agentName,
|
||||
soulMd: input.soulMd,
|
||||
agentsMd: input.agentsMd,
|
||||
packages: input.packages ?? [],
|
||||
billingAddress: input.billingAddress,
|
||||
billingNotes: input.billingNotes,
|
||||
billingAddress,
|
||||
billingNotes,
|
||||
encryptedSecrets,
|
||||
});
|
||||
|
||||
// Notify admin about the new request
|
||||
// Notify admin about the new request. For follow-up instances, include
|
||||
// the instance name in the notification so the admin sees what's
|
||||
// being requested without opening the panel.
|
||||
try {
|
||||
await sendAdminNotificationEmail(
|
||||
tenantRequest.contactEmail,
|
||||
tenantRequest.contactName,
|
||||
tenantRequest.companyName
|
||||
tenantRequest.instanceName
|
||||
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
||||
: tenantRequest.companyName
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to send admin notification:", e);
|
||||
}
|
||||
|
||||
// For diagnostics: how many other in-flight requests does this org
|
||||
// already have? Useful for the admin queue.
|
||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Request submitted.", request: tenantRequest },
|
||||
{
|
||||
message: "Request submitted.",
|
||||
request: publicRequestShape(tenantRequest),
|
||||
orgRequestCount: allRequests.length,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user