fix search feature
Build & Push / Pipeline Tests (push) Failing after 1m14s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 50s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m4s
Build & Push / Pipeline Tests (push) Failing after 1m14s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 50s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m4s
This commit is contained in:
@@ -36,6 +36,7 @@ import siteRouter from './modules/site/site.routes'
|
|||||||
import reviewsRouter from './modules/reviews/review.routes'
|
import reviewsRouter from './modules/reviews/review.routes'
|
||||||
import complaintsRouter from './modules/complaints/complaint.routes'
|
import complaintsRouter from './modules/complaints/complaint.routes'
|
||||||
import licenseValidationRouter from './modules/licenses/license.validation.routes'
|
import licenseValidationRouter from './modules/licenses/license.validation.routes'
|
||||||
|
import searchRouter from './modules/search/search.routes'
|
||||||
|
|
||||||
// ─── Centralized error handling ───────────────────────────────
|
// ─── Centralized error handling ───────────────────────────────
|
||||||
import { errorMiddleware } from './http/errors/errorMiddleware'
|
import { errorMiddleware } from './http/errors/errorMiddleware'
|
||||||
@@ -96,6 +97,7 @@ const routeDocs = [
|
|||||||
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
|
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
|
||||||
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
|
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
|
||||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
{ 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}/analytics/report`, description: 'Analytics report' },
|
||||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||||
{ method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' },
|
{ 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}/billing`, apiLimiter, billingRouter)
|
||||||
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
||||||
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
||||||
|
app.use(`${v1}/search`, apiLimiter, searchRouter)
|
||||||
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
|
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
|
||||||
|
|
||||||
// ─── Health / Docs ──────────────────────────────────────────
|
// ─── Health / Docs ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ describe('rateLimiter middleware configuration', () => {
|
|||||||
|
|
||||||
expect(authLimiter.max).toBe(20)
|
expect(authLimiter.max).toBe(20)
|
||||||
expect(authLimiter.windowMs).toBe(15 * 60 * 1000)
|
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.skipSuccessfulRequests).toBe(true)
|
||||||
expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 })
|
expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 })
|
||||||
expect(rateLimit).toHaveBeenCalled()
|
expect(rateLimit).toHaveBeenCalled()
|
||||||
@@ -63,7 +65,18 @@ describe('rateLimiter middleware configuration', () => {
|
|||||||
|
|
||||||
expect(publicLimiter.max).toBe(60)
|
expect(publicLimiter.max).toBe(60)
|
||||||
expect(publicLimiter.message.message).toBe('Rate limit exceeded')
|
expect(publicLimiter.message.message).toBe('Rate limit exceeded')
|
||||||
|
expect(publicLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
|
||||||
expect(adminLimiter.max).toBe(100)
|
expect(adminLimiter.max).toBe(100)
|
||||||
expect(adminLimiter.message.message).toBe('Too many admin requests')
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
|
||||||
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
|
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.
|
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing.
|
||||||
// Successful requests (e.g. GET /me profile reads) are skipped so only failed
|
// Successful requests (e.g. GET /me profile reads) are skipped so only failed
|
||||||
@@ -61,6 +62,7 @@ export const authLimiter = rateLimit({
|
|||||||
max: 20,
|
max: 20,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: skipPreflightRequest,
|
||||||
skipSuccessfulRequests: true,
|
skipSuccessfulRequests: true,
|
||||||
keyGenerator: (req) => getClientIpKey(req),
|
keyGenerator: (req) => getClientIpKey(req),
|
||||||
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
|
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
|
// Standard limiter for general authenticated API endpoints
|
||||||
export const apiLimiter = rateLimit({
|
export const apiLimiter = rateLimit({
|
||||||
windowMs: 60 * 1000, // 1 minute
|
windowMs: 60 * 1000, // 1 minute
|
||||||
max: 120,
|
max: 300,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: skipPreflightRequest,
|
||||||
keyGenerator: (req) => {
|
keyGenerator: (req) => {
|
||||||
const ip = getClientIpKey(req)
|
const ip = getClientIpKey(req)
|
||||||
const actorKey = getAuthenticatedActorKey(req)
|
const actorKey = getAuthenticatedActorKey(req)
|
||||||
@@ -88,6 +91,7 @@ export const publicLimiter = rateLimit({
|
|||||||
max: 60,
|
max: 60,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: skipPreflightRequest,
|
||||||
keyGenerator: (req) => getClientIpKey(req),
|
keyGenerator: (req) => getClientIpKey(req),
|
||||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||||
})
|
})
|
||||||
@@ -99,6 +103,7 @@ export const webhookLimiter = rateLimit({
|
|||||||
max: 30,
|
max: 30,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: skipPreflightRequest,
|
||||||
keyGenerator: (req) => getClientIpKey(req),
|
keyGenerator: (req) => getClientIpKey(req),
|
||||||
message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 },
|
message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 },
|
||||||
})
|
})
|
||||||
@@ -109,6 +114,7 @@ export const adminLimiter = rateLimit({
|
|||||||
max: 100,
|
max: 100,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: skipPreflightRequest,
|
||||||
keyGenerator: (req) => {
|
keyGenerator: (req) => {
|
||||||
const ip = getClientIpKey(req)
|
const ip = getClientIpKey(req)
|
||||||
return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}`
|
return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}`
|
||||||
@@ -124,6 +130,7 @@ export const actorLimiter = rateLimit({
|
|||||||
max: 240,
|
max: 240,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: skipPreflightRequest,
|
||||||
keyGenerator: (req) => {
|
keyGenerator: (req) => {
|
||||||
const ip = getClientIpKey(req)
|
const ip = getClientIpKey(req)
|
||||||
const actorKey = getAuthenticatedActorKey(req)
|
const actorKey = getAuthenticatedActorKey(req)
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ describe('reservation schemas edge cases', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('coerces list pagination and rejects unsafe update/extension payloads', () => {
|
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.parse({})).toMatchObject({ page: 1, pageSize: 20 })
|
||||||
expect(listQuerySchema.safeParse({ pageSize: 101 }).success).toBe(false)
|
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(updateSchema.safeParse({ depositAmount: -1 }).success).toBe(false)
|
||||||
expect(extendSchema.safeParse({ newEndDate: '2026-07-05T10:00:00.000Z', reason: '' }).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 })
|
expect(approvalSchema.parse({ approved: true })).toEqual({ approved: true })
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const listQuerySchema = z.object({
|
|||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
vehicleId: z.string().optional(),
|
vehicleId: z.string().optional(),
|
||||||
source: z.string().optional(),
|
source: z.string().optional(),
|
||||||
|
search: z.string().trim().max(100).optional(),
|
||||||
startDate: z.string().optional(),
|
startDate: z.string().optional(),
|
||||||
endDate: z.string().optional(),
|
endDate: z.string().optional(),
|
||||||
page: z.coerce.number().int().min(1).default(1),
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import { applyAdditionalDriversToReservation } from './reservation.additional-dr
|
|||||||
import { parseReservationExtras, normalizeOptionalString, serializeReservationForDashboard, buildReservationWorkflow } from './reservation.presenter'
|
import { parseReservationExtras, normalizeOptionalString, serializeReservationForDashboard, buildReservationWorkflow } from './reservation.presenter'
|
||||||
import * as repo from './reservation.repo'
|
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: {
|
export async function listReservations(companyId: string, query: {
|
||||||
status?: string; vehicleId?: string; source?: string
|
status?: string; vehicleId?: string; source?: string
|
||||||
|
search?: string
|
||||||
startDate?: string; endDate?: string; page?: number; pageSize?: number
|
startDate?: string; endDate?: string; page?: number; pageSize?: number
|
||||||
}) {
|
}) {
|
||||||
const page = query.page ?? 1
|
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.source) where.source = query.source
|
||||||
if (query.startDate) where.startDate = { gte: new Date(query.startDate) }
|
if (query.startDate) where.startDate = { gte: new Date(query.startDate) }
|
||||||
if (query.endDate) where.endDate = { lte: new Date(query.endDate) }
|
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)
|
const [reservations, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ import * as additionalDriverService from './reservation.additional-driver.servic
|
|||||||
import { validateLicense } from '../../services/licenseValidationService'
|
import { validateLicense } from '../../services/licenseValidationService'
|
||||||
import { sendNotification } from '../../services/notificationService'
|
import { sendNotification } from '../../services/notificationService'
|
||||||
import { buildReservationWorkflow } from './reservation.presenter'
|
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 { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service'
|
||||||
import { approveAdditionalDriver } from './reservation.additional-driver.service'
|
import { approveAdditionalDriver } from './reservation.additional-driver.service'
|
||||||
|
|
||||||
@@ -112,6 +112,47 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks()
|
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', () => {
|
describe('createReservation', () => {
|
||||||
it('creates a reservation and returns it', async () => {
|
it('creates a reservation and returns it', async () => {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<GlobalSearchResultType, GlobalSearchResult[]>
|
||||||
|
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<Omit<GlobalSearchResult, 'type'> & { 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<string | number | null | undefined>) {
|
||||||
|
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<GlobalSearchResultType, GlobalSearchResult[]> {
|
||||||
|
return {
|
||||||
|
page: [],
|
||||||
|
vehicle: [],
|
||||||
|
customer: [],
|
||||||
|
reservation: [],
|
||||||
|
contract: [],
|
||||||
|
invoice: [],
|
||||||
|
payment: [],
|
||||||
|
offer: [],
|
||||||
|
team: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function globalSearch(companyId: string, query: string, limit = 5): Promise<GlobalSearchResponse> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -52,14 +52,16 @@ describe('vehicle.schemas', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('coerces list and calendar query params while enforcing page and month bounds', () => {
|
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,
|
page: 3,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
status: 'AVAILABLE',
|
status: 'AVAILABLE',
|
||||||
|
search: 'c4',
|
||||||
})
|
})
|
||||||
expect(calendarQuerySchema.parse({ year: '2026', month: '6' })).toEqual({ year: 2026, month: 6 })
|
expect(calendarQuerySchema.parse({ year: '2026', month: '6' })).toEqual({ year: 2026, month: 6 })
|
||||||
expect(() => calendarQuerySchema.parse({ year: '2026', month: '13' })).toThrow()
|
expect(() => calendarQuerySchema.parse({ year: '2026', month: '13' })).toThrow()
|
||||||
expect(() => listQuerySchema.parse({ page: '0' })).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', () => {
|
it('accepts date-only and offset timestamps for calendar blocks', () => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const listQuerySchema = z.object({
|
|||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
published: z.string().optional(),
|
published: z.string().optional(),
|
||||||
|
search: z.string().trim().max(100).optional(),
|
||||||
page: z.coerce.number().int().min(1).default(1),
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 page = query.page ?? 1
|
||||||
const pageSize = query.pageSize ?? 20
|
const pageSize = query.pageSize ?? 20
|
||||||
const { status, category, published } = query
|
const { status, category, published } = query
|
||||||
@@ -353,6 +365,43 @@ export async function listVehicles(companyId: string, query: { status?: string;
|
|||||||
if (status) where.status = status
|
if (status) where.status = status
|
||||||
if (category) where.category = category
|
if (category) where.category = category
|
||||||
if (published !== undefined) where.isPublished = published === 'true'
|
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)
|
const [vehicles, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize)
|
||||||
return presentVehicleList(vehicles.map(presentVehicle), { total, page, pageSize, totalPages: Math.ceil(total / pageSize) })
|
return presentVehicleList(vehicles.map(presentVehicle), { total, page, pageSize, totalPages: Math.ceil(total / pageSize) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,65 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('vehicle.service', () => {
|
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', () => {
|
describe('createVehicle', () => {
|
||||||
it('creates a vehicle with companyId', async () => {
|
it('creates a vehicle with companyId', async () => {
|
||||||
vi.mocked(repo.create).mockResolvedValue(mockVehicle as any)
|
vi.mocked(repo.create).mockResolvedValue(mockVehicle as any)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'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 { Plus, Edit, Eye, Search, Upload, X, DollarSign } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@@ -570,15 +570,29 @@ export default function FleetPage() {
|
|||||||
const [publishedFilter, setPublishedFilter] = useState('')
|
const [publishedFilter, setPublishedFilter] = useState('')
|
||||||
const [maintenanceModal, setMaintenanceModal] = useState<{ vehicleId: string; vehicleName: string } | null>(null)
|
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)
|
setLoading(true)
|
||||||
apiFetch<Vehicle[]>('/vehicles?pageSize=100')
|
apiFetch<Vehicle[]>(`/vehicles?${params.toString()}`)
|
||||||
.then((result) => setVehicles(result ?? []))
|
.then((result) => setVehicles(result ?? []))
|
||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
.finally(() => setLoading(false))
|
.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(() => {
|
useEffect(() => {
|
||||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||||
@@ -644,14 +658,7 @@ export default function FleetPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = vehicles.filter((v) => {
|
const filtered = vehicles
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -45,10 +45,13 @@ export default function ReservationsPage() {
|
|||||||
const [showNoVehiclesModal, setShowNoVehiclesModal] = useState(false)
|
const [showNoVehiclesModal, setShowNoVehiclesModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
|
const params = new URLSearchParams({ pageSize: '100' })
|
||||||
|
if (search) params.set('search', search)
|
||||||
|
|
||||||
|
apiFetch<ReservationRow[]>(`/reservations?${params.toString()}`)
|
||||||
.then((result) => setRows(result ?? []))
|
.then((result) => setRows(result ?? []))
|
||||||
.catch((err) => setLoadError(err.message))
|
.catch((err) => setLoadError(err.message))
|
||||||
}, [])
|
}, [search])
|
||||||
|
|
||||||
const formatDate = (iso: string) =>
|
const formatDate = (iso: string) =>
|
||||||
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
|
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
|
||||||
|
|||||||
@@ -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<GlobalSearchResultType, GlobalSearchResult[]>
|
||||||
|
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<GlobalSearchResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(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<GlobalSearchResponse>(`/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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-50">Search</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{query ? `${data?.total ?? 0} result${data?.total === 1 ? '' : 's'} for "${query}"` : 'Search vehicles, reservations, customers, billing, offers, team, and pages.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!query ? (
|
||||||
|
<div className="card p-8 text-center">
|
||||||
|
<Search className="mx-auto h-8 w-8 text-slate-400" />
|
||||||
|
<p className="mt-3 text-sm text-slate-500 dark:text-slate-400">Type a search term in the top bar.</p>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="card p-8 text-center">
|
||||||
|
<div className="mx-auto h-8 w-8 rounded-full border-4 border-blue-600 border-t-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="card p-5 text-sm text-red-600">{error}</div>
|
||||||
|
) : populatedGroups.length === 0 ? (
|
||||||
|
<div className="card p-8 text-center">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">No results found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{populatedGroups.map((group) => {
|
||||||
|
const Icon = group.icon
|
||||||
|
return (
|
||||||
|
<section key={group.key} className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="h-4 w-4 text-blue-700 dark:text-blue-300" />
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">{group.label}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={`${item.type}-${item.id}`}
|
||||||
|
href={item.href}
|
||||||
|
className="card block p-4 transition hover:border-blue-300 hover:shadow-md dark:hover:border-blue-400/40"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">{item.title}</p>
|
||||||
|
<p className="mt-1 line-clamp-2 text-sm text-slate-500 dark:text-slate-400">{item.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-500/10 dark:text-blue-200">
|
||||||
|
{resultTypeLabel(item.type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.meta ? <p className="mt-3 text-xs text-slate-400 dark:text-slate-500">{item.meta}</p> : null}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -351,6 +351,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
'/subscription': 'Subscription',
|
'/subscription': 'Subscription',
|
||||||
'/billing': 'Billing',
|
'/billing': 'Billing',
|
||||||
'/contracts': 'Contracts',
|
'/contracts': 'Contracts',
|
||||||
|
'/search': 'Search',
|
||||||
'/notifications': 'Notifications',
|
'/notifications': 'Notifications',
|
||||||
'/settings': 'Settings',
|
'/settings': 'Settings',
|
||||||
},
|
},
|
||||||
@@ -720,6 +721,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
'/subscription': 'Abonnement',
|
'/subscription': 'Abonnement',
|
||||||
'/billing': 'Facturation',
|
'/billing': 'Facturation',
|
||||||
'/contracts': 'Contrats',
|
'/contracts': 'Contrats',
|
||||||
|
'/search': 'Recherche',
|
||||||
'/notifications': 'Notifications',
|
'/notifications': 'Notifications',
|
||||||
'/settings': 'Paramètres',
|
'/settings': 'Paramètres',
|
||||||
},
|
},
|
||||||
@@ -1089,6 +1091,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
'/subscription': 'الاشتراك',
|
'/subscription': 'الاشتراك',
|
||||||
'/billing': 'الفوترة',
|
'/billing': 'الفوترة',
|
||||||
'/contracts': 'العقود',
|
'/contracts': 'العقود',
|
||||||
|
'/search': 'البحث',
|
||||||
'/notifications': 'الإشعارات',
|
'/notifications': 'الإشعارات',
|
||||||
'/settings': 'الإعدادات',
|
'/settings': 'الإعدادات',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Bell, Search, Settings } from 'lucide-react'
|
|||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { io } from 'socket.io-client'
|
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 { useDashboardI18n } from '@/components/I18nProvider'
|
||||||
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
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()
|
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() {
|
export default function TopBar() {
|
||||||
const { dict } = useDashboardI18n()
|
const { dict } = useDashboardI18n()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -100,12 +76,14 @@ export default function TopBar() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socketEnabled) return
|
if (!socketEnabled) return
|
||||||
|
|
||||||
const socketOrigin = resolveSocketOrigin()
|
const socketOrigin = resolveApiOrigin()
|
||||||
if (!socketOrigin) return
|
if (!socketOrigin) return
|
||||||
|
|
||||||
const socket = io(socketOrigin, {
|
const socket = io(socketOrigin, {
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
reconnectionAttempts: 3,
|
||||||
|
timeout: 5000,
|
||||||
transports: ['polling', 'websocket'],
|
transports: ['polling', 'websocket'],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -210,14 +188,13 @@ export default function TopBar() {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const query = globalSearch.trim()
|
const query = globalSearch.trim()
|
||||||
const target = getSearchTarget(appPath)
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
params.set('search', query)
|
params.set('search', query)
|
||||||
router.push(`${target}?${params.toString()}`)
|
router.push(`/search?${params.toString()}`)
|
||||||
} else {
|
} else {
|
||||||
router.push(target)
|
router.push('/search')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ function installBrowser(token?: string) {
|
|||||||
value: {
|
value: {
|
||||||
location: {
|
location: {
|
||||||
hostname: 'localhost',
|
hostname: 'localhost',
|
||||||
|
origin: 'http://localhost',
|
||||||
},
|
},
|
||||||
localStorage: {
|
localStorage: {
|
||||||
getItem: vi.fn(() => token ?? null),
|
getItem: vi.fn(() => token ?? null),
|
||||||
@@ -28,7 +29,7 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('dashboard apiFetch', () => {
|
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()
|
installBrowser()
|
||||||
const fetchMock = vi.fn(async () => ({
|
const fetchMock = vi.fn(async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -39,6 +40,23 @@ describe('dashboard apiFetch', () => {
|
|||||||
const { apiFetch } = await import('./api')
|
const { apiFetch } = await import('./api')
|
||||||
await expect(apiFetch('/team')).resolves.toEqual({ ok: true })
|
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({
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/team$/), expect.objectContaining({
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
@@ -133,6 +151,22 @@ describe('dashboard apiFetch', () => {
|
|||||||
expect(calledUrl).not.toContain('/api/v1/api/v1')
|
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 () => {
|
it('does not force JSON content type for FormData payloads', async () => {
|
||||||
installBrowser()
|
installBrowser()
|
||||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) }))
|
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) }))
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ export function resolveApiBase(): string {
|
|||||||
return normalizeApiBase(shouldUseDashboardProxy(configuredApiBase) ? DASHBOARD_PROXY_API_BASE : (configuredApiBase || DASHBOARD_PROXY_API_BASE))
|
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 API_BASE = resolveApiBase()
|
||||||
|
|
||||||
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
|
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
|
||||||
@@ -45,7 +55,7 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
|
|||||||
...(options?.headers as Record<string, string> ?? {}),
|
...(options?.headers as Record<string, string> ?? {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isFormData) {
|
if (!isFormData && options?.body !== undefined) {
|
||||||
headers['Content-Type'] = 'application/json'
|
headers['Content-Type'] = 'application/json'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export const dashboardRoutePolicies: Record<string, DashboardRoutePolicy> = {
|
|||||||
'/subscription': OWNER_ONLY_BILLING_RECOVERY_POLICY,
|
'/subscription': OWNER_ONLY_BILLING_RECOVERY_POLICY,
|
||||||
'/subscription/success': OWNER_ONLY_BILLING_RECOVERY_POLICY,
|
'/subscription/success': OWNER_ONLY_BILLING_RECOVERY_POLICY,
|
||||||
'/subscription/cancel': 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 {
|
export function resolveDashboardRoutePolicy(pathname: string): DashboardRoutePolicy {
|
||||||
|
|||||||
Reference in New Issue
Block a user