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
+188
View File
@@ -2,11 +2,14 @@ import { Router } from 'express'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
import { authenticator } from 'otplib'
import qrcode from 'qrcode'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAuth'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../services/platformContentService'
import { generateInvoicePdf, buildInvoiceNumber } from '../services/invoicePdfService'
import { sendTransactionalEmail } from '../services/notificationService'
import type { MarketplaceHomepageContent, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep } from '@rentaldrivego/types'
const router = Router()
@@ -311,6 +314,70 @@ router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
} catch (err) { next(err) }
})
const ADMIN_RESET_TOKEN_TTL_MINUTES = 60
router.post('/auth/forgot-password', async (req, res, next) => {
try {
const { email } = z.object({ email: z.string().email().max(255).trim().toLowerCase() }).parse(req.body)
const admin = await prisma.adminUser.findUnique({ where: { email } })
if (admin && admin.isActive) {
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + ADMIN_RESET_TOKEN_TTL_MINUTES * 60 * 1000)
await prisma.adminUser.update({
where: { id: admin.id },
data: { passwordResetToken: rawToken, passwordResetExpiresAt: expiresAt },
})
const adminUrl = process.env.ADMIN_URL ?? 'http://localhost:3002'
const resetUrl = `${adminUrl}/reset-password?token=${rawToken}`
await sendTransactionalEmail({
to: email,
subject: 'Reset your RentalDriveGo admin password',
html: `<p>Hi ${admin.firstName},</p><p>Reset your admin password here:</p><p><a href="${resetUrl}" style="background:#059669;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset password</a></p><p>This link expires in ${ADMIN_RESET_TOKEN_TTL_MINUTES} minutes.</p><p>RentalDriveGo</p>`,
text: `Hi ${admin.firstName},\n\nReset your admin password here: ${resetUrl}\n\nThis link expires in ${ADMIN_RESET_TOKEN_TTL_MINUTES} minutes.\n\nRentalDriveGo`,
}).catch((err) => console.error('[AdminForgotPassword] Email send failed:', err?.message))
}
res.json({ data: { message: 'If that email is registered, a reset link has been sent.' } })
} catch (err) { next(err) }
})
router.post('/auth/reset-password', async (req, res, next) => {
try {
const { token, password } = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
}).parse(req.body)
const admin = await prisma.adminUser.findFirst({
where: {
passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() },
},
})
if (!admin) {
return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 })
}
const passwordHash = await bcrypt.hash(password, 12)
await prisma.adminUser.update({
where: { id: admin.id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
res.json({ data: { message: 'Password updated successfully. You can now sign in.' } })
} catch (err) { next(err) }
})
// ─── Companies ────────────────────────────────────────────────
router.get('/companies', requireAdminAuth, async (req, res, next) => {
@@ -665,4 +732,125 @@ router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPE
} catch (err) { next(err) }
})
// ─── Billing ──────────────────────────────────────────────────
const PLAN_MONTHLY_AMOUNT: Record<string, number> = {
STARTER: 29900,
GROWTH: 59900,
PRO: 99900,
}
router.get('/billing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { status, plan, page = '1', pageSize = '20' } = req.query as Record<string, string>
const where: any = {}
if (status) where.status = status
if (plan) where.plan = plan
const [subscriptions, total] = await Promise.all([
prisma.subscription.findMany({
where,
include: {
company: { select: { id: true, name: true, email: true, slug: true, status: true } },
invoices: { select: { id: true, amount: true, currency: true, status: true, paidAt: true, createdAt: true }, orderBy: { createdAt: 'desc' }, take: 5 },
_count: { select: { invoices: true } },
},
skip: (parseInt(page) - 1) * parseInt(pageSize),
take: parseInt(pageSize),
orderBy: { createdAt: 'desc' },
}),
prisma.subscription.count({ where }),
])
const activeStatuses = ['ACTIVE', 'TRIALING']
const allActive = await prisma.subscription.findMany({
where: { status: { in: activeStatuses } },
select: { plan: true, billingPeriod: true, currency: true },
})
let mrr = 0
for (const sub of allActive) {
const monthly = PLAN_MONTHLY_AMOUNT[sub.plan] ?? 0
mrr += sub.billingPeriod === 'ANNUAL' ? Math.round(monthly * 10 / 12) : monthly
}
const [activeCount, trialingCount, pastDueCount, cancelledCount] = await Promise.all([
prisma.subscription.count({ where: { status: 'ACTIVE' } }),
prisma.subscription.count({ where: { status: 'TRIALING' } }),
prisma.subscription.count({ where: { status: 'PAST_DUE' } }),
prisma.subscription.count({ where: { status: 'CANCELLED' } }),
])
res.json({
data: subscriptions,
total,
page: parseInt(page),
pageSize: parseInt(pageSize),
totalPages: Math.ceil(total / parseInt(pageSize)),
stats: { mrr, activeCount, trialingCount, pastDueCount, cancelledCount },
})
} catch (err) { next(err) }
})
router.get('/billing/:companyId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query as Record<string, string>
const [invoices, total] = await Promise.all([
prisma.subscriptionInvoice.findMany({
where: { companyId: req.params.companyId },
orderBy: { createdAt: 'desc' },
skip: (parseInt(page) - 1) * parseInt(pageSize),
take: parseInt(pageSize),
}),
prisma.subscriptionInvoice.count({ where: { companyId: req.params.companyId } }),
])
res.json({ data: invoices, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
} catch (err) { next(err) }
})
router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const invoice = await prisma.subscriptionInvoice.findUniqueOrThrow({
where: { id: req.params.invoiceId },
include: {
company: { select: { name: true, email: true, phone: true, address: true } },
subscription: { select: { plan: true, billingPeriod: true, currency: true, currentPeriodStart: true, currentPeriodEnd: true } },
},
})
const invoiceNumber = buildInvoiceNumber(invoice.id, invoice.createdAt)
const transactionId = invoice.amanpayTransactionId ?? invoice.paypalCaptureId ?? null
const pdfBuffer = await generateInvoicePdf({
invoiceNumber,
issueDate: invoice.createdAt.toISOString(),
dueDate: invoice.status === 'PENDING' ? invoice.createdAt.toISOString() : null,
company: {
name: invoice.company.name,
email: invoice.company.email,
phone: invoice.company.phone,
address: invoice.company.address,
},
subscription: {
plan: invoice.subscription.plan,
billingPeriod: invoice.subscription.billingPeriod,
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
currency: invoice.subscription.currency,
},
amount: invoice.amount,
currency: invoice.currency,
status: invoice.status,
paymentProvider: invoice.paymentProvider,
transactionId,
paidAt: invoice.paidAt?.toISOString(),
})
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${invoiceNumber}.pdf"`)
res.setHeader('Content-Length', pdfBuffer.length)
res.end(pdfBuffer)
} catch (err) { next(err) }
})
export default router
+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
+139
View File
@@ -0,0 +1,139 @@
import { Router } from 'express'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
import { prisma } from '../lib/prisma'
import { sendTransactionalEmail } from '../services/notificationService'
const router = Router()
const RESET_TOKEN_TTL_MINUTES = 60
function signEmployeeToken(employeeId: string) {
return jwt.sign(
{ sub: employeeId, type: 'employee' },
process.env.JWT_SECRET!,
{ expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'] },
)
}
function buildResetEmailHtml(resetUrl: string, firstName: string) {
return `
<p>Hi ${firstName},</p>
<p>You requested a password reset for your RentalDriveGo workspace account.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset your password</a></p>
<p>This link expires in ${RESET_TOKEN_TTL_MINUTES} minutes. If you did not request this, you can safely ignore this email.</p>
<p>RentalDriveGo</p>
`
}
router.post('/login', async (req, res, next) => {
try {
const { email, password } = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
password: z.string().max(128),
}).parse(req.body)
const employee = await prisma.employee.findFirst({
where: { email },
include: { company: true },
})
if (!employee || !employee.isActive) {
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
}
if (!employee.passwordHash) {
return res.status(401).json({ error: 'password_not_set', message: 'This account does not have a password yet. Use the invitation or reset email to set one first.', statusCode: 401 })
}
const valid = await bcrypt.compare(password, employee.passwordHash)
if (!valid) {
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
}
const token = signEmployeeToken(employee.id)
res.json({
data: {
token,
employee: {
id: employee.id,
email: employee.email,
firstName: employee.firstName,
lastName: employee.lastName,
role: employee.role,
companyId: employee.companyId,
companyName: employee.company.name,
companySlug: employee.company.slug,
},
},
})
} catch (err) { next(err) }
})
router.post('/forgot-password', async (req, res, next) => {
try {
const { email } = z.object({ email: z.string().email().max(255).trim().toLowerCase() }).parse(req.body)
// Always return success to avoid user enumeration
const employee = await prisma.employee.findFirst({ where: { email, isActive: true } })
if (employee) {
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000)
await prisma.employee.update({
where: { id: employee.id },
data: { passwordResetToken: rawToken, passwordResetExpiresAt: expiresAt },
})
const dashboardUrl = process.env.DASHBOARD_URL ?? 'http://localhost:3001'
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
await sendTransactionalEmail({
to: email,
subject: 'Reset your RentalDriveGo password',
html: buildResetEmailHtml(resetUrl, employee.firstName),
text: `Hi ${employee.firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${RESET_TOKEN_TTL_MINUTES} minutes.\n\nRentalDriveGo`,
}).catch((err) => console.error('[ForgotPassword] Email send failed:', err?.message))
}
res.json({ data: { message: 'If that email is registered, a reset link has been sent.' } })
} catch (err) { next(err) }
})
router.post('/reset-password', async (req, res, next) => {
try {
const { token, password } = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
}).parse(req.body)
const employee = await prisma.employee.findFirst({
where: {
passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() },
},
})
if (!employee) {
return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 })
}
const passwordHash = await bcrypt.hash(password, 12)
await prisma.employee.update({
where: { id: employee.id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
res.json({ data: { message: 'Password updated successfully. You can now sign in.' } })
} catch (err) { next(err) }
})
export default router
+3 -3
View File
@@ -2,7 +2,7 @@ import { Router } from 'express'
import { z } from 'zod'
import multer from 'multer'
import { prisma } from '../lib/prisma'
import { uploadImage } from '../lib/cloudinary'
import { uploadImage } from '../lib/storage'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
@@ -261,7 +261,7 @@ router.post('/me/brand/logo', upload.single('file'), async (req, res, next) => {
if (!req.file) {
return res.status(400).json({ error: 'missing_file', message: 'A logo file is required', statusCode: 400 })
}
const url = await uploadImage(req.file.buffer, `brands/${req.companyId}`, 'logo')
const url = await uploadImage(req.file.buffer, `companies/${req.companyId}/brand`, 'logo')
const brand = await prisma.brandSettings.upsert({
where: { companyId: req.companyId },
update: { logoUrl: url },
@@ -281,7 +281,7 @@ router.post('/me/brand/hero', upload.single('file'), async (req, res, next) => {
if (!req.file) {
return res.status(400).json({ error: 'missing_file', message: 'A hero image file is required', statusCode: 400 })
}
const url = await uploadImage(req.file.buffer, `brands/${req.companyId}`, 'hero')
const url = await uploadImage(req.file.buffer, `companies/${req.companyId}/brand`, 'hero')
const brand = await prisma.brandSettings.upsert({
where: { companyId: req.companyId },
update: { heroImageUrl: url },
+129 -7
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import { z } from 'zod'
import { prisma } from '../lib/prisma'
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
const router = Router()
router.use(optionalRenterAuth)
@@ -38,7 +39,7 @@ router.get('/companies', async (req, res, next) => {
const { city, hasOffer } = req.query as Record<string, string>
const { page, pageSize } = paginationSchema.parse(req.query)
const safeCity = city ? city.trim().slice(0, 100) : undefined
const where: any = { status: 'ACTIVE', brand: { isListedOnMarketplace: true } }
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }
if (safeCity) where.brand = { ...where.brand, publicCity: { contains: safeCity, mode: 'insensitive' } }
if (hasOffer === 'true') {
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
@@ -69,14 +70,18 @@ router.get('/search', async (req, res, next) => {
category: z.string().trim().max(50).optional(),
maxPrice: z.coerce.number().int().min(0).max(1_000_000).optional(),
transmission: z.string().trim().max(20).optional(),
make: z.string().trim().max(60).optional(),
model: z.string().trim().max(60).optional(),
})
const { city, startDate, endDate, category, maxPrice, transmission } = searchSchema.parse(req.query)
const { city, startDate, endDate, category, maxPrice, transmission, make, model } = searchSchema.parse(req.query)
const { page, pageSize } = paginationSchema.parse(req.query)
const where: any = { isPublished: true, company: { status: 'ACTIVE', brand: { isListedOnMarketplace: true } } }
const where: any = { isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } } }
if (category) where.category = category
if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice }
if (transmission) where.transmission = transmission
if (make) where.make = { contains: make, mode: 'insensitive' }
if (model) where.model = { contains: model, mode: 'insensitive' }
if (city) where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: city, mode: 'insensitive' } } }
const vehicles = await prisma.vehicle.findMany({
@@ -115,10 +120,127 @@ router.get('/search', async (req, res, next) => {
}
})
router.post('/reservations', async (req, res, next) => {
try {
const schema = z.object({
vehicleId: z.string().cuid(),
companySlug: z.string().trim().max(100),
firstName: z.string().min(1).max(100),
lastName: z.string().min(1).max(100),
email: z.string().email(),
phone: z.string().max(30).optional(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
notes: z.string().max(500).optional(),
})
const body = schema.parse(req.body)
const startDate = new Date(body.startDate)
const endDate = new Date(body.endDate)
if (endDate <= startDate) {
return res.status(400).json({ error: 'invalid_dates', message: 'End date must be after start date', statusCode: 400 })
}
const vehicle = await prisma.vehicle.findFirst({
where: { id: body.vehicleId, isPublished: true, company: { slug: body.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
include: { company: { include: { brand: true } } },
})
if (!vehicle) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
const conflict = await prisma.reservation.findFirst({
where: {
vehicleId: body.vehicleId,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lt: endDate },
endDate: { gt: startDate },
},
})
if (conflict) return res.status(409).json({ error: 'unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 })
const customer = await prisma.customer.upsert({
where: { companyId_email: { companyId: vehicle.companyId, email: body.email } },
create: { companyId: vehicle.companyId, firstName: body.firstName, lastName: body.lastName, email: body.email, phone: body.phone },
update: { firstName: body.firstName, lastName: body.lastName, ...(body.phone ? { phone: body.phone } : {}) },
})
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
const totalAmount = vehicle.dailyRate * totalDays
const reservation = await prisma.reservation.create({
data: {
companyId: vehicle.companyId,
vehicleId: body.vehicleId,
customerId: customer.id,
source: 'MARKETPLACE',
status: 'DRAFT',
startDate,
endDate,
dailyRate: vehicle.dailyRate,
totalDays,
totalAmount,
notes: body.notes,
},
})
const companyName = vehicle.company.brand?.displayName ?? vehicle.company.name
const rateDisplay = (vehicle.dailyRate / 100).toFixed(2)
const totalDisplay = (totalAmount / 100).toFixed(2)
const dateOpts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
const startStr = startDate.toLocaleDateString('en-GB', dateOpts)
const endStr = endDate.toLocaleDateString('en-GB', dateOpts)
try {
await sendTransactionalEmail({
to: body.email,
subject: `Reservation Request Received — ${vehicle.make} ${vehicle.model}`,
html: `
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
<h2 style="margin-bottom:4px">Hi ${body.firstName},</h2>
<p style="color:#57534e">Your reservation request has been received and is pending company confirmation.</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
<h3 style="margin-bottom:12px">${vehicle.year} ${vehicle.make} ${vehicle.model}</h3>
<p><strong>Company:</strong> ${companyName}</p>
<p><strong>Pick-up date:</strong> ${startStr}</p>
<p><strong>Return date:</strong> ${endStr}</p>
<p><strong>Duration:</strong> ${totalDays} day${totalDays > 1 ? 's' : ''}</p>
<p><strong>Daily rate:</strong> ${rateDisplay} MAD</p>
<p><strong>Estimated total:</strong> ${totalDisplay} MAD</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
<p style="color:#57534e;font-size:13px">The company will review your request and get in touch with you shortly. You may be contacted at ${body.email}${body.phone ? ` or ${body.phone}` : ''}.</p>
</div>
`,
text: `Hi ${body.firstName},\n\nYour reservation request for the ${vehicle.year} ${vehicle.make} ${vehicle.model} from ${companyName} has been received.\n\nDates: ${startStr}${endStr}\nDuration: ${totalDays} day(s)\nDaily rate: ${rateDisplay} MAD\nEstimated total: ${totalDisplay} MAD\n\nThe company will confirm your request shortly.\n\nThank you!`,
})
} catch {
// email failure is non-blocking — reservation still created
}
try {
await sendNotification({
type: 'NEW_BOOKING',
title: 'New Marketplace Reservation Request',
body: `${body.firstName} ${body.lastName} requested ${vehicle.make} ${vehicle.model} from ${startStr} to ${endStr}.`,
companyId: vehicle.companyId,
channels: ['IN_APP'],
})
} catch {
// notification failure is non-blocking
}
res.status(201).json({ data: { reservationId: reservation.id } })
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug', async (req, res, next) => {
try {
const company = await prisma.company.findFirst({
where: { slug: req.params.slug, status: 'ACTIVE' },
where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } },
include: {
brand: true,
vehicles: { where: { isPublished: true }, orderBy: { createdAt: 'desc' } },
@@ -153,7 +275,7 @@ router.get('/:slug/reviews', async (req, res, next) => {
router.get('/:slug/vehicles', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const vehicles = await prisma.vehicle.findMany({
where: { companyId: company.id, isPublished: true },
orderBy: { createdAt: 'desc' },
@@ -167,7 +289,7 @@ router.get('/:slug/vehicles', async (req, res, next) => {
router.get('/:slug/vehicles/:id', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const vehicle = await prisma.vehicle.findFirstOrThrow({
where: { id: req.params.id, companyId: company.id, isPublished: true },
include: {
@@ -189,7 +311,7 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
router.get('/:slug/offers', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const offers = await prisma.offer.findMany({
where: {
companyId: company.id,
+3 -3
View File
@@ -2,7 +2,7 @@ import { Router } from 'express'
import { z } from 'zod'
import multer from 'multer'
import { prisma } from '../lib/prisma'
import { uploadImage } from '../lib/cloudinary'
import { uploadImage } from '../lib/storage'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
@@ -16,7 +16,7 @@ const vehicleSchema = z.object({
make: z.string().min(1),
model: z.string().min(1),
year: z.number().int().min(1990).max(new Date().getFullYear() + 1),
color: z.string().min(1),
color: z.string().default(''),
licensePlate: z.string().min(1),
vin: z.string().optional(),
category: z.enum(['ECONOMY','COMPACT','MIDSIZE','FULLSIZE','SUV','LUXURY','VAN','TRUCK']),
@@ -83,7 +83,7 @@ router.post('/:id/photos', upload.array('photos', 10), async (req, res, next) =>
try {
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const files = req.files as Express.Multer.File[]
const urls = await Promise.all(files.map((f) => uploadImage(f.buffer, `vehicles/${req.companyId}`)))
const urls = await Promise.all(files.map((f) => uploadImage(f.buffer, `companies/${req.companyId}/vehicles`)))
const updated = await prisma.vehicle.update({ where: { id: vehicle.id }, data: { photos: [...vehicle.photos, ...urls] } })
res.json({ data: updated })
} catch (err) { next(err) }
+6 -32
View File
@@ -1,39 +1,13 @@
import { Router } from 'express'
import { Webhook } from 'svix'
import { EmployeeRole } from '@rentaldrivego/database'
import { handleInvitationAccepted } from '../services/teamService'
const router = Router()
router.post('/clerk', async (req, res) => {
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET
if (!webhookSecret) return res.status(500).json({ error: 'Webhook secret not configured' })
const wh = new Webhook(webhookSecret)
let event: any
try {
event = wh.verify(req.body, {
'svix-id': req.headers['svix-id'] as string,
'svix-timestamp': req.headers['svix-timestamp'] as string,
'svix-signature': req.headers['svix-signature'] as string,
})
} catch {
return res.status(400).json({ error: 'Invalid webhook signature' })
}
if (event.type === 'user.created') {
const clerkUserId: string = event.data.id
const email: string = event.data.email_addresses?.[0]?.email_address ?? ''
const meta = event.data.public_metadata ?? {}
const { companyId, role } = meta as { companyId?: string; role?: EmployeeRole }
if (companyId && role && email) {
await handleInvitationAccepted(clerkUserId, email, companyId, role).catch(console.error)
}
}
res.json({ received: true })
router.post('/clerk', (_req, res) => {
res.status(410).json({
error: 'disabled',
message: 'Clerk webhooks are disabled because Clerk has been removed from this project.',
statusCode: 410,
})
})
export default router