From 9bd0938951f536f0e88b99d267a7b630cf5888ca Mon Sep 17 00:00:00 2001 From: root Date: Sun, 24 May 2026 23:58:54 -0400 Subject: [PATCH] fix signin signout and apply the new style --- apps/api/src/app.ts | 7 +- apps/api/src/data/platform-content.json | 4 +- apps/api/src/index.ts | 2 +- .../src/modules/auth/auth.company.service.ts | 3 +- .../modules/marketplace/marketplace.repo.ts | 7 +- .../marketplace/marketplace.schemas.ts | 5 + .../marketplace/marketplace.service.ts | 73 ++++++++++- .../modules/marketplace/marketplace.test.ts | 30 ++++- .../subscriptions/subscription.routes.ts | 11 +- .../src/modules/vehicles/vehicle.presenter.ts | 11 +- .../src/modules/vehicles/vehicle.schemas.ts | 3 + .../src/modules/vehicles/vehicle.service.ts | 40 +++++- apps/api/src/tests/helpers/fixtures.ts | 3 + .../integration/auth-company-signup.test.ts | 45 +++++++ .../src/tests/integration/vehicles.test.ts | 24 ++++ apps/dashboard/next.config.js | 12 +- .../{dashboard => }/billing/page.tsx | 4 +- .../{dashboard => }/contracts/[id]/page.tsx | 4 +- .../{dashboard => }/contracts/page.tsx | 2 +- .../{dashboard => }/customers/page.tsx | 0 .../{dashboard => }/fleet/[id]/page.tsx | 70 ++++++++++ .../{dashboard => }/fleet/page.tsx | 69 +++++++++- .../{dashboard => }/notifications/page.tsx | 0 .../{dashboard => }/offers/page.tsx | 0 .../online-reservations/page.tsx | 2 +- .../app/(dashboard)/{dashboard => }/page.tsx | 8 +- .../{dashboard => }/reports/page.tsx | 0 .../reservations/[id]/page.tsx | 0 .../{dashboard => }/reservations/new/page.tsx | 4 +- .../{dashboard => }/reservations/page.tsx | 6 +- .../{dashboard => }/settings/page.tsx | 0 .../{dashboard => }/subscription/page.tsx | 6 +- .../(dashboard)/{dashboard => }/team/page.tsx | 0 apps/dashboard/src/app/(public)/layout.tsx | 3 + .../sign-in}/page.tsx | 2 +- apps/dashboard/src/app/icon.svg | 14 ++ apps/dashboard/src/app/icon.tsx | 32 ----- apps/dashboard/src/app/onboarding/page.tsx | 2 +- apps/dashboard/src/app/page.tsx | 5 - .../[[...sign-in]]/SignInPageClient.tsx | 6 +- .../src/app/sign-up/[[...sign-up]]/page.tsx | 10 +- .../dashboard/src/components/I18nProvider.tsx | 120 ++++++++++++------ .../src/components/layout/Sidebar.tsx | 41 +++--- .../src/components/layout/TopBar.tsx | 14 +- apps/dashboard/src/lib/dashboardPaths.ts | 19 +++ apps/dashboard/src/middleware.ts | 22 +++- .../app/(public)/company-workspace/page.tsx | 2 +- .../(public)/explore/ExploreSearchForm.tsx | 79 +++++++++--- .../explore/[slug]/vehicles/[id]/page.tsx | 6 + .../src/app/(public)/explore/page.tsx | 17 +++ .../app/(public)/platform-operations/page.tsx | 2 +- .../app/(public)/pricing/PricingClient.tsx | 4 +- .../(public)/pricing/PricingPageContent.tsx | 4 +- .../src/app/renter/dashboard/page.tsx | 4 +- .../src/app/renter/notifications/page.tsx | 2 +- .../src/app/renter/profile/page.tsx | 2 +- .../src/app/renter/saved-companies/page.tsx | 2 +- .../src/components/BookingForm.tsx | 102 ++++++++++++++- .../src/components/MarketplaceHeader.tsx | 2 +- .../src/components/RenterShell.tsx | 2 +- .../src/components/WorkspaceFrame.tsx | 22 +++- dev_docker _run.md | 15 +++ docker-compose.dev.yml | 6 +- package.json | 12 ++ .../migration.sql | 4 + .../migration.sql | 10 ++ packages/database/prisma/schema.prisma | 3 + packages/types/src/marketplace-homepage.ts | 4 +- 68 files changed, 851 insertions(+), 200 deletions(-) create mode 100644 apps/api/src/tests/integration/auth-company-signup.test.ts rename apps/dashboard/src/app/(dashboard)/{dashboard => }/billing/page.tsx (99%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/contracts/[id]/page.tsx (99%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/contracts/page.tsx (98%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/customers/page.tsx (100%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/fleet/[id]/page.tsx (90%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/fleet/page.tsx (91%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/notifications/page.tsx (100%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/offers/page.tsx (100%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/online-reservations/page.tsx (98%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/page.tsx (96%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/reports/page.tsx (100%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/reservations/[id]/page.tsx (100%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/reservations/new/page.tsx (99%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/reservations/page.tsx (92%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/settings/page.tsx (100%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/subscription/page.tsx (99%) rename apps/dashboard/src/app/(dashboard)/{dashboard => }/team/page.tsx (100%) create mode 100644 apps/dashboard/src/app/(public)/layout.tsx rename apps/dashboard/src/app/{sign-in/[[...sign-in]] => (public)/sign-in}/page.tsx (95%) create mode 100644 apps/dashboard/src/app/icon.svg delete mode 100644 apps/dashboard/src/app/icon.tsx delete mode 100644 apps/dashboard/src/app/page.tsx create mode 100644 apps/dashboard/src/lib/dashboardPaths.ts create mode 100644 dev_docker _run.md create mode 100644 packages/database/prisma/migrations/20260524000000_add_vehicle_pickup_dropoff/migration.sql create mode 100644 packages/database/prisma/migrations/20260524000001_backfill_vehicle_location_arrays/migration.sql diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f6a8348..7dee094 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -15,7 +15,10 @@ import offersRouter from './modules/offers/offer.routes' import analyticsRouter from './modules/analytics/analytics.routes' import notificationsRouter from './modules/notifications/notification.routes' import adminRouter from './modules/admin/admin.routes' -import subscriptionsRouter from './modules/subscriptions/subscription.routes' +import subscriptionsRouter, { + subscriptionPublicRouter, + subscriptionWebhookRouter, +} from './modules/subscriptions/subscription.routes' import paymentsRouter from './modules/payments/payment.routes' import customersRouter from './modules/customers/customer.routes' import vehiclesRouter from './modules/vehicles/vehicle.routes' @@ -113,6 +116,8 @@ export function createApp() { app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter) app.use(`${v1}/site`, publicLimiter, siteRouter) + app.use(`${v1}/subscriptions`, subscriptionPublicRouter) + app.use(`${v1}/subscriptions`, subscriptionWebhookRouter) app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter) app.use(`${v1}/reservations`, apiLimiter, reservationsRouter) diff --git a/apps/api/src/data/platform-content.json b/apps/api/src/data/platform-content.json index a5badbd..7e54b90 100644 --- a/apps/api/src/data/platform-content.json +++ b/apps/api/src/data/platform-content.json @@ -68,7 +68,7 @@ { "step": "1", "title": "Create your company workspace", - "body": "Pick a plan, launch your 14-day trial, and verify the owner account." + "body": "Pick a plan, launch your 90-day trial, and verify the owner account." }, { "step": "2", @@ -156,7 +156,7 @@ { "step": "1", "title": "Créez votre espace entreprise", - "body": "Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire." + "body": "Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire." }, { "step": "2", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 443950b..5626911 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -85,7 +85,7 @@ cron.schedule('0 9 * * *', async () => { const owner = sub.company.employees[0] if (owner) { await prisma.notification.create({ - data: { type: 'SUBSCRIPTION_TRIAL_ENDING', title: 'Your trial ends soon', body: 'Your 14-day free trial ends in 3 days. Add a payment method to keep access.', companyId: sub.companyId, employeeId: owner.id, channel: 'IN_APP', status: 'DELIVERED' }, + data: { type: 'SUBSCRIPTION_TRIAL_ENDING', title: 'Your trial ends soon', body: 'Your 90-day free trial ends in 3 days. Add a payment method to keep access.', companyId: sub.companyId, employeeId: owner.id, channel: 'IN_APP', status: 'DELIVERED' }, }) } } diff --git a/apps/api/src/modules/auth/auth.company.service.ts b/apps/api/src/modules/auth/auth.company.service.ts index d8ce190..d01e8ec 100644 --- a/apps/api/src/modules/auth/auth.company.service.ts +++ b/apps/api/src/modules/auth/auth.company.service.ts @@ -9,6 +9,7 @@ import { companySignupSchema } from './auth.company.schemas' import type { output } from 'zod' type CompanySignupInput = output +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, { diff --git a/apps/api/src/modules/marketplace/marketplace.repo.ts b/apps/api/src/modules/marketplace/marketplace.repo.ts index 30d10c8..be7ee6c 100644 --- a/apps/api/src/modules/marketplace/marketplace.repo.ts +++ b/apps/api/src/modules/marketplace/marketplace.repo.ts @@ -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' }, diff --git a/apps/api/src/modules/marketplace/marketplace.schemas.ts b/apps/api/src/modules/marketplace/marketplace.schemas.ts index dc10868..a958f46 100644 --- a/apps/api/src/modules/marketplace/marketplace.schemas.ts +++ b/apps/api/src/modules/marketplace/marketplace.schemas.ts @@ -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'), }) diff --git a/apps/api/src/modules/marketplace/marketplace.service.ts b/apps/api/src/modules/marketplace/marketplace.service.ts index 086723e..5cc6b7a 100644 --- a/apps/api/src/modules/marketplace/marketplace.service.ts +++ b/apps/api/src/modules/marketplace/marketplace.service.ts @@ -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 diff --git a/apps/api/src/modules/marketplace/marketplace.test.ts b/apps/api/src/modules/marketplace/marketplace.test.ts index 00afd2c..f707eb5 100644 --- a/apps/api/src/modules/marketplace/marketplace.test.ts +++ b/apps/api/src/modules/marketplace/marketplace.test.ts @@ -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' }), diff --git a/apps/api/src/modules/subscriptions/subscription.routes.ts b/apps/api/src/modules/subscriptions/subscription.routes.ts index a8c00e6..bef3c76 100644 --- a/apps/api/src/modules/subscriptions/subscription.routes.ts +++ b/apps/api/src/modules/subscriptions/subscription.routes.ts @@ -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, rawBody) @@ -97,3 +99,4 @@ router.post('/resume', requireRole('OWNER'), async (req, res, next) => { }) export default router +export { publicRouter as subscriptionPublicRouter, webhookRouter as subscriptionWebhookRouter } diff --git a/apps/api/src/modules/vehicles/vehicle.presenter.ts b/apps/api/src/modules/vehicles/vehicle.presenter.ts index afcddc8..115e8e4 100644 --- a/apps/api/src/modules/vehicles/vehicle.presenter.ts +++ b/apps/api/src/modules/vehicles/vehicle.presenter.ts @@ -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 }) { diff --git a/apps/api/src/modules/vehicles/vehicle.schemas.ts b/apps/api/src/modules/vehicles/vehicle.schemas.ts index 09bcc46..18da4a0 100644 --- a/apps/api/src/modules/vehicles/vehicle.schemas.ts +++ b/apps/api/src/modules/vehicles/vehicle.schemas.ts @@ -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({ diff --git a/apps/api/src/modules/vehicles/vehicle.service.ts b/apps/api/src/modules/vehicles/vehicle.service.ts index c669ab8..af88bbc 100644 --- a/apps/api/src/modules/vehicles/vehicle.service.ts +++ b/apps/api/src/modules/vehicles/vehicle.service.ts @@ -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() + 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') { diff --git a/apps/api/src/tests/helpers/fixtures.ts b/apps/api/src/tests/helpers/fixtures.ts index 6f9e042..eb2a5ee 100644 --- a/apps/api/src/tests/helpers/fixtures.ts +++ b/apps/api/src/tests/helpers/fixtures.ts @@ -52,6 +52,9 @@ export async function createVehicle(companyId: string, overrides: Record { + it('creates new accounts with a 90-day trial period', async () => { + const startedAt = Date.now() + + const res = await request(app) + .post('/api/v1/auth/company/signup') + .send({ + firstName: 'Trial', + lastName: 'Owner', + email: uniqueEmail('trial-owner'), + password: 'Password123!', + companyName: `Trial Cars ${Date.now()}`, + legalForm: 'LLC', + registrationNumber: `REG-${Date.now()}`, + streetAddress: '123 Main Street', + city: 'Casablanca', + country: 'Morocco', + zipCode: '20000', + companyPhone: '+212600000000', + yearsActive: '1', + preferredLanguage: 'en', + plan: 'STARTER', + billingPeriod: 'MONTHLY', + currency: 'MAD', + paymentProvider: 'AMANPAY', + }) + + expect(res.status).toBe(201) + + const trialEndsAt = new Date(res.body.data.trialEndsAt).getTime() + const trialDurationDays = (trialEndsAt - startedAt) / (24 * 60 * 60 * 1000) + + expect(trialDurationDays).toBeGreaterThan(89.9) + expect(trialDurationDays).toBeLessThan(90.1) + }) +}) diff --git a/apps/api/src/tests/integration/vehicles.test.ts b/apps/api/src/tests/integration/vehicles.test.ts index 9d9728f..9f24fe7 100644 --- a/apps/api/src/tests/integration/vehicles.test.ts +++ b/apps/api/src/tests/integration/vehicles.test.ts @@ -64,11 +64,17 @@ describe('Vehicles API', () => { licensePlate: `TEST-${Date.now()}`, category: 'COMPACT', dailyRate: 400, + pickupLocations: ['Casablanca Airport', 'Casablanca Downtown'], + allowDifferentDropoff: true, + dropoffLocations: ['Rabat Downtown'], }) expect(res.status).toBe(201) expect(res.body.data.make).toBe('Honda') expect(res.body.data.model).toBe('Civic') + expect(res.body.data.pickupLocations).toEqual(['Casablanca Airport', 'Casablanca Downtown']) + expect(res.body.data.allowDifferentDropoff).toBe(true) + expect(res.body.data.dropoffLocations).toEqual(['Rabat Downtown']) }) it('returns 400 for missing required fields', async () => { @@ -116,6 +122,24 @@ describe('Vehicles API', () => { expect(res.body.data.notes).toBe('Updated via integration test') }) + it('updates pickup and drop-off settings', async () => { + const vehicle = await createVehicle(companyId) + + const res = await request(app) + .patch(`/api/v1/vehicles/${vehicle.id}`) + .set(authHeader(token)) + .send({ + pickupLocations: ['Casablanca Airport'], + allowDifferentDropoff: true, + dropoffLocations: ['Rabat Downtown', 'Marrakech Center'], + }) + + expect(res.status).toBe(200) + expect(res.body.data.pickupLocations).toEqual(['Casablanca Airport']) + expect(res.body.data.allowDifferentDropoff).toBe(true) + expect(res.body.data.dropoffLocations).toEqual(['Rabat Downtown', 'Marrakech Center']) + }) + it('sets isPublished=false when status=MAINTENANCE', async () => { const vehicle = await createVehicle(companyId) diff --git a/apps/dashboard/next.config.js b/apps/dashboard/next.config.js index d4f4415..34da586 100644 --- a/apps/dashboard/next.config.js +++ b/apps/dashboard/next.config.js @@ -23,14 +23,14 @@ const storagePatterns = [ .filter(Boolean) .filter((p, i, arr) => arr.findIndex((q) => q.hostname === p.hostname && q.port === p.port) === i) -// When the dashboard is accessed via the marketplace proxy (localhost:3000/dashboard), -// the browser resolves _next/static chunk URLs relative to localhost:3000 — but those -// chunks only exist on the dashboard's own server (localhost:3001). DASHBOARD_ASSET_PREFIX -// makes Next.js embed absolute chunk URLs so the browser fetches them from the right port. -// Set in .env.docker.dev; unset for local/production (where no cross-port proxy is used). +// In Docker dev the dashboard app runs on port 3001 while the marketplace proxy +// is served from port 3000. When DASHBOARD_ASSET_PREFIX is set, Next should emit +// absolute chunk URLs so /dashboard pages load their own JS/CSS from port 3001. +const assetPrefix = process.env.DASHBOARD_ASSET_PREFIX || undefined + const nextConfig = { basePath: '/dashboard', - ...(process.env.DASHBOARD_ASSET_PREFIX ? { assetPrefix: process.env.DASHBOARD_ASSET_PREFIX } : {}), + ...(assetPrefix ? { assetPrefix } : {}), images: { remotePatterns: [ { diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx b/apps/dashboard/src/app/(dashboard)/billing/page.tsx similarity index 99% rename from apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx rename to apps/dashboard/src/app/(dashboard)/billing/page.tsx index 765f029..1ed5210 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/billing/page.tsx @@ -549,10 +549,10 @@ export default function BillingPage() { ) : ( {copy.paymentActionDisabled} )} - + {copy.openContract} - + {copy.openBooking} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/contracts/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/contracts/[id]/page.tsx similarity index 99% rename from apps/dashboard/src/app/(dashboard)/dashboard/contracts/[id]/page.tsx rename to apps/dashboard/src/app/(dashboard)/contracts/[id]/page.tsx index 972fbaf..e8dc88f 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/contracts/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/contracts/[id]/page.tsx @@ -843,7 +843,7 @@ export default function ContractDetailPage() {
- {copy.back} + {copy.back}

{contract.company.name}

{contract.contractNumber ?? copy.contractNo}

@@ -855,7 +855,7 @@ export default function ContractDetailPage() {

- + {copy.actionOpenBooking}
+
+
+ +