262 lines
7.4 KiB
TypeScript
262 lines
7.4 KiB
TypeScript
import { Router } from 'express'
|
|
import { z } from 'zod'
|
|
import bcrypt from 'bcryptjs'
|
|
import { prisma } from '../lib/prisma'
|
|
import { sendNotification } from '../services/notificationService'
|
|
|
|
const router = Router()
|
|
|
|
const signupSchema = z.object({
|
|
firstName: z.string().min(1).max(80),
|
|
lastName: z.string().min(1).max(80),
|
|
email: z.string().email(),
|
|
password: z.string().min(8).max(128),
|
|
companyName: z.string().min(2).max(120),
|
|
legalForm: z.string().min(1).max(80),
|
|
registrationNumber: z.string().min(1).max(120),
|
|
streetAddress: z.string().min(1).max(200),
|
|
city: z.string().min(1).max(120),
|
|
country: z.string().min(1).max(120),
|
|
zipCode: z.string().min(1).max(40),
|
|
companyPhone: z.string().min(1).max(80),
|
|
fax: z.string().max(80).optional(),
|
|
yearsActive: z.string().min(1).max(80),
|
|
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
|
|
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
|
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
|
currency: z.enum(['MAD', 'USD', 'EUR']),
|
|
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
|
|
})
|
|
|
|
type EmailDeliveryResult = {
|
|
attempted: boolean
|
|
success: boolean
|
|
error: string | null
|
|
}
|
|
|
|
function slugifyCompanyName(value: string) {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 50) || 'company'
|
|
}
|
|
|
|
async function generateUniqueSlug(baseName: string) {
|
|
const base = slugifyCompanyName(baseName)
|
|
|
|
for (let attempt = 0; attempt < 25; attempt += 1) {
|
|
const slug = attempt === 0 ? base : `${base}-${attempt + 1}`
|
|
const existing = await prisma.company.findUnique({ where: { slug } })
|
|
if (!existing) return slug
|
|
}
|
|
|
|
return `${base}-${Date.now().toString(36)}`
|
|
}
|
|
|
|
function addDays(date: Date, days: number) {
|
|
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
|
|
}
|
|
|
|
function buildSignupConfirmationEmail(opts: {
|
|
firstName: string
|
|
companyName: string
|
|
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
|
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
|
currency: 'MAD' | 'USD' | 'EUR'
|
|
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
|
trialEndAt: Date
|
|
}) {
|
|
const greetingName = opts.firstName.trim() || 'there'
|
|
|
|
return [
|
|
`Hi ${greetingName},`,
|
|
'',
|
|
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
|
|
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
|
|
`Currency: ${opts.currency}`,
|
|
`Primary payment provider: ${opts.paymentProvider}`,
|
|
`Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})}.`,
|
|
'',
|
|
'Your workspace is ready and you can sign in with the email address and password you chose during signup.',
|
|
'',
|
|
'RentalDriveGo',
|
|
].join('\n')
|
|
}
|
|
|
|
async function sendSignupEmailNotification(opts: Parameters<typeof sendNotification>[0]): Promise<EmailDeliveryResult> {
|
|
const result = await sendNotification(opts)
|
|
const emailResult = result.find((entry) => entry.channel === 'EMAIL')
|
|
|
|
if (!emailResult) {
|
|
return { attempted: false, success: false, error: null }
|
|
}
|
|
|
|
return {
|
|
attempted: true,
|
|
success: emailResult.success,
|
|
error: emailResult.error ?? null,
|
|
}
|
|
}
|
|
|
|
router.post('/signup', async (req, res, next) => {
|
|
try {
|
|
const body = signupSchema.parse(req.body)
|
|
|
|
const existingCompany = await prisma.company.findUnique({ where: { email: body.email } })
|
|
if (existingCompany) {
|
|
return res.status(409).json({
|
|
error: 'email_taken',
|
|
message: 'A company account with this email already exists',
|
|
statusCode: 409,
|
|
})
|
|
}
|
|
|
|
const existingEmployee = await prisma.employee.findFirst({ where: { email: body.email } })
|
|
if (existingEmployee) {
|
|
return res.status(409).json({
|
|
error: 'email_taken',
|
|
message: 'An employee account with this email already exists',
|
|
statusCode: 409,
|
|
})
|
|
}
|
|
|
|
const slug = await generateUniqueSlug(body.companyName)
|
|
const now = new Date()
|
|
const trialEndAt = addDays(now, 14)
|
|
const passwordHash = await bcrypt.hash(body.password, 12)
|
|
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
const company = await tx.company.create({
|
|
data: {
|
|
name: body.companyName,
|
|
slug,
|
|
email: body.email,
|
|
phone: body.companyPhone,
|
|
address: {
|
|
streetAddress: body.streetAddress,
|
|
city: body.city,
|
|
country: body.country,
|
|
zipCode: body.zipCode,
|
|
legalForm: body.legalForm,
|
|
managerName: `${body.firstName} ${body.lastName}`.trim(),
|
|
fax: body.fax || null,
|
|
yearsActive: body.yearsActive,
|
|
},
|
|
status: 'TRIALING',
|
|
},
|
|
})
|
|
|
|
await tx.brandSettings.create({
|
|
data: {
|
|
companyId: company.id,
|
|
displayName: body.companyName,
|
|
subdomain: slug,
|
|
publicEmail: body.email,
|
|
publicPhone: body.companyPhone,
|
|
publicAddress: body.streetAddress,
|
|
publicCity: body.city,
|
|
publicCountry: body.country,
|
|
defaultCurrency: body.currency,
|
|
},
|
|
})
|
|
|
|
await tx.contractSettings.create({
|
|
data: {
|
|
companyId: company.id,
|
|
legalName: body.companyName,
|
|
registrationNumber: body.registrationNumber,
|
|
},
|
|
})
|
|
|
|
await tx.subscription.create({
|
|
data: {
|
|
companyId: company.id,
|
|
plan: body.plan,
|
|
billingPeriod: body.billingPeriod,
|
|
currency: body.currency,
|
|
status: 'TRIALING',
|
|
trialStartAt: now,
|
|
trialEndAt,
|
|
currentPeriodStart: now,
|
|
currentPeriodEnd: trialEndAt,
|
|
},
|
|
})
|
|
|
|
const employee = await tx.employee.create({
|
|
data: {
|
|
companyId: company.id,
|
|
clerkUserId: `local_owner_${company.id}`,
|
|
firstName: body.firstName,
|
|
lastName: body.lastName,
|
|
email: body.email,
|
|
phone: body.companyPhone,
|
|
passwordHash,
|
|
role: 'OWNER',
|
|
preferredLanguage: body.preferredLanguage,
|
|
isActive: true,
|
|
},
|
|
})
|
|
|
|
return {
|
|
company,
|
|
employeeId: employee.id,
|
|
}
|
|
})
|
|
|
|
const emailDelivery = await sendSignupEmailNotification({
|
|
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
|
title: 'Your workspace is ready',
|
|
body: buildSignupConfirmationEmail({
|
|
firstName: body.firstName,
|
|
companyName: body.companyName,
|
|
plan: body.plan,
|
|
billingPeriod: body.billingPeriod,
|
|
currency: body.currency,
|
|
paymentProvider: body.paymentProvider,
|
|
trialEndAt,
|
|
}),
|
|
companyId: result.company.id,
|
|
employeeId: result.employeeId,
|
|
email: body.email,
|
|
channels: ['EMAIL', 'IN_APP'],
|
|
})
|
|
|
|
res.status(201).json({
|
|
data: {
|
|
companyId: result.company.id,
|
|
companyName: result.company.name,
|
|
slug: result.company.slug,
|
|
invitationId: null,
|
|
trialEndsAt: trialEndAt.toISOString(),
|
|
nextStep: 'workspace_created',
|
|
emailDelivery,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
router.post('/complete-signup', (_req, res) => {
|
|
res.status(410).json({
|
|
error: 'disabled',
|
|
message: 'Clerk-based signup has been removed. Use /auth/company/signup instead.',
|
|
statusCode: 410,
|
|
})
|
|
})
|
|
|
|
router.post('/verify-email', (_req, res) => {
|
|
res.status(410).json({
|
|
error: 'disabled',
|
|
message: 'Email verification resend via Clerk has been removed from this project.',
|
|
statusCode: 410,
|
|
})
|
|
})
|
|
|
|
export default router
|