fixing platform admin
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { clerkClient } from '@clerk/clerk-sdk-node'
|
||||
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(),
|
||||
companyName: z.string().min(2).max(120),
|
||||
companyPhone: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
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(),
|
||||
})
|
||||
|
||||
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
|
||||
isResend?: boolean
|
||||
usesInvitation?: boolean
|
||||
}) {
|
||||
const greetingName = opts.firstName.trim() || 'there'
|
||||
const lines = [
|
||||
`Hi ${greetingName},`,
|
||||
'',
|
||||
opts.isResend
|
||||
? `We sent a fresh owner invitation for ${opts.companyName}.`
|
||||
: `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(
|
||||
'',
|
||||
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.',
|
||||
'',
|
||||
'RentalDriveGo'
|
||||
)
|
||||
|
||||
return lines.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',
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 usesInvitation = Boolean(process.env.CLERK_SECRET_KEY && process.env.DASHBOARD_URL)
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const company = await tx.company.create({
|
||||
data: {
|
||||
name: body.companyName,
|
||||
slug,
|
||||
email: body.email,
|
||||
phone: body.companyPhone || null,
|
||||
status: 'TRIALING',
|
||||
},
|
||||
})
|
||||
|
||||
await tx.brandSettings.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
displayName: body.companyName,
|
||||
subdomain: slug,
|
||||
publicEmail: body.email,
|
||||
publicPhone: body.companyPhone || null,
|
||||
publicCountry: body.country || null,
|
||||
publicCity: body.city || null,
|
||||
defaultCurrency: body.currency,
|
||||
},
|
||||
})
|
||||
|
||||
const subscription = 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 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}`,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
email: body.email,
|
||||
phone: body.companyPhone || null,
|
||||
role: 'OWNER',
|
||||
isActive: !invitation,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
company,
|
||||
subscription,
|
||||
employeeId: employee.id,
|
||||
invitationId: invitation?.id ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
await sendNotification({
|
||||
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,
|
||||
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,
|
||||
trialEndsAt: trialEndAt.toISOString(),
|
||||
nextStep: usesInvitation ? 'check_email_for_invitation' : 'workspace_created',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
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('/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)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user