fix signin signout and apply the new style

This commit is contained in:
root
2026-05-24 23:58:54 -04:00
parent bf97a072dd
commit 9bd0938951
68 changed files with 851 additions and 200 deletions
@@ -9,6 +9,7 @@ import { companySignupSchema } from './auth.company.schemas'
import type { output } from 'zod'
type CompanySignupInput = output<typeof companySignupSchema>
const TRIAL_PERIOD_DAYS = 90
function slugify(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
@@ -37,7 +38,7 @@ export async function signup(body: CompanySignupInput) {
const slug = await generateUniqueSlug(body.companyName)
const now = new Date()
const trialEndAt = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000)
const trialEndAt = new Date(now.getTime() + TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000)
const passwordHash = await bcrypt.hash(body.password, 12)
const result = await prisma.$transaction((tx) => repo.createCompanySignup(tx, {
@@ -31,14 +31,12 @@ export async function findListedCompanies(where: any, skip: number, take: number
})
}
export async function findPublishedVehicles(where: any, skip: number, take: number) {
export async function findPublishedVehicles(where: any) {
return prisma.vehicle.findMany({
where,
include: {
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
},
skip,
take,
orderBy: { dailyRate: 'asc' },
})
}
@@ -62,7 +60,8 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
export async function createMarketplaceReservation(data: {
companyId: string; vehicleId: string; customerId: string
startDate: Date; endDate: Date; dailyRate: number; totalDays: number; totalAmount: number; notes?: string
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
dailyRate: number; totalDays: number; totalAmount: number; notes?: string
}) {
return prisma.reservation.create({
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
@@ -7,6 +7,9 @@ export const paginationSchema = z.object({
export const searchSchema = z.object({
city: z.string().trim().max(100).optional(),
pickupLocation: z.string().trim().max(100).optional(),
dropoffLocation: z.string().trim().max(100).optional(),
dropoffMode: z.enum(['same', 'different']).optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
category: z.string().trim().max(50).optional(),
@@ -30,6 +33,8 @@ export const marketplaceReservationSchema = z.object({
phone: z.string().max(30).optional(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
pickupLocation: z.string().trim().max(100).optional(),
returnLocation: z.string().trim().max(100).optional(),
notes: z.string().max(500).optional(),
language: z.enum(['en', 'fr', 'ar']).default('fr'),
})
@@ -4,6 +4,46 @@ import { sendNotification, sendTransactionalEmail } from '../../services/notific
import { marketplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './marketplace.repo'
function normalizeLocation(value?: string | null) {
const normalized = value?.trim()
return normalized ? normalized : null
}
function normalizeLocationList(values?: string[] | null) {
if (!Array.isArray(values)) return []
return values
.map((value) => normalizeLocation(value))
.filter((value): value is string => Boolean(value))
}
function includesLocation(values: string[], target: string) {
const key = target.toLocaleLowerCase()
return values.some((value) => value.toLocaleLowerCase() === key)
}
function matchesVehicleLocationRules(
vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean },
params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' },
) {
const pickupLocation = normalizeLocation(params.pickupLocation)
const dropoffLocation = normalizeLocation(params.dropoffLocation)
const pickupLocations = normalizeLocationList(vehicle.pickupLocations)
const dropoffLocations = normalizeLocationList(vehicle.dropoffLocations)
if (pickupLocation && pickupLocations.length > 0 && !includesLocation(pickupLocations, pickupLocation)) {
return false
}
if (params.dropoffMode === 'different') {
if (!vehicle.allowDifferentDropoff) return false
if (dropoffLocation && dropoffLocations.length > 0 && !includesLocation(dropoffLocations, dropoffLocation)) {
return false
}
}
return true
}
export async function getPublicOffers() {
return repo.findPublicOffers()
}
@@ -34,7 +74,8 @@ export async function getListedCompanies(params: {
}
export async function searchVehicles(params: {
city?: string; startDate?: string; endDate?: string; category?: string
city?: string; pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different'
startDate?: string; endDate?: string; category?: string
maxPrice?: number; transmission?: string; make?: string; model?: string
page?: number; pageSize?: number
}) {
@@ -54,10 +95,12 @@ export async function searchVehicles(params: {
where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
}
const vehicles = await repo.findPublishedVehicles(where, (page - 1) * pageSize, pageSize)
const vehicles = await repo.findPublishedVehicles(where)
const locationFiltered = vehicles.filter((vehicle) => matchesVehicleLocationRules(vehicle, params))
const pagedVehicles = locationFiltered.slice((page - 1) * pageSize, page * pageSize)
const availability = await Promise.all(
vehicles.map(async (v) => {
pagedVehicles.map(async (v) => {
const a = await getVehicleAvailabilitySummary(v.id, {
range: params.startDate && params.endDate
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
@@ -68,7 +111,7 @@ export async function searchVehicles(params: {
)
const availMap = new Map(availability)
return vehicles.map((v) => ({
return pagedVehicles.map((v) => ({
...v,
availability: availMap.get(v.id)?.available ?? null,
availabilityStatus: availMap.get(v.id)?.status ?? 'AVAILABLE',
@@ -79,7 +122,7 @@ export async function searchVehicles(params: {
export async function createMarketplaceReservation(body: {
vehicleId: string; companySlug: string; firstName: string; lastName: string
email: string; phone?: string; startDate: string; endDate: string
notes?: string; language?: string
pickupLocation?: string; returnLocation?: string; notes?: string; language?: string
}) {
const startDate = new Date(body.startDate)
const endDate = new Date(body.endDate)
@@ -91,6 +134,24 @@ export async function createMarketplaceReservation(body: {
const vehicle = await repo.findVehicleForMarketplace(body.vehicleId, body.companySlug)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const pickupLocation = normalizeLocation(body.pickupLocation)
const returnLocation = normalizeLocation(body.returnLocation) ?? pickupLocation
const pickupLocations = normalizeLocationList(vehicle.pickupLocations)
const dropoffLocations = normalizeLocationList(vehicle.dropoffLocations)
if (pickupLocation && pickupLocations.length > 0 && !includesLocation(pickupLocations, pickupLocation)) {
throw new AppError('Selected pickup location is not available for this vehicle', 400, 'invalid_pickup_location')
}
if (pickupLocation && returnLocation && pickupLocation.toLocaleLowerCase() !== returnLocation.toLocaleLowerCase()) {
if (!vehicle.allowDifferentDropoff) {
throw new AppError('This vehicle must be returned to the same pickup location', 400, 'same_dropoff_required')
}
if (dropoffLocations.length > 0 && !includesLocation(dropoffLocations, returnLocation)) {
throw new AppError('Selected return location is not available for this vehicle', 400, 'invalid_return_location')
}
}
const availability = await getVehicleAvailabilitySummary(body.vehicleId, { range: { startDate, endDate } })
if (!availability.available) {
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
@@ -107,7 +168,7 @@ export async function createMarketplaceReservation(body: {
const reservation = await repo.createMarketplaceReservation({
companyId: vehicle.companyId, vehicleId: body.vehicleId, customerId: customer.id,
startDate, endDate, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes,
startDate, endDate, pickupLocation, returnLocation, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes,
})
const lang = (body.language ?? 'fr') as Lang
@@ -52,6 +52,9 @@ function makeVehicle(overrides: object = {}) {
return {
id: 'v-1', dailyRate: 10000, year: 2022, make: 'Toyota', model: 'Camry',
isPublished: true, status: 'AVAILABLE', companyId: 'co-1',
pickupLocations: ['Casablanca'],
allowDifferentDropoff: false,
dropoffLocations: [],
company: { name: 'Test Co', brand: { displayName: 'Test Co' } },
...overrides,
}
@@ -98,6 +101,19 @@ describe('searchVehicles', () => {
})
expect(result[0].availability).toBe(false)
})
it('filters out vehicles that do not support different drop-off when requested', async () => {
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([
makeVehicle({ id: 'same-only' }),
makeVehicle({ id: 'one-way', allowDifferentDropoff: true, dropoffLocations: ['Rabat'] }),
] as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
const result = await searchVehicles({ pickupLocation: 'Casablanca', dropoffLocation: 'Rabat', dropoffMode: 'different' })
expect(result).toHaveLength(1)
expect(result[0].id).toBe('one-way')
})
})
// ────────────────────────────────────────────────────────────────────────────
@@ -114,8 +130,12 @@ describe('createMarketplaceReservation', () => {
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1' } as any)
const result = await createMarketplaceReservation(baseBody)
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
expect(result.reservationId).toBe('r-1')
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
}))
})
it('throws NotFoundError when vehicle not found', async () => {
@@ -131,6 +151,14 @@ describe('createMarketplaceReservation', () => {
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
})
it('rejects different return locations when the vehicle requires same drop-off', async () => {
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
await expect(
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
).rejects.toMatchObject({ error: 'same_dropoff_required' })
})
it('throws AppError for invalid date range', async () => {
await expect(
createMarketplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
@@ -9,21 +9,23 @@ import * as paypal from '../../services/paypalService'
import * as service from './subscription.service'
import { checkoutSchema, changePlanSchema, capturePaypalSchema } from './subscription.schemas'
const publicRouter = Router()
const webhookRouter = Router()
const router = Router()
// ─── Public ────────────────────────────────────────────────────
router.get('/plans', (_req, res) => {
publicRouter.get('/plans', (_req, res) => {
ok(res, service.getPlans())
})
router.get('/providers', (_req, res) => {
publicRouter.get('/providers', (_req, res) => {
ok(res, service.getProviders())
})
// ─── Webhooks (no auth) ────────────────────────────────────────
router.post('/webhooks/amanpay', async (req, res, next) => {
webhookRouter.post('/webhooks/amanpay', async (req, res, next) => {
try {
const rawBody = JSON.stringify(req.body)
const signature = (req.headers['x-amanpay-signature'] as string) ?? ''
@@ -35,7 +37,7 @@ router.post('/webhooks/amanpay', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/webhooks/paypal', async (req, res, next) => {
webhookRouter.post('/webhooks/paypal', async (req, res, next) => {
try {
const rawBody = JSON.stringify(req.body)
const isValid = await paypal.verifyWebhookEvent(req.headers as Record<string, string>, rawBody)
@@ -97,3 +99,4 @@ router.post('/resume', requireRole('OWNER'), async (req, res, next) => {
})
export default router
export { publicRouter as subscriptionPublicRouter, webhookRouter as subscriptionWebhookRouter }
@@ -1,5 +1,14 @@
function normalizeLocations(value: unknown) {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
export function presentVehicle(vehicle: any) {
return vehicle
return {
...vehicle,
pickupLocations: normalizeLocations(vehicle?.pickupLocations),
allowDifferentDropoff: Boolean(vehicle?.allowDifferentDropoff),
dropoffLocations: normalizeLocations(vehicle?.dropoffLocations),
}
}
export function presentVehicleList(vehicles: any[], meta: { total: number; page: number; pageSize: number; totalPages: number }) {
@@ -17,6 +17,9 @@ export const vehicleSchema = z.object({
notes: z.string().optional(),
isPublished: z.boolean().default(true),
status: z.enum(['AVAILABLE', 'RENTED', 'MAINTENANCE', '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([]),
})
export const listQuerySchema = z.object({
@@ -3,6 +3,42 @@ import { NotFoundError, ValidationError } from '../../http/errors'
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
import * as repo from './vehicle.repo'
function normalizeLocations(locations: unknown) {
if (!Array.isArray(locations)) return []
const seen = new Set<string>()
const normalized: string[] = []
for (const entry of locations) {
if (typeof entry !== 'string') continue
const value = entry.trim()
const key = value.toLocaleLowerCase()
if (!value || seen.has(key)) continue
seen.add(key)
normalized.push(value)
}
return normalized
}
function applyLocationSettings(data: any) {
const patch = { ...data }
if (patch.pickupLocations !== undefined) {
patch.pickupLocations = normalizeLocations(patch.pickupLocations)
}
if (patch.dropoffLocations !== undefined) {
patch.dropoffLocations = normalizeLocations(patch.dropoffLocations)
}
if (patch.allowDifferentDropoff === false) {
patch.dropoffLocations = []
}
if (patch.allowDifferentDropoff === true && patch.dropoffLocations !== undefined && patch.dropoffLocations.length === 0) {
throw new ValidationError('Drop-off locations are required when different drop-off is enabled')
}
return patch
}
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1
const pageSize = query.pageSize ?? 20
@@ -22,11 +58,11 @@ export async function getVehicle(id: string, companyId: string) {
}
export async function createVehicle(data: any, companyId: string) {
return presentVehicle(await repo.create({ ...data, companyId }))
return presentVehicle(await repo.create({ ...applyLocationSettings(data), companyId }))
}
export async function updateVehicle(id: string, companyId: string, data: any) {
const patch = { ...data }
const patch = applyLocationSettings(data)
if (patch.status === 'MAINTENANCE' || patch.status === 'OUT_OF_SERVICE') {
patch.isPublished = false
} else if (patch.status === 'AVAILABLE' || patch.status === 'RENTED') {