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

232
src/lib/tenant.ts Normal file
View File

@@ -0,0 +1,232 @@
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { TenantStatus } from "@prisma/client";
// =============================================================================
// Tenant Service
// =============================================================================
// Handles tenant provisioning (creating new ISP accounts).
// Each createTenant() call produces one Tenant + one admin User in a transaction.
// =============================================================================
export interface CreateTenantInput {
businessName: string;
ownerFirstName: string;
ownerLastName: string;
ownerEmail: string;
password: string;
businessAddress?: string;
contactPhone?: string;
}
export interface CreateTenantResult {
tenant: {
id: string;
name: string;
slug: string;
ownerEmail: string;
businessAddress: string | null;
contactPhone: string | null;
status: TenantStatus;
createdAt: Date;
};
user: {
id: string;
email: string;
firstName: string;
lastName: string;
tenantId: string | null;
roles: string[];
isActive: boolean;
createdAt: Date;
};
}
/**
* Slugify a business name to a URL-friendly identifier.
* Example: "My ISP Co." -> "my-isp-co"
*/
function slugify(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Generates a unique slug by appending a numeric suffix if the base slug is taken.
*/
async function generateUniqueSlug(businessName: string): Promise<string> {
const baseSlug = slugify(businessName);
if (!baseSlug) {
throw new Error("Business name must contain at least one alphanumeric character");
}
// Check if slug is available
const existing = await prisma.tenant.findUnique({
where: { slug: baseSlug },
});
if (!existing) {
return baseSlug;
}
// Try appending incrementing numbers
for (let i = 2; i <= 99; i++) {
const candidateSlug = `${baseSlug}-${i}`;
const taken = await prisma.tenant.findUnique({ where: { slug: candidateSlug } });
if (!taken) {
return candidateSlug;
}
}
// Fallback: append timestamp
return `${baseSlug}-${Date.now()}`;
}
/**
* Creates a new ISP tenant along with its initial admin user.
*
* Validates inputs, then runs a single Prisma transaction that:
* 1. Creates the Tenant record
* 2. Creates the admin User record linked to the tenant
*
* Returns both records without sensitive fields (no passwordHash).
*
* Throws descriptive errors for:
* - Empty business name
* - Password too short (< 8 chars)
* - Duplicate email within the new tenant context
*/
export async function createTenant(input: CreateTenantInput): Promise<CreateTenantResult> {
const {
businessName,
ownerFirstName,
ownerLastName,
ownerEmail,
password,
businessAddress,
contactPhone,
} = input;
// --- Validation ---
if (!businessName || !businessName.trim()) {
throw new Error("Business name is required");
}
if (!ownerEmail || !ownerEmail.trim()) {
throw new Error("Owner email is required");
}
// Basic email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(ownerEmail.trim())) {
throw new Error("Invalid email address");
}
if (!password || password.length < 8) {
throw new Error("Password must be at least 8 characters");
}
if (!ownerFirstName || !ownerFirstName.trim()) {
throw new Error("First name is required");
}
if (!ownerLastName || !ownerLastName.trim()) {
throw new Error("Last name is required");
}
const normalizedEmail = ownerEmail.trim().toLowerCase();
// Check if this email is already used as an ownerEmail for any tenant
// (The database uniqueness is per [email, tenantId], but we also want
// to prevent someone signing up twice with the same email globally
// at the tenant owner level.)
//
// Note: Same email CAN exist in different tenants for non-owner users,
// but when creating a new tenant the owner email should be fresh.
const emailInUse = await prisma.user.findFirst({
where: {
email: normalizedEmail,
isSuperAdmin: false,
},
});
if (emailInUse) {
throw new EmailAlreadyExistsError("An account with this email address already exists");
}
// Generate a unique slug for the tenant
const slug = await generateUniqueSlug(businessName.trim());
// Hash password with cost factor 12
const passwordHash = await bcrypt.hash(password, 12);
// --- Transaction: create Tenant + User atomically ---
const result = await prisma.$transaction(async (tx) => {
const tenant = await tx.tenant.create({
data: {
name: businessName.trim(),
slug,
ownerEmail: normalizedEmail,
businessAddress: businessAddress?.trim() || null,
contactPhone: contactPhone?.trim() || null,
status: TenantStatus.ACTIVE,
},
});
const user = await tx.user.create({
data: {
email: normalizedEmail,
passwordHash,
firstName: ownerFirstName.trim(),
lastName: ownerLastName.trim(),
tenantId: tenant.id,
roles: ["ADMIN"],
isActive: true,
isSuperAdmin: false,
},
});
return { tenant, user };
});
// Return without passwordHash
return {
tenant: {
id: result.tenant.id,
name: result.tenant.name,
slug: result.tenant.slug,
ownerEmail: result.tenant.ownerEmail,
businessAddress: result.tenant.businessAddress,
contactPhone: result.tenant.contactPhone,
status: result.tenant.status,
createdAt: result.tenant.createdAt,
},
user: {
id: result.user.id,
email: result.user.email,
firstName: result.user.firstName,
lastName: result.user.lastName,
tenantId: result.user.tenantId,
roles: result.user.roles,
isActive: result.user.isActive,
createdAt: result.user.createdAt,
},
};
}
/**
* Custom error class for duplicate email during tenant signup.
* API route uses this to return 409 Conflict.
*/
export class EmailAlreadyExistsError extends Error {
constructor(message: string) {
super(message);
this.name = "EmailAlreadyExistsError";
}
}