253 lines
12 KiB
JavaScript
253 lines
12 KiB
JavaScript
"use strict";
|
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
}
|
|
Object.defineProperty(o, k2, desc);
|
|
}) : (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
o[k2] = m[k];
|
|
}));
|
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
}) : function(o, v) {
|
|
o["default"] = v;
|
|
});
|
|
var __importStar = (this && this.__importStar) || (function () {
|
|
var ownKeys = function(o) {
|
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
var ar = [];
|
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
return ar;
|
|
};
|
|
return ownKeys(o);
|
|
};
|
|
return function (mod) {
|
|
if (mod && mod.__esModule) return mod;
|
|
var result = {};
|
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
__setModuleDefault(result, mod);
|
|
return result;
|
|
};
|
|
})();
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.confirmReservation = confirmReservation;
|
|
exports.checkinReservation = checkinReservation;
|
|
exports.checkoutReservation = checkoutReservation;
|
|
exports.closeReservation = closeReservation;
|
|
exports.extendReservation = extendReservation;
|
|
exports.cancelReservation = cancelReservation;
|
|
const crypto_1 = __importDefault(require("crypto"));
|
|
const prisma_1 = require("../../lib/prisma");
|
|
const errors_1 = require("../../http/errors");
|
|
const licenseValidationService_1 = require("../../services/licenseValidationService");
|
|
const notificationService_1 = require("../../services/notificationService");
|
|
const emailTranslations_1 = require("../../lib/emailTranslations");
|
|
const notificationLocalizationService_1 = require("../../services/notificationLocalizationService");
|
|
const reservation_presenter_1 = require("./reservation.presenter");
|
|
const repo = __importStar(require("./reservation.repo"));
|
|
function buildPickupAddress(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return '';
|
|
const address = value;
|
|
return [
|
|
typeof address.streetAddress === 'string' ? address.streetAddress.trim() : '',
|
|
typeof address.city === 'string' ? address.city.trim() : '',
|
|
typeof address.country === 'string' ? address.country.trim() : '',
|
|
].filter(Boolean).join(', ');
|
|
}
|
|
async function assertLicenseCompliance(reservationId, companyId) {
|
|
const reservation = await repo.findForLicenseCheck(reservationId, companyId);
|
|
const customerLicense = (0, licenseValidationService_1.validateLicense)(reservation.customer.licenseExpiry);
|
|
const customerDenied = ['DENIED', 'EXPIRED'].includes(reservation.customer.licenseValidationStatus);
|
|
if (customerDenied || customerLicense.status === 'EXPIRED') {
|
|
throw new errors_1.AppError('Primary driver license is not valid for this reservation', 400, 'invalid_primary_license');
|
|
}
|
|
if (customerLicense.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') {
|
|
throw new errors_1.AppError('Primary driver license requires manager approval before this reservation can proceed', 400, 'primary_license_requires_approval');
|
|
}
|
|
const blockedDriver = reservation.additionalDrivers.find((d) => {
|
|
if (d.licenseExpired)
|
|
return true;
|
|
return d.requiresApproval && !d.approvedAt;
|
|
});
|
|
if (blockedDriver) {
|
|
throw new errors_1.AppError(`Additional driver ${blockedDriver.firstName} ${blockedDriver.lastName} requires approval before this reservation can proceed`, 400, 'additional_driver_requires_approval');
|
|
}
|
|
}
|
|
async function confirmReservation(id, companyId) {
|
|
const reservation = await repo.findByIdSimple(id, companyId);
|
|
if (reservation.status !== 'DRAFT')
|
|
throw new errors_1.AppError('Only DRAFT reservations can be confirmed', 400, 'invalid_status');
|
|
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),
|
|
repo.findCompanyWithBrand(companyId),
|
|
repo.findVehicle(reservation.vehicleId, companyId),
|
|
]);
|
|
const fallbackLocale = (0, notificationLocalizationService_1.coerceNotificationLocale)(company?.brand?.defaultLocale);
|
|
const pickupLocation = reservation.pickupLocation?.trim() || buildPickupAddress(company?.address);
|
|
await (0, notificationService_1.sendNotification)({
|
|
type: 'BOOKING_CONFIRMED',
|
|
companyId,
|
|
renterId: reservation.renterId ?? undefined,
|
|
email: customer.email,
|
|
channels: ['EMAIL', 'IN_APP'],
|
|
locale: reservation.renterId ? undefined : fallbackLocale,
|
|
templateKey: 'booking.confirmed',
|
|
templateVariables: {
|
|
firstName: customer.firstName,
|
|
companyName: company?.brand?.displayName ?? company?.name ?? 'RentalDriveGo',
|
|
startDate: {
|
|
type: 'date',
|
|
value: reservation.startDate,
|
|
options: {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
},
|
|
},
|
|
endDate: {
|
|
type: 'date',
|
|
value: reservation.endDate,
|
|
options: {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
},
|
|
},
|
|
pickupLocation,
|
|
vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
|
|
},
|
|
}).catch(() => null);
|
|
return updated;
|
|
}
|
|
async function checkinReservation(id, companyId, mileage) {
|
|
const reservation = await repo.findByIdSimple(id, companyId);
|
|
if (reservation.status !== 'CONFIRMED')
|
|
throw new errors_1.AppError('Only CONFIRMED reservations can be checked in', 400, 'invalid_status');
|
|
await assertLicenseCompliance(reservation.id, companyId);
|
|
const updated = await repo.updateById(id, {
|
|
status: 'ACTIVE',
|
|
checkedInAt: new Date(),
|
|
checkInMileage: mileage ?? null,
|
|
});
|
|
await repo.updateVehicleStatus(reservation.vehicleId, 'RENTED');
|
|
return updated;
|
|
}
|
|
async function checkoutReservation(id, companyId, mileage) {
|
|
const reservation = await repo.findByIdForCheckout(id, companyId);
|
|
if (reservation.status !== 'ACTIVE')
|
|
throw new errors_1.AppError('Only ACTIVE reservations can be checked out', 400, 'invalid_status');
|
|
const reviewToken = reservation.reviewToken ?? crypto_1.default.randomBytes(32).toString('hex');
|
|
const updated = await repo.updateById(id, {
|
|
status: 'COMPLETED',
|
|
checkedOutAt: new Date(),
|
|
checkOutMileage: mileage ?? null,
|
|
reviewToken,
|
|
});
|
|
await repo.updateVehicleStatus(reservation.vehicleId, 'RETURNED', mileage);
|
|
return updated;
|
|
}
|
|
async function closeReservation(id, companyId, closedBy) {
|
|
const reservation = await repo.findForClose(id, companyId);
|
|
const workflow = (0, reservation_presenter_1.buildReservationWorkflow)(reservation);
|
|
if (reservation.status !== 'COMPLETED')
|
|
throw new errors_1.AppError('Only completed reservations can be closed', 400, 'invalid_status');
|
|
if (workflow.closed)
|
|
throw new errors_1.AppError('This reservation is already closed', 400, 'already_closed');
|
|
const extras = (0, reservation_presenter_1.parseReservationExtras)(reservation.extras);
|
|
extras.reservationClosedAt = new Date().toISOString();
|
|
extras.reservationClosedBy = closedBy;
|
|
const updated = await prisma_1.prisma.reservation.update({
|
|
where: { id: reservation.id },
|
|
data: { extras: extras },
|
|
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
|
|
});
|
|
// Send review request email if eligible
|
|
if (reservation.reviewToken && !reservation.reviewPaused) {
|
|
try {
|
|
const customer = updated.customer;
|
|
if (!customer.reviewOptOut && customer.email) {
|
|
const company = await repo.findCompanyWithBrand(companyId);
|
|
const lang = (company?.brand?.defaultLocale ?? 'fr');
|
|
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}`;
|
|
(0, notificationService_1.sendTransactionalEmail)({
|
|
to: customer.email,
|
|
subject: emailTranslations_1.reviewRequestEmail.subject(vehicleLabel, lang),
|
|
html: emailTranslations_1.reviewRequestEmail.html({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
|
|
text: emailTranslations_1.reviewRequestEmail.text({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
|
|
}).catch(() => null);
|
|
await prisma_1.prisma.reservation.update({
|
|
where: { id: reservation.id },
|
|
data: { reviewRequestSentAt: new Date() },
|
|
});
|
|
}
|
|
}
|
|
catch {
|
|
// Non-blocking: do not fail close if email fails
|
|
}
|
|
}
|
|
return (0, reservation_presenter_1.serializeReservationForDashboard)(updated);
|
|
}
|
|
async function extendReservation(id, companyId, newEndDate, reason) {
|
|
const reservation = await repo.findByIdSimple(id, companyId);
|
|
if (!['CONFIRMED', 'ACTIVE'].includes(reservation.status)) {
|
|
throw new errors_1.AppError('Only CONFIRMED or ACTIVE reservations can be extended', 400, 'invalid_status');
|
|
}
|
|
if (newEndDate <= reservation.endDate) {
|
|
throw new errors_1.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 errors_1.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 = (0, reservation_presenter_1.parseReservationExtras)(reservation.extras);
|
|
if (!Array.isArray(extras.extensions))
|
|
extras.extensions = [];
|
|
extras.extensions.push({
|
|
previousEndDate: reservation.endDate.toISOString(),
|
|
newEndDate: newEndDate.toISOString(),
|
|
additionalDays,
|
|
additionalAmount,
|
|
reason,
|
|
approvedAt: new Date().toISOString(),
|
|
});
|
|
const updated = await prisma_1.prisma.reservation.update({
|
|
where: { id },
|
|
data: {
|
|
endDate: newEndDate,
|
|
totalDays: reservation.totalDays + additionalDays,
|
|
totalAmount: reservation.totalAmount + additionalAmount,
|
|
extras: extras,
|
|
},
|
|
});
|
|
return updated;
|
|
}
|
|
async function cancelReservation(id, companyId, reason) {
|
|
const reservation = await repo.findByIdSimple(id, companyId);
|
|
if (['COMPLETED', 'CANCELLED'].includes(reservation.status)) {
|
|
throw new errors_1.AppError('Reservation cannot be cancelled', 400, 'invalid_status');
|
|
}
|
|
const updated = await repo.updateById(id, { status: 'CANCELLED', cancelReason: reason ?? null, cancelledBy: 'COMPANY' });
|
|
if (['CONFIRMED', 'ACTIVE', 'DRAFT'].includes(reservation.status)) {
|
|
await repo.updateVehicleStatus(reservation.vehicleId, 'AVAILABLE');
|
|
}
|
|
return updated;
|
|
}
|
|
//# sourceMappingURL=reservation.lifecycle.service.js.map
|