From 2b422ca1977c04b9690f2aeaaf3dde035328735c Mon Sep 17 00:00:00 2001 From: root Date: Sun, 26 Jul 2026 01:18:05 -0400 Subject: [PATCH] fix search feature --- apps/api/src/app.ts | 3 + apps/api/src/middleware/rateLimiter.test.ts | 13 + apps/api/src/middleware/rateLimiter.ts | 9 +- .../reservation.schemas.edge.test.ts | 3 +- .../reservations/reservation.schemas.ts | 1 + .../reservations/reservation.service.ts | 22 ++ .../modules/reservations/reservation.test.ts | 43 ++- apps/api/src/modules/search/search.routes.ts | 21 ++ apps/api/src/modules/search/search.schemas.ts | 6 + .../src/modules/search/search.service.test.ts | 83 +++++ apps/api/src/modules/search/search.service.ts | 327 ++++++++++++++++++ .../modules/vehicles/vehicle.schemas.test.ts | 4 +- .../src/modules/vehicles/vehicle.schemas.ts | 1 + .../src/modules/vehicles/vehicle.service.ts | 51 ++- apps/api/src/modules/vehicles/vehicle.test.ts | 59 ++++ .../src/app/(dashboard)/fleet/page.tsx | 33 +- .../src/app/(dashboard)/reservations/page.tsx | 7 +- .../src/app/(dashboard)/search/page.tsx | 153 ++++++++ .../dashboard/src/components/I18nProvider.tsx | 3 + .../src/components/layout/TopBar.tsx | 35 +- apps/dashboard/src/lib/api.test.ts | 36 +- apps/dashboard/src/lib/api.ts | 12 +- .../src/lib/dashboardRoutePolicies.ts | 6 + 23 files changed, 880 insertions(+), 51 deletions(-) create mode 100644 apps/api/src/modules/search/search.routes.ts create mode 100644 apps/api/src/modules/search/search.schemas.ts create mode 100644 apps/api/src/modules/search/search.service.test.ts create mode 100644 apps/api/src/modules/search/search.service.ts create mode 100644 apps/dashboard/src/app/(dashboard)/search/page.tsx diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7247ff8..9ad86cf 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -36,6 +36,7 @@ import siteRouter from './modules/site/site.routes' import reviewsRouter from './modules/reviews/review.routes' import complaintsRouter from './modules/complaints/complaint.routes' import licenseValidationRouter from './modules/licenses/license.validation.routes' +import searchRouter from './modules/search/search.routes' // ─── Centralized error handling ─────────────────────────────── import { errorMiddleware } from './http/errors/errorMiddleware' @@ -96,6 +97,7 @@ const routeDocs = [ { method: 'POST', path: `${v1}/customers`, description: 'Create customer' }, { method: 'GET', path: `${v1}/offers`, description: 'List offers' }, { method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' }, + { method: 'GET', path: `${v1}/search`, description: 'Global account search' }, { method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' }, { method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' }, { method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' }, @@ -224,6 +226,7 @@ export function createApp() { app.use(`${v1}/billing`, apiLimiter, billingRouter) app.use(`${v1}/reviews`, apiLimiter, reviewsRouter) app.use(`${v1}/complaints`, apiLimiter, complaintsRouter) + app.use(`${v1}/search`, apiLimiter, searchRouter) app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter) // ─── Health / Docs ────────────────────────────────────────── diff --git a/apps/api/src/middleware/rateLimiter.test.ts b/apps/api/src/middleware/rateLimiter.test.ts index a1efcc9..e918232 100644 --- a/apps/api/src/middleware/rateLimiter.test.ts +++ b/apps/api/src/middleware/rateLimiter.test.ts @@ -31,6 +31,8 @@ describe('rateLimiter middleware configuration', () => { expect(authLimiter.max).toBe(20) expect(authLimiter.windowMs).toBe(15 * 60 * 1000) + expect(authLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true) + expect(authLimiter.skip({ method: 'POST' } as any)).toBe(false) expect(authLimiter.skipSuccessfulRequests).toBe(true) expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 }) expect(rateLimit).toHaveBeenCalled() @@ -63,7 +65,18 @@ describe('rateLimiter middleware configuration', () => { expect(publicLimiter.max).toBe(60) expect(publicLimiter.message.message).toBe('Rate limit exceeded') + expect(publicLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true) expect(adminLimiter.max).toBe(100) expect(adminLimiter.message.message).toBe('Too many admin requests') + expect(adminLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true) + }) + + it('uses a higher API cap and skips preflight requests before authenticated actor limits', async () => { + const { apiLimiter, actorLimiter } = await import('./rateLimiter') + + expect(apiLimiter.max).toBe(300) + expect(apiLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true) + expect(apiLimiter.skip({ method: 'GET' } as any)).toBe(false) + expect(actorLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true) }) }) diff --git a/apps/api/src/middleware/rateLimiter.ts b/apps/api/src/middleware/rateLimiter.ts index 97d53ba..adc1077 100644 --- a/apps/api/src/middleware/rateLimiter.ts +++ b/apps/api/src/middleware/rateLimiter.ts @@ -52,6 +52,7 @@ function getAuthenticatedActorKey(req: Request): string | null { // req.ip is already the real client IP when app.set('trust proxy', 1) is configured const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '') +const skipPreflightRequest = (req: Request) => req.method === 'OPTIONS' // Strict limiter for auth endpoints — prevents brute-force and credential stuffing. // Successful requests (e.g. GET /me profile reads) are skipped so only failed @@ -61,6 +62,7 @@ export const authLimiter = rateLimit({ max: 20, standardHeaders: 'draft-7', legacyHeaders: false, + skip: skipPreflightRequest, skipSuccessfulRequests: true, keyGenerator: (req) => getClientIpKey(req), message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 }, @@ -69,9 +71,10 @@ export const authLimiter = rateLimit({ // Standard limiter for general authenticated API endpoints export const apiLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute - max: 120, + max: 300, standardHeaders: 'draft-7', legacyHeaders: false, + skip: skipPreflightRequest, keyGenerator: (req) => { const ip = getClientIpKey(req) const actorKey = getAuthenticatedActorKey(req) @@ -88,6 +91,7 @@ export const publicLimiter = rateLimit({ max: 60, standardHeaders: 'draft-7', legacyHeaders: false, + skip: skipPreflightRequest, keyGenerator: (req) => getClientIpKey(req), message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 }, }) @@ -99,6 +103,7 @@ export const webhookLimiter = rateLimit({ max: 30, standardHeaders: 'draft-7', legacyHeaders: false, + skip: skipPreflightRequest, keyGenerator: (req) => getClientIpKey(req), message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 }, }) @@ -109,6 +114,7 @@ export const adminLimiter = rateLimit({ max: 100, standardHeaders: 'draft-7', legacyHeaders: false, + skip: skipPreflightRequest, keyGenerator: (req) => { const ip = getClientIpKey(req) return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}` @@ -124,6 +130,7 @@ export const actorLimiter = rateLimit({ max: 240, standardHeaders: 'draft-7', legacyHeaders: false, + skip: skipPreflightRequest, keyGenerator: (req) => { const ip = getClientIpKey(req) const actorKey = getAuthenticatedActorKey(req) diff --git a/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts b/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts index 8e90e5b..3b348ac 100644 --- a/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts +++ b/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts @@ -31,9 +31,10 @@ describe('reservation schemas edge cases', () => { }) it('coerces list pagination and rejects unsafe update/extension payloads', () => { - expect(listQuerySchema.parse({ page: '3', pageSize: '50', status: 'CONFIRMED' })).toMatchObject({ page: 3, pageSize: 50 }) + expect(listQuerySchema.parse({ page: '3', pageSize: '50', status: 'CONFIRMED', search: ' c4 ' })).toMatchObject({ page: 3, pageSize: 50, search: 'c4' }) expect(listQuerySchema.parse({})).toMatchObject({ page: 1, pageSize: 20 }) expect(listQuerySchema.safeParse({ pageSize: 101 }).success).toBe(false) + expect(listQuerySchema.safeParse({ search: 'x'.repeat(101) }).success).toBe(false) expect(updateSchema.safeParse({ depositAmount: -1 }).success).toBe(false) expect(extendSchema.safeParse({ newEndDate: '2026-07-05T10:00:00.000Z', reason: '' }).success).toBe(false) expect(approvalSchema.parse({ approved: true })).toEqual({ approved: true }) diff --git a/apps/api/src/modules/reservations/reservation.schemas.ts b/apps/api/src/modules/reservations/reservation.schemas.ts index 37ee45e..c6530dc 100644 --- a/apps/api/src/modules/reservations/reservation.schemas.ts +++ b/apps/api/src/modules/reservations/reservation.schemas.ts @@ -63,6 +63,7 @@ export const listQuerySchema = z.object({ status: z.string().optional(), vehicleId: z.string().optional(), source: z.string().optional(), + search: z.string().trim().max(100).optional(), startDate: z.string().optional(), endDate: z.string().optional(), page: z.coerce.number().int().min(1).default(1), diff --git a/apps/api/src/modules/reservations/reservation.service.ts b/apps/api/src/modules/reservations/reservation.service.ts index 49d4698..e461fc8 100644 --- a/apps/api/src/modules/reservations/reservation.service.ts +++ b/apps/api/src/modules/reservations/reservation.service.ts @@ -7,8 +7,12 @@ import { applyAdditionalDriversToReservation } from './reservation.additional-dr import { parseReservationExtras, normalizeOptionalString, serializeReservationForDashboard, buildReservationWorkflow } from './reservation.presenter' import * as repo from './reservation.repo' +const RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED', 'NO_SHOW'] as const +const BOOKING_SOURCES = ['DASHBOARD', 'PUBLIC_SITE', 'CARPLACE', 'API'] as const + export async function listReservations(companyId: string, query: { status?: string; vehicleId?: string; source?: string + search?: string startDate?: string; endDate?: string; page?: number; pageSize?: number }) { const page = query.page ?? 1 @@ -19,6 +23,24 @@ export async function listReservations(companyId: string, query: { if (query.source) where.source = query.source if (query.startDate) where.startDate = { gte: new Date(query.startDate) } if (query.endDate) where.endDate = { lte: new Date(query.endDate) } + const search = query.search?.trim() + if (search) { + const normalizedEnumSearch = search.toUpperCase().replace(/[\s-]+/g, '_') + where.OR = [ + { contractNumber: { contains: search, mode: 'insensitive' } }, + { vehicle: { is: { make: { contains: search, mode: 'insensitive' } } } }, + { vehicle: { is: { model: { contains: search, mode: 'insensitive' } } } }, + { customer: { is: { firstName: { contains: search, mode: 'insensitive' } } } }, + { customer: { is: { lastName: { contains: search, mode: 'insensitive' } } } }, + { customer: { is: { email: { contains: search, mode: 'insensitive' } } } }, + ] + if ((RESERVATION_STATUSES as readonly string[]).includes(normalizedEnumSearch)) { + where.OR.push({ status: normalizedEnumSearch }) + } + if ((BOOKING_SOURCES as readonly string[]).includes(normalizedEnumSearch)) { + where.OR.push({ source: normalizedEnumSearch }) + } + } const [reservations, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize) return { diff --git a/apps/api/src/modules/reservations/reservation.test.ts b/apps/api/src/modules/reservations/reservation.test.ts index fa74cf9..742fff5 100644 --- a/apps/api/src/modules/reservations/reservation.test.ts +++ b/apps/api/src/modules/reservations/reservation.test.ts @@ -74,7 +74,7 @@ import * as additionalDriverService from './reservation.additional-driver.servic import { validateLicense } from '../../services/licenseValidationService' import { sendNotification } from '../../services/notificationService' import { buildReservationWorkflow } from './reservation.presenter' -import { createReservation } from './reservation.service' +import { createReservation, listReservations } from './reservation.service' import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service' import { approveAdditionalDriver } from './reservation.additional-driver.service' @@ -112,6 +112,47 @@ beforeEach(() => { vi.clearAllMocks() }) +// ──────────────────────────────────────────────────────────────────────────── +describe('listReservations', () => { + it('passes search across vehicle, customer, contract, status and source fields', async () => { + vi.mocked(repo.findMany).mockResolvedValue([[makeReservation({ vehicle: { make: 'Citroen', model: 'C4' } })], 1] as any) + + await expect(listReservations(COMPANY, { search: 'c4', pageSize: 100 })).resolves.toMatchObject({ + total: 1, + page: 1, + pageSize: 100, + }) + + expect(repo.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: COMPANY, + OR: expect.arrayContaining([ + { vehicle: { is: { model: { contains: 'c4', mode: 'insensitive' } } } }, + { vehicle: { is: { make: { contains: 'c4', mode: 'insensitive' } } } }, + { customer: { is: { email: { contains: 'c4', mode: 'insensitive' } } } }, + { contractNumber: { contains: 'c4', mode: 'insensitive' } }, + ]), + }), + 0, + 100, + ) + }) + + it('adds exact enum filters for status and source search terms', async () => { + vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any) + + await listReservations(COMPANY, { search: 'public site' }) + + expect(repo.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + OR: expect.arrayContaining([{ source: 'PUBLIC_SITE' }]), + }), + 0, + 20, + ) + }) +}) + // ──────────────────────────────────────────────────────────────────────────── describe('createReservation', () => { it('creates a reservation and returns it', async () => { diff --git a/apps/api/src/modules/search/search.routes.ts b/apps/api/src/modules/search/search.routes.ts new file mode 100644 index 0000000..1e0a577 --- /dev/null +++ b/apps/api/src/modules/search/search.routes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express' +import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' +import { requireTenant } from '../../middleware/requireTenant' +import { requireSubscriptionRead } from '../../middleware/requireSubscription' +import { parseQuery } from '../../http/validate' +import { ok } from '../../http/respond' +import { globalSearchQuerySchema } from './search.schemas' +import * as service from './search.service' + +const router = Router() + +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) + +router.get('/', async (req, res, next) => { + try { + const { search, limit } = parseQuery(globalSearchQuerySchema, req) + ok(res, await service.globalSearch(req.companyId, search, limit)) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/modules/search/search.schemas.ts b/apps/api/src/modules/search/search.schemas.ts new file mode 100644 index 0000000..88abf02 --- /dev/null +++ b/apps/api/src/modules/search/search.schemas.ts @@ -0,0 +1,6 @@ +import { z } from 'zod' + +export const globalSearchQuerySchema = z.object({ + search: z.string().trim().max(100).optional().default(''), + limit: z.coerce.number().int().min(1).max(10).optional().default(5), +}) diff --git a/apps/api/src/modules/search/search.service.test.ts b/apps/api/src/modules/search/search.service.test.ts new file mode 100644 index 0000000..75de812 --- /dev/null +++ b/apps/api/src/modules/search/search.service.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../lib/prisma', () => ({ + prisma: { + vehicle: { findMany: vi.fn() }, + customer: { findMany: vi.fn() }, + reservation: { findMany: vi.fn() }, + rentalPayment: { findMany: vi.fn() }, + billingInvoice: { findMany: vi.fn() }, + offer: { findMany: vi.fn() }, + employee: { findMany: vi.fn() }, + }, +})) + +import { prisma } from '../../lib/prisma' +import { globalSearch } from './search.service' + +describe('globalSearch', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prisma.vehicle.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.customer.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.reservation.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.rentalPayment.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.billingInvoice.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.offer.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.employee.findMany).mockResolvedValue([] as never) + }) + + it('searches tenant-scoped account records and returns normalized vehicle hits', async () => { + vi.mocked(prisma.vehicle.findMany).mockResolvedValue([ + { + id: 'vehicle_1', + make: 'Citroen', + model: 'C4', + licensePlate: '123-A-45', + status: 'AVAILABLE', + category: 'COMPACT', + year: 2024, + }, + ] as never) + + const result = await globalSearch('company_1', 'c4', 5) + + expect(prisma.vehicle.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + companyId: 'company_1', + OR: expect.arrayContaining([ + { model: { contains: 'c4', mode: 'insensitive' } }, + { licensePlate: { contains: 'c4', mode: 'insensitive' } }, + { features: { hasSome: ['c4', 'C4'] } }, + { pickupLocations: { hasSome: ['c4', 'C4'] } }, + { dropoffLocations: { hasSome: ['c4', 'C4'] } }, + ]), + }), + take: 5, + })) + expect(result.groups.vehicle).toEqual([ + expect.objectContaining({ + title: 'Citroen C4', + href: '/fleet/vehicle_1', + }), + ]) + expect(result.total).toBeGreaterThan(0) + }) + + it('returns page hits and exact enum matches', async () => { + await globalSearch('company_1', 'manual', 3) + + expect(prisma.vehicle.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([{ transmission: 'MANUAL' }]), + }), + })) + }) + + it('does not query data tables for empty search text', async () => { + const result = await globalSearch('company_1', ' ', 5) + + expect(result.total).toBe(0) + expect(prisma.vehicle.findMany).not.toHaveBeenCalled() + }) +}) diff --git a/apps/api/src/modules/search/search.service.ts b/apps/api/src/modules/search/search.service.ts new file mode 100644 index 0000000..a709826 --- /dev/null +++ b/apps/api/src/modules/search/search.service.ts @@ -0,0 +1,327 @@ +import { prisma } from '../../lib/prisma' + +export type GlobalSearchResultType = + | 'page' + | 'vehicle' + | 'customer' + | 'reservation' + | 'contract' + | 'invoice' + | 'payment' + | 'offer' + | 'team' + +export type GlobalSearchResult = { + id: string + type: GlobalSearchResultType + title: string + subtitle: string + href: string + meta?: string +} + +export type GlobalSearchResponse = { + query: string + results: GlobalSearchResult[] + groups: Record + total: number +} + +const RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED', 'NO_SHOW'] as const +const BOOKING_SOURCES = ['DASHBOARD', 'PUBLIC_SITE', 'CARPLACE', 'API'] as const +const VEHICLE_STATUSES = ['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE'] as const +const VEHICLE_CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const +const VEHICLE_TRANSMISSIONS = ['AUTOMATIC', 'MANUAL'] as const +const VEHICLE_FUEL_TYPES = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const + +const DASHBOARD_PAGES: Array & { keywords: string[] }> = [ + { id: 'page-dashboard', title: 'Dashboard', subtitle: 'KPIs, recent reservations, and analytics', href: '/', keywords: ['dashboard', 'overview', 'analytics', 'kpi', 'home'] }, + { id: 'page-reservations', title: 'Reservations', subtitle: 'Bookings, rental workflow, and reservation status', href: '/reservations', keywords: ['reservation', 'reservations', 'booking', 'bookings', 'rental'] }, + { id: 'page-fleet', title: 'Fleet', subtitle: 'Vehicles, status, pricing, and maintenance', href: '/fleet', keywords: ['fleet', 'vehicle', 'vehicles', 'car', 'cars', 'maintenance', 'pricing'] }, + { id: 'page-customers', title: 'Customers', subtitle: 'Customer records, license details, and flags', href: '/customers', keywords: ['customer', 'customers', 'license', 'driver', 'renter'] }, + { id: 'page-contracts', title: 'Contracts', subtitle: 'Rental agreements and contract documents', href: '/contracts', keywords: ['contract', 'contracts', 'agreement', 'agreements'] }, + { id: 'page-billing', title: 'Billing', subtitle: 'Invoices, payments, deposits, and balances', href: '/billing', keywords: ['billing', 'invoice', 'invoices', 'payment', 'payments', 'deposit', 'balance'] }, + { id: 'page-offers', title: 'Offers', subtitle: 'Promotions, promo codes, and public offers', href: '/offers', keywords: ['offer', 'offers', 'promo', 'promotion', 'discount', 'coupon'] }, + { id: 'page-team', title: 'Team', subtitle: 'Employees, roles, seats, and invitations', href: '/team', keywords: ['team', 'employee', 'employees', 'member', 'members', 'role', 'roles'] }, + { id: 'page-notifications', title: 'Notifications', subtitle: 'Inbox, alerts, and message preferences', href: '/notifications', keywords: ['notification', 'notifications', 'alert', 'alerts', 'inbox'] }, + { id: 'page-settings', title: 'Settings', subtitle: 'Company profile, contract, billing, insurance, and pricing settings', href: '/settings', keywords: ['settings', 'company', 'profile', 'brand', 'insurance', 'accounting'] }, +] + +function compact(parts: Array) { + return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== '').join(' · ') +} + +function normalizeEnumSearch(query: string) { + return query.toUpperCase().replace(/[\s-]+/g, '_') +} + +function textContains(query: string) { + return { contains: query, mode: 'insensitive' as const } +} + +function listTextVariants(value: string) { + const lower = value.toLowerCase() + const upper = value.toUpperCase() + const title = lower.replace(/\b\w/g, (char) => char.toUpperCase()) + return Array.from(new Set([value, lower, upper, title])) +} + +function pageResults(query: string, limit: number): GlobalSearchResult[] { + const normalized = query.toLowerCase() + return DASHBOARD_PAGES + .filter((page) => + page.title.toLowerCase().includes(normalized) || + page.subtitle.toLowerCase().includes(normalized) || + page.keywords.some((keyword) => keyword.includes(normalized)), + ) + .slice(0, limit) + .map(({ keywords: _keywords, ...page }) => ({ ...page, type: 'page' as const })) +} + +function emptyGroups(): Record { + return { + page: [], + vehicle: [], + customer: [], + reservation: [], + contract: [], + invoice: [], + payment: [], + offer: [], + team: [], + } +} + +export async function globalSearch(companyId: string, query: string, limit = 5): Promise { + const search = query.trim().slice(0, 100) + const groups = emptyGroups() + if (!search) return { query: search, results: [], groups, total: 0 } + + const enumSearch = normalizeEnumSearch(search) + const exactTextMatches = listTextVariants(search) + const numericSearch = Number(search) + const vehicleOr: any[] = [ + { make: textContains(search) }, + { model: textContains(search) }, + { licensePlate: textContains(search) }, + { vin: textContains(search) }, + { color: textContains(search) }, + { notes: textContains(search) }, + { features: { hasSome: exactTextMatches } }, + { pickupLocations: { hasSome: exactTextMatches } }, + { dropoffLocations: { hasSome: exactTextMatches } }, + ] + if ((VEHICLE_STATUSES as readonly string[]).includes(enumSearch)) vehicleOr.push({ status: enumSearch }) + if ((VEHICLE_CATEGORIES as readonly string[]).includes(enumSearch)) vehicleOr.push({ category: enumSearch }) + if ((VEHICLE_TRANSMISSIONS as readonly string[]).includes(enumSearch)) vehicleOr.push({ transmission: enumSearch }) + if ((VEHICLE_FUEL_TYPES as readonly string[]).includes(enumSearch)) vehicleOr.push({ fuelType: enumSearch }) + if (Number.isInteger(numericSearch)) { + vehicleOr.push( + { year: numericSearch }, + { seats: numericSearch }, + { mileage: numericSearch }, + { dailyRate: numericSearch }, + ) + } + + const reservationOr: any[] = [ + { contractNumber: textContains(search) }, + { invoiceNumber: textContains(search) }, + { bookingReference: textContains(search) }, + { carplaceRef: textContains(search) }, + { paymentStatus: textContains(search) }, + { promoCodeUsed: textContains(search) }, + { pickupLocation: textContains(search) }, + { returnLocation: textContains(search) }, + { notes: textContains(search) }, + { vehicle: { is: { make: textContains(search) } } }, + { vehicle: { is: { model: textContains(search) } } }, + { vehicle: { is: { licensePlate: textContains(search) } } }, + { customer: { is: { firstName: textContains(search) } } }, + { customer: { is: { lastName: textContains(search) } } }, + { customer: { is: { email: textContains(search) } } }, + { customer: { is: { phone: textContains(search) } } }, + ] + if ((RESERVATION_STATUSES as readonly string[]).includes(enumSearch)) reservationOr.push({ status: enumSearch }) + if ((BOOKING_SOURCES as readonly string[]).includes(enumSearch)) reservationOr.push({ source: enumSearch }) + + const [ + vehicles, + customers, + reservations, + payments, + billingInvoices, + offers, + employees, + ] = await Promise.all([ + prisma.vehicle.findMany({ + where: { companyId, OR: vehicleOr }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }), + prisma.customer.findMany({ + where: { + companyId, + OR: [ + { firstName: textContains(search) }, + { lastName: textContains(search) }, + { email: textContains(search) }, + { phone: textContains(search) }, + { driverLicense: textContains(search) }, + { licenseNumber: textContains(search) }, + { nationality: textContains(search) }, + { notes: textContains(search) }, + { flagReason: textContains(search) }, + ], + }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }), + prisma.reservation.findMany({ + where: { companyId, OR: reservationOr }, + include: { vehicle: true, customer: true }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }), + prisma.rentalPayment.findMany({ + where: { + companyId, + OR: [ + { reference: textContains(search) }, + { note: textContains(search) }, + { paymentMethod: textContains(search) }, + { amanpayTransactionId: textContains(search) }, + { paypalCaptureId: textContains(search) }, + { reservation: { is: { invoiceNumber: textContains(search) } } }, + { reservation: { is: { contractNumber: textContains(search) } } }, + { reservation: { is: { customer: { is: { email: textContains(search) } } } } }, + ], + }, + include: { reservation: { include: { customer: true, vehicle: true } } }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }), + prisma.billingInvoice.findMany({ + where: { + companyId, + OR: [ + { invoiceNumber: textContains(search) }, + { billingName: textContains(search) }, + { billingEmail: textContains(search) }, + { providerInvoiceId: textContains(search) }, + { adminReason: textContains(search) }, + ], + }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }), + prisma.offer.findMany({ + where: { + companyId, + OR: [ + { title: textContains(search) }, + { description: textContains(search) }, + { termsAndConds: textContains(search) }, + { promoCode: textContains(search) }, + ], + }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }), + prisma.employee.findMany({ + where: { + companyId, + OR: [ + { firstName: textContains(search) }, + { lastName: textContains(search) }, + { email: textContains(search) }, + { phone: textContains(search) }, + ], + }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }), + ]) + + groups.page = pageResults(search, limit) + groups.vehicle = vehicles.map((vehicle: any) => ({ + id: vehicle.id, + type: 'vehicle', + title: `${vehicle.make} ${vehicle.model}`, + subtitle: compact([vehicle.licensePlate, vehicle.status, vehicle.category]), + href: `/fleet/${vehicle.id}`, + meta: vehicle.year ? String(vehicle.year) : undefined, + })) + groups.customer = customers.map((customer: any) => ({ + id: customer.id, + type: 'customer', + title: `${customer.firstName} ${customer.lastName}`, + subtitle: compact([customer.email, customer.phone, customer.flagged ? 'Flagged' : null]), + href: `/customers?search=${encodeURIComponent(customer.email || `${customer.firstName} ${customer.lastName}`)}`, + meta: customer.licenseValidationStatus, + })) + groups.reservation = reservations.map((reservation: any) => ({ + id: reservation.id, + type: 'reservation', + title: reservation.bookingReference || `${reservation.customer.firstName} ${reservation.customer.lastName}`, + subtitle: compact([`${reservation.vehicle.make} ${reservation.vehicle.model}`, reservation.status, reservation.source]), + href: `/reservations/${reservation.id}`, + meta: compact([reservation.contractNumber, reservation.invoiceNumber]), + })) + groups.contract = reservations + .filter((reservation: any) => reservation.contractNumber) + .map((reservation: any) => ({ + id: `contract-${reservation.id}`, + type: 'contract', + title: reservation.contractNumber, + subtitle: compact([`${reservation.customer.firstName} ${reservation.customer.lastName}`, `${reservation.vehicle.make} ${reservation.vehicle.model}`]), + href: `/contracts/${reservation.id}`, + meta: reservation.status, + })) + groups.invoice = [ + ...reservations + .filter((reservation: any) => reservation.invoiceNumber) + .map((reservation: any) => ({ + id: `reservation-invoice-${reservation.id}`, + type: 'invoice' as const, + title: reservation.invoiceNumber, + subtitle: compact([`${reservation.customer.firstName} ${reservation.customer.lastName}`, `${reservation.vehicle.make} ${reservation.vehicle.model}`]), + href: `/billing?search=${encodeURIComponent(reservation.invoiceNumber)}`, + meta: reservation.paymentStatus, + })), + ...billingInvoices.map((invoice: any) => ({ + id: invoice.id, + type: 'invoice' as const, + title: invoice.invoiceNumber || invoice.id, + subtitle: compact([invoice.billingName, invoice.billingEmail, invoice.status]), + href: `/billing?search=${encodeURIComponent(invoice.invoiceNumber || invoice.billingEmail || invoice.id)}`, + meta: invoice.amountDue != null ? `${invoice.amountDue} ${invoice.currency}` : undefined, + })), + ].slice(0, limit) + groups.payment = payments.map((payment: any) => ({ + id: payment.id, + type: 'payment', + title: payment.reference || payment.amanpayTransactionId || payment.paypalCaptureId || payment.id, + subtitle: compact([payment.status, payment.type, payment.reservation?.customer?.email]), + href: `/billing?search=${encodeURIComponent(payment.reference || payment.reservation?.invoiceNumber || payment.id)}`, + meta: `${payment.amount} ${payment.currency}`, + })) + groups.offer = offers.map((offer: any) => ({ + id: offer.id, + type: 'offer', + title: offer.title, + subtitle: compact([offer.promoCode, offer.isActive ? 'Active' : 'Inactive', offer.isPublic ? 'Public' : 'Private']), + href: `/offers?search=${encodeURIComponent(offer.promoCode || offer.title)}`, + meta: offer.type, + })) + groups.team = employees.map((employee: any) => ({ + id: employee.id, + type: 'team', + title: `${employee.firstName} ${employee.lastName}`, + subtitle: compact([employee.email, employee.role, employee.isActive ? 'Active' : 'Inactive']), + href: `/team?search=${encodeURIComponent(employee.email)}`, + })) + + const results = Object.values(groups).flat() + return { query: search, results, groups, total: results.length } +} diff --git a/apps/api/src/modules/vehicles/vehicle.schemas.test.ts b/apps/api/src/modules/vehicles/vehicle.schemas.test.ts index 70a5e14..ee88c12 100644 --- a/apps/api/src/modules/vehicles/vehicle.schemas.test.ts +++ b/apps/api/src/modules/vehicles/vehicle.schemas.test.ts @@ -52,14 +52,16 @@ describe('vehicle.schemas', () => { }) it('coerces list and calendar query params while enforcing page and month bounds', () => { - expect(listQuerySchema.parse({ page: '3', pageSize: '50', status: 'AVAILABLE' })).toEqual({ + expect(listQuerySchema.parse({ page: '3', pageSize: '50', status: 'AVAILABLE', search: ' c4 ' })).toEqual({ page: 3, pageSize: 50, status: 'AVAILABLE', + search: 'c4', }) expect(calendarQuerySchema.parse({ year: '2026', month: '6' })).toEqual({ year: 2026, month: 6 }) expect(() => calendarQuerySchema.parse({ year: '2026', month: '13' })).toThrow() expect(() => listQuerySchema.parse({ page: '0' })).toThrow() + expect(() => listQuerySchema.parse({ search: 'x'.repeat(101) })).toThrow() }) it('accepts date-only and offset timestamps for calendar blocks', () => { diff --git a/apps/api/src/modules/vehicles/vehicle.schemas.ts b/apps/api/src/modules/vehicles/vehicle.schemas.ts index 8fdc50f..2c45d37 100644 --- a/apps/api/src/modules/vehicles/vehicle.schemas.ts +++ b/apps/api/src/modules/vehicles/vehicle.schemas.ts @@ -28,6 +28,7 @@ export const listQuerySchema = z.object({ status: z.string().optional(), category: z.string().optional(), published: z.string().optional(), + search: z.string().trim().max(100).optional(), page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(20), }) diff --git a/apps/api/src/modules/vehicles/vehicle.service.ts b/apps/api/src/modules/vehicles/vehicle.service.ts index 76c17c8..3d5dc6d 100644 --- a/apps/api/src/modules/vehicles/vehicle.service.ts +++ b/apps/api/src/modules/vehicles/vehicle.service.ts @@ -345,7 +345,19 @@ function presentVehiclePricing(vehicle: any, configuration: any) { } } -export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; page?: number; pageSize?: number }) { +const VEHICLE_STATUSES = ['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE'] as const +const VEHICLE_CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const +const VEHICLE_TRANSMISSIONS = ['AUTOMATIC', 'MANUAL'] as const +const VEHICLE_FUEL_TYPES = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const + +function listTextVariants(value: string) { + const lower = value.toLowerCase() + const upper = value.toUpperCase() + const title = lower.replace(/\b\w/g, (char) => char.toUpperCase()) + return Array.from(new Set([value, lower, upper, title])) +} + +export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; search?: string; page?: number; pageSize?: number }) { const page = query.page ?? 1 const pageSize = query.pageSize ?? 20 const { status, category, published } = query @@ -353,6 +365,43 @@ export async function listVehicles(companyId: string, query: { status?: string; if (status) where.status = status if (category) where.category = category if (published !== undefined) where.isPublished = published === 'true' + const search = query.search?.trim() + if (search) { + const normalizedEnumSearch = search.toUpperCase().replace(/[\s-]+/g, '_') + const exactTextMatches = listTextVariants(search) + const numericSearch = Number(search) + where.OR = [ + { make: { contains: search, mode: 'insensitive' } }, + { model: { contains: search, mode: 'insensitive' } }, + { licensePlate: { contains: search, mode: 'insensitive' } }, + { vin: { contains: search, mode: 'insensitive' } }, + { color: { contains: search, mode: 'insensitive' } }, + { notes: { contains: search, mode: 'insensitive' } }, + { features: { hasSome: exactTextMatches } }, + { pickupLocations: { hasSome: exactTextMatches } }, + { dropoffLocations: { hasSome: exactTextMatches } }, + ] + if ((VEHICLE_STATUSES as readonly string[]).includes(normalizedEnumSearch)) { + where.OR.push({ status: normalizedEnumSearch }) + } + if ((VEHICLE_CATEGORIES as readonly string[]).includes(normalizedEnumSearch)) { + where.OR.push({ category: normalizedEnumSearch }) + } + if ((VEHICLE_TRANSMISSIONS as readonly string[]).includes(normalizedEnumSearch)) { + where.OR.push({ transmission: normalizedEnumSearch }) + } + if ((VEHICLE_FUEL_TYPES as readonly string[]).includes(normalizedEnumSearch)) { + where.OR.push({ fuelType: normalizedEnumSearch }) + } + if (Number.isInteger(numericSearch)) { + where.OR.push( + { year: numericSearch }, + { seats: numericSearch }, + { mileage: numericSearch }, + { dailyRate: numericSearch }, + ) + } + } const [vehicles, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize) return presentVehicleList(vehicles.map(presentVehicle), { total, page, pageSize, totalPages: Math.ceil(total / pageSize) }) } diff --git a/apps/api/src/modules/vehicles/vehicle.test.ts b/apps/api/src/modules/vehicles/vehicle.test.ts index b236b88..a9b1623 100644 --- a/apps/api/src/modules/vehicles/vehicle.test.ts +++ b/apps/api/src/modules/vehicles/vehicle.test.ts @@ -28,6 +28,65 @@ beforeEach(() => { }) describe('vehicle.service', () => { + describe('listVehicles', () => { + it('passes fleet search across vehicle identity, feature, location, and numeric fields', async () => { + vi.mocked(repo.findMany).mockResolvedValue([[{ ...mockVehicle, make: 'Citroen', model: 'C4' }], 1] as any) + + const result = await service.listVehicles('comp_1', { search: 'c4', pageSize: 100 }) + + expect(repo.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: 'comp_1', + OR: expect.arrayContaining([ + { make: { contains: 'c4', mode: 'insensitive' } }, + { model: { contains: 'c4', mode: 'insensitive' } }, + { licensePlate: { contains: 'c4', mode: 'insensitive' } }, + { vin: { contains: 'c4', mode: 'insensitive' } }, + { features: { hasSome: ['c4', 'C4'] } }, + { pickupLocations: { hasSome: ['c4', 'C4'] } }, + { dropoffLocations: { hasSome: ['c4', 'C4'] } }, + ]), + }), + 0, + 100, + ) + expect(result.data[0]).toMatchObject({ make: 'Citroen', model: 'C4' }) + }) + + it('searches numeric vehicle fields when the query is an integer', async () => { + vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any) + + await service.listVehicles('comp_1', { search: '2024' }) + + expect(repo.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + OR: expect.arrayContaining([ + { year: 2024 }, + { seats: 2024 }, + { mileage: 2024 }, + { dailyRate: 2024 }, + ]), + }), + 0, + 20, + ) + }) + + it('adds exact enum matches for fleet search terms', async () => { + vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any) + + await service.listVehicles('comp_1', { search: 'electric' }) + + expect(repo.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + OR: expect.arrayContaining([{ fuelType: 'ELECTRIC' }]), + }), + 0, + 20, + ) + }) + }) + describe('createVehicle', () => { it('creates a vehicle with companyId', async () => { vi.mocked(repo.create).mockResolvedValue(mockVehicle as any) diff --git a/apps/dashboard/src/app/(dashboard)/fleet/page.tsx b/apps/dashboard/src/app/(dashboard)/fleet/page.tsx index 2c8509a..03aeae2 100644 --- a/apps/dashboard/src/app/(dashboard)/fleet/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/fleet/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Plus, Edit, Eye, Search, Upload, X, DollarSign } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' @@ -570,15 +570,29 @@ export default function FleetPage() { const [publishedFilter, setPublishedFilter] = useState('') const [maintenanceModal, setMaintenanceModal] = useState<{ vehicleId: string; vehicleName: string } | null>(null) - const fetchVehicles = () => { + const fetchVehicles = useCallback(() => { + const params = new URLSearchParams({ pageSize: '100' }) + const q = search.trim() + if (q) params.set('search', q) + if (statusFilter) params.set('status', statusFilter) + if (categoryFilter) params.set('category', categoryFilter) + if (publishedFilter === 'published') params.set('published', 'true') + if (publishedFilter === 'unpublished') params.set('published', 'false') + setLoading(true) - apiFetch('/vehicles?pageSize=100') + apiFetch(`/vehicles?${params.toString()}`) .then((result) => setVehicles(result ?? [])) .catch((err) => setError(err.message)) .finally(() => setLoading(false)) - } + }, [categoryFilter, publishedFilter, search, statusFilter]) - useEffect(() => { fetchVehicles() }, []) + useEffect(() => { + const timer = window.setTimeout(() => { + fetchVehicles() + }, search.trim() ? 250 : 0) + + return () => window.clearTimeout(timer) + }, [fetchVehicles, search]) useEffect(() => { const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY) @@ -644,14 +658,7 @@ export default function FleetPage() { } } - const filtered = vehicles.filter((v) => { - if (search && !`${v.make} ${v.model} ${v.licensePlate} ${v.vin ?? ''}`.toLowerCase().includes(search.toLowerCase())) return false - if (statusFilter && v.status !== statusFilter) return false - if (categoryFilter && v.category !== categoryFilter) return false - if (publishedFilter === 'published' && !v.isPublished) return false - if (publishedFilter === 'unpublished' && v.isPublished) return false - return true - }) + const filtered = vehicles return (
diff --git a/apps/dashboard/src/app/(dashboard)/reservations/page.tsx b/apps/dashboard/src/app/(dashboard)/reservations/page.tsx index f27eca4..bc1c296 100644 --- a/apps/dashboard/src/app/(dashboard)/reservations/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/reservations/page.tsx @@ -45,10 +45,13 @@ export default function ReservationsPage() { const [showNoVehiclesModal, setShowNoVehiclesModal] = useState(false) useEffect(() => { - apiFetch('/reservations?pageSize=100') + const params = new URLSearchParams({ pageSize: '100' }) + if (search) params.set('search', search) + + apiFetch(`/reservations?${params.toString()}`) .then((result) => setRows(result ?? [])) .catch((err) => setLoadError(err.message)) - }, []) + }, [search]) const formatDate = (iso: string) => new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' }) diff --git a/apps/dashboard/src/app/(dashboard)/search/page.tsx b/apps/dashboard/src/app/(dashboard)/search/page.tsx new file mode 100644 index 0000000..70b6737 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/search/page.tsx @@ -0,0 +1,153 @@ +'use client' + +import Link from 'next/link' +import { useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'next/navigation' +import { Search, Car, Users, CalendarClock, FileText, Receipt, CreditCard, BadgePercent, UserCog, LayoutDashboard } from 'lucide-react' +import { apiFetch } from '@/lib/api' + +type GlobalSearchResultType = + | 'page' + | 'vehicle' + | 'customer' + | 'reservation' + | 'contract' + | 'invoice' + | 'payment' + | 'offer' + | 'team' + +type GlobalSearchResult = { + id: string + type: GlobalSearchResultType + title: string + subtitle: string + href: string + meta?: string +} + +type GlobalSearchResponse = { + query: string + results: GlobalSearchResult[] + groups: Record + total: number +} + +const GROUPS: Array<{ key: GlobalSearchResultType; label: string; icon: typeof Search }> = [ + { key: 'page', label: 'Pages', icon: LayoutDashboard }, + { key: 'vehicle', label: 'Vehicles', icon: Car }, + { key: 'customer', label: 'Customers', icon: Users }, + { key: 'reservation', label: 'Reservations', icon: CalendarClock }, + { key: 'contract', label: 'Contracts', icon: FileText }, + { key: 'invoice', label: 'Invoices', icon: Receipt }, + { key: 'payment', label: 'Payments', icon: CreditCard }, + { key: 'offer', label: 'Offers', icon: BadgePercent }, + { key: 'team', label: 'Team', icon: UserCog }, +] + +function resultTypeLabel(type: GlobalSearchResultType) { + return GROUPS.find((group) => group.key === type)?.label ?? type +} + +export default function GlobalSearchPage() { + const searchParams = useSearchParams() + const query = (searchParams.get('search') ?? '').trim() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + setData(null) + setError(null) + + if (!query) { + setLoading(false) + return () => { cancelled = true } + } + + setLoading(true) + const params = new URLSearchParams({ search: query, limit: '6' }) + apiFetch(`/search?${params.toString()}`) + .then((result) => { + if (!cancelled) setData(result) + }) + .catch((err) => { + if (!cancelled) setError(err.message ?? 'Search failed') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { cancelled = true } + }, [query]) + + const populatedGroups = useMemo(() => { + if (!data) return [] + return GROUPS.map((group) => ({ ...group, items: data.groups[group.key] ?? [] })).filter((group) => group.items.length > 0) + }, [data]) + + return ( +
+
+
+

