fix first online resevation
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 418 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user