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
+2 -5
View File
@@ -3,18 +3,16 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"dev": "node --env-file=../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"start": "node --env-file=../../.env.local dist/index.js",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@clerk/clerk-sdk-node": "^5.0.0",
"@react-pdf/renderer": "^3.4.3",
"@rentaldrivego/database": "*",
"@rentaldrivego/types": "*",
"bcryptjs": "^2.4.3",
"cloudinary": "^2.2.0",
"cors": "^2.8.5",
"dayjs": "^1.11.11",
"express": "^4.19.2",
@@ -33,7 +31,6 @@
"react-dom": "^18.3.1",
"resend": "^3.2.0",
"socket.io": "^4.7.5",
"svix": "^1.20.0",
"twilio": "^5.1.0",
"zod": "^3.23.0"
},
+9 -4
View File
@@ -13,8 +13,9 @@ import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middlewa
// ─── Routes ───────────────────────────────────────────────────
import webhookRouter from './routes/webhooks'
import companyAuthRouter from './routes/auth.company'
import renterAuthRouter from './routes/auth.renter'
import companyAuthRouter from './routes/auth.company'
import employeeAuthRouter from './routes/auth.employee'
import renterAuthRouter from './routes/auth.renter'
import vehiclesRouter from './routes/vehicles'
import reservationsRouter from './routes/reservations'
import teamRouter from './routes/team'
@@ -114,6 +115,9 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
})
// ─── Middleware ────────────────────────────────────────────────
// Serve local file storage
app.use('/storage', express.static(require('path').resolve(__dirname, 'lib/storage')))
// Webhook must use raw body BEFORE express.json()
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
@@ -124,8 +128,9 @@ app.use(express.json({ limit: '10mb' }))
// ─── API Routes ───────────────────────────────────────────────
// Auth routes: strict brute-force protection
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
// Admin routes: dedicated limit
app.use(`${v1}/admin`, adminLimiter, adminRouter)
-31
View File
@@ -1,31 +0,0 @@
import { v2 as cloudinary } from 'cloudinary'
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
secure: true,
})
export { cloudinary }
export async function uploadImage(
buffer: Buffer,
folder: string,
publicId?: string
): Promise<string> {
return new Promise((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{ folder, public_id: publicId, resource_type: 'image', quality: 'auto', fetch_format: 'auto' },
(error, result) => {
if (error) return reject(error)
resolve(result!.secure_url)
}
)
uploadStream.end(buffer)
})
}
export async function deleteImage(publicId: string): Promise<void> {
await cloudinary.uploader.destroy(publicId)
}
+35
View File
@@ -0,0 +1,35 @@
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
const STORAGE_ROOT = path.resolve(__dirname, 'storage')
function getApiBase(): string {
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
}
export async function uploadImage(
buffer: Buffer,
folder: string,
publicId?: string
): Promise<string> {
const folderPath = path.join(STORAGE_ROOT, folder)
fs.mkdirSync(folderPath, { recursive: true })
const filename = publicId
? `${publicId}.jpg`
: `${crypto.randomBytes(16).toString('hex')}.jpg`
fs.writeFileSync(path.join(folderPath, filename), buffer)
return `${getApiBase()}/storage/${folder}/${filename}`
}
export async function deleteImage(imageUrl: string): Promise<void> {
const marker = '/storage/'
const idx = imageUrl.indexOf(marker)
if (idx === -1) return
const relative = imageUrl.slice(idx + marker.length)
const filePath = path.join(STORAGE_ROOT, relative)
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
}
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+19 -19
View File
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express'
import { clerkClient } from '@clerk/clerk-sdk-node'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
@@ -15,26 +15,26 @@ export async function requireCompanyAuth(req: Request, res: Response, next: Next
}
try {
const session = await clerkClient.sessions.verifySession(sessionToken, sessionToken)
const clerkUserId = session.userId
const employee = await prisma.employee.findUnique({
where: { clerkUserId },
include: { company: true },
})
if (!employee || !employee.isActive) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Employee account not found or inactive',
statusCode: 401,
const payload = jwt.verify(sessionToken, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type === 'employee') {
const employee = await prisma.employee.findUnique({
where: { id: payload.sub },
include: { company: true },
})
}
req.employee = employee
req.company = employee.company
req.companyId = employee.companyId
next()
if (!employee || !employee.isActive) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Employee account not found or inactive',
statusCode: 401,
})
}
req.employee = employee
req.company = employee.company
req.companyId = employee.companyId
return next()
}
} catch {
return res.status(401).json({
error: 'invalid_token',
+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
+467
View File
@@ -0,0 +1,467 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import * as fs from 'fs/promises'
import * as path from 'path'
import { prisma } from '../lib/prisma'
const execFileAsync = promisify(execFile)
let composeCommand: 'docker-compose' | 'docker' | null = null
// ── Config ────────────────────────────────────────────────────────────────────
const COMPOSE_DIR = process.env.COMPANIES_COMPOSE_DIR || '/opt/companies'
const COMPOSE_FILE = path.join(COMPOSE_DIR, 'docker-compose.companies.yml')
const SERVICES_FILE = path.join(COMPOSE_DIR, 'companies-services.json') // our source of truth
const DASHBOARD_IMAGE = process.env.DASHBOARD_CONTAINER_IMAGE || 'rentaldrivego/dashboard:latest'
const PORT_RANGE_START = parseInt(process.env.CONTAINER_PORT_START || '5100', 10)
const PORT_RANGE_END = parseInt(process.env.CONTAINER_PORT_END || '5999', 10)
const API_INTERNAL_URL = process.env.API_INTERNAL_URL || 'http://api:4000'
const CONTAINER_NETWORK = process.env.CONTAINER_NETWORK || 'rentaldrivego_default'
// ── Types ─────────────────────────────────────────────────────────────────────
interface ServiceDef {
companyId: string
slug: string
image: string
port: number
}
type ServicesMap = Record<string, ServiceDef> // key = service name e.g. "company-slug"
type ServiceStatus = 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'ERROR'
// ── Internal helpers ──────────────────────────────────────────────────────────
function serviceName(slug: string) {
return `company-${slug}`
}
function containerName(slug: string) {
return `rdg-company-${slug}`
}
async function ensureDir(): Promise<void> {
await fs.mkdir(COMPOSE_DIR, { recursive: true })
}
async function readServices(): Promise<ServicesMap> {
try {
const raw = await fs.readFile(SERVICES_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return {}
}
}
async function writeServices(services: ServicesMap): Promise<void> {
await ensureDir()
await fs.writeFile(SERVICES_FILE, JSON.stringify(services, null, 2), 'utf8')
await fs.writeFile(COMPOSE_FILE, buildComposeYaml(services), 'utf8')
}
async function composeFilesExist(): Promise<boolean> {
try {
await fs.access(COMPOSE_FILE)
await fs.access(SERVICES_FILE)
return true
} catch {
return false
}
}
async function rebuildServicesFromDatabase(): Promise<ServicesMap> {
const records = await prisma.companyContainer.findMany({
include: { company: { select: { slug: true } } },
})
return Object.fromEntries(
records.map((record) => [
serviceName(record.company.slug),
{
companyId: record.companyId,
slug: record.company.slug,
image: record.image,
port: record.port,
},
]),
)
}
async function ensureComposeFiles(): Promise<void> {
if (await composeFilesExist()) return
const services = await rebuildServicesFromDatabase()
await writeServices(services)
}
function buildComposeYaml(services: ServicesMap): string {
const lines: string[] = ['services:']
for (const [name, svc] of Object.entries(services)) {
lines.push(` ${name}:`)
lines.push(` image: "${svc.image}"`)
lines.push(` container_name: "${containerName(svc.slug)}"`)
lines.push(` restart: unless-stopped`)
lines.push(` environment:`)
lines.push(` COMPANY_ID: "${svc.companyId}"`)
lines.push(` COMPANY_SLUG: "${svc.slug}"`)
lines.push(` API_URL: "${API_INTERNAL_URL}"`)
lines.push(` NEXT_PUBLIC_API_URL: "${API_INTERNAL_URL}"`)
lines.push(` PORT: "3000"`)
lines.push(` NODE_ENV: "production"`)
lines.push(` ports:`)
lines.push(` - "${svc.port}:3000"`)
lines.push(` networks:`)
lines.push(` - ${CONTAINER_NETWORK}`)
lines.push(` labels:`)
lines.push(` rdg.managed: "true"`)
lines.push(` rdg.company.id: "${svc.companyId}"`)
lines.push(` rdg.company.slug: "${svc.slug}"`)
}
lines.push('')
lines.push('networks:')
lines.push(` ${CONTAINER_NETWORK}:`)
lines.push(` external: true`)
lines.push('')
return lines.join('\n')
}
async function resolveComposeCommand(): Promise<'docker-compose' | 'docker'> {
if (composeCommand) return composeCommand
try {
await execFileAsync('docker-compose', ['version'])
composeCommand = 'docker-compose'
return composeCommand
} catch {
try {
await execFileAsync('docker', ['compose', 'version'])
composeCommand = 'docker'
return composeCommand
} catch (err) {
throw mapDockerError(err)
}
}
}
async function compose(...args: string[]): Promise<{ stdout: string; stderr: string }> {
try {
await ensureComposeFiles()
const command = await resolveComposeCommand()
if (command === 'docker-compose') {
return await execFileAsync('docker-compose', ['-f', COMPOSE_FILE, ...args])
}
return await execFileAsync('docker', ['compose', '-f', COMPOSE_FILE, ...args])
} catch (err) {
throw mapDockerError(err)
}
}
function dockerUnavailableError(message: string, details?: string) {
const err = Object.assign(new Error(message), {
statusCode: 503,
code: 'docker_unavailable',
})
if (details) {
;(err as Error & { details?: string }).details = details
}
return err
}
function mapDockerError(err: unknown) {
if (err && typeof err === 'object') {
const error = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }
const stderr = error.stderr?.trim()
if (error.code === 'ENOENT' && error.path === 'docker') {
return dockerUnavailableError(
'Docker CLI is not available to the API service.',
'Install Docker in the API container or run the API on a host with Docker available in PATH.',
)
}
if (error.code === 'ENOENT' && error.path === 'docker-compose') {
return dockerUnavailableError(
'Docker Compose is not available to the API service.',
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
)
}
if (
stderr?.includes("docker: 'compose' is not a docker command.") ||
stderr?.includes("unknown shorthand flag: 'f' in -f")
) {
return dockerUnavailableError(
'Docker Compose is not available to the API service.',
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
)
}
if (
stderr?.includes('Cannot connect to the Docker daemon') ||
stderr?.includes('permission denied while trying to connect to the Docker daemon socket') ||
stderr?.includes('error during connect')
) {
return dockerUnavailableError(
'Docker daemon is not reachable from the API service.',
'Mount `/var/run/docker.sock` into the API container and ensure the Docker daemon is running.',
)
}
// Catch-all: docker ran but exited non-zero — surface stderr as a readable 502
if (typeof error.code === 'number' && error.code !== 0) {
const detail = stderr || error.stdout?.trim() || 'docker compose exited with a non-zero status'
return Object.assign(new Error(detail), { statusCode: 502, code: 'docker_error' })
}
}
return err
}
async function setContainerError(companyId: string, message: string): Promise<void> {
await prisma.companyContainer.update({
where: { companyId },
data: { status: 'ERROR', errorMessage: message },
}).catch(() => null)
}
async function runContainerAction(
companyId: string,
slug: string,
command: 'start' | 'stop' | 'restart',
successStatus: Exclude<ServiceStatus, 'ERROR'>,
preStatus?: Exclude<ServiceStatus, 'ERROR'>,
): Promise<void> {
if (preStatus) {
await prisma.companyContainer.update({ where: { companyId }, data: { status: preStatus, errorMessage: null } })
}
try {
// Use `up -d --no-recreate` for start so it creates the container if it doesn't exist yet
const args = command === 'start'
? ['up', '-d', '--no-recreate', serviceName(slug)]
: [command, serviceName(slug)]
await compose(...args)
await prisma.companyContainer.update({
where: { companyId },
data: { status: successStatus, errorMessage: null },
})
} catch (err) {
const message = err instanceof Error ? err.message : `Failed to ${command} container`
await setContainerError(companyId, message)
throw err
}
}
async function allocatePort(): Promise<number> {
const last = await prisma.companyContainer.findFirst({
orderBy: { port: 'desc' },
select: { port: true },
})
const next = last ? last.port + 1 : PORT_RANGE_START
if (next > PORT_RANGE_END) throw new Error('No available ports in container port range')
return next
}
async function getDockerServiceId(slug: string): Promise<string | null> {
try {
const name = serviceName(slug)
const { stdout } = await compose('ps', '--format', 'json', name)
const line = stdout.trim().split('\n')[0]
if (!line) return null
const info = JSON.parse(line)
return info.ID ?? info.Id ?? null
} catch {
return null
}
}
// Maps docker compose State strings to our DB enum
function mapDockerState(state: string): ServiceStatus {
switch (state.toLowerCase()) {
case 'running': return 'RUNNING'
case 'restarting': return 'RESTARTING'
case 'exited':
case 'created':
case 'paused': return 'STOPPED'
default: return 'ERROR'
}
}
// ── Public API ────────────────────────────────────────────────────────────────
export async function createCompanyContainer(company: {
id: string
slug: string
name: string
}): Promise<void> {
const name = serviceName(company.slug)
const port = await allocatePort()
const record = await prisma.companyContainer.create({
data: {
companyId: company.id,
containerName: containerName(company.slug),
status: 'CREATING',
port,
image: DASHBOARD_IMAGE,
},
})
try {
const services = await readServices()
services[name] = { companyId: company.id, slug: company.slug, image: DASHBOARD_IMAGE, port }
await writeServices(services)
await compose('up', '-d', '--no-recreate', name)
const dockerId = await getDockerServiceId(company.slug)
await prisma.companyContainer.update({
where: { id: record.id },
data: { dockerId, status: 'RUNNING' },
})
} catch (err) {
await prisma.companyContainer.update({
where: { id: record.id },
data: {
status: 'ERROR',
errorMessage: err instanceof Error ? err.message : 'Unknown error during service creation',
},
})
throw err
}
}
export async function startCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'start', 'RUNNING')
}
export async function stopCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'stop', 'STOPPED')
}
export async function restartCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'restart', 'RUNNING', 'RESTARTING')
}
export async function removeCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
const name = serviceName(record.company.slug)
await prisma.companyContainer.update({ where: { companyId }, data: { status: 'REMOVING' } })
try {
await compose('stop', name)
} catch { /* already stopped */ }
try {
await compose('rm', '-f', name)
} catch { /* already removed */ }
const services = await readServices()
delete services[name]
await writeServices(services)
await prisma.companyContainer.delete({ where: { companyId } })
}
export async function redeployCompanyContainer(company: {
id: string
slug: string
name: string
}): Promise<void> {
const name = serviceName(company.slug)
const existing = await prisma.companyContainer.findUnique({ where: { companyId: company.id } })
if (existing) {
try { await compose('stop', name) } catch { /* ok */ }
try { await compose('rm', '-f', name) } catch { /* ok */ }
await prisma.companyContainer.delete({ where: { companyId: company.id } })
}
// Remove from compose file too, then recreate
const services = await readServices()
delete services[name]
await writeServices(services)
await createCompanyContainer(company)
}
export async function getContainerLogs(companyId: string, tail = 150): Promise<string> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
try {
const { stdout } = await compose(
'logs',
'--no-log-prefix',
`--tail=${tail}`,
serviceName(record.company.slug),
)
return stdout
} catch {
return ''
}
}
export async function syncContainerStatuses(): Promise<void> {
const records = await prisma.companyContainer.findMany({
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
include: { company: { select: { slug: true } } },
})
try {
// Get all compose service states in one shot
const { stdout } = await compose('ps', '--format', 'json')
const rows = stdout
.trim()
.split('\n')
.filter(Boolean)
.map((line) => {
try { return JSON.parse(line) } catch { return null }
})
.filter(Boolean) as Array<{ Service: string; State: string }>
const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State]))
await Promise.allSettled(
records.map(async (rec) => {
const name = serviceName(rec.company.slug)
const dockerState = stateByService[name]
const status = dockerState ? mapDockerState(dockerState) : 'STOPPED'
if (rec.status !== status) {
await prisma.companyContainer.update({ where: { id: rec.id }, data: { status } })
}
}),
)
} catch {
// Docker daemon unreachable — leave statuses as-is
}
}
+497
View File
@@ -0,0 +1,497 @@
import React from 'react'
import { renderToBuffer, Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
interface InvoiceData {
invoiceNumber: string
issueDate: string
dueDate: string | null
company: {
name: string
email: string
phone?: string | null
address?: any
}
subscription: {
plan: string
billingPeriod: string
currentPeriodStart?: string | null
currentPeriodEnd?: string | null
currency: string
}
amount: number
currency: string
status: string
paymentProvider: string
transactionId?: string | null
paidAt?: string | null
}
const PLAN_LABEL: Record<string, string> = {
STARTER: 'Starter',
GROWTH: 'Growth',
PRO: 'Pro',
}
const PERIOD_LABEL: Record<string, string> = {
MONTHLY: 'Monthly',
ANNUAL: 'Annual',
}
const STATUS_COLORS: Record<string, string> = {
PAID: '#10b981',
PENDING: '#f59e0b',
FAILED: '#ef4444',
REFUNDED: '#6b7280',
}
const colors = {
bg: '#0f0f11',
surface: '#18181b',
border: '#27272a',
textPrimary: '#f4f4f5',
textSecondary: '#a1a1aa',
textMuted: '#71717a',
accent: '#10b981',
white: '#ffffff',
}
const s = StyleSheet.create({
page: {
backgroundColor: colors.bg,
paddingHorizontal: 48,
paddingVertical: 48,
fontFamily: 'Helvetica',
color: colors.textPrimary,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 40,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
brandName: {
fontSize: 22,
fontFamily: 'Helvetica-Bold',
color: colors.accent,
letterSpacing: 0.5,
},
brandTagline: {
fontSize: 9,
color: colors.textMuted,
marginTop: 3,
},
invoiceMeta: {
alignItems: 'flex-end',
},
invoiceTitle: {
fontSize: 26,
fontFamily: 'Helvetica-Bold',
color: colors.white,
letterSpacing: 1,
},
invoiceNumber: {
fontSize: 11,
color: colors.textSecondary,
marginTop: 4,
},
section: {
marginBottom: 24,
},
sectionLabel: {
fontSize: 8,
fontFamily: 'Helvetica-Bold',
color: colors.textMuted,
letterSpacing: 1.2,
textTransform: 'uppercase',
marginBottom: 8,
},
row: {
flexDirection: 'row',
gap: 24,
marginBottom: 24,
},
col: {
flex: 1,
backgroundColor: colors.surface,
borderRadius: 8,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
},
colLabel: {
fontSize: 8,
fontFamily: 'Helvetica-Bold',
color: colors.textMuted,
letterSpacing: 1,
textTransform: 'uppercase',
marginBottom: 10,
},
companyName: {
fontSize: 13,
fontFamily: 'Helvetica-Bold',
color: colors.textPrimary,
marginBottom: 4,
},
companyDetail: {
fontSize: 10,
color: colors.textSecondary,
marginBottom: 2,
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 6,
},
metaKey: {
fontSize: 9,
color: colors.textMuted,
},
metaValue: {
fontSize: 9,
color: colors.textSecondary,
fontFamily: 'Helvetica-Bold',
},
table: {
backgroundColor: colors.surface,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
marginBottom: 20,
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#1c1c1f',
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
tableHeaderCell: {
fontSize: 8,
fontFamily: 'Helvetica-Bold',
color: colors.textMuted,
letterSpacing: 0.8,
textTransform: 'uppercase',
},
tableRow: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
tableCell: {
fontSize: 10,
color: colors.textPrimary,
},
tableCellMuted: {
fontSize: 10,
color: colors.textSecondary,
},
col60: { flex: 6 },
col20: { flex: 2, textAlign: 'right' as const },
col20Center: { flex: 2, textAlign: 'center' as const },
totalRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginBottom: 6,
},
totalLabel: {
fontSize: 11,
color: colors.textSecondary,
marginRight: 32,
},
totalValue: {
fontSize: 11,
fontFamily: 'Helvetica-Bold',
color: colors.white,
minWidth: 100,
textAlign: 'right' as const,
},
grandTotalRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 8,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: colors.border,
},
grandTotalLabel: {
fontSize: 13,
fontFamily: 'Helvetica-Bold',
color: colors.textSecondary,
marginRight: 32,
},
grandTotalValue: {
fontSize: 15,
fontFamily: 'Helvetica-Bold',
color: colors.accent,
minWidth: 100,
textAlign: 'right' as const,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
alignSelf: 'flex-start',
marginTop: 6,
},
statusText: {
fontSize: 9,
fontFamily: 'Helvetica-Bold',
letterSpacing: 0.5,
color: colors.white,
},
paymentBox: {
backgroundColor: colors.surface,
borderRadius: 8,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
marginBottom: 24,
},
paymentRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
paymentKey: {
fontSize: 9,
color: colors.textMuted,
},
paymentValue: {
fontSize: 9,
color: colors.textSecondary,
fontFamily: 'Helvetica-Bold',
maxWidth: 280,
textAlign: 'right' as const,
},
footer: {
position: 'absolute',
bottom: 32,
left: 48,
right: 48,
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 12,
flexDirection: 'row',
justifyContent: 'space-between',
},
footerText: {
fontSize: 8,
color: colors.textMuted,
},
})
function fmt(amount: number, currency: string) {
return `${(amount / 100).toFixed(2)} ${currency}`
}
function fmtDate(iso: string | null | undefined) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })
}
function formatAddress(address: any): string {
if (!address) return ''
if (typeof address === 'string') return address
const parts = [address.street, address.city, address.region, address.country, address.zip]
return parts.filter(Boolean).join(', ')
}
function InvoiceDocument({ data }: { data: InvoiceData }) {
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
const addressStr = formatAddress(data.company.address)
const planLabel = PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan
const periodLabel = PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod
const description = `${planLabel} Plan — ${periodLabel} Subscription`
const periodStr = data.subscription.currentPeriodStart && data.subscription.currentPeriodEnd
? `${fmtDate(data.subscription.currentPeriodStart)} ${fmtDate(data.subscription.currentPeriodEnd)}`
: '—'
return React.createElement(
Document,
{ author: 'RentalDriveGo', title: `Invoice ${data.invoiceNumber}`, subject: 'Subscription Invoice' },
React.createElement(
Page,
{ size: 'A4', style: s.page },
// ── Header ──────────────────────────────────────────────────
React.createElement(
View,
{ style: s.header },
React.createElement(
View,
null,
React.createElement(Text, { style: s.brandName }, 'RentalDriveGo'),
React.createElement(Text, { style: s.brandTagline }, 'Car Rental Management Platform'),
),
React.createElement(
View,
{ style: s.invoiceMeta },
React.createElement(Text, { style: s.invoiceTitle }, 'INVOICE'),
React.createElement(Text, { style: s.invoiceNumber }, data.invoiceNumber),
React.createElement(
View,
{ style: [s.statusBadge, { backgroundColor: statusColor }] },
React.createElement(Text, { style: s.statusText }, data.status),
),
),
),
// ── Bill To / Invoice Dates ──────────────────────────────────
React.createElement(
View,
{ style: s.row },
React.createElement(
View,
{ style: s.col },
React.createElement(Text, { style: s.colLabel }, 'Bill To'),
React.createElement(Text, { style: s.companyName }, data.company.name),
React.createElement(Text, { style: s.companyDetail }, data.company.email),
data.company.phone
? React.createElement(Text, { style: s.companyDetail }, data.company.phone)
: null,
addressStr
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, addressStr)
: null,
),
React.createElement(
View,
{ style: s.col },
React.createElement(Text, { style: s.colLabel }, 'Invoice Details'),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Invoice Number'),
React.createElement(Text, { style: s.metaValue }, data.invoiceNumber),
),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Issue Date'),
React.createElement(Text, { style: s.metaValue }, fmtDate(data.issueDate)),
),
data.dueDate
? React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Due Date'),
React.createElement(Text, { style: s.metaValue }, fmtDate(data.dueDate)),
)
: null,
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Currency'),
React.createElement(Text, { style: s.metaValue }, data.currency),
),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Billing Period'),
React.createElement(Text, { style: s.metaValue }, periodLabel),
),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Plan'),
React.createElement(Text, { style: s.metaValue }, planLabel),
),
),
),
// ── Line Items ───────────────────────────────────────────────
React.createElement(
View,
{ style: s.table },
React.createElement(
View,
{ style: s.tableHeader },
React.createElement(Text, { style: [s.tableHeaderCell, s.col60] }, 'Description'),
React.createElement(Text, { style: [s.tableHeaderCell, s.col20Center] }, 'Period'),
React.createElement(Text, { style: [s.tableHeaderCell, s.col20] }, 'Amount'),
),
React.createElement(
View,
{ style: s.tableRow },
React.createElement(Text, { style: [s.tableCell, s.col60] }, description),
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr),
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(data.amount, data.currency)),
),
),
// ── Totals ───────────────────────────────────────────────────
React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Subtotal'),
React.createElement(Text, { style: s.totalValue }, fmt(data.amount, data.currency)),
),
React.createElement(
View,
{ style: s.grandTotalRow },
React.createElement(Text, { style: s.grandTotalLabel }, 'Total Due'),
React.createElement(Text, { style: s.grandTotalValue }, fmt(data.amount, data.currency)),
),
// ── Payment Info ─────────────────────────────────────────────
React.createElement(
View,
{ style: [s.paymentBox, { marginTop: 24 }] },
React.createElement(Text, { style: [s.colLabel, { marginBottom: 10 }] }, 'Payment Information'),
React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Payment Provider'),
React.createElement(Text, { style: s.paymentValue }, data.paymentProvider),
),
data.transactionId
? React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Transaction ID'),
React.createElement(Text, { style: s.paymentValue }, data.transactionId),
)
: null,
React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Payment Date'),
React.createElement(Text, { style: s.paymentValue }, fmtDate(data.paidAt)),
),
React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Status'),
React.createElement(Text, { style: [s.paymentValue, { color: statusColor }] }, data.status),
),
),
// ── Footer ───────────────────────────────────────────────────
React.createElement(
View,
{ style: s.footer },
React.createElement(Text, { style: s.footerText }, 'RentalDriveGo — Car Rental Management Platform'),
React.createElement(Text, { style: s.footerText }, `Generated on ${fmtDate(new Date().toISOString())}`),
),
),
)
}
export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
const doc = React.createElement(InvoiceDocument, { data })
const buffer = await renderToBuffer(doc as any)
return buffer as unknown as Buffer
}
export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
const year = createdAt.getFullYear()
const short = invoiceId.slice(-6).toUpperCase()
return `INV-${year}-${short}`
}
@@ -252,3 +252,37 @@ export async function sendNotification(opts: SendNotificationOptions) {
return results
}
export async function sendTransactionalEmail(opts: {
to: string
subject: string
html: string
text: string
}) {
if (resend) {
if (!emailFromAddress || !emailFromName) throw new Error('Email sender identity is not configured')
const { error } = await resend.emails.send({
from: `${emailFromName} <${emailFromAddress}>`,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
})
if (error) throw new Error(error.message)
return
}
if (smtpTransport) {
if (!smtpFromAddress) throw new Error('SMTP sender identity is not configured')
await smtpTransport.sendMail({
from: smtpFromName ? `${smtpFromName} <${smtpFromAddress}>` : smtpFromAddress,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
})
return
}
throw new Error('No email provider is configured')
}
+63 -64
View File
@@ -1,6 +1,9 @@
import crypto from 'crypto'
import { EmployeeRole } from '@rentaldrivego/database'
import { clerkClient } from '@clerk/clerk-sdk-node'
import { prisma } from '../lib/prisma'
import { sendTransactionalEmail } from './notificationService'
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
export interface InvitePayload {
firstName: string
@@ -24,24 +27,33 @@ export interface TeamMember {
invitationStatus?: 'accepted' | 'pending' | 'revoked'
}
function buildInviteEmail(resetUrl: string, companyName: string, firstName: string, role: EmployeeRole) {
const greetingName = firstName.trim() || 'there'
return {
subject: `You have been invited to ${companyName}`,
html: `
<p>Hi ${greetingName},</p>
<p>You were invited to join ${companyName} on RentalDriveGo as a ${role.toLowerCase()}.</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;">Set your password</a></p>
<p>This invitation link expires in 7 days.</p>
<p>RentalDriveGo</p>
`,
text: `Hi ${greetingName},\n\nYou were invited to join ${companyName} on RentalDriveGo as a ${role.toLowerCase()}.\n\nSet your password here: ${resetUrl}\n\nThis invitation link expires in 7 days.\n\nRentalDriveGo`,
}
}
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
const employees = await prisma.employee.findMany({
where: { companyId },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
})
const hydrated = await Promise.allSettled(
employees.map(async (emp: (typeof employees)[number]) => {
try {
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
return { ...emp, lastActiveAt: clerkUser.lastActiveAt ? new Date(clerkUser.lastActiveAt) : null, invitationStatus: 'accepted' as const }
} catch {
return { ...emp, lastActiveAt: null, invitationStatus: 'pending' as const }
}
})
)
return hydrated.flatMap((r): TeamMember[] => (r.status === 'fulfilled' ? [r.value] : []))
return employees.map((employee) => ({
...employee,
lastActiveAt: null,
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
}))
}
export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) {
@@ -54,39 +66,53 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' })
}
const company = await prisma.company.findUniqueOrThrow({
where: { id: companyId },
include: { brand: { select: { subdomain: true, displayName: true } } },
})
let clerkInvitation: any
try {
clerkInvitation = await clerkClient.invitations.createInvitation({
emailAddress: payload.email,
redirectUrl: `${process.env.DASHBOARD_URL}/onboarding/accept-invite`,
publicMetadata: { companyId, companyName: company.brand?.displayName ?? company.name, role: payload.role, invitedBy: inviterId },
})
} catch (err: any) {
if (err?.errors?.[0]?.code === 'duplicate_record') {
throw Object.assign(new Error('This email already has a pending invitation'), { statusCode: 409, code: 'duplicate_invitation' })
}
throw err
}
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
const employee = await prisma.employee.create({
data: { companyId, clerkUserId: `pending_${clerkInvitation.id}`, firstName: payload.firstName, lastName: payload.lastName, email: payload.email, role: payload.role, isActive: false },
data: {
companyId,
clerkUserId: `local_member_${crypto.randomUUID()}`,
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
role: payload.role,
isActive: true,
passwordResetToken: rawToken,
passwordResetExpiresAt: expiresAt,
},
})
return { employee, invitationId: clerkInvitation.id }
const dashboardUrl = process.env.DASHBOARD_URL ?? 'http://localhost:3001'
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
const message = buildInviteEmail(resetUrl, company.name, payload.firstName, payload.role)
await sendTransactionalEmail({
to: payload.email,
subject: message.subject,
html: message.html,
text: message.text,
})
return {
employee: {
...employee,
lastActiveAt: null,
invitationStatus: 'pending' as const,
},
invitationId: employee.id,
invitedBy: inviterId,
}
}
export async function updateEmployeeRole(companyId: string, requesterId: string, requesterRole: EmployeeRole, employeeId: string, payload: { role: EmployeeRole }) {
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can change team member roles'), { statusCode: 403, code: 'forbidden' })
if (target.role === 'OWNER') throw Object.assign(new Error("Cannot change the role of the account owner"), { statusCode: 400 })
if (payload.role === 'OWNER') throw Object.assign(new Error("Cannot assign the OWNER role via this endpoint"), { statusCode: 400 })
if (target.id === requesterId) throw Object.assign(new Error("You cannot change your own role"), { statusCode: 400 })
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot change the role of the account owner'), { statusCode: 400 })
if (payload.role === 'OWNER') throw Object.assign(new Error('Cannot assign the OWNER role via this endpoint'), { statusCode: 400 })
if (target.id === requesterId) throw Object.assign(new Error('You cannot change your own role'), { statusCode: 400 })
return prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } })
}
@@ -96,20 +122,12 @@ export async function deactivateEmployee(companyId: string, requesterRole: Emplo
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot deactivate the account owner'), { statusCode: 400 })
if (!target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.users.banUser(target.clerkUserId) } catch { /* non-fatal */ }
}
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } })
}
export async function reactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can reactivate team members'), { statusCode: 403 })
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (!target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.users.unbanUser(target.clerkUserId) } catch { /* non-fatal */ }
}
await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } })
}
@@ -119,25 +137,6 @@ export async function removeEmployee(companyId: string, requesterRole: EmployeeR
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot remove the account owner'), { statusCode: 400 })
if (target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.invitations.revokeInvitation(target.clerkUserId.replace('pending_', '')) } catch { /* expired */ }
} else {
try { await clerkClient.users.banUser(target.clerkUserId) } catch { /* non-fatal */ }
}
await prisma.employee.delete({ where: { id: employeeId } })
return { success: true }
}
export async function handleInvitationAccepted(clerkUserId: string, email: string, companyId: string, role: EmployeeRole) {
const pendingEmployee = await prisma.employee.findFirst({
where: { companyId, email, clerkUserId: { startsWith: 'pending_' } },
})
if (!pendingEmployee) return null
return prisma.employee.update({
where: { id: pendingEmployee.id },
data: { clerkUserId, isActive: true, role },
})
}