fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
+72 -14
View File
@@ -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}`