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:
232
src/lib/tenant.ts
Normal file
232
src/lib/tenant.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user