Search

+

+ {query ? `${data?.total ?? 0} result${data?.total === 1 ? '' : 's'} for "${query}"` : 'Search vehicles, reservations, customers, billing, offers, team, and pages.'} +

+
+
+ + {!query ? ( +
+ +

Type a search term in the top bar.

+
+ ) : loading ? ( +
+
+
+ ) : error ? ( +
{error}
+ ) : populatedGroups.length === 0 ? ( +
+

No results found.

+
+ ) : ( +
+ {populatedGroups.map((group) => { + const Icon = group.icon + return ( +
+
+ +

{group.label}

+
+
+ {group.items.map((item) => ( + +
+
+

{item.title}

+

{item.subtitle}

+
+ + {resultTypeLabel(item.type)} + +
+ {item.meta ?

{item.meta}

: null} + + ))} +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/apps/dashboard/src/components/I18nProvider.tsx b/apps/dashboard/src/components/I18nProvider.tsx index 6e38392..0b56aff 100644 --- a/apps/dashboard/src/components/I18nProvider.tsx +++ b/apps/dashboard/src/components/I18nProvider.tsx @@ -351,6 +351,7 @@ const dictionaries: Record = { '/subscription': 'Subscription', '/billing': 'Billing', '/contracts': 'Contracts', + '/search': 'Search', '/notifications': 'Notifications', '/settings': 'Settings', }, @@ -720,6 +721,7 @@ const dictionaries: Record = { '/subscription': 'Abonnement', '/billing': 'Facturation', '/contracts': 'Contrats', + '/search': 'Recherche', '/notifications': 'Notifications', '/settings': 'Paramètres', }, @@ -1089,6 +1091,7 @@ const dictionaries: Record = { '/subscription': 'الاشتراك', '/billing': 'الفوترة', '/contracts': 'العقود', + '/search': 'البحث', '/notifications': 'الإشعارات', '/settings': 'الإعدادات', }, diff --git a/apps/dashboard/src/components/layout/TopBar.tsx b/apps/dashboard/src/components/layout/TopBar.tsx index 92dfa39..2c49cb1 100644 --- a/apps/dashboard/src/components/layout/TopBar.tsx +++ b/apps/dashboard/src/components/layout/TopBar.tsx @@ -5,7 +5,7 @@ import { Bell, Search, Settings } from 'lucide-react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useState, useEffect } from 'react' import { io } from 'socket.io-client' -import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api' +import { EMPLOYEE_PROFILE_KEY, apiFetch, resolveApiOrigin } from '@/lib/api' import { useDashboardI18n } from '@/components/I18nProvider' import { toDashboardAppPath } from '@/lib/dashboardPaths' @@ -16,30 +16,6 @@ function computeInitials(name: string): string { return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase() } -function resolveSocketOrigin(): string | null { - if (typeof window === 'undefined') return null - - const configuredApiUrl = process.env.NEXT_PUBLIC_API_URL - if (configuredApiUrl) { - try { - return new URL(configuredApiUrl, window.location.origin).origin - } catch {} - } - - if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { - return 'http://localhost:4000' - } - - return window.location.origin -} - -const SEARCHABLE_ROUTES = ['/reservations', '/fleet', '/customers', '/contracts', '/billing'] - -function getSearchTarget(appPath: string): string { - const match = SEARCHABLE_ROUTES.find((route) => appPath === route || appPath.startsWith(`${route}/`)) - return match ?? '/reservations' -} - export default function TopBar() { const { dict } = useDashboardI18n() const pathname = usePathname() @@ -100,12 +76,14 @@ export default function TopBar() { useEffect(() => { if (!socketEnabled) return - const socketOrigin = resolveSocketOrigin() + const socketOrigin = resolveApiOrigin() if (!socketOrigin) return const socket = io(socketOrigin, { autoConnect: false, withCredentials: true, + reconnectionAttempts: 3, + timeout: 5000, transports: ['polling', 'websocket'], }) @@ -210,14 +188,13 @@ export default function TopBar() { event.preventDefault() const query = globalSearch.trim() - const target = getSearchTarget(appPath) const params = new URLSearchParams() if (query) { params.set('search', query) - router.push(`${target}?${params.toString()}`) + router.push(`/search?${params.toString()}`) } else { - router.push(target) + router.push('/search') } } diff --git a/apps/dashboard/src/lib/api.test.ts b/apps/dashboard/src/lib/api.test.ts index cdc247f..abc3082 100644 --- a/apps/dashboard/src/lib/api.test.ts +++ b/apps/dashboard/src/lib/api.test.ts @@ -6,6 +6,7 @@ function installBrowser(token?: string) { value: { location: { hostname: 'localhost', + origin: 'http://localhost', }, localStorage: { getItem: vi.fn(() => token ?? null), @@ -28,7 +29,7 @@ afterEach(() => { }) describe('dashboard apiFetch', () => { - it('adds JSON headers and sends cookies for browser requests', async () => { + it('sends cookies without forcing JSON headers for bodyless browser requests', async () => { installBrowser() const fetchMock = vi.fn(async () => ({ ok: true, @@ -39,6 +40,23 @@ describe('dashboard apiFetch', () => { const { apiFetch } = await import('./api') await expect(apiFetch('/team')).resolves.toEqual({ ok: true }) + expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/team$/), expect.objectContaining({ + credentials: 'include', + headers: expect.not.objectContaining({ 'Content-Type': expect.any(String) }), + })) + }) + + it('adds JSON headers for JSON payload requests', async () => { + installBrowser() + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: { ok: true } }), + })) + Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) + + const { apiFetch } = await import('./api') + await expect(apiFetch('/team', { method: 'POST', body: JSON.stringify({ name: 'Ops' }) })).resolves.toEqual({ ok: true }) + expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/team$/), expect.objectContaining({ credentials: 'include', headers: expect.objectContaining({ @@ -133,6 +151,22 @@ describe('dashboard apiFetch', () => { expect(calledUrl).not.toContain('/api/v1/api/v1') }) + it('resolves the API origin for realtime connections from relative and absolute API bases', async () => { + installBrowser() + setBrowserHostname('rentaldrivego.ma') + process.env.NEXT_PUBLIC_API_URL = '/dashboard/api/v1' + + let api = await import('./api') + expect(api.resolveApiOrigin()).toBe('http://localhost') + + vi.resetModules() + installBrowser() + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:4000/api/v1' + + api = await import('./api') + expect(api.resolveApiOrigin()).toBe('http://localhost:4000') + }) + it('does not force JSON content type for FormData payloads', async () => { installBrowser() const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) })) diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index baa1c79..9327bf4 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -34,6 +34,16 @@ export function resolveApiBase(): string { return normalizeApiBase(shouldUseDashboardProxy(configuredApiBase) ? DASHBOARD_PROXY_API_BASE : (configuredApiBase || DASHBOARD_PROXY_API_BASE)) } +export function resolveApiOrigin(): string | null { + if (typeof window === 'undefined') return null + + try { + return new URL(resolveApiBase(), window.location.origin).origin + } catch { + return window.location.origin + } +} + export const API_BASE = resolveApiBase() export const EMPLOYEE_PROFILE_KEY = 'employee_profile' @@ -45,7 +55,7 @@ export async function apiFetch(path: string, options?: RequestInit): Promise< ...(options?.headers as Record ?? {}), } - if (!isFormData) { + if (!isFormData && options?.body !== undefined) { headers['Content-Type'] = 'application/json' } diff --git a/apps/dashboard/src/lib/dashboardRoutePolicies.ts b/apps/dashboard/src/lib/dashboardRoutePolicies.ts index d57bbc4..6842a5f 100644 --- a/apps/dashboard/src/lib/dashboardRoutePolicies.ts +++ b/apps/dashboard/src/lib/dashboardRoutePolicies.ts @@ -35,6 +35,12 @@ export const dashboardRoutePolicies: Record = { '/subscription': OWNER_ONLY_BILLING_RECOVERY_POLICY, '/subscription/success': OWNER_ONLY_BILLING_RECOVERY_POLICY, '/subscription/cancel': OWNER_ONLY_BILLING_RECOVERY_POLICY, + '/search': { + authenticationRequired: true, + allowedRoles: null, + subscriptionRequired: true, + menuRegistrationRequired: false, + }, } export function resolveDashboardRoutePolicy(pathname: string): DashboardRoutePolicy {