add review and booking policies
This commit is contained in:
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user