275 lines
9.5 KiB
TypeScript
275 lines
9.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getSessionUser, canMutate } from "@/lib/session";
|
|
import {
|
|
getInvoiceById,
|
|
getTenantRequestById,
|
|
updateTenantRequestStatus,
|
|
updateTenantRequestEditableFields,
|
|
} from "@/lib/db";
|
|
import { encryptSecrets } from "@/lib/crypto";
|
|
import { setTenantAnnotation } from "@/lib/k8s";
|
|
import { onboardingSchema } from "@/lib/validation";
|
|
import { safeError } from "@/lib/errors";
|
|
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
|
import type { SessionUser, TenantRequest } from "@/types";
|
|
|
|
/**
|
|
* Customer-side controls for a single tenant_request row.
|
|
*
|
|
* - DELETE /api/onboarding/[id] → cancel a still-pending request
|
|
* - PATCH /api/onboarding/[id] → edit fields of a still-pending
|
|
* request (Bug 6)
|
|
*
|
|
* Both endpoints share the same authorization check: the caller must
|
|
* be a customer owner (or platform staff) of the request's org. We
|
|
* also enforce status === 'pending' on the row — once an admin has
|
|
* acted on it, the customer can no longer mutate it from the portal.
|
|
*
|
|
* Reading these is via the existing GET /api/onboarding?id=... handler.
|
|
*/
|
|
|
|
async function loadAuthorized(
|
|
id: string
|
|
): Promise<
|
|
| { error: NextResponse }
|
|
| { req: TenantRequest; user: SessionUser }
|
|
> {
|
|
const user = await getSessionUser();
|
|
if (!user) {
|
|
return {
|
|
error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
|
|
};
|
|
}
|
|
if (!canMutate(user)) {
|
|
return {
|
|
error: NextResponse.json({ error: "Forbidden" }, { status: 403 }),
|
|
};
|
|
}
|
|
const tr = await getTenantRequestById(id);
|
|
if (!tr) {
|
|
return {
|
|
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
|
|
};
|
|
}
|
|
// Customers may only read their own org's requests; platform users
|
|
// may read any. Same scope as `GET /api/onboarding?id=...`.
|
|
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
|
|
return {
|
|
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
|
|
};
|
|
}
|
|
return { req: tr, user };
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/onboarding/[id]
|
|
*
|
|
* Customer cancels a still-pending request. Status flips to 'cancelled';
|
|
* the row is preserved for audit. The customer can dismiss the
|
|
* cancelled card afterwards (Bug 13 reuse — same dismissal mechanism).
|
|
*
|
|
* Once admin has approved/provisioned/rejected, this endpoint refuses
|
|
* (409). Cancelling a tenant that's already running goes through the
|
|
* subscription-suspend flow on the tenant detail page, not here.
|
|
*/
|
|
export async function DELETE(
|
|
_req: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const { id } = await params;
|
|
const loaded = await loadAuthorized(id);
|
|
if ("error" in loaded) return loaded.error;
|
|
const tr = loaded.req!;
|
|
|
|
if (tr.status !== "pending") {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
"Only pending requests can be cancelled. Approved or provisioning instances must be managed from the tenant page.",
|
|
code: "not_pending",
|
|
currentStatus: tr.status,
|
|
},
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
|
|
try {
|
|
await updateTenantRequestStatus(id, "cancelled");
|
|
|
|
// Phase 9b: a 'pending' provision request has already had its
|
|
// setup fee charged (the order-time Checkout completed before
|
|
// the webhook flipped it to 'pending'). Cancelling it must
|
|
// refund that payment, exactly as an admin rejection does.
|
|
// Resume requests never carry a setup_invoice_id, so this only
|
|
// fires for provision orders. Best-effort: a refund failure is
|
|
// logged + surfaced but doesn't block the cancellation (admin
|
|
// can refund manually from the invoice page).
|
|
let refund: { attempted: boolean; succeeded: boolean; error?: string } = {
|
|
attempted: false,
|
|
succeeded: false,
|
|
};
|
|
if (tr.requestType === "provision" && tr.setupInvoiceId) {
|
|
refund.attempted = true;
|
|
try {
|
|
const inv = await getInvoiceById(tr.setupInvoiceId);
|
|
if (!inv) {
|
|
throw new Error(`Linked setup invoice ${tr.setupInvoiceId} not found`);
|
|
}
|
|
const remaining =
|
|
Math.round((inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100) / 100;
|
|
if (remaining <= 0) {
|
|
refund.succeeded = true; // nothing left to refund
|
|
} else {
|
|
await refundInvoice({
|
|
invoiceId: tr.setupInvoiceId,
|
|
amountChf: remaining,
|
|
reason: "Order cancelled by customer",
|
|
refundedBy: loaded.user!.id,
|
|
});
|
|
refund.succeeded = true;
|
|
}
|
|
} catch (e: any) {
|
|
refund.error =
|
|
e instanceof RefundNotAllowedError
|
|
? e.message
|
|
: (e?.message ?? "refund failed");
|
|
console.error(
|
|
`Setup-fee refund failed for cancelled request ${id} (invoice ${tr.setupInvoiceId}):`,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
// Customer cancels their own pending resume request: clear the
|
|
// operator-side annotation so the 60-day TTL resumes counting.
|
|
// Best-effort — the operator handles missing annotation gracefully.
|
|
if (tr.requestType === "resume" && tr.tenantName) {
|
|
try {
|
|
await setTenantAnnotation(
|
|
tr.tenantName,
|
|
"pieced.ch/resume-request-pending",
|
|
null
|
|
);
|
|
} catch (e) {
|
|
console.warn(
|
|
"post-cancel annotation clear failed; not blocking",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ message: "Request cancelled.", id, refund });
|
|
} catch (e: any) {
|
|
console.error("Failed to cancel request:", e);
|
|
return NextResponse.json(
|
|
{ error: safeError(e, "Failed to cancel request") },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/onboarding/[id]
|
|
*
|
|
* Customer edits a still-pending request. Validation is the same as on
|
|
* POST /api/onboarding (shared schema). Only customer-input fields are
|
|
* editable; status/tenant_name/admin_notes/etc. are server-managed.
|
|
*
|
|
* Note on company-level fields
|
|
* ----------------------------
|
|
* For a follow-up instance (org has prior approved rows), the POST
|
|
* handler intentionally ignores the wizard's billingAddress and uses
|
|
* the on-file value instead. We mirror that here: company-level fields
|
|
* (companyName, contactName, contactEmail, billingAddress) on a
|
|
* follow-up edit are NOT updated through this endpoint. The customer
|
|
* should use a future settings page (Bug 11) for those. For now,
|
|
* editing only mutates per-instance fields — agent name, instance
|
|
* name, packages, soulMd, agentsMd, billingNotes, packageSecrets.
|
|
*
|
|
* For the FIRST instance (no prior approved rows), billingAddress IS
|
|
* editable here, since the customer is still defining their company's
|
|
* billing data.
|
|
*/
|
|
export async function PATCH(
|
|
req: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const { id } = await params;
|
|
const loaded = await loadAuthorized(id);
|
|
if ("error" in loaded) return loaded.error;
|
|
const tr = loaded.req!;
|
|
|
|
if (tr.status !== "pending") {
|
|
return NextResponse.json(
|
|
{
|
|
error: "Only pending requests can be edited.",
|
|
code: "not_pending",
|
|
currentStatus: tr.status,
|
|
},
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
|
|
const body = await req.json().catch(() => null);
|
|
const parsed = onboardingSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: "Invalid input", details: parsed.error.flatten() },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
const input = parsed.data;
|
|
|
|
// Re-encrypt package secrets if present in the patch body. When the
|
|
// user re-opens the wizard to edit, the secrets array is populated
|
|
// afresh from the wizard (we never decrypt and return existing
|
|
// secrets — that'd be a security regression). If the user didn't
|
|
// touch any secret-bearing package, the wizard sends no
|
|
// packageSecrets and we leave the existing encrypted blob alone.
|
|
let encryptedSecrets: Buffer | null | 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 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Only first-instance edits get billingAddress; follow-ups inherit
|
|
// company billing from the on-file approved row.
|
|
const isFirstInstance = !tr.tenantName; // approximation; covers the
|
|
// "no prior approved row for this org" case the POST handler treats
|
|
// identically. A more rigorous check would call
|
|
// getMostRecentApprovedRequestForOrg, but in practice an org with
|
|
// an approved row for some other tenant has a tenantName on those
|
|
// rows, not on the pending one being edited — so the simple check
|
|
// here is fine for the only state the endpoint accepts (pending).
|
|
|
|
try {
|
|
const updated = await updateTenantRequestEditableFields(id, {
|
|
instanceName: input.instanceName,
|
|
agentName: input.agentName,
|
|
soulMd: input.soulMd,
|
|
agentsMd: input.agentsMd,
|
|
packages: input.packages ?? [],
|
|
billingAddress: isFirstInstance ? input.billingAddress : undefined,
|
|
billingNotes: input.billingNotes,
|
|
encryptedSecrets,
|
|
});
|
|
if (!updated) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
return NextResponse.json({ message: "Request updated.", id });
|
|
} catch (e: any) {
|
|
console.error("Failed to edit request:", e);
|
|
return NextResponse.json(
|
|
{ error: safeError(e, "Failed to edit request") },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|