feat(01-03): tenant signup API, service, and UI
- Add businessAddress and contactPhone fields to Tenant schema - Create src/lib/tenant.ts with createTenant() function: - Validates input, slugifies business name, hashes password (bcrypt 12) - Prisma transaction creates Tenant + admin User atomically - Custom EmailAlreadyExistsError for 409 Conflict responses - Create POST /api/tenants/signup route returning 201/400/409/500 - Create /signup page with full form (business name, owner info, password, optional fields) - Client-side validation: required fields, email format, password match - Redirects to /login?registered=true on success - Update /login page to show success banner when ?registered=true
This commit is contained in:
120
src/app/api/tenants/signup/route.ts
Normal file
120
src/app/api/tenants/signup/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createTenant, EmailAlreadyExistsError } from "@/lib/tenant";
|
||||
|
||||
// =============================================================================
|
||||
// POST /api/tenants/signup
|
||||
// =============================================================================
|
||||
// Creates a new ISP tenant and its admin user.
|
||||
//
|
||||
// Request body (JSON):
|
||||
// businessName: string (required)
|
||||
// ownerFirstName: string (required)
|
||||
// ownerLastName: string (required)
|
||||
// ownerEmail: string (required)
|
||||
// password: string (required, min 8 chars)
|
||||
// businessAddress: string (optional)
|
||||
// contactPhone: string (optional)
|
||||
//
|
||||
// Responses:
|
||||
// 201 Created — { tenant: { id, name, slug }, user: { id, email } }
|
||||
// 400 Bad Request — { error: string } — validation failure
|
||||
// 409 Conflict — { error: string } — email already registered
|
||||
// 500 Internal — { error: string } — unexpected server error
|
||||
// =============================================================================
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: unknown;
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json(
|
||||
{ error: "Request body must be a JSON object" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
businessName,
|
||||
ownerFirstName,
|
||||
ownerLastName,
|
||||
ownerEmail,
|
||||
password,
|
||||
businessAddress,
|
||||
contactPhone,
|
||||
} = body as Record<string, unknown>;
|
||||
|
||||
// Basic presence checks before passing to service layer
|
||||
if (!businessName || typeof businessName !== "string") {
|
||||
return NextResponse.json({ error: "businessName is required" }, { status: 400 });
|
||||
}
|
||||
if (!ownerFirstName || typeof ownerFirstName !== "string") {
|
||||
return NextResponse.json({ error: "ownerFirstName is required" }, { status: 400 });
|
||||
}
|
||||
if (!ownerLastName || typeof ownerLastName !== "string") {
|
||||
return NextResponse.json({ error: "ownerLastName is required" }, { status: 400 });
|
||||
}
|
||||
if (!ownerEmail || typeof ownerEmail !== "string") {
|
||||
return NextResponse.json({ error: "ownerEmail is required" }, { status: 400 });
|
||||
}
|
||||
if (!password || typeof password !== "string") {
|
||||
return NextResponse.json({ error: "password is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { tenant, user } = await createTenant({
|
||||
businessName,
|
||||
ownerFirstName,
|
||||
ownerLastName,
|
||||
ownerEmail,
|
||||
password,
|
||||
businessAddress: typeof businessAddress === "string" ? businessAddress : undefined,
|
||||
contactPhone: typeof contactPhone === "string" ? contactPhone : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
tenant: {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
},
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof EmailAlreadyExistsError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
// Validation errors thrown by createTenant are descriptive user-facing messages
|
||||
const isValidationError =
|
||||
error.message.includes("required") ||
|
||||
error.message.includes("at least") ||
|
||||
error.message.includes("Invalid") ||
|
||||
error.message.includes("alphanumeric");
|
||||
|
||||
if (isValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
console.error("[POST /api/tenants/signup] Unexpected error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "An unexpected error occurred. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user