Files
carmanagement/apps/api/src/routes/auth.company.ts
T
2026-05-19 21:25:38 -04:00

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