fix architecture and write new tests
This commit is contained in:
@@ -10,7 +10,43 @@ import { prisma } from '../../lib/prisma'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as repo from './site.repo'
|
||||
import { presentBrand } from './site.presenter'
|
||||
import { presentBrand, presentPublicBooking } from './site.presenter'
|
||||
import { generatePublicAccessToken, hashPublicAccessToken } from '../../security/publicAccessTokens'
|
||||
|
||||
function assertAllowedPaymentRedirect(urlValue: string, company: any) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(urlValue)
|
||||
} catch {
|
||||
throw new AppError('Invalid payment redirect URL', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
|
||||
throw new AppError('Payment redirect URLs must use HTTPS', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
const allowedHosts = new Set<string>()
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
allowedHosts.add('localhost:3000')
|
||||
allowedHosts.add('localhost:4000')
|
||||
allowedHosts.add('127.0.0.1:3000')
|
||||
allowedHosts.add('127.0.0.1:4000')
|
||||
}
|
||||
for (const value of [process.env.MARKETPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_MARKETPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
|
||||
if (!value) continue
|
||||
try { allowedHosts.add(new URL(value).host) } catch {}
|
||||
}
|
||||
|
||||
const brand = company.brand as any
|
||||
if (brand?.customDomain && brand?.customDomainVerified) allowedHosts.add(brand.customDomain)
|
||||
if (brand?.subdomain && process.env.PUBLIC_SITE_BASE_DOMAIN) {
|
||||
allowedHosts.add(`${brand.subdomain}.${process.env.PUBLIC_SITE_BASE_DOMAIN}`)
|
||||
}
|
||||
|
||||
if (!allowedHosts.has(parsed.host)) {
|
||||
throw new AppError('Payment redirect host is not allowed', 400, 'invalid_redirect_url')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformHomepage() {
|
||||
return getMarketplaceHomepageContent()
|
||||
@@ -26,7 +62,7 @@ export async function getPlatformPricing() {
|
||||
|
||||
const prices = configs.length === 0
|
||||
? PLAN_PRICES
|
||||
: configs.reduce<Record<string, Record<string, Record<string, number>>>>((acc, config) => {
|
||||
: configs.reduce<Record<string, Record<string, Record<string, number>>>>((acc: Record<string, Record<string, Record<string, number>>>, config: any) => {
|
||||
if (!acc[config.plan]) acc[config.plan] = {}
|
||||
acc[config.plan]![config.billingPeriod] = { MAD: config.amount }
|
||||
return acc
|
||||
@@ -34,7 +70,7 @@ export async function getPlatformPricing() {
|
||||
|
||||
const planFeatures = Object.fromEntries(
|
||||
Object.entries(PLAN_FEATURES).map(([plan, fallback]) => {
|
||||
const labels = features.filter((feature) => feature.plan === plan).map((feature) => feature.label)
|
||||
const labels = features.filter((feature: any) => feature.plan === plan).map((feature: any) => feature.label)
|
||||
return [plan, labels.length > 0 ? labels : fallback]
|
||||
}),
|
||||
)
|
||||
@@ -51,8 +87,8 @@ export async function getPublicVehicles(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const vehicles = await repo.findPublishedVehicles(company.id)
|
||||
return Promise.all(
|
||||
vehicles.map(async (v) => {
|
||||
const a = await getVehicleAvailabilitySummary(v.id)
|
||||
vehicles.map(async (v: any) => {
|
||||
const a = await getVehicleAvailabilitySummary(v.id, { companyId: v.companyId })
|
||||
return { ...v, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
|
||||
}),
|
||||
)
|
||||
@@ -61,7 +97,7 @@ export async function getPublicVehicles(slug: string) {
|
||||
export async function getVehicleDetail(slug: string, vehicleId: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const vehicle = await repo.findVehicleById(vehicleId, company.id)
|
||||
const a = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
const a = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId })
|
||||
return { ...vehicle, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
|
||||
}
|
||||
|
||||
@@ -77,8 +113,10 @@ export async function getBookingOptions(slug: string) {
|
||||
}
|
||||
|
||||
export async function checkAvailability(slug: string, vehicleId: string, startDate: string, endDate: string) {
|
||||
await repo.findCompanyBySlug(slug)
|
||||
const availability = await getVehicleAvailabilitySummary(vehicleId, {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const vehicle = await repo.findVehicleById(vehicleId, company.id)
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||
companyId: company.id,
|
||||
range: { startDate: new Date(startDate), endDate: new Date(endDate) },
|
||||
})
|
||||
return { available: availability.available, nextAvailableAt: availability.nextAvailableAt }
|
||||
@@ -106,7 +144,7 @@ export async function createBooking(slug: string, body: {
|
||||
const end = new Date(body.endDate)
|
||||
if (end <= start) throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, { range: { startDate: start, endDate: end } })
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: company.id, range: { startDate: start, endDate: end } })
|
||||
if (!availability.available) {
|
||||
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
@@ -185,24 +223,41 @@ export async function createBooking(slug: string, body: {
|
||||
await validateAndFlagLicense(customer.id).catch(() => null)
|
||||
}
|
||||
|
||||
const publicAccess = generatePublicAccessToken()
|
||||
await repo.createReservationPublicAccess(
|
||||
reservation.id,
|
||||
publicAccess.tokenHash,
|
||||
new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
|
||||
)
|
||||
|
||||
const refreshed = await repo.findReservationWithDetails(reservation.id)
|
||||
return {
|
||||
...refreshed,
|
||||
publicAccessToken: publicAccess.token,
|
||||
requiresManualApproval:
|
||||
primaryLicenseResult.requiresApproval ||
|
||||
refreshed.additionalDrivers.some((d) => d.requiresApproval),
|
||||
refreshed.additionalDrivers.some((d: any) => d.requiresApproval),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBooking(slug: string, reservationId: string) {
|
||||
async function assertPublicBookingAccess(reservationId: string, token: string | undefined) {
|
||||
if (!token) throw new AppError('Booking access token is required', 403, 'booking_token_required')
|
||||
const access = await repo.findReservationPublicAccess(reservationId, hashPublicAccessToken(token))
|
||||
if (!access) throw new AppError('Booking not found', 404, 'not_found')
|
||||
await repo.markReservationPublicAccessUsed(access.id)
|
||||
}
|
||||
|
||||
export async function getBooking(slug: string, reservationId: string, accessToken?: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return repo.findBooking(reservationId, company.id)
|
||||
await assertPublicBookingAccess(reservationId, accessToken)
|
||||
return presentPublicBooking(await repo.findBooking(reservationId, company.id))
|
||||
}
|
||||
|
||||
export async function initPayment(slug: string, reservationId: string, body: {
|
||||
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string
|
||||
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string; accessToken?: string
|
||||
}) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
await assertPublicBookingAccess(reservationId, body.accessToken)
|
||||
const reservation = await repo.findReservationForPayment(reservationId, company.id)
|
||||
|
||||
if (reservation.paymentStatus === 'PAID') {
|
||||
@@ -214,12 +269,15 @@ export async function initPayment(slug: string, reservationId: string, body: {
|
||||
reservation.customer.licenseValidationStatus === 'DENIED' ||
|
||||
customerLicenseResult.status === 'EXPIRED' ||
|
||||
(customerLicenseResult.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') ||
|
||||
reservation.additionalDrivers.some((d) => d.licenseExpired || (d.requiresApproval && !d.approvedAt))
|
||||
reservation.additionalDrivers.some((d: any) => d.licenseExpired || (d.requiresApproval && !d.approvedAt))
|
||||
|
||||
if (licenseBlocked) {
|
||||
throw new AppError('This reservation requires license review before payment can be processed', 409, 'license_review_required')
|
||||
}
|
||||
|
||||
assertAllowedPaymentRedirect(body.successUrl, company)
|
||||
assertAllowedPaymentRedirect(body.failureUrl, company)
|
||||
|
||||
const currency = body.currency ?? 'MAD'
|
||||
const amount = reservation.totalAmount
|
||||
const description = `Rental: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
|
||||
Reference in New Issue
Block a user