add review and booking policies

This commit is contained in:
root
2026-05-25 18:29:05 -04:00
parent 8ed572b3bd
commit 0d969ab095
37 changed files with 3775 additions and 113 deletions
+5
View File
@@ -26,6 +26,8 @@ import companiesRouter from './modules/companies/company.routes'
import reservationsRouter from './modules/reservations/reservation.routes'
import marketplaceRouter from './modules/marketplace/marketplace.routes'
import siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.routes'
// ─── Centralized error handling ───────────────────────────────
import { errorMiddleware } from './http/errors/errorMiddleware'
@@ -62,6 +64,7 @@ const routeDocs = [
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
{ method: 'GET', path: `${v1}/subscriptions/features`, description: 'Subscription plan features' },
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
]
@@ -129,6 +132,8 @@ export function createApp() {
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
// ─── Health / Docs ──────────────────────────────────────────
app.get('/health', (_req, res) => {
@@ -0,0 +1,84 @@
import { prisma } from '../../lib/prisma'
const COMPLAINT_INCLUDE = {
reservation: {
select: {
id: true,
startDate: true,
endDate: true,
status: true,
vehicle: { select: { make: true, model: true, year: true } },
},
},
customer: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
},
},
review: {
select: {
id: true,
overallRating: true,
comment: true,
},
},
}
export async function findMany(
companyId: string,
where: Record<string, unknown>,
skip: number,
take: number,
) {
const baseWhere = { companyId, ...where }
return Promise.all([
prisma.complaint.findMany({
where: baseWhere,
include: COMPLAINT_INCLUDE,
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.complaint.count({ where: baseWhere }),
])
}
export async function findById(id: string, companyId: string) {
return prisma.complaint.findFirst({
where: { id, companyId },
include: COMPLAINT_INCLUDE,
})
}
export async function create(data: {
companyId: string
reservationId?: string
reviewId?: string
customerId?: string
severity: string
category: string
subject: string
description?: string
assignedTo?: string
}) {
return prisma.complaint.create({
data: data as any,
include: COMPLAINT_INCLUDE,
})
}
export async function updateById(id: string, data: Record<string, unknown>) {
return prisma.complaint.update({
where: { id },
data,
include: COMPLAINT_INCLUDE,
})
}
export async function deleteById(id: string) {
return prisma.complaint.delete({ where: { id } })
}
@@ -0,0 +1,55 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import * as service from './complaint.service'
import { createSchema, updateSchema, listQuerySchema, idParamSchema } from './complaint.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/', async (req, res, next) => {
try {
const query = parseQuery(listQuerySchema, req)
const result = await service.listComplaints(req.companyId, query)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
try {
const body = parseBody(createSchema, req)
const complaint = await service.createComplaint(req.companyId, body)
created(res, complaint)
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const complaint = await service.getComplaint(id, req.companyId)
ok(res, complaint)
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(updateSchema, req)
const updated = await service.updateComplaint(id, req.companyId, body)
ok(res, updated)
} catch (err) { next(err) }
})
router.delete('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const result = await service.deleteComplaint(id, req.companyId)
ok(res, result)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,44 @@
import { z } from 'zod'
const CATEGORIES = [
'BOOKING', 'PICKUP', 'VEHICLE_CLEANLINESS', 'VEHICLE_CONDITION', 'STAFF_SERVICE',
'PRICING', 'DEPOSIT', 'INSURANCE', 'DAMAGE_CLAIM', 'RETURN_PROCESS',
'COMMUNICATION', 'ROADSIDE_ASSISTANCE', 'BILLING', 'OTHER',
] as const
const SEVERITIES = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3'] as const
const STATUSES = ['OPEN', 'INVESTIGATING', 'RESOLVED', 'CLOSED'] as const
export const createSchema = z.object({
reservationId: z.string().optional(),
reviewId: z.string().optional(),
customerId: z.string().optional(),
severity: z.enum(SEVERITIES).default('LEVEL_1'),
category: z.enum(CATEGORIES),
subject: z.string().min(1),
description: z.string().optional(),
assignedTo: z.string().optional(),
})
export const updateSchema = z.object({
status: z.enum(STATUSES).optional(),
severity: z.enum(SEVERITIES).optional(),
category: z.enum(CATEGORIES).optional(),
subject: z.string().min(1).optional(),
description: z.string().optional(),
notes: z.string().optional(),
resolution: z.string().optional(),
assignedTo: z.string().optional(),
})
export const listQuerySchema = z.object({
status: z.string().optional(),
severity: z.string().optional(),
category: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const idParamSchema = z.object({
id: z.string(),
})
@@ -0,0 +1,75 @@
import { NotFoundError } from '../../http/errors'
import * as repo from './complaint.repo'
export async function listComplaints(
companyId: string,
query: { status?: string; severity?: string; category?: string; page: number; pageSize: number },
) {
const { status, severity, category, page, pageSize } = query
const where: Record<string, unknown> = {}
if (status) where.status = status
if (severity) where.severity = severity
if (category) where.category = category
const [complaints, total] = await repo.findMany(companyId, where, (page - 1) * pageSize, pageSize)
return {
data: complaints,
meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
}
}
export async function getComplaint(id: string, companyId: string) {
const complaint = await repo.findById(id, companyId)
if (!complaint) throw new NotFoundError('Complaint not found')
return complaint
}
export async function createComplaint(
companyId: string,
data: {
reservationId?: string
reviewId?: string
customerId?: string
severity: string
category: string
subject: string
description?: string
assignedTo?: string
},
) {
return repo.create({ companyId, ...data })
}
export async function updateComplaint(
id: string,
companyId: string,
data: {
status?: string
severity?: string
category?: string
subject?: string
description?: string
notes?: string
resolution?: string
assignedTo?: string
},
) {
const existing = await repo.findById(id, companyId)
if (!existing) throw new NotFoundError('Complaint not found')
const updateData: Record<string, unknown> = { ...data }
// When status changes to RESOLVED, set resolvedAt
if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') {
updateData.resolvedAt = new Date()
}
return repo.updateById(id, updateData)
}
export async function deleteComplaint(id: string, companyId: string) {
const existing = await repo.findById(id, companyId)
if (!existing) throw new NotFoundError('Complaint not found')
await repo.deleteById(id)
return { success: true }
}
@@ -50,6 +50,7 @@ export async function confirmReservation(id: string, companyId: string) {
await assertLicenseCompliance(reservation.id, companyId)
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
await repo.updateVehicleStatus(reservation.vehicleId, 'RESERVED')
const [customer, company, vehicle] = await Promise.all([
repo.findCustomerById(reservation.customerId),
@@ -117,30 +118,14 @@ export async function checkoutReservation(id: string, companyId: string, mileage
const reservation = await repo.findByIdForCheckout(id, companyId)
if (reservation.status !== 'ACTIVE') throw new AppError('Only ACTIVE reservations can be checked out', 400, 'invalid_status')
const reviewToken = crypto.randomBytes(32).toString('hex')
const reviewToken = reservation.reviewToken ?? crypto.randomBytes(32).toString('hex')
const updated = await repo.updateById(id, {
status: 'COMPLETED',
checkedOutAt: new Date(),
checkOutMileage: mileage ?? null,
reviewToken,
})
await repo.updateVehicleStatus(reservation.vehicleId, 'AVAILABLE', mileage)
const [customer, company] = await Promise.all([
repo.findCustomerById(reservation.customerId),
repo.findCompanyWithBrand(companyId),
])
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`
const reviewUrl = `${process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reviewToken}`
sendTransactionalEmail({
to: customer.email,
subject: reviewRequestEmail.subject(vehicleLabel, lang),
html: reviewRequestEmail.html({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
text: reviewRequestEmail.text({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
}).catch(() => null)
await repo.updateVehicleStatus(reservation.vehicleId, 'RETURNED', mileage)
return updated
}
@@ -161,16 +146,81 @@ export async function closeReservation(id: string, companyId: string, closedBy:
data: { extras: extras as any },
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
})
// Send review request email if eligible
if (reservation.reviewToken && !(reservation as any).reviewPaused) {
try {
const customer = updated.customer as any
if (!customer.reviewOptOut && customer.email) {
const company = await repo.findCompanyWithBrand(companyId)
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${updated.vehicle.year} ${updated.vehicle.make} ${updated.vehicle.model}`
const reviewUrl = `${process.env.MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
sendTransactionalEmail({
to: customer.email,
subject: reviewRequestEmail.subject(vehicleLabel, lang),
html: reviewRequestEmail.html({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
text: reviewRequestEmail.text({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
}).catch(() => null)
await prisma.reservation.update({
where: { id: reservation.id },
data: { reviewRequestSentAt: new Date() },
})
}
} catch {
// Non-blocking: do not fail close if email fails
}
}
return serializeReservationForDashboard(updated)
}
export async function extendReservation(id: string, companyId: string, newEndDate: Date, reason: string) {
const reservation = await repo.findByIdSimple(id, companyId)
if (!['CONFIRMED', 'ACTIVE'].includes(reservation.status)) {
throw new AppError('Only CONFIRMED or ACTIVE reservations can be extended', 400, 'invalid_status')
}
if (newEndDate <= reservation.endDate) {
throw new AppError('New end date must be after the current end date', 400, 'invalid_end_date')
}
const conflict = await repo.findConflict(reservation.vehicleId, reservation.endDate, newEndDate, id)
if (conflict) {
throw new AppError('Vehicle is unavailable for the requested extension period', 409, 'vehicle_unavailable')
}
const additionalDays = Math.ceil((newEndDate.getTime() - reservation.endDate.getTime()) / (1000 * 60 * 60 * 24))
const additionalAmount = additionalDays * reservation.dailyRate
const extras = parseReservationExtras(reservation.extras)
if (!Array.isArray(extras.extensions)) extras.extensions = []
;(extras.extensions as unknown[]).push({
previousEndDate: reservation.endDate.toISOString(),
newEndDate: newEndDate.toISOString(),
additionalDays,
additionalAmount,
reason,
approvedAt: new Date().toISOString(),
})
const updated = await prisma.reservation.update({
where: { id },
data: {
endDate: newEndDate,
totalDays: reservation.totalDays + additionalDays,
totalAmount: reservation.totalAmount + additionalAmount,
extras: extras as any,
},
})
return updated
}
export async function cancelReservation(id: string, companyId: string, reason?: string) {
const reservation = await repo.findByIdSimple(id, companyId)
if (['COMPLETED', 'CANCELLED'].includes(reservation.status)) {
throw new AppError('Reservation cannot be cancelled', 400, 'invalid_status')
}
const updated = await repo.updateById(id, { status: 'CANCELLED', cancelReason: reason ?? null, cancelledBy: 'COMPANY' })
if (['CONFIRMED', 'ACTIVE'].includes(reservation.status)) {
if (['CONFIRMED', 'ACTIVE', 'DRAFT'].includes(reservation.status)) {
await repo.updateVehicleStatus(reservation.vehicleId, 'AVAILABLE')
}
return updated
@@ -0,0 +1,33 @@
import { prisma } from '../../lib/prisma'
import { uploadImage, deleteImage } from '../../lib/storage'
import { NotFoundError } from '../../http/errors'
export async function listPhotos(reservationId: string, companyId: string) {
const reservation = await prisma.reservation.findFirst({ where: { id: reservationId, companyId }, select: { id: true } })
if (!reservation) throw new NotFoundError('Reservation not found')
return prisma.reservationPhoto.findMany({
where: { reservationId },
orderBy: { createdAt: 'asc' },
})
}
export async function uploadPhoto(
reservationId: string,
companyId: string,
type: 'PICKUP' | 'DROPOFF',
buffer: Buffer,
) {
const reservation = await prisma.reservation.findFirst({ where: { id: reservationId, companyId }, select: { id: true } })
if (!reservation) throw new NotFoundError('Reservation not found')
const url = await uploadImage(buffer, `companies/${companyId}/reservations/${reservationId}/photos`)
return prisma.reservationPhoto.create({ data: { reservationId, type, url } })
}
export async function deletePhoto(photoId: string, reservationId: string, companyId: string) {
const photo = await prisma.reservationPhoto.findFirst({
where: { id: photoId, reservationId, reservation: { companyId } },
})
if (!photo) throw new NotFoundError('Photo not found')
await deleteImage(photo.url)
await prisma.reservationPhoto.delete({ where: { id: photoId } })
}
@@ -74,7 +74,7 @@ export async function findForBilling(id: string, companyId: string) {
}
export async function findForClose(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
return prisma.reservation.findFirstOrThrow({ where: { id, companyId }, include: { customer: true } })
}
export async function findForInspection(id: string, companyId: string) {
@@ -1,21 +1,27 @@
import { Router } from 'express'
import { z } from 'zod'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { imageUpload, assertImageFile } from '../../http/upload'
import * as service from './reservation.service'
import * as lifecycle from './reservation.lifecycle.service'
import * as inspectionService from './reservation.inspection.service'
import * as additionalDriverService from './reservation.additional-driver.service'
import * as photoService from './reservation.photo.service'
import { getContract, getBilling } from './reservation.document.service'
import {
createSchema, updateSchema, listQuerySchema,
checkinSchema, checkoutSchema, cancelSchema, approvalSchema,
checkinSchema, checkoutSchema, extendSchema, cancelSchema, approvalSchema,
inspectionSchema, inspectionTypeParam,
idParamSchema, driverParamSchema, inspectionParamSchema,
} from './reservation.schemas'
const photoTypeSchema = z.enum(['PICKUP', 'DROPOFF'])
const photoParamSchema = z.object({ id: z.string(), photoId: z.string() })
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
@@ -103,6 +109,14 @@ router.post('/:id/close', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/extend', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { newEndDate, reason } = parseBody(extendSchema, req)
ok(res, await lifecycle.extendReservation(id, req.companyId, new Date(newEndDate), reason))
} catch (err) { next(err) }
})
router.post('/:id/cancel', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
@@ -141,4 +155,29 @@ router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next
} catch (err) { next(err) }
})
router.get('/:id/photos', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await photoService.listPhotos(id, req.companyId))
} catch (err) { next(err) }
})
router.post('/:id/photos', imageUpload.single('photo'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
assertImageFile(req.file, 'photo')
const type = photoTypeSchema.parse(req.body.type)
const photo = await photoService.uploadPhoto(id, req.companyId, type, req.file.buffer)
created(res, photo)
} catch (err) { next(err) }
})
router.delete('/:id/photos/:photoId', async (req, res, next) => {
try {
const { id, photoId } = parseParams(photoParamSchema, req)
await photoService.deletePhoto(photoId, id, req.companyId)
ok(res, { deleted: true })
} catch (err) { next(err) }
})
export default router
@@ -70,6 +70,7 @@ export const listQuerySchema = z.object({
export const checkinSchema = z.object({ mileage: z.number().int().optional() })
export const checkoutSchema = z.object({ mileage: z.number().int().optional() })
export const extendSchema = z.object({ newEndDate: z.string().datetime(), reason: z.string().min(1) })
export const cancelSchema = z.object({ reason: z.string().optional() })
export const approvalSchema = z.object({ approved: z.boolean(), note: z.string().optional() })
export const inspectionTypeParam = z.enum(['CHECKIN', 'CHECKOUT'])
+109
View File
@@ -0,0 +1,109 @@
import { prisma } from '../../lib/prisma'
const REVIEW_INCLUDE = {
reservation: {
select: {
id: true,
startDate: true,
endDate: true,
reviewToken: true,
reviewRequestSentAt: true,
reviewReminderSentAt: true,
reviewFinalReminderSentAt: true,
reviewPaused: true,
customer: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
reviewOptOut: true,
},
},
vehicle: {
select: {
id: true,
make: true,
model: true,
year: true,
},
},
},
},
}
export async function findMany(
companyId: string,
where: Record<string, unknown>,
skip: number,
take: number,
) {
const baseWhere = { companyId, ...where }
return Promise.all([
prisma.review.findMany({
where: baseWhere,
include: REVIEW_INCLUDE,
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.review.count({ where: baseWhere }),
])
}
export async function findById(id: string, companyId: string) {
return prisma.review.findFirst({
where: { id, companyId },
include: REVIEW_INCLUDE,
})
}
export async function updateById(id: string, data: Record<string, unknown>) {
return prisma.review.update({
where: { id },
data,
include: REVIEW_INCLUDE,
})
}
export async function getStats(companyId: string) {
const reviews = await prisma.review.findMany({
where: { companyId },
select: { overallRating: true, vehicleRating: true, serviceRating: true },
})
const total = reviews.length
const byRating: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }
let sumOverall = 0
let sumVehicle = 0
let countVehicle = 0
let sumService = 0
let countService = 0
for (const r of reviews) {
sumOverall += r.overallRating
byRating[r.overallRating] = (byRating[r.overallRating] ?? 0) + 1
if (r.vehicleRating != null) { sumVehicle += r.vehicleRating; countVehicle++ }
if (r.serviceRating != null) { sumService += r.serviceRating; countService++ }
}
return {
total,
averageOverall: total > 0 ? Math.round((sumOverall / total) * 10) / 10 : 0,
averageVehicle: countVehicle > 0 ? Math.round((sumVehicle / countVehicle) * 10) / 10 : 0,
averageService: countService > 0 ? Math.round((sumService / countService) * 10) / 10 : 0,
byRating,
}
}
export async function sendReminder(
reservationId: string,
sentAt: Date,
field: 'reviewReminderSentAt' | 'reviewFinalReminderSentAt',
) {
return prisma.reservation.update({
where: { id: reservationId },
data: { [field]: sentAt },
})
}
@@ -0,0 +1,54 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok } from '../../http/respond'
import * as service from './review.service'
import { replySchema, listQuerySchema, idParamSchema } from './review.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/', async (req, res, next) => {
try {
const query = parseQuery(listQuerySchema, req)
const result = await service.listReviews(req.companyId, query)
ok(res, result)
} catch (err) { next(err) }
})
router.get('/stats', async (req, res, next) => {
try {
const stats = await service.getStats(req.companyId)
ok(res, stats)
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const review = await service.getReview(id, req.companyId)
ok(res, review)
} catch (err) { next(err) }
})
router.patch('/:id/reply', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { companyReply } = parseBody(replySchema, req)
const updated = await service.replyToReview(id, req.companyId, companyReply)
ok(res, updated)
} catch (err) { next(err) }
})
router.post('/:id/remind', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const result = await service.sendReviewReminder(id, req.companyId)
ok(res, result)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,15 @@
import { z } from 'zod'
export const replySchema = z.object({
companyReply: z.string().min(1),
})
export const listQuerySchema = z.object({
rating: z.coerce.number().int().min(1).max(5).optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const idParamSchema = z.object({
id: z.string(),
})
@@ -0,0 +1,76 @@
import { NotFoundError, AppError } from '../../http/errors'
import { sendTransactionalEmail } from '../../services/notificationService'
import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './review.repo'
import * as reservationRepo from '../reservations/reservation.repo'
export async function listReviews(
companyId: string,
query: { rating?: number; page: number; pageSize: number },
) {
const { rating, page, pageSize } = query
const where: Record<string, unknown> = {}
if (rating != null) where.overallRating = rating
const [reviews, total] = await repo.findMany(companyId, where, (page - 1) * pageSize, pageSize)
return {
data: reviews,
meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
}
}
export async function getReview(id: string, companyId: string) {
const review = await repo.findById(id, companyId)
if (!review) throw new NotFoundError('Review not found')
return review
}
export async function replyToReview(id: string, companyId: string, companyReply: string) {
const review = await repo.findById(id, companyId)
if (!review) throw new NotFoundError('Review not found')
return repo.updateById(id, {
companyReply,
companyRepliedAt: new Date(),
})
}
export async function getStats(companyId: string) {
return repo.getStats(companyId)
}
export async function sendReviewReminder(id: string, companyId: string) {
const review = await repo.findById(id, companyId)
if (!review) throw new NotFoundError('Review not found')
const reservation = review.reservation as any
if (!reservation) throw new AppError('Review has no linked reservation', 400, 'no_reservation')
if (reservation.reviewPaused) throw new AppError('Review requests are paused for this reservation', 400, 'review_paused')
const customer = reservation.customer
if (!customer || customer.reviewOptOut) throw new AppError('Customer has opted out of review requests', 400, 'opted_out')
if (!customer.email) throw new AppError('Customer has no email address', 400, 'no_email')
if (!reservation.reviewToken) throw new AppError('No review token available for this reservation', 400, 'no_token')
// Determine which reminder field to update
const field: 'reviewReminderSentAt' | 'reviewFinalReminderSentAt' =
reservation.reviewReminderSentAt ? 'reviewFinalReminderSentAt' : 'reviewReminderSentAt'
const company = await reservationRepo.findCompanyWithBrand(companyId)
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicle = reservation.vehicle
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : 'your rental vehicle'
const reviewUrl = `${process.env.MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
await sendTransactionalEmail({
to: customer.email,
subject: reviewRequestEmail.subject(vehicleLabel, lang),
html: reviewRequestEmail.html({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
text: reviewRequestEmail.text({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
})
await repo.sendReminder(reservation.id, new Date(), field)
return { success: true, field }
}
@@ -30,6 +30,10 @@ publicRouter.get('/providers', (_req, res) => {
ok(res, service.getProviders())
})
publicRouter.get('/features', (_req, res, next) => {
service.getPlanFeatures().then((d) => ok(res, d)).catch(next)
})
// ─── Webhooks (no auth) ────────────────────────────────────────
webhookRouter.post('/webhooks/amanpay', async (req, res, next) => {
@@ -31,6 +31,12 @@ export function getProviders() {
return { amanpay: amanpay.isConfigured(), paypal: paypal.isConfigured() }
}
export function getPlanFeatures() {
return prisma.planFeature.findMany({
orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
})
}
// ─── Reads ────────────────────────────────────────────────────
export function getSubscription(companyId: string) {
@@ -9,7 +9,7 @@ import { imageUpload, assertImageFiles } from '../../http/upload'
import * as service from './vehicle.service'
import {
vehicleSchema, listQuerySchema, availabilityQuerySchema, calendarQuerySchema,
calendarBlockSchema, maintenanceLogSchema, publishSchema,
calendarBlockSchema, maintenanceLogSchema, publishSchema, statusSchema,
idParamSchema, photoIdxSchema, blockIdParamSchema,
} from './vehicle.schemas'
@@ -76,6 +76,14 @@ router.delete('/:id/photos/:idx', requireRole('MANAGER'), async (req, res, next)
} catch (err) { next(err) }
})
router.patch('/:id/status', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { status } = parseBody(statusSchema, req)
ok(res, await service.setStatus(id, req.companyId, status))
} catch (err) { next(err) }
})
router.patch('/:id/publish', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
@@ -16,7 +16,7 @@ export const vehicleSchema = z.object({
mileage: z.number().int().optional(),
notes: z.string().optional(),
isPublished: z.boolean().default(true),
status: z.enum(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']).optional(),
status: z.enum(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE']).optional(),
pickupLocations: z.array(z.string().trim().min(1)).default([]),
allowDifferentDropoff: z.boolean().default(false),
dropoffLocations: z.array(z.string().trim().min(1)).default([]),
@@ -58,6 +58,7 @@ export const maintenanceLogSchema = z.object({
})
export const publishSchema = z.object({ isPublished: z.boolean() })
export const statusSchema = z.object({ status: z.enum(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE']) })
export const idParamSchema = z.object({ id: z.string() })
export const photoIdxSchema = z.object({ id: z.string(), idx: z.string() })
@@ -61,18 +61,25 @@ export async function createVehicle(data: any, companyId: string) {
return presentVehicle(await repo.create({ ...applyLocationSettings(data), companyId }))
}
const PUBLISHED_STATUSES = new Set(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
export async function updateVehicle(id: string, companyId: string, data: any) {
const patch = applyLocationSettings(data)
if (patch.status === 'MAINTENANCE' || patch.status === 'OUT_OF_SERVICE') {
patch.isPublished = false
} else if (patch.status === 'AVAILABLE' || patch.status === 'RENTED') {
patch.isPublished = true
if (patch.status) {
patch.isPublished = PUBLISHED_STATUSES.has(patch.status)
}
const existing = await repo.findFirst(id, companyId)
if (!existing) throw new NotFoundError('Vehicle not found')
return presentVehicle(await repo.updateById(id, patch))
}
export async function setStatus(id: string, companyId: string, status: string) {
const existing = await repo.findFirst(id, companyId)
if (!existing) throw new NotFoundError('Vehicle not found')
const isPublished = PUBLISHED_STATUSES.has(status)
return presentVehicle(await repo.updateById(id, { status, isPublished }))
}
export async function deleteVehicle(id: string, companyId: string) {
return repo.softDelete(id, companyId)
}