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:
kevin-asprec
2026-03-04 18:40:40 +08:00
parent 71a9277913
commit 43761d94ee
5 changed files with 874 additions and 0 deletions

View 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 }
);
}
}