fix first online resevation

This commit is contained in:
root
2026-05-09 20:01:51 -04:00
parent c4a45c8b21
commit 09b0e3b55f
75 changed files with 6394 additions and 2190 deletions
+52 -358
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'
import { z } from 'zod'
import { clerkClient } from '@clerk/clerk-sdk-node'
import bcrypt from 'bcryptjs'
import { prisma } from '../lib/prisma'
import { sendNotification } from '../services/notificationService'
@@ -10,6 +10,7 @@ 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),
companyPhone: z.string().optional(),
country: z.string().optional(),
@@ -20,24 +21,11 @@ const signupSchema = z.object({
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
})
const ownerWorkspaceSchema = z.object({
companyName: z.string().min(2).max(120),
companyPhone: z.string().nullish(),
country: z.string().nullish(),
city: z.string().nullish(),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
})
const completeSignupMetadataSchema = ownerWorkspaceSchema.extend({
signupFlow: z.literal('company-owner'),
})
const verifySchema = z.object({
email: z.string().email(),
})
type EmailDeliveryResult = {
attempted: boolean
success: boolean
error: string | null
}
function slugifyCompanyName(value: string) {
return value
@@ -69,207 +57,42 @@ function buildSignupConfirmationEmail(opts: {
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD' | 'USD' | 'EUR'
paymentProvider?: 'AMANPAY' | 'PAYPAL'
trialEndAt?: Date
isResend?: boolean
usesInvitation?: boolean
paymentProvider: 'AMANPAY' | 'PAYPAL'
trialEndAt: Date
}) {
const greetingName = opts.firstName.trim() || 'there'
const lines = [
return [
`Hi ${greetingName},`,
'',
opts.isResend
? `We sent a fresh owner invitation for ${opts.companyName}.`
: `Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
`Currency: ${opts.currency}`,
]
if (opts.paymentProvider) {
lines.push(`Primary payment provider: ${opts.paymentProvider}`)
}
if (opts.trialEndAt) {
lines.push(
`Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`
)
}
lines.push(
`Primary payment provider: ${opts.paymentProvider}`,
`Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`,
'',
opts.usesInvitation === false
? 'Your workspace is ready and the owner record has been created for this environment.'
: 'Open the invitation email from Clerk to confirm your account, finish onboarding, and enter the dashboard.',
'Your workspace is ready and you can sign in with the email address and password you chose during signup.',
'',
'RentalDriveGo'
)
return lines.join('\n')
'RentalDriveGo',
].join('\n')
}
async function createOwnerInvitation(email: string, companyId: string, companyName: string) {
const dashboardUrl = process.env.DASHBOARD_URL
if (!process.env.CLERK_SECRET_KEY || !dashboardUrl) {
throw Object.assign(new Error('Clerk signup is not configured on this environment'), {
statusCode: 503,
code: 'clerk_not_configured',
})
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 clerkClient.invitations.createInvitation({
emailAddress: email,
redirectUrl: `${dashboardUrl}/onboarding/accept-invite`,
publicMetadata: {
companyId,
companyName,
role: 'OWNER',
signupFlow: 'company-owner',
},
})
}
async function verifyClerkSessionToken(sessionToken: string) {
return clerkClient.sessions.verifySession(sessionToken, sessionToken)
}
function getPrimaryEmail(user: Awaited<ReturnType<typeof clerkClient.users.getUser>>) {
const primary = user.emailAddresses.find((email) => email.id === user.primaryEmailAddressId)
return primary ?? user.emailAddresses[0] ?? null
}
async function createCompanyWorkspaceForOwner(opts: {
clerkUserId: string
firstName: string
lastName: string
email: string
companyName: string
companyPhone?: string | null
country?: string | null
city?: string | null
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD' | 'USD' | 'EUR'
paymentProvider: 'AMANPAY' | 'PAYPAL'
}) {
const existingEmployeeByClerkUser = await prisma.employee.findUnique({
where: { clerkUserId: opts.clerkUserId },
include: { company: true },
})
if (existingEmployeeByClerkUser) {
return {
company: existingEmployeeByClerkUser.company,
employeeId: existingEmployeeByClerkUser.id,
invitationId: null,
trialEndAt: null,
created: false,
}
}
const existingCompany = await prisma.company.findUnique({ where: { email: opts.email } })
if (existingCompany) {
throw Object.assign(new Error('A company account with this email already exists'), {
statusCode: 409,
code: 'email_taken',
})
}
const existingEmployeeByEmail = await prisma.employee.findFirst({ where: { email: opts.email } })
if (existingEmployeeByEmail) {
throw Object.assign(new Error('An employee account with this email already exists'), {
statusCode: 409,
code: 'email_taken',
})
}
const slug = await generateUniqueSlug(opts.companyName)
const now = new Date()
const trialEndAt = addDays(now, 14)
const result = await prisma.$transaction(async (tx) => {
const company = await tx.company.create({
data: {
name: opts.companyName,
slug,
email: opts.email,
phone: opts.companyPhone || null,
status: 'TRIALING',
},
})
await tx.brandSettings.create({
data: {
companyId: company.id,
displayName: opts.companyName,
subdomain: slug,
publicEmail: opts.email,
publicPhone: opts.companyPhone || null,
publicCountry: opts.country || null,
publicCity: opts.city || null,
defaultCurrency: opts.currency,
},
})
await tx.subscription.create({
data: {
companyId: company.id,
plan: opts.plan,
billingPeriod: opts.billingPeriod,
currency: opts.currency,
status: 'TRIALING',
trialStartAt: now,
trialEndAt,
currentPeriodStart: now,
currentPeriodEnd: trialEndAt,
},
})
const employee = await tx.employee.create({
data: {
companyId: company.id,
clerkUserId: opts.clerkUserId,
firstName: opts.firstName,
lastName: opts.lastName,
email: opts.email,
phone: opts.companyPhone || null,
role: 'OWNER',
isActive: true,
},
})
return {
company,
employeeId: employee.id,
trialEndAt,
created: true,
}
})
await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
title: 'Your workspace is ready',
body: buildSignupConfirmationEmail({
firstName: opts.firstName,
companyName: opts.companyName,
plan: opts.plan,
billingPeriod: opts.billingPeriod,
currency: opts.currency,
paymentProvider: opts.paymentProvider,
trialEndAt: result.trialEndAt,
usesInvitation: false,
}),
companyId: result.company.id,
employeeId: result.employeeId,
email: opts.email,
channels: ['EMAIL', 'IN_APP'],
}).catch(() => null)
return {
...result,
invitationId: null,
attempted: true,
success: emailResult.success,
error: emailResult.error ?? null,
}
}
@@ -298,7 +121,7 @@ router.post('/signup', async (req, res, next) => {
const slug = await generateUniqueSlug(body.companyName)
const now = new Date()
const trialEndAt = addDays(now, 14)
const usesInvitation = Boolean(process.env.CLERK_SECRET_KEY && process.env.DASHBOARD_URL)
const passwordHash = await bcrypt.hash(body.password, 12)
const result = await prisma.$transaction(async (tx) => {
const company = await tx.company.create({
@@ -324,7 +147,7 @@ router.post('/signup', async (req, res, next) => {
},
})
const subscription = await tx.subscription.create({
await tx.subscription.create({
data: {
companyId: company.id,
plan: body.plan,
@@ -338,32 +161,27 @@ router.post('/signup', async (req, res, next) => {
},
})
const invitation = usesInvitation
? await createOwnerInvitation(body.email, company.id, body.companyName)
: null
const employee = await tx.employee.create({
data: {
companyId: company.id,
clerkUserId: invitation ? `pending_${invitation.id}` : `local_owner_${company.id}`,
clerkUserId: `local_owner_${company.id}`,
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
phone: body.companyPhone || null,
passwordHash,
role: 'OWNER',
isActive: !invitation,
isActive: true,
},
})
return {
company,
subscription,
employeeId: employee.id,
invitationId: invitation?.id ?? null,
}
})
await sendNotification({
const emailDelivery = await sendSignupEmailNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
title: 'Your workspace is ready',
body: buildSignupConfirmationEmail({
@@ -374,22 +192,22 @@ router.post('/signup', async (req, res, next) => {
currency: body.currency,
paymentProvider: body.paymentProvider,
trialEndAt,
usesInvitation,
}),
companyId: result.company.id,
employeeId: result.employeeId,
email: body.email,
channels: ['EMAIL', 'IN_APP'],
}).catch(() => null)
})
res.status(201).json({
data: {
companyId: result.company.id,
companyName: result.company.name,
slug: result.company.slug,
invitationId: result.invitationId,
invitationId: null,
trialEndsAt: trialEndAt.toISOString(),
nextStep: usesInvitation ? 'check_email_for_invitation' : 'workspace_created',
nextStep: 'workspace_created',
emailDelivery,
},
})
} catch (err) {
@@ -397,144 +215,20 @@ router.post('/signup', async (req, res, next) => {
}
})
router.post('/complete-signup', async (req, res, next) => {
try {
const authHeader = req.headers.authorization
const sessionToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!sessionToken) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Authentication required',
statusCode: 401,
})
}
const session = await verifyClerkSessionToken(sessionToken)
const user = await clerkClient.users.getUser(session.userId)
const primaryEmail = getPrimaryEmail(user)
if (!primaryEmail?.emailAddress) {
return res.status(400).json({
error: 'email_missing',
message: 'The signed-in Clerk user does not have a primary email address',
statusCode: 400,
})
}
if (primaryEmail.verification?.status !== 'verified') {
return res.status(400).json({
error: 'email_not_verified',
message: 'Verify the owner email address before creating the workspace',
statusCode: 400,
})
}
const metadata = completeSignupMetadataSchema.parse(user.unsafeMetadata ?? {})
const existingEmployee = await prisma.employee.findUnique({
where: { clerkUserId: user.id },
include: { company: true },
})
if (existingEmployee) {
return res.json({
data: {
companyId: existingEmployee.companyId,
companyName: existingEmployee.company.name,
slug: existingEmployee.company.slug,
trialEndsAt: null,
nextStep: 'enter_dashboard',
},
})
}
const result = await createCompanyWorkspaceForOwner({
clerkUserId: user.id,
firstName: user.firstName?.trim() || 'Owner',
lastName: user.lastName?.trim() || 'Account',
email: primaryEmail.emailAddress,
companyName: metadata.companyName,
companyPhone: metadata.companyPhone ?? null,
country: metadata.country ?? null,
city: metadata.city ?? null,
plan: metadata.plan,
billingPeriod: metadata.billingPeriod,
currency: metadata.currency,
paymentProvider: metadata.paymentProvider,
})
res.status(result.created ? 201 : 200).json({
data: {
companyId: result.company.id,
companyName: result.company.name,
slug: result.company.slug,
trialEndsAt: result.trialEndAt?.toISOString() ?? null,
nextStep: 'enter_dashboard',
},
})
} 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', async (req, res, next) => {
try {
const { email } = verifySchema.parse(req.body)
const pendingEmployee = await prisma.employee.findFirst({
where: {
email,
role: 'OWNER',
clerkUserId: { startsWith: 'pending_' },
},
include: { company: true },
})
if (!pendingEmployee) {
return res.status(404).json({
error: 'pending_signup_not_found',
message: 'No pending company signup was found for this email',
statusCode: 404,
})
}
const newInvitation = await createOwnerInvitation(email, pendingEmployee.companyId, pendingEmployee.company.name)
await prisma.employee.update({
where: { id: pendingEmployee.id },
data: { clerkUserId: `pending_${newInvitation.id}` },
})
const subscription = await prisma.subscription.findUnique({
where: { companyId: pendingEmployee.companyId },
})
await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
title: 'Your owner invitation has been resent',
body: buildSignupConfirmationEmail({
firstName: pendingEmployee.firstName,
companyName: pendingEmployee.company.name,
plan: subscription?.plan ?? 'STARTER',
billingPeriod: subscription?.billingPeriod ?? 'MONTHLY',
currency: subscription?.currency === 'USD' || subscription?.currency === 'EUR' ? subscription.currency : 'MAD',
trialEndAt: subscription?.trialEndAt ?? undefined,
isResend: true,
}),
companyId: pendingEmployee.companyId,
employeeId: pendingEmployee.id,
email,
channels: ['EMAIL', 'IN_APP'],
}).catch(() => null)
res.json({
data: {
success: true,
invitationId: newInvitation.id,
message: 'A fresh invitation link has been sent to your email address',
},
})
} catch (err) {
next(err)
}
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