Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddaccff28c | |||
| 44f59423ec | |||
| af5b7fda7e | |||
| b52c6305b5 | |||
| b5a224dc04 | |||
| 6b97c895e0 | |||
| 37a6ed8a76 | |||
| 6eeba99c45 | |||
| 919f583677 | |||
| f9b793a05f |
@@ -0,0 +1,135 @@
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
|
||||
concurrency:
|
||||
group: build-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
# Gitea built-in container registry (HTTPS on port 443)
|
||||
REGISTRY_HOST: 192.168.3.80
|
||||
DOCKERFILE_PATH: Dockerfile.production
|
||||
# VPS deployment target
|
||||
DEPLOY_ROOT: /opt/rentaldrivego
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image_repository: ${{ steps.image-meta.outputs.repository }}
|
||||
docker_image: ${{ steps.image-meta.outputs.full }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker image metadata
|
||||
id: image-meta
|
||||
run: |
|
||||
# Gitea container registry path: host/owner/repo
|
||||
REPO="${{ gitea.repository }}"
|
||||
TAG="${{ gitea.sha }}"
|
||||
echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
|
||||
echo "full=${REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "latest=${REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITEA_TOKEN }}" | \
|
||||
docker login "$REGISTRY_HOST" \
|
||||
-u "${{ gitea.actor }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE_PATH }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.image-meta.outputs.full }}
|
||||
${{ steps.image-meta.outputs.latest }}
|
||||
build-args: |
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
||||
ADMIN_INTERNAL_URL=http://admin:3002
|
||||
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=${{ secrets.NEXT_PUBLIC_MARKETPLACE_URL }}
|
||||
NEXT_PUBLIC_DASHBOARD_URL=${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
|
||||
NEXT_PUBLIC_ADMIN_URL=${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
||||
|
||||
deploy:
|
||||
name: Deploy to VPS
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-image]
|
||||
env:
|
||||
DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
|
||||
IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check deploy credentials
|
||||
id: check-creds
|
||||
run: |
|
||||
if [ -z "${{ secrets.VPS_SSH_KEY }}" ] || \
|
||||
[ -z "${{ secrets.VPS_IP }}" ] || \
|
||||
[ -z "${{ secrets.VPS_USER }}" ]; then
|
||||
echo "VPS secrets not fully configured — skipping deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install SSH client
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client
|
||||
|
||||
- name: Set up SSH key
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
run: |
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H "${{ secrets.VPS_IP }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Sync deployment assets
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
run: |
|
||||
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" \
|
||||
"mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' '$DEPLOY_ROOT/dynamic'"
|
||||
scp docker-compose.production.yml \
|
||||
docker-compose.portainer.production.yml \
|
||||
docker-compose.registry.production.yml \
|
||||
docker-compose.registry.local.yml \
|
||||
traefik.yaml \
|
||||
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/"
|
||||
scp scripts/docker-prod-common.sh \
|
||||
scripts/docker-prod-deploy.sh \
|
||||
scripts/docker-prod-up-registry.sh \
|
||||
scripts/docker-registry-local-up.sh \
|
||||
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/scripts/"
|
||||
scp docker/pgmanage/override.py \
|
||||
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/docker/pgmanage/"
|
||||
|
||||
- name: Run deploy script on VPS
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
run: |
|
||||
REGISTRY_PASSWORD_B64="$(printf '%s' "${{ secrets.GITEA_TOKEN }}" | base64 | tr -d '\n')"
|
||||
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
|
||||
set -e
|
||||
cd '$DEPLOY_ROOT'
|
||||
APP_IMAGE='$IMAGE_REPOSITORY' \
|
||||
APP_VERSION='${{ gitea.sha }}' \
|
||||
REGISTRY_HOST='$REGISTRY_HOST' \
|
||||
REGISTRY_USER='${{ gitea.actor }}' \
|
||||
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
|
||||
bash scripts/docker-prod-deploy.sh
|
||||
"
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
pull_request:
|
||||
branches: [develop]
|
||||
|
||||
concurrency:
|
||||
group: test-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
|
||||
jobs:
|
||||
api-tests:
|
||||
name: API Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run db:generate
|
||||
- run: npm run type-check
|
||||
- run: npm run test:api
|
||||
|
||||
marketplace-tests:
|
||||
name: Marketplace Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run build --workspace @rentaldrivego/types
|
||||
- run: npm run test:marketplace
|
||||
|
||||
admin-tests:
|
||||
name: Admin Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run test:admin
|
||||
|
||||
dashboard-tests:
|
||||
name: Dashboard Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run build --workspace @rentaldrivego/types
|
||||
- run: npm run test:dashboard
|
||||
|
||||
integration-tests:
|
||||
name: API Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: rentaldrivego_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:password@postgres:5432/rentaldrivego_test
|
||||
REDIS_URL: redis://redis:6379
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: test-secret
|
||||
JWT_EXPIRY: 8h
|
||||
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run db:generate
|
||||
- run: npm run db:deploy
|
||||
- run: npx prisma db push --schema packages/database/prisma/schema.prisma
|
||||
- run: npm run test:api:integration
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"save-serve.language": "en"
|
||||
}
|
||||
@@ -8,6 +8,7 @@ RUN corepack enable
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
COPY config ./config
|
||||
|
||||
RUN npm ci --no-fund --no-audit
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
COPY config ./config
|
||||
|
||||
# These URLs are read by Next.js config during `next build`, so the production
|
||||
# image must bake Docker-network service URLs instead of localhost defaults.
|
||||
|
||||
@@ -1,52 +1,16 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https: wss:",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join('; '),
|
||||
},
|
||||
]
|
||||
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
|
||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||
const apiUrl = new URL(apiOrigin)
|
||||
const ADMIN_BASE_PATH = '/admin'
|
||||
|
||||
function ensureAdminAssetPrefix(rawValue) {
|
||||
if (!rawValue) return undefined
|
||||
|
||||
try {
|
||||
const url = new URL(rawValue)
|
||||
const pathname = url.pathname.replace(/\/$/, '')
|
||||
if (!pathname.endsWith(ADMIN_BASE_PATH)) {
|
||||
url.pathname = `${pathname}${ADMIN_BASE_PATH}`
|
||||
}
|
||||
return url.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
const trimmed = rawValue.replace(/\/$/, '')
|
||||
return trimmed.endsWith(ADMIN_BASE_PATH) ? trimmed : `${trimmed}${ADMIN_BASE_PATH}`
|
||||
}
|
||||
}
|
||||
|
||||
// In Docker dev the admin app runs on port 3002 while the marketplace proxy
|
||||
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
|
||||
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
|
||||
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
|
||||
const assetPrefix = ensureAdminAssetPrefix(process.env.ADMIN_ASSET_PREFIX)
|
||||
const assetPrefix = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH)
|
||||
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] })
|
||||
|
||||
const nextConfig = {
|
||||
basePath: ADMIN_BASE_PATH,
|
||||
@@ -80,6 +44,10 @@ const nextConfig = {
|
||||
source: '/api/:path*',
|
||||
destination: `${apiOrigin}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/storage/:path*',
|
||||
destination: `${apiOrigin}/storage/:path*`,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canAccessAdminMetrics } from './AdminSessionContext'
|
||||
|
||||
describe('canAccessAdminMetrics', () => {
|
||||
it('allows finance and higher roles after admin 2FA enrollment', () => {
|
||||
expect(canAccessAdminMetrics({ role: 'FINANCE', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'SUPPORT', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'SUPER_ADMIN', totpEnabled: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('denies viewer role and admins who still need 2FA enrollment', () => {
|
||||
expect(canAccessAdminMetrics({ role: 'VIEWER', totpEnabled: true })).toBe(false)
|
||||
expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: false })).toBe(false)
|
||||
expect(canAccessAdminMetrics(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWER'
|
||||
|
||||
export type AdminSessionUser = {
|
||||
id: string
|
||||
email: string
|
||||
role: AdminRole
|
||||
totpEnabled?: boolean
|
||||
}
|
||||
|
||||
const METRICS_ALLOWED_ROLES = new Set<AdminRole>(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE'])
|
||||
|
||||
const AdminSessionContext = createContext<AdminSessionUser | null>(null)
|
||||
|
||||
export function AdminSessionProvider({
|
||||
admin,
|
||||
children,
|
||||
}: {
|
||||
admin: AdminSessionUser
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <AdminSessionContext.Provider value={admin}>{children}</AdminSessionContext.Provider>
|
||||
}
|
||||
|
||||
export function useAdminSession() {
|
||||
return useContext(AdminSessionContext)
|
||||
}
|
||||
|
||||
export function canAccessAdminMetrics(admin: Pick<AdminSessionUser, 'role' | 'totpEnabled'> | null | undefined) {
|
||||
return Boolean(admin && admin.totpEnabled && METRICS_ALLOWED_ROLES.has(admin.role))
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
|
||||
|
||||
function buildUnifiedLoginUrl(nextPath: string) {
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
@@ -36,16 +38,24 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
const { dict } = useAdminI18n()
|
||||
const pathname = usePathname()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL ?? '/admin/api/v1'}/admin/auth/me`, {
|
||||
fetch(`${ADMIN_API_BASE}/admin/auth/me`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
if (cancelled) return
|
||||
if (response.ok) {
|
||||
const json = await response.json().catch(() => null)
|
||||
const resolvedAdmin = (json?.data ?? json) as AdminSessionUser | null
|
||||
if (!resolvedAdmin?.id || !resolvedAdmin?.email || !resolvedAdmin?.role) {
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
return
|
||||
}
|
||||
setAdmin(resolvedAdmin)
|
||||
setReady(true)
|
||||
} else {
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
@@ -74,48 +84,50 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
|
||||
<aside className="flex w-60 flex-shrink-0 flex-col border-r border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/78">
|
||||
<Link href="/" className="block px-5 py-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">RentalDriveGo</p>
|
||||
</Link>
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{navLinks.map((link) => {
|
||||
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||
active ? 'bg-orange-600 text-white dark:bg-orange-500 dark:text-white' : 'text-stone-500 hover:text-blue-900 hover:bg-stone-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
|
||||
</svg>
|
||||
{dict.nav[link.key]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="px-3 py-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-stone-500 transition-colors hover:bg-stone-100 hover:text-red-500 dark:text-stone-400 dark:hover:bg-[#162038] dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{dict.logout}
|
||||
</button>
|
||||
<div className="mt-4 flex items-center gap-2 border-t border-stone-200/80 pt-4 dark:border-blue-900">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
<AdminSessionProvider admin={admin as AdminSessionUser}>
|
||||
<div className="flex h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
|
||||
<aside className="flex w-60 flex-shrink-0 flex-col border-r border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/78">
|
||||
<Link href="/" className="block px-5 py-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">RentalDriveGo</p>
|
||||
</Link>
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{navLinks.map((link) => {
|
||||
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||
active ? 'bg-orange-600 text-white dark:bg-orange-500 dark:text-white' : 'text-stone-500 hover:text-blue-900 hover:bg-stone-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
|
||||
</svg>
|
||||
{dict.nav[link.key]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="px-3 py-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-stone-500 transition-colors hover:bg-stone-100 hover:text-red-500 dark:text-stone-400 dark:hover:bg-[#162038] dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{dict.logout}
|
||||
</button>
|
||||
<div className="mt-4 flex items-center gap-2 border-t border-stone-200/80 pt-4 dark:border-blue-900">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto transition-colors">{children}</main>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto transition-colors">{children}</main>
|
||||
</div>
|
||||
</AdminSessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
import { canAccessAdminMetrics, useAdminSession } from './AdminSessionContext'
|
||||
|
||||
interface Metrics {
|
||||
totalCompanies: number
|
||||
@@ -15,6 +16,7 @@ interface Metrics {
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const admin = useAdminSession()
|
||||
const copy = {
|
||||
en: {
|
||||
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
|
||||
@@ -48,15 +50,27 @@ export default function AdminDashboardPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!canAccessAdminMetrics(admin)) {
|
||||
setMetrics(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/metrics`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setMetrics(json.data))
|
||||
.then(async (response) => {
|
||||
const json = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
setMetrics(null)
|
||||
return
|
||||
}
|
||||
setMetrics((json?.data ?? json) as Metrics)
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
}, [admin])
|
||||
|
||||
const kpis = metrics
|
||||
? [
|
||||
|
||||
@@ -31,6 +31,7 @@ import marketplaceRouter from './modules/marketplace/marketplace.routes'
|
||||
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'
|
||||
|
||||
// ─── Centralized error handling ───────────────────────────────
|
||||
import { errorMiddleware } from './http/errors/errorMiddleware'
|
||||
@@ -170,6 +171,7 @@ export function createApp() {
|
||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
||||
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
||||
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
|
||||
|
||||
// ─── Health / Docs ──────────────────────────────────────────
|
||||
app.get(v1, (_req, res) => {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sanitizeAndFormat, toTitleCase, toTitleCaseAll, toLowerCase, toUpperCase, preserveOriginal, getMaxLength } from './inputValidation'
|
||||
|
||||
describe('toTitleCase', () => {
|
||||
it('uppercases first letter and lowercases rest of each word', () => {
|
||||
expect(toTitleCase('john doe')).toBe('John Doe')
|
||||
expect(toTitleCase('MARIA')).toBe('Maria')
|
||||
expect(toTitleCase('new york')).toBe('New York')
|
||||
})
|
||||
|
||||
it('preserves numbers and special chars that survive sanitization', () => {
|
||||
expect(toTitleCase('123 main st.')).toBe('123 Main St.')
|
||||
})
|
||||
|
||||
it('handles single word', () => {
|
||||
expect(toTitleCase('hello')).toBe('Hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toTitleCaseAll', () => {
|
||||
it('uppercases first letter of every word', () => {
|
||||
expect(toTitleCaseAll('acme corporation ltd.')).toBe('Acme Corporation Ltd.')
|
||||
expect(toTitleCaseAll('123 main street, apt 4b')).toBe('123 Main Street, Apt 4b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toLowerCase', () => {
|
||||
it('lowercases all characters', () => {
|
||||
expect(toLowerCase('John.Doe@Example.COM')).toBe('john.doe@example.com')
|
||||
expect(toLowerCase('ALLCAPS')).toBe('allcaps')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toUpperCase', () => {
|
||||
it('uppercases letters, leaves numbers unchanged', () => {
|
||||
expect(toUpperCase('abc-123')).toBe('ABC-123')
|
||||
expect(toUpperCase('ab123cd')).toBe('AB123CD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserveOriginal', () => {
|
||||
it('returns the string unchanged', () => {
|
||||
expect(preserveOriginal('SW1A 1AA')).toBe('SW1A 1AA')
|
||||
expect(preserveOriginal('12345')).toBe('12345')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeAndFormat', () => {
|
||||
// ─── Title Case fields
|
||||
|
||||
it('formats name fields (title case, max 50)', () => {
|
||||
expect(sanitizeAndFormat('john doe', 'name')).toBe('John Doe')
|
||||
expect(sanitizeAndFormat('MARIA', 'name')).toBe('Maria')
|
||||
})
|
||||
|
||||
it('formats streetAddress (title case, max 100)', () => {
|
||||
expect(sanitizeAndFormat('123 main st.', 'streetAddress')).toBe('123 Main St')
|
||||
expect(sanitizeAndFormat('elm street 42', 'streetAddress')).toBe('Elm Street 42')
|
||||
})
|
||||
|
||||
it('formats city (title case, max 50)', () => {
|
||||
expect(sanitizeAndFormat('new york', 'city')).toBe('New York')
|
||||
})
|
||||
|
||||
it('formats pickupLocation and returnLocation', () => {
|
||||
expect(sanitizeAndFormat('airport terminal 1', 'pickupLocation')).toBe('Airport Terminal 1')
|
||||
expect(sanitizeAndFormat('downtown garage', 'returnLocation')).toBe('Downtown Garage')
|
||||
})
|
||||
|
||||
it('formats country (title case)', () => {
|
||||
expect(sanitizeAndFormat('united kingdom', 'country')).toBe('United Kingdom')
|
||||
})
|
||||
|
||||
// ─── Title Case All fields
|
||||
|
||||
it('formats fullAddress (title case all, max 200)', () => {
|
||||
expect(sanitizeAndFormat('123 main street, apt 4b', 'fullAddress')).toBe('123 Main Street, Apt 4b')
|
||||
})
|
||||
|
||||
it('formats commercialName (title case all, max 100)', () => {
|
||||
expect(sanitizeAndFormat('acme corporation', 'commercialName')).toBe('Acme Corporation')
|
||||
})
|
||||
|
||||
it('formats legalCompanyName (title case all, max 100)', () => {
|
||||
expect(sanitizeAndFormat('global rentals llc', 'legalCompanyName')).toBe('Global Rentals Llc')
|
||||
})
|
||||
|
||||
// ─── Email
|
||||
|
||||
it('formats email (lowercase)', () => {
|
||||
expect(sanitizeAndFormat('John.Doe@Example.COM', 'email')).toBe('john.doe@example.com')
|
||||
})
|
||||
|
||||
// ─── Uppercase fields
|
||||
|
||||
it('formats licensePlate (uppercase, preserves hyphens)', () => {
|
||||
expect(sanitizeAndFormat('abc-123', 'licensePlate')).toBe('ABC-123')
|
||||
})
|
||||
|
||||
it('formats VIN (uppercase)', () => {
|
||||
expect(sanitizeAndFormat('1hgbh41jxmn109186', 'vin')).toBe('1HGBH41JXMN109186')
|
||||
})
|
||||
|
||||
it('formats passportNumber (uppercase)', () => {
|
||||
expect(sanitizeAndFormat('ab123456', 'passportNumber')).toBe('AB123456')
|
||||
})
|
||||
|
||||
it('formats driverLicenseNumber (uppercase, preserves hyphens)', () => {
|
||||
expect(sanitizeAndFormat('d123-456-789', 'driverLicenseNumber')).toBe('D123-456-789')
|
||||
})
|
||||
|
||||
it('formats licenseCategory (uppercase)', () => {
|
||||
expect(sanitizeAndFormat('b', 'licenseCategory')).toBe('B')
|
||||
})
|
||||
|
||||
// ─── Car fields
|
||||
|
||||
it('formats carMark (title case)', () => {
|
||||
expect(sanitizeAndFormat('TOYOTA', 'carMark')).toBe('Toyota')
|
||||
expect(sanitizeAndFormat('mercedes-benz', 'carMark')).toBe('Mercedes-Benz')
|
||||
})
|
||||
|
||||
it('formats carModel (title case)', () => {
|
||||
expect(sanitizeAndFormat('corolla', 'carModel')).toBe('Corolla')
|
||||
})
|
||||
|
||||
it('formats carColor (title case)', () => {
|
||||
expect(sanitizeAndFormat('midnight-blue', 'carColor')).toBe('Midnight-Blue')
|
||||
})
|
||||
|
||||
// ─── ZIP Code (preserved)
|
||||
|
||||
it('preserves ZIP code format', () => {
|
||||
expect(sanitizeAndFormat('12345', 'zipCode')).toBe('12345')
|
||||
expect(sanitizeAndFormat('SW1A 1AA', 'zipCode')).toBe('SW1A 1AA')
|
||||
})
|
||||
|
||||
// ─── Sanitization
|
||||
|
||||
it('removes disallowed characters', () => {
|
||||
expect(sanitizeAndFormat('John!@#', 'name')).toBe('John')
|
||||
})
|
||||
|
||||
it('strips disallowed characters including angle brackets', () => {
|
||||
const result = sanitizeAndFormat('test<script>alert(1)</script>', 'name')
|
||||
expect(result).toBe('Testscriptalertscript')
|
||||
})
|
||||
|
||||
// ─── Truncation
|
||||
|
||||
it('truncates to max length', () => {
|
||||
const longName = 'A'.repeat(60)
|
||||
expect(sanitizeAndFormat(longName, 'name')).toHaveLength(50)
|
||||
})
|
||||
|
||||
it('does not truncate strings within limit', () => {
|
||||
expect(sanitizeAndFormat('John', 'name')).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxLength', () => {
|
||||
it('returns correct max lengths', () => {
|
||||
expect(getMaxLength('name')).toBe(50)
|
||||
expect(getMaxLength('streetAddress')).toBe(100)
|
||||
expect(getMaxLength('fullAddress')).toBe(200)
|
||||
expect(getMaxLength('email')).toBe(100)
|
||||
expect(getMaxLength('licensePlate')).toBe(30)
|
||||
expect(getMaxLength('zipCode')).toBe(10)
|
||||
expect(getMaxLength('carMark')).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* User Input Validation and Formatting Library
|
||||
*
|
||||
* Implements the rules defined in docs/input_validation_plan.md.
|
||||
* Provides formatting functions and a Zod-integrated pipeline that:
|
||||
* 1. Removes disallowed characters
|
||||
* 2. Trims leading/trailing whitespace
|
||||
* 3. Validates format (email)
|
||||
* 4. Applies case transformation
|
||||
* 5. Truncates to max length
|
||||
*/
|
||||
|
||||
// ─── Character class definitions ───────────────────────────────
|
||||
|
||||
const LETTERS_NUMBERS = 'a-zA-Z0-9'
|
||||
const LETTERS_NUMBERS_HYPHEN = `${LETTERS_NUMBERS}\\-`
|
||||
const LETTERS_SPACES_HYPHEN = 'a-zA-Z\\s\\-'
|
||||
const BASE_TEXT = `${LETTERS_NUMBERS}\\s\\-\\/`
|
||||
const EXTENDED_TEXT = `${BASE_TEXT},`
|
||||
|
||||
// ─── Field type definitions ────────────────────────────────────
|
||||
|
||||
export type FieldType =
|
||||
| 'name' | 'nationality' | 'streetAddress' | 'city'
|
||||
| 'pickupLocation' | 'returnLocation' | 'country'
|
||||
| 'fullAddress' | 'commercialName' | 'legalCompanyName'
|
||||
| 'email'
|
||||
| 'licensePlate' | 'vin' | 'cin' | 'passportNumber'
|
||||
| 'internationalPermitNumber' | 'driverLicenseNumber'
|
||||
| 'licenseCategory' | 'iceNumber' | 'commercialRegistry'
|
||||
| 'carMark' | 'carModel' | 'carColor'
|
||||
| 'zipCode'
|
||||
|
||||
interface FieldConfig {
|
||||
maxLength: number
|
||||
allowedPattern: RegExp
|
||||
transform: (value: string) => string
|
||||
}
|
||||
|
||||
// ─── Formatting functions ──────────────────────────────────────
|
||||
|
||||
/** Uppercase first letter of each word, lowercase the rest */
|
||||
export function toTitleCase(str: string): string {
|
||||
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
}
|
||||
|
||||
/** Uppercase first letter of every word */
|
||||
export function toTitleCaseAll(str: string): string {
|
||||
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
}
|
||||
|
||||
export function toLowerCase(str: string): string {
|
||||
return str.toLowerCase()
|
||||
}
|
||||
|
||||
/** Uppercase letters only, leave numbers unchanged */
|
||||
export function toUpperCase(str: string): string {
|
||||
return str.replace(/[a-zA-Z]/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
export function preserveOriginal(str: string): string {
|
||||
return str
|
||||
}
|
||||
|
||||
// ─── Field configuration ───────────────────────────────────────
|
||||
|
||||
const FIELD_CONFIGS: Record<FieldType, FieldConfig> = {
|
||||
// Group 1: Title Case
|
||||
name: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
nationality: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
streetAddress: { maxLength: 100, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
||||
city: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
pickupLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
||||
returnLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
||||
country: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
|
||||
// Group 2: Title Case All
|
||||
fullAddress: { maxLength: 200, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
||||
commercialName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
||||
legalCompanyName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
||||
|
||||
// Group 3: Lowercase — email
|
||||
email: { maxLength: 100, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
|
||||
|
||||
// Group 4: Uppercase (hyphens allowed per plan examples: "abc-123" → "ABC-123")
|
||||
licensePlate: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
vin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
cin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
passportNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
internationalPermitNumber:{ maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
driverLicenseNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
licenseCategory: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
iceNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
commercialRegistry: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
|
||||
// Group 5: Car fields
|
||||
carMark: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
|
||||
carModel: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
|
||||
carColor: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
|
||||
|
||||
// Group 6: Preserved (spaces for UK postcodes like "SW1A 1AA")
|
||||
zipCode: { maxLength: 10, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: preserveOriginal },
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────
|
||||
|
||||
/** Full processing pipeline: sanitize → trim → format → truncate */
|
||||
export function sanitizeAndFormat(value: string, fieldType: FieldType): string {
|
||||
const config = FIELD_CONFIGS[fieldType]
|
||||
|
||||
// Step 1: Remove disallowed characters
|
||||
let sanitized = ''
|
||||
for (const char of value) {
|
||||
if (config.allowedPattern.test(char)) {
|
||||
sanitized += char
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Trim
|
||||
sanitized = sanitized.trim()
|
||||
|
||||
// Step 3: Apply case transformation
|
||||
const formatted = config.transform(sanitized)
|
||||
|
||||
// Step 4: Truncate
|
||||
if (formatted.length > config.maxLength) {
|
||||
return formatted.slice(0, config.maxLength)
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
/** Get the max length for a given field type */
|
||||
export function getMaxLength(fieldType: FieldType): number {
|
||||
return FIELD_CONFIGS[fieldType].maxLength
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { z } from 'zod'
|
||||
import { sanitizeAndFormat, getMaxLength } from './inputValidation'
|
||||
import type { FieldType } from './inputValidation'
|
||||
|
||||
// ─── Regex constants ───────────────────────────────────────────
|
||||
|
||||
const GLOBAL_ALLOWED = /^[a-zA-Z0-9@\-/\s]*$/
|
||||
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/
|
||||
|
||||
/** Morocco phone: (+212|00212|0) + 5/6/7 + 8 digits (optional spaces) */
|
||||
const MA_PHONE_REGEX = /^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$/
|
||||
|
||||
// ─── Phone sanitization ───────────────────────────────────────
|
||||
|
||||
function sanitizePhone(raw: string): string {
|
||||
let out = ''
|
||||
for (const ch of raw) {
|
||||
if (/[\d\s+]/.test(ch)) out += ch
|
||||
}
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
// ─── Required fields ───────────────────────────────────────────
|
||||
|
||||
function applyFieldRules(fieldType: FieldType) {
|
||||
const maxLength = getMaxLength(fieldType)
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'This field is required' })
|
||||
.trim()
|
||||
.transform((val: string) => sanitizeAndFormat(val, fieldType))
|
||||
.pipe(
|
||||
z.string().refine(
|
||||
(val: string) => val.length <= maxLength,
|
||||
{ message: 'Maximum ' + maxLength + ' characters allowed' }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Optional fields ───────────────────────────────────────────
|
||||
|
||||
function applyOptionalFieldRules(fieldType: FieldType) {
|
||||
const maxLength = getMaxLength(fieldType)
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
return sanitizeAndFormat(val, fieldType)
|
||||
})
|
||||
.pipe(
|
||||
z.string().optional().refine(
|
||||
(val) => val === undefined || val.length <= maxLength,
|
||||
{ message: 'Maximum ' + maxLength + ' characters allowed' }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Exported helpers ──────────────────────────────────────────
|
||||
|
||||
export function textField(fieldType: FieldType) {
|
||||
return applyFieldRules(fieldType)
|
||||
}
|
||||
export function optionalTextField(fieldType: FieldType) {
|
||||
return applyOptionalFieldRules(fieldType)
|
||||
}
|
||||
export function titleCaseAllField(fieldType: FieldType) {
|
||||
return applyFieldRules(fieldType)
|
||||
}
|
||||
export function optionalTitleCaseAllField(fieldType: FieldType) {
|
||||
return applyOptionalFieldRules(fieldType)
|
||||
}
|
||||
export function upperField(fieldType: FieldType) {
|
||||
return applyFieldRules(fieldType)
|
||||
}
|
||||
export function optionalUpperField(fieldType: FieldType) {
|
||||
return applyOptionalFieldRules(fieldType)
|
||||
}
|
||||
export function carTextField(fieldType: FieldType) {
|
||||
return applyFieldRules(fieldType)
|
||||
}
|
||||
export function optionalCarTextField(fieldType: FieldType) {
|
||||
return applyOptionalFieldRules(fieldType)
|
||||
}
|
||||
export function zipField() {
|
||||
return applyFieldRules('zipCode')
|
||||
}
|
||||
export function optionalZipField() {
|
||||
return applyOptionalFieldRules('zipCode')
|
||||
}
|
||||
|
||||
// ─── Email fields ──────────────────────────────────────────────
|
||||
|
||||
export function emailField() {
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'Email is required' })
|
||||
.trim()
|
||||
.transform((val: string) => sanitizeAndFormat(val, 'email'))
|
||||
.pipe(
|
||||
z.string().refine(
|
||||
(val: string) => EMAIL_REGEX.test(val),
|
||||
{ message: 'Please enter a valid email address' }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function optionalEmailField() {
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
return sanitizeAndFormat(val, 'email')
|
||||
})
|
||||
.pipe(
|
||||
z.string().optional().refine(
|
||||
(val) => val === undefined || EMAIL_REGEX.test(val),
|
||||
{ message: 'Please enter a valid email address' }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Phone fields ──────────────────────────────────────────────
|
||||
|
||||
export function phoneField() {
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'Phone number is required' })
|
||||
.trim()
|
||||
.transform((val: string) => sanitizePhone(val))
|
||||
.pipe(
|
||||
z.string().refine(
|
||||
(val: string) => MA_PHONE_REGEX.test(val),
|
||||
{ message: 'Please enter a valid Morocco phone number' }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function optionalPhoneField() {
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
return sanitizePhone(val)
|
||||
})
|
||||
.pipe(
|
||||
z.string().optional().refine(
|
||||
(val) => val === undefined || MA_PHONE_REGEX.test(val),
|
||||
{ message: 'Please enter a valid Morocco phone number' }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import { optionalTextField, optionalTitleCaseAllField, optionalUpperField, optionalEmailField, optionalPhoneField } from '../../lib/zodValidation'
|
||||
|
||||
export const companySchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
name: optionalTextField('commercialName'),
|
||||
email: optionalEmailField(),
|
||||
phone: optionalPhoneField(),
|
||||
address: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
@@ -12,18 +13,18 @@ export const brandSchema = z.object({
|
||||
tagline: z.string().optional(),
|
||||
primaryColor: z.string().optional(),
|
||||
accentColor: z.string().optional(),
|
||||
publicEmail: z.string().email().optional(),
|
||||
publicPhone: z.string().optional(),
|
||||
publicEmail: optionalEmailField(),
|
||||
publicPhone: optionalPhoneField(),
|
||||
publicAddress: z.string().optional(),
|
||||
publicCity: z.string().optional(),
|
||||
publicCountry: z.string().optional(),
|
||||
publicCity: optionalTextField('city'),
|
||||
publicCountry: optionalTextField('country'),
|
||||
websiteUrl: z.string().url().optional(),
|
||||
whatsappNumber: z.string().optional(),
|
||||
defaultLocale: z.string().optional(),
|
||||
defaultCurrency: z.literal('MAD').optional(),
|
||||
amanpayMerchantId: z.string().optional(),
|
||||
amanpaySecretKey: z.string().optional(),
|
||||
paypalEmail: z.string().email().optional(),
|
||||
paypalEmail: optionalEmailField(),
|
||||
paypalMerchantId: z.string().optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: z.object({
|
||||
@@ -97,8 +98,8 @@ export const brandSchema = z.object({
|
||||
})
|
||||
|
||||
export const contractSettingsSchema = z.object({
|
||||
legalName: z.string().optional(),
|
||||
registrationNumber: z.string().optional(),
|
||||
legalName: optionalTitleCaseAllField('legalCompanyName'),
|
||||
registrationNumber: optionalUpperField('commercialRegistry'),
|
||||
taxId: z.string().optional(),
|
||||
terms: z.string().optional(),
|
||||
fuelPolicy: z.string().optional(),
|
||||
@@ -147,8 +148,8 @@ export const accountingSettingsSchema = z.object({
|
||||
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
|
||||
fiscalYearStart: z.number().int().min(1).max(12).optional(),
|
||||
currency: z.literal('MAD').optional(),
|
||||
accountantEmail: z.string().email().optional(),
|
||||
accountantName: z.string().optional(),
|
||||
accountantEmail: optionalEmailField(),
|
||||
accountantName: optionalTextField('name'),
|
||||
autoSendReport: z.boolean().optional(),
|
||||
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { textField, optionalTextField, emailField, optionalUpperField, optionalPhoneField } from '../../lib/zodValidation'
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
@@ -6,20 +7,20 @@ export const paginationSchema = z.object({
|
||||
})
|
||||
|
||||
export const customerSchema = z.object({
|
||||
firstName: z.string().min(1).max(100).trim(),
|
||||
lastName: z.string().min(1).max(100).trim(),
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
phone: z.string().max(30).trim().optional(),
|
||||
driverLicense: z.string().max(50).trim().optional(),
|
||||
firstName: textField('name'),
|
||||
lastName: textField('name'),
|
||||
email: emailField(),
|
||||
phone: optionalPhoneField(),
|
||||
driverLicense: optionalUpperField('driverLicenseNumber'),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().max(100).trim().optional(),
|
||||
nationality: optionalTextField('nationality'),
|
||||
address: z.record(z.unknown()).optional(),
|
||||
notes: z.string().max(2000).trim().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
licenseCountry: z.string().max(100).trim().optional(),
|
||||
licenseNumber: z.string().max(50).trim().optional(),
|
||||
licenseCategory: z.string().max(20).trim().optional(),
|
||||
licenseCountry: optionalTextField('country'),
|
||||
licenseNumber: optionalUpperField('driverLicenseNumber'),
|
||||
licenseCategory: optionalUpperField('licenseCategory'),
|
||||
})
|
||||
|
||||
export const listQuerySchema = paginationSchema.extend({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Router } from 'express'
|
||||
import { parseBody } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { validateLicenseSchema } from './license.validation.schemas'
|
||||
import { parseExpirationDate, validateLicenseDate, ErrorCode } from '../../services/licenseValidationService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/**
|
||||
* POST /api/v1/licenses/validate
|
||||
*
|
||||
* Validates a driver's license expiration date against the minimum validity rule.
|
||||
*
|
||||
* Request body:
|
||||
* { expiration_date: string, license_number?: string, state?: string }
|
||||
*
|
||||
* Success (200): license is valid (≥30 days remaining)
|
||||
* Error (400): invalid format, missing date, future date
|
||||
* Error (422): expired or expiring soon (<30 days remaining)
|
||||
*/
|
||||
router.post('/validate', async (req, res, next) => {
|
||||
try {
|
||||
const { expiration_date } = parseBody(validateLicenseSchema, req)
|
||||
const parsedDate = parseExpirationDate(expiration_date)
|
||||
|
||||
if (!parsedDate) {
|
||||
throw new AppError(
|
||||
`Unable to parse date "${expiration_date}". Please use YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY, or DD-MMM-YYYY format.`,
|
||||
400,
|
||||
ErrorCode.LICENSE_INVALID_FORMAT,
|
||||
{
|
||||
details: {
|
||||
expiration_date,
|
||||
suggested_action: 'Please provide the date in a supported format (e.g., YYYY-MM-DD).',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const result = validateLicenseDate(parsedDate)
|
||||
|
||||
if (!result.valid) {
|
||||
// Map error codes to HTTP status
|
||||
const statusCode =
|
||||
result.error_code === ErrorCode.LICENSE_EXPIRED || result.error_code === ErrorCode.LICENSE_EXPIRING_SOON
|
||||
? 422
|
||||
: 400
|
||||
|
||||
throw new AppError(result.message, statusCode, result.error_code!, {
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
ok(res, {
|
||||
valid: true,
|
||||
days_remaining: result.days_remaining,
|
||||
expiration_date: result.expiration_date,
|
||||
message: result.message,
|
||||
next_reminder: result.days_remaining
|
||||
? new Date(Date.now() + (result.days_remaining - 30) * 86400000).toISOString().split('T')[0] ?? null
|
||||
: null,
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Request body for POST /api/v1/licenses/validate */
|
||||
export const validateLicenseSchema = z.object({
|
||||
expiration_date: z.string().min(1, 'expiration_date is required'),
|
||||
license_number: z.string().max(100).optional(),
|
||||
state: z.string().length(2).optional(),
|
||||
})
|
||||
|
||||
export type ValidateLicenseInput = z.infer<typeof validateLicenseSchema>
|
||||
@@ -48,6 +48,17 @@ export async function findVehicleForMarketplace(vehicleId: string, companySlug:
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleForMarketplaceById(vehicleId: string) {
|
||||
return prisma.vehicle.findFirst({
|
||||
where: {
|
||||
id: vehicleId,
|
||||
isPublished: true,
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
|
||||
},
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
email: string
|
||||
firstName: string
|
||||
@@ -107,13 +118,45 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
export async function createMarketplaceReservation(data: {
|
||||
companyId: string; vehicleId: string; customerId: string
|
||||
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
|
||||
dailyRate: number; totalDays: number; totalAmount: number; notes?: string
|
||||
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
|
||||
}) {
|
||||
return prisma.reservation.create({
|
||||
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createMarketplaceFunnelEvent(data: {
|
||||
eventName: string
|
||||
companySlug: string
|
||||
vehicleId: string
|
||||
renterId?: string | null
|
||||
sessionId?: string
|
||||
path?: string
|
||||
metadata?: Record<string, string | number | boolean | null>
|
||||
}) {
|
||||
const vehicle = await prisma.vehicle.findFirst({
|
||||
where: {
|
||||
id: data.vehicleId,
|
||||
isPublished: true,
|
||||
company: { slug: data.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
},
|
||||
select: { companyId: true },
|
||||
})
|
||||
|
||||
return prisma.marketplaceFunnelEvent.create({
|
||||
data: {
|
||||
eventName: data.eventName,
|
||||
companyId: vehicle?.companyId ?? null,
|
||||
companySlug: data.companySlug,
|
||||
vehicleId: data.vehicleId,
|
||||
renterId: data.renterId ?? null,
|
||||
sessionId: data.sessionId,
|
||||
path: data.path,
|
||||
metadata: (data.metadata ?? {}) as any,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyPage(slug: string) {
|
||||
return prisma.company.findFirst({
|
||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as service from './marketplace.service'
|
||||
import {
|
||||
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema,
|
||||
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
|
||||
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
|
||||
} from './marketplace.schemas'
|
||||
|
||||
const router = Router()
|
||||
@@ -65,6 +66,18 @@ router.post('/reservations', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/events', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(marketplaceFunnelEventSchema, req)
|
||||
ok(res, await service.trackMarketplaceFunnelEvent(body, req.renterId))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Analytics events are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
@@ -137,6 +150,19 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(vehicleParamSchema, req)
|
||||
const { startDate, endDate } = parseQuery(vehicleAvailabilityQuerySchema, req)
|
||||
ok(res, await service.getMarketplaceVehicleAvailability(slug, id, { startDate, endDate }))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
|
||||
@@ -24,9 +24,14 @@ export const companiesQuerySchema = z.object({
|
||||
hasOffer: z.string().optional(),
|
||||
})
|
||||
|
||||
export const vehicleAvailabilityQuerySchema = z.object({
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
})
|
||||
|
||||
export const marketplaceReservationSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
companySlug: z.string().trim().max(100),
|
||||
companySlug: z.string().trim().max(100).optional(),
|
||||
firstName: z.string().min(1).max(100),
|
||||
lastName: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
@@ -51,6 +56,23 @@ export const marketplaceReservationSchema = z.object({
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
})
|
||||
|
||||
export const marketplaceFunnelEventSchema = z.object({
|
||||
eventName: z.enum([
|
||||
'booking_form_viewed',
|
||||
'trip_dates_selected',
|
||||
'contact_details_started',
|
||||
'driver_details_expanded',
|
||||
'booking_request_submitted',
|
||||
'booking_request_failed',
|
||||
'booking_request_success',
|
||||
]),
|
||||
companySlug: z.string().trim().max(100),
|
||||
vehicleId: z.string().cuid(),
|
||||
sessionId: z.string().trim().max(100).optional(),
|
||||
path: z.string().trim().max(500).optional(),
|
||||
metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
|
||||
})
|
||||
|
||||
export const reviewBodySchema = z.object({
|
||||
overallRating: z.number().int().min(1).max(5),
|
||||
vehicleRating: z.number().int().min(1).max(5).optional(),
|
||||
|
||||
@@ -120,8 +120,42 @@ export async function searchVehicles(params: {
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getMarketplaceVehicleAvailability(
|
||||
slug: string,
|
||||
vehicleId: string,
|
||||
params: { startDate: string; endDate: string },
|
||||
) {
|
||||
const vehicle = await repo.findVehicleForMarketplace(vehicleId, slug)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const startDate = new Date(params.startDate)
|
||||
const endDate = new Date(params.endDate)
|
||||
|
||||
if (endDate <= startDate) {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||
companyId: vehicle.companyId,
|
||||
range: { startDate, endDate },
|
||||
})
|
||||
|
||||
return {
|
||||
available: availability.available,
|
||||
availabilityStatus: availability.status,
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
blockingReason: availability.blockingReason,
|
||||
}
|
||||
}
|
||||
|
||||
function generateBookingReference() {
|
||||
const year = new Date().getUTCFullYear()
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
|
||||
return `BK-${year}-${random}`
|
||||
}
|
||||
|
||||
export async function createMarketplaceReservation(body: {
|
||||
vehicleId: string; companySlug: string; firstName: string; lastName: string
|
||||
vehicleId: string; companySlug?: string; firstName: string; lastName: string
|
||||
email: string; phone?: string; dateOfBirth?: string; nationality?: string
|
||||
identityDocumentNumber?: string; fullAddress?: string
|
||||
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
|
||||
@@ -136,7 +170,7 @@ export async function createMarketplaceReservation(body: {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
|
||||
const vehicle = await repo.findVehicleForMarketplace(body.vehicleId, body.companySlug)
|
||||
const vehicle = await repo.findVehicleForMarketplaceById(body.vehicleId)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const pickupLocation = normalizeLocation(body.pickupLocation)
|
||||
@@ -185,8 +219,18 @@ export async function createMarketplaceReservation(body: {
|
||||
const totalAmount = vehicle.dailyRate * totalDays
|
||||
|
||||
const reservation = await repo.createMarketplaceReservation({
|
||||
companyId: vehicle.companyId, vehicleId: body.vehicleId, customerId: customer.id,
|
||||
startDate, endDate, pickupLocation, returnLocation, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes,
|
||||
companyId: vehicle.companyId,
|
||||
vehicleId: body.vehicleId,
|
||||
customerId: customer.id,
|
||||
startDate,
|
||||
endDate,
|
||||
pickupLocation,
|
||||
returnLocation,
|
||||
dailyRate: vehicle.dailyRate,
|
||||
totalDays,
|
||||
totalAmount,
|
||||
notes: body.notes,
|
||||
bookingReference: generateBookingReference(),
|
||||
})
|
||||
|
||||
const lang = (body.language ?? 'fr') as Lang
|
||||
@@ -214,7 +258,32 @@ export async function createMarketplaceReservation(body: {
|
||||
channels: ['IN_APP'],
|
||||
}).catch(() => null)
|
||||
|
||||
return { reservationId: reservation.id }
|
||||
return {
|
||||
reservationId: reservation.id,
|
||||
bookingReference: (reservation as any).bookingReference ?? null,
|
||||
companyName,
|
||||
vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
status: 'PENDING',
|
||||
message: 'The company will review your request.',
|
||||
}
|
||||
}
|
||||
|
||||
export async function trackMarketplaceFunnelEvent(body: {
|
||||
eventName: string
|
||||
companySlug: string
|
||||
vehicleId: string
|
||||
sessionId?: string
|
||||
path?: string
|
||||
metadata?: Record<string, string | number | boolean | null>
|
||||
}, renterId?: string) {
|
||||
await repo.createMarketplaceFunnelEvent({
|
||||
...body,
|
||||
renterId: renterId ?? null,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function getCompanyPage(slug: string) {
|
||||
|
||||
@@ -7,6 +7,7 @@ vi.mock('./marketplace.repo', () => ({
|
||||
findListedCompanies: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
findVehicleForMarketplace: vi.fn(),
|
||||
findVehicleForMarketplaceById: vi.fn(),
|
||||
upsertMarketplaceCustomer: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
findCompanyPage: vi.fn(),
|
||||
@@ -136,13 +137,14 @@ describe('createMarketplaceReservation', () => {
|
||||
}
|
||||
|
||||
it('creates reservation and returns reservationId', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1' } as any)
|
||||
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
|
||||
|
||||
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||
expect(result.reservationId).toBe('r-1')
|
||||
expect(result.bookingReference).toBe('BK-2026-ABC123')
|
||||
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
||||
identityDocumentNumber: 'CIN123456',
|
||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||
@@ -155,20 +157,20 @@ describe('createMarketplaceReservation', () => {
|
||||
})
|
||||
|
||||
it('throws NotFoundError when vehicle not found', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(null)
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(null)
|
||||
|
||||
await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws AppError when vehicle is unavailable', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||
|
||||
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||
})
|
||||
|
||||
it('rejects different return locations when the vehicle requires same drop-off', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
|
||||
await expect(
|
||||
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { validateLicense } from '../../services/licenseValidationService'
|
||||
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import { coerceNotificationLocale } from '../../services/notificationLocalizationService'
|
||||
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
|
||||
import { buildBookingRequestProgress, buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
|
||||
import * as repo from './reservation.repo'
|
||||
|
||||
function buildPickupAddress(value: unknown) {
|
||||
@@ -47,7 +47,6 @@ async function assertLicenseCompliance(reservationId: string, companyId: string)
|
||||
export async function confirmReservation(id: string, companyId: string) {
|
||||
const reservation = await repo.findByIdSimple(id, companyId)
|
||||
if (reservation.status !== 'DRAFT') throw new AppError('Only DRAFT reservations can be confirmed', 400, 'invalid_status')
|
||||
await assertLicenseCompliance(reservation.id, companyId)
|
||||
|
||||
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
|
||||
await repo.updateVehicleStatus(reservation.vehicleId, 'RESERVED')
|
||||
@@ -97,6 +96,44 @@ export async function confirmReservation(id: string, companyId: string) {
|
||||
},
|
||||
}).catch(() => null)
|
||||
|
||||
if (reservation.source === 'MARKETPLACE') {
|
||||
const progress = buildBookingRequestProgress({
|
||||
source: reservation.source,
|
||||
status: 'CONFIRMED',
|
||||
paymentStatus: updated.paymentStatus,
|
||||
customer,
|
||||
})
|
||||
const reminderChannels = reservation.renterId ? ['EMAIL', 'IN_APP'] as const : ['EMAIL'] as const
|
||||
|
||||
if (progress.documentsRequired) {
|
||||
await sendNotification({
|
||||
type: 'DOCUMENTS_REQUIRED',
|
||||
companyId,
|
||||
renterId: reservation.renterId ?? undefined,
|
||||
email: customer.email,
|
||||
channels: [...reminderChannels],
|
||||
locale: reservation.renterId ? undefined : fallbackLocale,
|
||||
title: 'Driver documents still needed',
|
||||
body: `${company?.brand?.displayName ?? company?.name ?? 'The rental company'} confirmed availability. Please send the remaining driver documents so the booking can move forward.`,
|
||||
data: { reservationId: reservation.id, missingItems: progress.documentsMissing },
|
||||
}).catch(() => null)
|
||||
}
|
||||
|
||||
if (progress.paymentRequired) {
|
||||
await sendNotification({
|
||||
type: 'PAYMENT_REQUIRED',
|
||||
companyId,
|
||||
renterId: reservation.renterId ?? undefined,
|
||||
email: customer.email,
|
||||
channels: [...reminderChannels],
|
||||
locale: reservation.renterId ? undefined : fallbackLocale,
|
||||
title: 'Payment or deposit is still pending',
|
||||
body: `${company?.brand?.displayName ?? company?.name ?? 'The rental company'} accepted your request. Complete any required payment or deposit to finalize the booking.`,
|
||||
data: { reservationId: reservation.id, paymentStatus: updated.paymentStatus },
|
||||
}).catch(() => null)
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
|
||||
@@ -60,10 +60,19 @@ describe('reservation.presenter boundary behavior', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation_1',
|
||||
status: 'DRAFT',
|
||||
source: 'MARKETPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
extras: { paymentMode: 'CARD' },
|
||||
customer: { id: 'customer_1', licenseImageUrl: '/storage/raw-license.jpg' },
|
||||
customer: {
|
||||
id: 'customer_1',
|
||||
driverLicense: null,
|
||||
dateOfBirth: null,
|
||||
address: {},
|
||||
licenseImageUrl: '/storage/raw-license.jpg',
|
||||
licenseValidationStatus: 'PENDING',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.paymentMode).toBe('CARD')
|
||||
@@ -75,10 +84,19 @@ describe('reservation.presenter boundary behavior', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation_1',
|
||||
status: 'DRAFT',
|
||||
source: 'MARKETPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
extras: { paymentMode: 42 },
|
||||
customer: { id: 'customer_1', licenseImageUrl: null },
|
||||
customer: {
|
||||
id: 'customer_1',
|
||||
driverLicense: null,
|
||||
dateOfBirth: null,
|
||||
address: {},
|
||||
licenseImageUrl: null,
|
||||
licenseValidationStatus: 'PENDING',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.paymentMode).toBeNull()
|
||||
|
||||
@@ -19,12 +19,18 @@ describe('serializeReservationForDashboard', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation-1',
|
||||
status: 'CONFIRMED',
|
||||
source: 'MARKETPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
extras: {},
|
||||
customer: {
|
||||
id: 'customer-1',
|
||||
driverLicense: 'DL-1',
|
||||
dateOfBirth: new Date('1990-01-01T00:00:00.000Z'),
|
||||
address: { identityDocumentNumber: 'CIN-1', fullAddress: 'Casablanca' },
|
||||
licenseImageUrl: 'http://localhost:4000/storage/companies/company-1/customers/customer-1/license.jpg',
|
||||
licenseValidationStatus: 'APPROVED',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ export function normalizeOptionalString(value: string | null | undefined): strin
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function readAddressField(address: unknown, key: string): string | null {
|
||||
const extras = parseReservationExtras(address)
|
||||
const value = extras[key]
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
export function buildReservationWorkflow(reservation: {
|
||||
status: string
|
||||
contractNumber: string | null
|
||||
@@ -33,16 +39,95 @@ export function buildReservationWorkflow(reservation: {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBookingRequestProgress(reservation: {
|
||||
source: string
|
||||
status: string
|
||||
paymentStatus?: string | null
|
||||
customer?: {
|
||||
driverLicense?: string | null
|
||||
dateOfBirth?: Date | null
|
||||
address?: unknown
|
||||
licenseImageUrl?: string | null
|
||||
licenseValidationStatus?: string | null
|
||||
} | null
|
||||
}) {
|
||||
const customer = reservation.customer
|
||||
const paymentStatus = reservation.paymentStatus ?? 'UNPAID'
|
||||
const identityDocumentNumber = readAddressField(customer?.address, 'identityDocumentNumber')
|
||||
const fullAddress = readAddressField(customer?.address, 'fullAddress')
|
||||
|
||||
const documentsMissing: string[] = []
|
||||
|
||||
if (!customer?.dateOfBirth) documentsMissing.push('DATE_OF_BIRTH')
|
||||
if (!identityDocumentNumber) documentsMissing.push('IDENTITY_DOCUMENT')
|
||||
if (!fullAddress) documentsMissing.push('FULL_ADDRESS')
|
||||
if (!customer?.driverLicense) documentsMissing.push('DRIVER_LICENSE')
|
||||
if (!customer?.licenseImageUrl) documentsMissing.push('LICENSE_IMAGE')
|
||||
if ((customer?.licenseValidationStatus ?? 'PENDING') !== 'APPROVED') {
|
||||
documentsMissing.push('LICENSE_APPROVAL')
|
||||
}
|
||||
|
||||
const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status)
|
||||
const documentsRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && documentsMissing.length > 0
|
||||
const paymentRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
|
||||
|
||||
let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED'
|
||||
if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED'
|
||||
else if (!companyConfirmed) stage = 'REQUEST_SENT'
|
||||
else if (documentsRequired) stage = 'DOCUMENTS_REQUIRED'
|
||||
else if (paymentRequired) stage = 'PAYMENT_REQUIRED'
|
||||
else if (companyConfirmed) stage = 'BOOKING_CONFIRMED'
|
||||
else stage = 'COMPANY_CONFIRMED'
|
||||
|
||||
const activeStepKey =
|
||||
stage === 'REQUEST_SENT'
|
||||
? 'REQUEST_SENT'
|
||||
: stage === 'CANCELLED'
|
||||
? 'BOOKING_CONFIRMED'
|
||||
: stage === 'DOCUMENTS_REQUIRED'
|
||||
? 'DOCUMENTS_COMPLETED'
|
||||
: stage === 'PAYMENT_REQUIRED'
|
||||
? 'PAYMENT_COMPLETED'
|
||||
: 'BOOKING_CONFIRMED'
|
||||
|
||||
const steps = [
|
||||
{ key: 'REQUEST_SENT', completed: true, active: activeStepKey === 'REQUEST_SENT' },
|
||||
{ key: 'AVAILABILITY_CONFIRMED', completed: companyConfirmed, active: companyConfirmed && activeStepKey === 'REQUEST_SENT' },
|
||||
{ key: 'DOCUMENTS_COMPLETED', completed: companyConfirmed && !documentsRequired, active: activeStepKey === 'DOCUMENTS_COMPLETED' },
|
||||
{ key: 'PAYMENT_COMPLETED', completed: companyConfirmed && !paymentRequired, active: activeStepKey === 'PAYMENT_COMPLETED' },
|
||||
{ key: 'BOOKING_CONFIRMED', completed: companyConfirmed && !documentsRequired && !paymentRequired, active: activeStepKey === 'BOOKING_CONFIRMED' },
|
||||
]
|
||||
|
||||
return {
|
||||
stage,
|
||||
companyConfirmed,
|
||||
documentsRequired,
|
||||
paymentRequired,
|
||||
documentsMissing,
|
||||
steps,
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeReservationForDashboard<T extends {
|
||||
extras: unknown
|
||||
status: string
|
||||
source: string
|
||||
contractNumber: string | null
|
||||
invoiceNumber: string | null
|
||||
paymentStatus?: string | null
|
||||
customer?: {
|
||||
id: string
|
||||
driverLicense?: string | null
|
||||
dateOfBirth?: Date | null
|
||||
address?: unknown
|
||||
licenseImageUrl?: string | null
|
||||
licenseValidationStatus?: string | null
|
||||
} | null
|
||||
}>(reservation: T): T & { paymentMode: string | null; workflow: ReturnType<typeof buildReservationWorkflow> } {
|
||||
}>(reservation: T): T & {
|
||||
paymentMode: string | null
|
||||
workflow: ReturnType<typeof buildReservationWorkflow>
|
||||
bookingRequest: ReturnType<typeof buildBookingRequestProgress>
|
||||
} {
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
const customer =
|
||||
reservation.customer
|
||||
@@ -59,5 +144,6 @@ export function serializeReservationForDashboard<T extends {
|
||||
...(customer !== undefined ? { customer } : {}),
|
||||
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
||||
workflow: buildReservationWorkflow(reservation),
|
||||
bookingRequest: buildBookingRequestProgress({ ...reservation, customer }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
import { textField, optionalTextField, optionalEmailField, upperField, optionalUpperField, optionalPhoneField } from '../../lib/zodValidation'
|
||||
|
||||
export const additionalDriverSchema = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
driverLicense: z.string().min(1),
|
||||
firstName: textField('name'),
|
||||
lastName: textField('name'),
|
||||
email: optionalEmailField(),
|
||||
phone: optionalPhoneField(),
|
||||
driverLicense: upperField('driverLicenseNumber'),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
nationality: optionalTextField('nationality'),
|
||||
})
|
||||
|
||||
export const inspectionSchema = z.object({
|
||||
@@ -35,8 +36,8 @@ export const createSchema = z.object({
|
||||
customerId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
pickupLocation: z.string().optional(),
|
||||
returnLocation: z.string().optional(),
|
||||
pickupLocation: optionalTextField('pickupLocation'),
|
||||
returnLocation: optionalTextField('returnLocation'),
|
||||
offerId: z.string().cuid().optional(),
|
||||
promoCodeUsed: z.string().optional(),
|
||||
depositAmount: z.number().int().min(0).default(0),
|
||||
@@ -49,8 +50,8 @@ export const createSchema = z.object({
|
||||
export const updateSchema = z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
pickupLocation: z.string().optional().nullable(),
|
||||
returnLocation: z.string().optional().nullable(),
|
||||
pickupLocation: optionalTextField('pickupLocation').nullable(),
|
||||
returnLocation: optionalTextField('returnLocation').nullable(),
|
||||
depositAmount: z.number().int().min(0).optional(),
|
||||
notes: z.string().optional().nullable(),
|
||||
paymentMode: z.string().max(50).optional().nullable(),
|
||||
|
||||
@@ -53,9 +53,13 @@ function maskPhone(phone?: string | null) {
|
||||
export function presentPublicBooking(reservation: any) {
|
||||
return {
|
||||
id: reservation.id,
|
||||
status: reservation.status,
|
||||
bookingReference: reservation.bookingReference ?? null,
|
||||
status: reservation.status === 'DRAFT' ? 'PENDING' : reservation.status,
|
||||
companyName: reservation.company?.brand?.displayName ?? reservation.company?.name ?? null,
|
||||
pickupAt: reservation.startDate instanceof Date ? reservation.startDate.toISOString() : reservation.startDate,
|
||||
returnAt: reservation.endDate instanceof Date ? reservation.endDate.toISOString() : reservation.endDate,
|
||||
pickupLocation: reservation.pickupLocation ?? null,
|
||||
returnLocation: reservation.returnLocation ?? null,
|
||||
vehicle: {
|
||||
make: reservation.vehicle?.make,
|
||||
model: reservation.vehicle?.model,
|
||||
@@ -72,5 +76,6 @@ export function presentPublicBooking(reservation: any) {
|
||||
currency: 'MAD',
|
||||
},
|
||||
paymentStatus: reservation.paymentStatus,
|
||||
message: 'The company will review your request.',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('site.repo public booking boundaries', () => {
|
||||
where: { id: 'customer_1' },
|
||||
data: expect.objectContaining({
|
||||
firstName: 'Nora',
|
||||
address: { city: 'Marrakesh', old: 'kept', fullAddress: 'New address', identityDocumentNumber: 'ID-9', internationalLicenseNumber: null },
|
||||
address: expect.objectContaining({ city: 'Marrakesh', fullAddress: 'New address', identityDocumentNumber: 'ID-9' }),
|
||||
licenseNumber: 'DL-1',
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -45,46 +45,53 @@ export async function upsertCustomer(companyId: string, data: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
phone: string
|
||||
driverLicense: string
|
||||
dateOfBirth: string
|
||||
licenseExpiry: string
|
||||
licenseIssuedAt: string
|
||||
nationality: string
|
||||
identityDocumentNumber: string
|
||||
fullAddress: string
|
||||
licenseCountry: string
|
||||
driverLicense?: string
|
||||
dateOfBirth?: string
|
||||
licenseExpiry?: string
|
||||
licenseIssuedAt?: string
|
||||
nationality?: string
|
||||
identityDocumentNumber?: string
|
||||
fullAddress?: string
|
||||
licenseCountry?: string
|
||||
licenseNumber?: string | null
|
||||
licenseCategory: string
|
||||
licenseCategory?: string
|
||||
internationalLicenseNumber?: string | null
|
||||
}) {
|
||||
const normalizedEmail = data.email.trim().toLowerCase()
|
||||
const existing = await prisma.customer.findUnique({
|
||||
where: { companyId_email: { companyId, email: data.email } },
|
||||
where: { companyId_email: { companyId, email: normalizedEmail } },
|
||||
select: { id: true, address: true },
|
||||
})
|
||||
|
||||
const address = {
|
||||
...(existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address)
|
||||
? existing.address as Record<string, unknown>
|
||||
: {}),
|
||||
fullAddress: data.fullAddress,
|
||||
identityDocumentNumber: data.identityDocumentNumber,
|
||||
internationalLicenseNumber: data.internationalLicenseNumber ?? null,
|
||||
const addressPatch = {
|
||||
...(data.fullAddress ? { fullAddress: data.fullAddress } : {}),
|
||||
...(data.identityDocumentNumber ? { identityDocumentNumber: data.identityDocumentNumber } : {}),
|
||||
...(data.internationalLicenseNumber !== undefined ? { internationalLicenseNumber: data.internationalLicenseNumber ?? null } : {}),
|
||||
}
|
||||
|
||||
const address = Object.keys(addressPatch).length > 0
|
||||
? {
|
||||
...(existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address)
|
||||
? existing.address as Record<string, unknown>
|
||||
: {}),
|
||||
...addressPatch,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const payload = {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
email: normalizedEmail,
|
||||
phone: data.phone,
|
||||
driverLicense: data.driverLicense,
|
||||
dateOfBirth: new Date(data.dateOfBirth),
|
||||
nationality: data.nationality,
|
||||
address,
|
||||
licenseExpiry: new Date(data.licenseExpiry),
|
||||
licenseIssuedAt: new Date(data.licenseIssuedAt),
|
||||
licenseCountry: data.licenseCountry,
|
||||
licenseNumber: data.licenseNumber ?? data.driverLicense,
|
||||
licenseCategory: data.licenseCategory,
|
||||
...(data.driverLicense ? { driverLicense: data.driverLicense } : {}),
|
||||
...(data.dateOfBirth ? { dateOfBirth: new Date(data.dateOfBirth) } : {}),
|
||||
...(data.nationality ? { nationality: data.nationality } : {}),
|
||||
...(address ? { address } : {}),
|
||||
...(data.licenseExpiry ? { licenseExpiry: new Date(data.licenseExpiry) } : {}),
|
||||
...(data.licenseIssuedAt ? { licenseIssuedAt: new Date(data.licenseIssuedAt) } : {}),
|
||||
...(data.licenseCountry ? { licenseCountry: data.licenseCountry } : {}),
|
||||
...((data.licenseNumber ?? data.driverLicense) ? { licenseNumber: data.licenseNumber ?? data.driverLicense } : {}),
|
||||
...(data.licenseCategory ? { licenseCategory: data.licenseCategory } : {}),
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
@@ -106,14 +113,14 @@ export async function createReservation(data: object) {
|
||||
export async function findReservationWithDetails(reservationId: string) {
|
||||
return prisma.reservation.findUniqueOrThrow({
|
||||
where: { id: reservationId },
|
||||
include: { insurances: true, additionalDrivers: true },
|
||||
include: { insurances: true, additionalDrivers: true, vehicle: true, customer: true, company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findBooking(reservationId: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id: reservationId, companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
include: { vehicle: true, customer: true, company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,8 @@ describe('site.schemas', () => {
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
phone: '+212600000000',
|
||||
driverLicense: 'DL-123',
|
||||
dateOfBirth: '1995-01-01T00:00:00.000Z',
|
||||
licenseExpiry: '2028-01-01T00:00:00.000Z',
|
||||
licenseIssuedAt: '2020-01-01T00:00:00.000Z',
|
||||
nationality: 'Moroccan',
|
||||
identityDocumentNumber: 'ID-123',
|
||||
fullAddress: '12 Main Street',
|
||||
licenseCountry: 'MA',
|
||||
licenseCategory: 'B',
|
||||
pickupLocation: 'Casablanca Airport',
|
||||
returnLocation: 'Casablanca Airport',
|
||||
}
|
||||
|
||||
it('defaults optional public booking arrays and source', () => {
|
||||
@@ -53,7 +46,7 @@ describe('site.schemas', () => {
|
||||
expect(paySchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.test', failureUrl: 'https://fail.test', accessToken: 'booking-access-token-123' }).currency).toBe('MAD')
|
||||
})
|
||||
|
||||
it('keeps contact, PayPal capture, and required identity fields strict', () => {
|
||||
it('keeps contact, PayPal capture, and guest booking essentials strict', () => {
|
||||
expect(contactSchema.parse({ name: 'Aya', email: 'aya@example.com', message: 'Hello' })).toEqual({
|
||||
name: 'Aya', email: 'aya@example.com', message: 'Hello',
|
||||
})
|
||||
|
||||
@@ -19,16 +19,18 @@ export const bookSchema = z.object({
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(1).max(30),
|
||||
driverLicense: z.string().min(1).max(50),
|
||||
dateOfBirth: z.string().datetime(),
|
||||
licenseExpiry: z.string().datetime(),
|
||||
licenseIssuedAt: z.string().datetime(),
|
||||
nationality: z.string().min(1).max(100),
|
||||
identityDocumentNumber: z.string().min(1).max(100),
|
||||
fullAddress: z.string().min(1).max(500),
|
||||
licenseCountry: z.string().min(1).max(100),
|
||||
pickupLocation: z.string().trim().min(1).max(100),
|
||||
returnLocation: z.string().trim().min(1).max(100),
|
||||
driverLicense: z.string().min(1).max(50).optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
nationality: z.string().min(1).max(100).optional(),
|
||||
identityDocumentNumber: z.string().min(1).max(100).optional(),
|
||||
fullAddress: z.string().min(1).max(500).optional(),
|
||||
licenseCountry: z.string().min(1).max(100).optional(),
|
||||
licenseNumber: z.string().min(1).max(50).optional(),
|
||||
licenseCategory: z.string().min(1).max(20),
|
||||
licenseCategory: z.string().min(1).max(20).optional(),
|
||||
internationalLicenseNumber: z.string().trim().max(100).optional(),
|
||||
offerId: z.string().cuid().optional(),
|
||||
promoCodeUsed: z.string().optional(),
|
||||
|
||||
@@ -18,7 +18,13 @@ vi.mock('../../services/pricingRuleService', () => ({ applyPricingRules: vi.fn()
|
||||
vi.mock('../../services/licenseValidationService', () => ({ validateLicense: vi.fn(), validateAndFlagLicense: vi.fn().mockResolvedValue(undefined) }))
|
||||
vi.mock('../../services/amanpayService', () => ({ isConfigured: vi.fn(), createCheckout: vi.fn(), verifyWebhookSignature: vi.fn(), findTransactionFromWebhook: vi.fn() }))
|
||||
vi.mock('../../services/paypalService', () => ({ createOrder: vi.fn(), captureOrder: vi.fn() }))
|
||||
vi.mock('./site.presenter', () => ({ presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })) }))
|
||||
vi.mock('./site.presenter', () => ({
|
||||
presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })),
|
||||
presentPublicBooking: vi.fn((reservation: any) => ({
|
||||
bookingReference: reservation.bookingReference ?? null,
|
||||
status: reservation.status === 'DRAFT' ? 'PENDING' : reservation.status,
|
||||
})),
|
||||
}))
|
||||
vi.mock('./site.repo', () => ({
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
@@ -62,15 +68,8 @@ function bookingBody(overrides: Record<string, unknown> = {}) {
|
||||
lastName: 'Renter',
|
||||
email: 'nora@example.test',
|
||||
phone: '+212600000000',
|
||||
driverLicense: 'DL-1',
|
||||
dateOfBirth: '1992-01-01',
|
||||
licenseExpiry: '2027-01-01',
|
||||
licenseIssuedAt: '2022-01-01',
|
||||
nationality: 'MA',
|
||||
identityDocumentNumber: 'ID-1',
|
||||
fullAddress: 'Casablanca',
|
||||
licenseCountry: 'MA',
|
||||
licenseCategory: 'B',
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
...overrides,
|
||||
} as any
|
||||
}
|
||||
@@ -108,7 +107,23 @@ describe('site.service public booking/payment boundaries', () => {
|
||||
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'customer_1' } as never)
|
||||
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'offer_1', type: 'FREE_DAY', discountValue: 1 } as never)
|
||||
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'reservation_1' } as never)
|
||||
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'reservation_1', additionalDrivers: [{ requiresApproval: true }] } as never)
|
||||
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({
|
||||
id: 'reservation_1',
|
||||
bookingReference: 'BK-2026-AB12CD',
|
||||
status: 'DRAFT',
|
||||
startDate: new Date('2026-07-01T10:00:00.000Z'),
|
||||
endDate: new Date('2026-07-04T10:00:00.000Z'),
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
vehicle: { make: 'Dacia', model: 'Duster', year: 2024 },
|
||||
customer: { firstName: 'Aya', lastName: 'Benali', email: 'aya@example.test', phone: '+212600000000' },
|
||||
company: { name: 'Atlas Cars', brand: { displayName: 'Atlas Cars' } },
|
||||
additionalDrivers: [],
|
||||
insurances: [],
|
||||
totalAmount: 1090,
|
||||
paidAmount: 0,
|
||||
paymentStatus: 'UNPAID',
|
||||
} as never)
|
||||
|
||||
const result = await service.createBooking('atlas', bookingBody({
|
||||
promoCodeUsed: 'FREEDAY',
|
||||
@@ -122,13 +137,16 @@ describe('site.service public booking/payment boundaries', () => {
|
||||
discountAmount: 500,
|
||||
pricingRulesTotal: 90,
|
||||
totalAmount: 1090,
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
status: 'DRAFT',
|
||||
source: 'PUBLIC_SITE',
|
||||
}))
|
||||
expect(applyInsurancesToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', ['insurance_1'], 3, 1500)
|
||||
expect(applyAdditionalDriversToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', [{ firstName: 'Second' }], 3)
|
||||
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1')
|
||||
expect(result.requiresManualApproval).toBe(true)
|
||||
expect(applyInsurancesToReservation).not.toHaveBeenCalled()
|
||||
expect(applyAdditionalDriversToReservation).not.toHaveBeenCalled()
|
||||
expect(validateAndFlagLicense).not.toHaveBeenCalled()
|
||||
expect(result.status).toBe('PENDING')
|
||||
expect(result.bookingReference).toBe('BK-2026-AB12CD')
|
||||
})
|
||||
|
||||
it('rejects payment initialization for already-paid reservations before provider calls', async () => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { AppError, NotFoundError } from '../../http/errors'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { applyInsurancesToReservation } from '../../services/insuranceService'
|
||||
import { applyAdditionalDriversToReservation } from '../../services/additionalDriverService'
|
||||
import { applyPricingRules } from '../../services/pricingRuleService'
|
||||
import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService'
|
||||
import { validateLicense } from '../../services/licenseValidationService'
|
||||
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
@@ -48,6 +46,12 @@ function assertAllowedPaymentRedirect(urlValue: string, company: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function generateBookingReference() {
|
||||
const year = new Date().getUTCFullYear()
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
|
||||
return `BK-${year}-${random}`
|
||||
}
|
||||
|
||||
export async function getPlatformHomepage() {
|
||||
return getMarketplaceHomepageContent()
|
||||
}
|
||||
@@ -132,8 +136,9 @@ export async function validatePromoCode(slug: string, code: string) {
|
||||
export async function createBooking(slug: string, body: {
|
||||
vehicleId: string; startDate: string; endDate: string
|
||||
firstName: string; lastName: string; email: string; phone: string
|
||||
driverLicense: string; dateOfBirth: string; licenseExpiry: string; licenseIssuedAt: string; nationality: string
|
||||
identityDocumentNumber: string; fullAddress: string; licenseCountry: string; licenseNumber?: string; licenseCategory: string; internationalLicenseNumber?: string
|
||||
pickupLocation: string; returnLocation: string
|
||||
driverLicense?: string; dateOfBirth?: string; licenseExpiry?: string; licenseIssuedAt?: string; nationality?: string
|
||||
identityDocumentNumber?: string; fullAddress?: string; licenseCountry?: string; licenseNumber?: string; licenseCategory?: string; internationalLicenseNumber?: string
|
||||
offerId?: string; promoCodeUsed?: string; notes?: string; source?: string
|
||||
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
|
||||
}) {
|
||||
@@ -143,6 +148,7 @@ export async function createBooking(slug: string, body: {
|
||||
const start = new Date(body.startDate)
|
||||
const end = new Date(body.endDate)
|
||||
if (end <= start) throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
if (start < new Date()) throw new AppError('Start date cannot be in the past', 400, 'invalid_dates')
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: company.id, range: { startDate: start, endDate: end } })
|
||||
if (!availability.available) {
|
||||
@@ -182,16 +188,10 @@ export async function createBooking(slug: string, body: {
|
||||
}
|
||||
}
|
||||
|
||||
const additionalDriversList = body.additionalDrivers ?? []
|
||||
const { applied, total: pricingRulesTotal } = await applyPricingRules(
|
||||
company.id, customer.id, additionalDriversList as any[], vehicle.dailyRate, totalDays,
|
||||
company.id, customer.id, [], vehicle.dailyRate, totalDays,
|
||||
)
|
||||
|
||||
const primaryLicenseResult = validateLicense(body.licenseExpiry ? new Date(body.licenseExpiry) : null)
|
||||
if (primaryLicenseResult.status === 'EXPIRED') {
|
||||
throw new AppError('The primary driver license is expired', 400, 'license_expired')
|
||||
}
|
||||
|
||||
const reservation = await repo.createReservation({
|
||||
companyId: company.id,
|
||||
vehicleId: vehicle.id,
|
||||
@@ -202,6 +202,8 @@ export async function createBooking(slug: string, body: {
|
||||
source: body.source ?? 'PUBLIC_SITE',
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
pickupLocation: body.pickupLocation,
|
||||
returnLocation: body.returnLocation,
|
||||
dailyRate: vehicle.dailyRate,
|
||||
totalDays,
|
||||
totalAmount: baseAmount - discountAmount + pricingRulesTotal,
|
||||
@@ -209,20 +211,10 @@ export async function createBooking(slug: string, body: {
|
||||
pricingRulesApplied: applied,
|
||||
pricingRulesTotal,
|
||||
notes: body.notes ?? null,
|
||||
bookingReference: generateBookingReference(),
|
||||
status: 'DRAFT',
|
||||
})
|
||||
|
||||
const insuranceIds = body.selectedInsurancePolicyIds ?? []
|
||||
if (insuranceIds.length > 0) {
|
||||
await applyInsurancesToReservation(reservation.id, company.id, insuranceIds, totalDays, baseAmount)
|
||||
}
|
||||
if (additionalDriversList.length > 0) {
|
||||
await applyAdditionalDriversToReservation(reservation.id, company.id, additionalDriversList, totalDays)
|
||||
}
|
||||
if (body.licenseExpiry) {
|
||||
await validateAndFlagLicense(customer.id).catch(() => null)
|
||||
}
|
||||
|
||||
const publicAccess = generatePublicAccessToken()
|
||||
await repo.createReservationPublicAccess(
|
||||
reservation.id,
|
||||
@@ -232,11 +224,10 @@ export async function createBooking(slug: string, body: {
|
||||
|
||||
const refreshed = await repo.findReservationWithDetails(reservation.id)
|
||||
return {
|
||||
...refreshed,
|
||||
...presentPublicBooking({ ...refreshed, company }),
|
||||
publicAccessToken: publicAccess.token,
|
||||
requiresManualApproval:
|
||||
primaryLicenseResult.requiresApproval ||
|
||||
refreshed.additionalDrivers.some((d: any) => d.requiresApproval),
|
||||
bookingReference: (refreshed as any).bookingReference,
|
||||
message: 'The company will review your request.',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,9 +175,12 @@ describe('validatePromoCode', () => {
|
||||
describe('createBooking', () => {
|
||||
const baseBody = {
|
||||
vehicleId: 'v-1',
|
||||
startDate: '2025-06-01T00:00:00.000Z',
|
||||
endDate: '2025-06-04T00:00:00.000Z',
|
||||
startDate: '2026-07-01T00:00:00.000Z',
|
||||
endDate: '2026-07-04T00:00:00.000Z',
|
||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||
phone: '+212600000000',
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -186,20 +189,36 @@ describe('createBooking', () => {
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||
vi.mocked(applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
|
||||
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
|
||||
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'r-1' } as any)
|
||||
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'r-1', additionalDrivers: [], insurances: [] } as any)
|
||||
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({
|
||||
id: 'r-1',
|
||||
bookingReference: 'BK-2026-ABC123',
|
||||
status: 'DRAFT',
|
||||
startDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||
endDate: new Date('2026-07-04T00:00:00.000Z'),
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
vehicle: { make: 'Toyota', model: 'Camry', year: 2022 },
|
||||
customer: { firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', phone: '+212600000000' },
|
||||
company: { name: 'Test Co', brand: { displayName: 'Test Co' } },
|
||||
additionalDrivers: [],
|
||||
insurances: [],
|
||||
totalAmount: 30000,
|
||||
paidAmount: 0,
|
||||
paymentStatus: 'UNPAID',
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('creates a booking and returns reservation with requiresManualApproval flag', async () => {
|
||||
it('creates a booking and returns a pending guest confirmation', async () => {
|
||||
const result = await createBooking(SLUG, baseBody)
|
||||
expect(repo.createReservation).toHaveBeenCalledOnce()
|
||||
expect((result as any).requiresManualApproval).toBe(false)
|
||||
expect((result as any).status).toBe('PENDING')
|
||||
expect((result as any).bookingReference).toBe('BK-2026-ABC123')
|
||||
})
|
||||
|
||||
it('throws AppError for invalid dates', async () => {
|
||||
await expect(
|
||||
createBooking(SLUG, { ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
|
||||
createBooking(SLUG, { ...baseBody, startDate: '2026-07-04T00:00:00.000Z', endDate: '2026-07-01T00:00:00.000Z' }),
|
||||
).rejects.toMatchObject({ error: 'invalid_dates' })
|
||||
})
|
||||
|
||||
@@ -207,11 +226,6 @@ describe('createBooking', () => {
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||
await expect(createBooking(SLUG, baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||
})
|
||||
|
||||
it('throws AppError when primary driver license is expired', async () => {
|
||||
vi.mocked(validateLicense).mockReturnValue({ status: 'EXPIRED', requiresApproval: false } as any)
|
||||
await expect(createBooking(SLUG, { ...baseBody, licenseExpiry: '2020-01-01T00:00:00.000Z' })).rejects.toMatchObject({ error: 'license_expired' })
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { z } from 'zod'
|
||||
import { sanitizeAndFormat } from '../../lib/inputValidation'
|
||||
import { carTextField, upperField, optionalUpperField } from '../../lib/zodValidation'
|
||||
|
||||
export const vehicleSchema = z.object({
|
||||
make: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
make: carTextField('carMark'),
|
||||
model: carTextField('carModel'),
|
||||
year: z.number().int().min(1990).max(new Date().getFullYear() + 1),
|
||||
color: z.string().default(''),
|
||||
licensePlate: z.string().min(1),
|
||||
vin: z.string().optional(),
|
||||
color: z.string().optional().default('').transform((v: string) => v ? sanitizeAndFormat(v, 'carColor') : ''),
|
||||
licensePlate: upperField('licensePlate'),
|
||||
vin: optionalUpperField('vin'),
|
||||
category: z.enum(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK']),
|
||||
seats: z.number().int().min(1).max(20).default(5),
|
||||
transmission: z.enum(['AUTOMATIC', 'MANUAL']).default('AUTOMATIC'),
|
||||
|
||||
@@ -12,19 +12,29 @@ vi.mock('../lib/prisma', () => ({
|
||||
}))
|
||||
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { validateAndFlagLicense, validateLicense } from './licenseValidationService'
|
||||
import {
|
||||
validateAndFlagLicense,
|
||||
validateLicense,
|
||||
validateLicenseDate,
|
||||
parseExpirationDate,
|
||||
ErrorCode,
|
||||
} from './licenseValidationService'
|
||||
|
||||
const NOW = new Date('2026-06-09T12:00:00.000Z')
|
||||
|
||||
describe('licenseValidationService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// ─── validateLicense (legacy) ────────────────────────────────────────
|
||||
|
||||
it('treats missing license expiry as valid but explicitly records that no date exists', () => {
|
||||
expect(validateLicense(null)).toEqual({
|
||||
status: 'VALID',
|
||||
@@ -34,13 +44,22 @@ describe('licenseValidationService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('requires approval for licenses expiring inside the three-month risk window', () => {
|
||||
const result = validateLicense(new Date('2026-07-09T12:00:00.000Z'))
|
||||
it('requires approval for licenses expiring inside the 30-day risk window', () => {
|
||||
const result = validateLicense(new Date('2026-07-04T12:00:00.000Z'))
|
||||
|
||||
expect(result.status).toBe('EXPIRING')
|
||||
expect(result.daysUntilExpiry).toBe(30)
|
||||
expect(result.daysUntilExpiry).toBe(25)
|
||||
expect(result.requiresApproval).toBe(true)
|
||||
expect(result.message).toContain('License expires in 30 day(s)')
|
||||
expect(result.message).toContain('License expires in 25 day(s)')
|
||||
})
|
||||
|
||||
it('marks exactly 30 days as valid (not expiring)', () => {
|
||||
// 2026-07-09 is exactly 30 days from 2026-06-09
|
||||
const result = validateLicense(new Date('2026-07-09T12:00:00.000Z'))
|
||||
|
||||
expect(result.status).toBe('VALID')
|
||||
expect(result.daysUntilExpiry).toBe(30)
|
||||
expect(result.requiresApproval).toBe(false)
|
||||
})
|
||||
|
||||
it('requires approval for already expired licenses and reports negative days until expiry', () => {
|
||||
@@ -73,4 +92,108 @@ describe('licenseValidationService', () => {
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// ─── validateLicenseDate (new public API) ────────────────────────────
|
||||
|
||||
it('rejects missing date', () => {
|
||||
const result = validateLicenseDate(null)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error_code).toBe(ErrorCode.LICENSE_MISSING_DATE)
|
||||
})
|
||||
|
||||
it('rejects undefined date', () => {
|
||||
const result = validateLicenseDate(undefined)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error_code).toBe(ErrorCode.LICENSE_MISSING_DATE)
|
||||
})
|
||||
|
||||
it('rejects already expired license', () => {
|
||||
const result = validateLicenseDate(new Date('2026-06-04T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error_code).toBe(ErrorCode.LICENSE_EXPIRED)
|
||||
expect(result.days_remaining).toBe(-5)
|
||||
})
|
||||
|
||||
it('rejects license expiring in less than 30 days', () => {
|
||||
const result = validateLicenseDate(new Date('2026-06-20T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error_code).toBe(ErrorCode.LICENSE_EXPIRING_SOON)
|
||||
expect(result.days_remaining).toBe(11)
|
||||
})
|
||||
|
||||
it('accepts license with exactly 30 days remaining', () => {
|
||||
const result = validateLicenseDate(new Date('2026-07-09T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.days_remaining).toBe(30)
|
||||
})
|
||||
|
||||
it('accepts license with more than 30 days remaining', () => {
|
||||
const result = validateLicenseDate(new Date('2027-06-09T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.days_remaining).toBe(365)
|
||||
expect(result.message).toBe('License verified successfully')
|
||||
})
|
||||
|
||||
it('rejects future date more than 20 years from now', () => {
|
||||
const result = validateLicenseDate(new Date('2050-06-09T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error_code).toBe(ErrorCode.LICENSE_FUTURE_DATE)
|
||||
})
|
||||
|
||||
it('handles leap year date correctly', () => {
|
||||
const result = validateLicenseDate(new Date('2028-02-29T12:00:00.000Z'))
|
||||
expect(result.valid).toBe(true)
|
||||
// 2028-02-29 minus 2026-06-09 = should be > 30 days
|
||||
expect(result.days_remaining).toBeGreaterThan(600)
|
||||
})
|
||||
|
||||
// ─── parseExpirationDate ─────────────────────────────────────────────
|
||||
|
||||
it('parses ISO 8601 date (YYYY-MM-DD)', () => {
|
||||
const d = parseExpirationDate('2027-06-11')
|
||||
expect(d).toBeInstanceOf(Date)
|
||||
expect(d!.getUTCFullYear()).toBe(2027)
|
||||
expect(d!.getUTCMonth()).toBe(5) // June
|
||||
expect(d!.getUTCDate()).toBe(11)
|
||||
})
|
||||
|
||||
it('parses US format (MM/DD/YYYY)', () => {
|
||||
const d = parseExpirationDate('06/11/2027')
|
||||
expect(d).toBeInstanceOf(Date)
|
||||
expect(d!.getFullYear()).toBe(2027)
|
||||
expect(d!.getMonth()).toBe(5) // June
|
||||
expect(d!.getDate()).toBe(11)
|
||||
})
|
||||
|
||||
it('parses DD/MM/YYYY when first part > 12', () => {
|
||||
const d = parseExpirationDate('13/06/2027')
|
||||
expect(d).toBeInstanceOf(Date)
|
||||
expect(d!.getFullYear()).toBe(2027)
|
||||
expect(d!.getMonth()).toBe(5) // June
|
||||
expect(d!.getDate()).toBe(13)
|
||||
})
|
||||
|
||||
it('parses DD-MMM-YYYY (military format)', () => {
|
||||
const d = parseExpirationDate('11-JUN-2027')
|
||||
expect(d).toBeInstanceOf(Date)
|
||||
expect(d!.getFullYear()).toBe(2027)
|
||||
expect(d!.getMonth()).toBe(5) // June
|
||||
expect(d!.getDate()).toBe(11)
|
||||
})
|
||||
|
||||
it('returns null for completely invalid date string', () => {
|
||||
expect(parseExpirationDate('not-a-date')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseExpirationDate('')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(parseExpirationDate(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for undefined input', () => {
|
||||
expect(parseExpirationDate(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { LicenseStatus } from '@rentaldrivego/database'
|
||||
|
||||
const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24
|
||||
|
||||
/** Minimum days of remaining validity required (configurable via env, default 30). */
|
||||
function getMinValidityDays(): number {
|
||||
const raw = process.env.LICENSE_MIN_VALIDITY_DAYS
|
||||
if (raw) {
|
||||
const n = Number(raw)
|
||||
if (Number.isFinite(n) && n > 0) return n
|
||||
}
|
||||
return 30
|
||||
}
|
||||
|
||||
/** Maximum years in the future considered reasonable for a license expiration date. */
|
||||
const MAX_FUTURE_YEARS = 20
|
||||
|
||||
// ── Error codes matching doc/error-code reference ────────────────────────
|
||||
export const ErrorCode = {
|
||||
LICENSE_EXPIRED: 'LICENSE_EXPIRED',
|
||||
LICENSE_EXPIRING_SOON: 'LICENSE_EXPIRING_SOON',
|
||||
LICENSE_INVALID_FORMAT:'LICENSE_INVALID_FORMAT',
|
||||
LICENSE_FUTURE_DATE: 'LICENSE_FUTURE_DATE',
|
||||
LICENSE_MISSING_DATE: 'LICENSE_MISSING_DATE',
|
||||
} as const
|
||||
|
||||
export interface LicenseValidationResult {
|
||||
status: 'VALID' | 'EXPIRING' | 'EXPIRED'
|
||||
@@ -10,19 +32,176 @@ export interface LicenseValidationResult {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface DetailedValidationResult {
|
||||
valid: boolean
|
||||
days_remaining: number | null
|
||||
expiration_date: string | null
|
||||
message: string
|
||||
error_code?: string
|
||||
details?: {
|
||||
expiration_date?: string
|
||||
days_remaining?: number
|
||||
minimum_required?: number
|
||||
suggested_action?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an expiration date string into a Date.
|
||||
* Returns null if the string is not a valid date per ISO 8601 or common
|
||||
* US/EU formats (YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY).
|
||||
*/
|
||||
export function parseExpirationDate(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null
|
||||
|
||||
// Try ISO 8601 first (YYYY-MM-DD or full ISO)
|
||||
const iso = new Date(raw)
|
||||
if (!isNaN(iso.getTime()) && raw.length >= 10) {
|
||||
// Ensure we don't accept "2026-06" as valid — need at least YYYY-MM-DD
|
||||
const datePart = raw.split('T')[0]
|
||||
if (!datePart) return null
|
||||
const parts = datePart.split('-')
|
||||
if (parts.length === 3) return iso
|
||||
}
|
||||
|
||||
// Try MM/DD/YYYY and DD/MM/YYYY
|
||||
const slashMatch = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
|
||||
if (slashMatch) {
|
||||
// Prefer US order (MM/DD/YYYY) as default; document says "varies by state"
|
||||
const m1 = Number(slashMatch[1])
|
||||
const m2 = Number(slashMatch[2])
|
||||
const year = Number(slashMatch[3])
|
||||
// If first part > 12, it's likely DD/MM/YYYY
|
||||
if (m1 > 12) {
|
||||
const d = new Date(year, m2 - 1, m1)
|
||||
if (!isNaN(d.getTime())) return d
|
||||
}
|
||||
const d = new Date(year, m1 - 1, m2)
|
||||
if (!isNaN(d.getTime())) return d
|
||||
}
|
||||
|
||||
// Try DD-MMM-YYYY (e.g., 11-JUN-2027)
|
||||
const mmmMatch = raw.match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/)
|
||||
if (mmmMatch) {
|
||||
const d = new Date(`${mmmMatch[2]} ${mmmMatch[1]}, ${mmmMatch[3]}`)
|
||||
if (!isNaN(d.getTime())) return d
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure validation logic — no side effects, no DB calls.
|
||||
*
|
||||
* Returns a DetailedValidationResult suitable for the public API.
|
||||
*/
|
||||
export function validateLicenseDate(expirationDate: Date | null | undefined): DetailedValidationResult {
|
||||
if (!expirationDate) {
|
||||
return {
|
||||
valid: false,
|
||||
days_remaining: null,
|
||||
expiration_date: null,
|
||||
message: 'No expiration date provided. Please provide a valid driver\'s license expiration date.',
|
||||
error_code: ErrorCode.LICENSE_MISSING_DATE,
|
||||
details: {
|
||||
suggested_action: 'Please provide the expiration date from your driver\'s license.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const nowMs = now.getTime()
|
||||
const expMs = expirationDate.getTime()
|
||||
const daysRemaining = Math.ceil((expMs - nowMs) / MS_PER_DAY)
|
||||
const expDateStr = expirationDate.toISOString().split('T')[0] ?? ''
|
||||
const minValidityDays = getMinValidityDays()
|
||||
|
||||
// Expired
|
||||
if (expMs <= nowMs) {
|
||||
return {
|
||||
valid: false,
|
||||
days_remaining: daysRemaining,
|
||||
expiration_date: expDateStr,
|
||||
message: `License has expired. Your license expired on ${expDateStr}.`,
|
||||
error_code: ErrorCode.LICENSE_EXPIRED,
|
||||
details: {
|
||||
expiration_date: expDateStr,
|
||||
days_remaining: daysRemaining,
|
||||
minimum_required: minValidityDays,
|
||||
suggested_action: 'Please renew your driver\'s license and upload the updated document.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Expiring soon (1 – minValidityDays-1 days remaining)
|
||||
if (daysRemaining < minValidityDays) {
|
||||
return {
|
||||
valid: false,
|
||||
days_remaining: daysRemaining,
|
||||
expiration_date: expDateStr,
|
||||
message: `License must have at least ${minValidityDays} days remaining validity. Your license expires in ${daysRemaining} days on ${expDateStr}.`,
|
||||
error_code: ErrorCode.LICENSE_EXPIRING_SOON,
|
||||
details: {
|
||||
expiration_date: expDateStr,
|
||||
days_remaining: daysRemaining,
|
||||
minimum_required: minValidityDays,
|
||||
suggested_action: `Please upload a valid license with more than ${minValidityDays} days remaining.`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Future date (>MAX_FUTURE_YEARS from now)
|
||||
const maxFutureMs = nowMs + MAX_FUTURE_YEARS * 365 * MS_PER_DAY
|
||||
if (expMs > maxFutureMs) {
|
||||
return {
|
||||
valid: false,
|
||||
days_remaining: daysRemaining,
|
||||
expiration_date: expDateStr,
|
||||
message: `The provided expiration date (${expDateStr}) is too far in the future. Please verify the date is correct.`,
|
||||
error_code: ErrorCode.LICENSE_FUTURE_DATE,
|
||||
details: {
|
||||
expiration_date: expDateStr,
|
||||
days_remaining: daysRemaining,
|
||||
minimum_required: minValidityDays,
|
||||
suggested_action: 'Please check the expiration date on your license and re-enter it correctly.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Valid
|
||||
const nextReminder = new Date(expMs - minValidityDays * MS_PER_DAY).toISOString().split('T')[0]
|
||||
return {
|
||||
valid: true,
|
||||
days_remaining: daysRemaining,
|
||||
expiration_date: expDateStr,
|
||||
message: 'License verified successfully',
|
||||
details: {
|
||||
days_remaining: daysRemaining,
|
||||
minimum_required: minValidityDays,
|
||||
suggested_action: 'License is valid.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy validation function used internally by the customer service.
|
||||
* Returns a simpler result compatible with existing callers.
|
||||
*/
|
||||
export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult {
|
||||
if (!licenseExpiry) {
|
||||
return { status: 'VALID', daysUntilExpiry: null, requiresApproval: false, message: 'No expiry date on record' }
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / MS_PER_DAY)
|
||||
const minValidityDays = getMinValidityDays()
|
||||
const minValidityMs = minValidityDays * MS_PER_DAY
|
||||
|
||||
if (licenseExpiry <= now) {
|
||||
return { status: 'EXPIRED', daysUntilExpiry, requiresApproval: true, message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` }
|
||||
}
|
||||
|
||||
if (licenseExpiry.getTime() - now.getTime() < THREE_MONTHS_MS) {
|
||||
if (licenseExpiry.getTime() - now.getTime() < minValidityMs) {
|
||||
return { status: 'EXPIRING', daysUntilExpiry, requiresApproval: true, message: `License expires in ${daysUntilExpiry} day(s) — approval required` }
|
||||
}
|
||||
|
||||
@@ -42,11 +221,11 @@ export async function validateAndFlagLicense(customerId: string, companyId?: str
|
||||
await prisma.customer.updateMany({
|
||||
where: { id: customerId, companyId },
|
||||
data: {
|
||||
licenseExpired: result.status === 'EXPIRED',
|
||||
licenseExpiringSoon: result.status === 'EXPIRING',
|
||||
licenseValidationStatus: status,
|
||||
flagged: result.requiresApproval ? true : customer.flagged,
|
||||
flagReason: result.requiresApproval ? result.message : customer.flagReason,
|
||||
licenseExpired: result.status === 'EXPIRED',
|
||||
licenseExpiringSoon: result.status === 'EXPIRING',
|
||||
licenseValidationStatus: status,
|
||||
flagged: result.requiresApproval ? true : customer.flagged,
|
||||
flagReason: result.requiresApproval ? result.message : customer.flagReason,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
/** Return today's date as YYYY-MM-DD */
|
||||
function today(): string {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/** Return a date offset by `days` from today, as YYYY-MM-DD */
|
||||
function offsetDate(days: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + days)
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
describe('License Validation API — POST /api/v1/licenses/validate', () => {
|
||||
// ─── Success scenarios ──────────────────────────────────────────────
|
||||
|
||||
it('accepts a license expiring in 365 days (valid)', async () => {
|
||||
const future = offsetDate(365)
|
||||
const res = await request(app)
|
||||
.post('/api/v1/licenses/validate')
|
||||
.send({ expiration_date: future })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data).toMatchObject({
|
||||
valid: true,
|
||||
message: 'License verified successfully',
|
||||
})
|
||||
expect(res.body.data.days_remaining).toBeGreaterThanOrEqual(365)
|
||||
expect(res.body.data.next_reminder).toBeDefined()
|
||||
})
|
||||
|
||||
it('accepts a license expiring in exactly 30 days', async () => {
|
||||
const future = offsetDate(30)
|
||||
const res = await request(app)
|
||||
.post('/api/v1/licenses/validate')
|
||||
.send({ expiration_date: future })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.valid).toBe(true)
|
||||
expect(res.body.data.days_remaining).toBe(30)
|
||||
})
|
||||
|
||||
// ─── Rejection scenarios ────────────────────────────────────────────
|
||||
|
||||
it('rejects a license expiring in 15 days with 422', async () => {
|
||||
const nearFuture = offsetDate(15)
|
||||
const res = await request(app)
|
||||
.post('/api/v1/licenses/validate')
|
||||
.send({ expiration_date: nearFuture })
|
||||
|
||||
expect(res.status).toBe(422)
|
||||
expect(res.body.error).toBe('LICENSE_EXPIRING_SOON')
|
||||
expect(res.body.message).toContain('30 days')
|
||||
expect(res.body.details).toBeDefined()
|
||||
expect(res.body.details.days_remaining).toBe(15)
|
||||
expect(res.body.details.minimum_required).toBe(30)
|
||||
})
|
||||
|
||||
it('rejects an expired license with 422', async () => {
|
||||
const past = offsetDate(-5)
|
||||
const res = await request(app)
|
||||
.post('/api/v1/licenses/validate')
|
||||
.send({ expiration_date: past })
|
||||
|
||||
expect(res.status).toBe(422)
|
||||
expect(res.body.error).toBe('LICENSE_EXPIRED')
|
||||
expect(res.body.message).toContain('expired')
|
||||
})
|
||||
|
||||
it('rejects an invalid date format with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/licenses/validate')
|
||||
.send({ expiration_date: 'not-a-date' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toBe('LICENSE_INVALID_FORMAT')
|
||||
expect(res.body.message).toContain('Unable to parse')
|
||||
})
|
||||
|
||||
it('rejects an empty expiration_date with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/licenses/validate')
|
||||
.send({ expiration_date: '' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toBe('validation_error')
|
||||
})
|
||||
|
||||
it('rejects a date too far in the future with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/licenses/validate')
|
||||
.send({ expiration_date: '2055-01-01' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toBe('LICENSE_FUTURE_DATE')
|
||||
})
|
||||
|
||||
// ─── Multiple date formats ──────────────────────────────────────────
|
||||
|
||||
it('parses MM/DD/YYYY format', async () => {
|
||||
const future = new Date()
|
||||
future.setFullYear(future.getFullYear() + 1)
|
||||
const mm = String(future.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(future.getDate()).padStart(2, '0')
|
||||
const yyyy = future.getFullYear()
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/licenses/validate')
|
||||
.send({ expiration_date: `${mm}/${dd}/${yyyy}` })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('parses DD-MMM-YYYY format', async () => {
|
||||
const future = new Date()
|
||||
future.setFullYear(future.getFullYear() + 1)
|
||||
const dd = String(future.getDate()).padStart(2, '0')
|
||||
const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
|
||||
const mmm = months[future.getMonth()]
|
||||
const yyyy = future.getFullYear()
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/licenses/validate')
|
||||
.send({ expiration_date: `${dd}-${mmm}-${yyyy}` })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.valid).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https: wss:",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join('; '),
|
||||
},
|
||||
]
|
||||
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
|
||||
const toStoragePattern = (rawUrl) => {
|
||||
try {
|
||||
const u = new URL(rawUrl.replace(/\/api\/v1\/?$/, ''))
|
||||
@@ -46,13 +25,16 @@ const storagePatterns = [
|
||||
.filter(Boolean)
|
||||
.filter((p, i, arr) => arr.findIndex((q) => q.hostname === p.hostname && q.port === p.port) === i)
|
||||
|
||||
const DASHBOARD_BASE_PATH = '/dashboard'
|
||||
|
||||
// In Docker dev the dashboard app runs on port 3001 while the marketplace proxy
|
||||
// is served from port 3000. When DASHBOARD_ASSET_PREFIX is set, Next should emit
|
||||
// absolute chunk URLs so /dashboard pages load their own JS/CSS from port 3001.
|
||||
const assetPrefix = process.env.DASHBOARD_ASSET_PREFIX || undefined
|
||||
const assetPrefix = normalizeAssetPrefix(process.env.DASHBOARD_ASSET_PREFIX, DASHBOARD_BASE_PATH)
|
||||
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] })
|
||||
|
||||
const nextConfig = {
|
||||
basePath: '/dashboard',
|
||||
basePath: DASHBOARD_BASE_PATH,
|
||||
...(assetPrefix ? { assetPrefix } : {}),
|
||||
images: {
|
||||
remotePatterns: [
|
||||
@@ -79,6 +61,10 @@ const nextConfig = {
|
||||
source: '/api/:path*',
|
||||
destination: `${apiOrigin}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/storage/:path*',
|
||||
destination: `${apiOrigin}/storage/:path*`,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Plus, Edit, Eye, Search, Upload, X, ChevronDown, DollarSign } from 'lucide-react'
|
||||
import { Plus, Edit, Eye, Search, Upload, X, DollarSign } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
@@ -31,35 +32,13 @@ interface AddVehicleModalProps {
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<Vehicle['status'], string> = {
|
||||
AVAILABLE: 'badge-green',
|
||||
RESERVED: 'badge-blue',
|
||||
READY: 'badge-purple',
|
||||
RENTED: 'badge-indigo',
|
||||
RETURNED: 'badge-amber',
|
||||
NEEDS_CLEANING: 'badge-amber',
|
||||
MAINTENANCE: 'badge-amber',
|
||||
DAMAGE_REVIEW: 'badge-red',
|
||||
OUT_OF_SERVICE: 'badge-red',
|
||||
}
|
||||
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
||||
|
||||
const ALL_STATUSES: Vehicle['status'][] = [
|
||||
'AVAILABLE', 'RESERVED', 'READY', 'RENTED',
|
||||
'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE',
|
||||
]
|
||||
|
||||
const STATUS_DOT: Record<Vehicle['status'], string> = {
|
||||
AVAILABLE: 'bg-green-500',
|
||||
RESERVED: 'bg-blue-500',
|
||||
READY: 'bg-teal-500',
|
||||
RENTED: 'bg-indigo-500',
|
||||
RETURNED: 'bg-amber-400',
|
||||
NEEDS_CLEANING: 'bg-amber-500',
|
||||
MAINTENANCE: 'bg-orange-500',
|
||||
DAMAGE_REVIEW: 'bg-red-400',
|
||||
OUT_OF_SERVICE: 'bg-red-600',
|
||||
}
|
||||
|
||||
interface MaintenanceModalProps {
|
||||
vehicleId: string
|
||||
vehicleName: string
|
||||
@@ -156,88 +135,48 @@ function MaintenanceModal({ vehicleId, vehicleName, open, onClose, onSaved }: Ma
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDropdown({ vehicleId, status, onStatusChange }: {
|
||||
function StatusDropdown({ vehicleId, status, onStatusChange, disabled, disabledReason, onError }: {
|
||||
vehicleId: string
|
||||
status: Vehicle['status']
|
||||
onStatusChange: (id: string, status: Vehicle['status']) => void
|
||||
disabled?: boolean
|
||||
disabledReason?: string
|
||||
onError: (message: string) => void
|
||||
}) {
|
||||
const { dict } = useDashboardI18n()
|
||||
const f = dict.fleet
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const openDropdown = () => {
|
||||
if (triggerRef.current) {
|
||||
const r = triggerRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, left: r.left })
|
||||
}
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = (e: MouseEvent) => {
|
||||
if (
|
||||
!triggerRef.current?.contains(e.target as Node) &&
|
||||
!panelRef.current?.contains(e.target as Node)
|
||||
) setOpen(false)
|
||||
}
|
||||
const closeOnScroll = () => setOpen(false)
|
||||
document.addEventListener('mousedown', close)
|
||||
window.addEventListener('scroll', closeOnScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close)
|
||||
window.removeEventListener('scroll', closeOnScroll, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const select = async (next: Vehicle['status']) => {
|
||||
if (next === status) { setOpen(false); return }
|
||||
setOpen(false)
|
||||
if (next === status) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch(`/vehicles/${vehicleId}/status`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
|
||||
onStatusChange(vehicleId, next)
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
onError(err.message ?? 'Failed to update vehicle status')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={openDropdown}
|
||||
disabled={saving}
|
||||
className={`inline-flex items-center gap-1 ${STATUS_BADGE[status]} cursor-pointer select-none`}
|
||||
>
|
||||
{saving ? '…' : f.statusLabels[status]}
|
||||
<ChevronDown className="w-3 h-3 opacity-60" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
className="fixed z-50 min-w-[160px] rounded-xl border border-slate-100 bg-white shadow-lg py-1"
|
||||
>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => select(s)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-start hover:bg-slate-50 transition-colors ${s === status ? 'font-semibold' : ''}`}
|
||||
>
|
||||
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${STATUS_DOT[s]}`} />
|
||||
{f.statusLabels[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<select
|
||||
value={status}
|
||||
disabled={disabled || saving}
|
||||
title={disabled ? disabledReason : undefined}
|
||||
onChange={(e) => void select(e.target.value as Vehicle['status'])}
|
||||
className={[
|
||||
'rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm',
|
||||
disabled || saving ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-slate-300',
|
||||
].join(' ')}
|
||||
>
|
||||
{ALL_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{f.statusLabels[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -605,12 +544,23 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
}
|
||||
|
||||
export default function FleetPage() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const f = dict.fleet
|
||||
const managerRequiredMessage =
|
||||
language === 'fr'
|
||||
? 'Cette action nécessite le rôle Gestionnaire ou supérieur.'
|
||||
: language === 'ar'
|
||||
? 'يتطلب هذا الإجراء دور المدير أو أعلى.'
|
||||
: 'This action requires the Manager role or higher.'
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [employeeRole, setEmployeeRole] = useState<EmployeeRole | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
@@ -627,12 +577,51 @@ export default function FleetPage() {
|
||||
|
||||
useEffect(() => { fetchVehicles() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||
if (cached) {
|
||||
try {
|
||||
const profile = JSON.parse(cached) as { role?: EmployeeRole }
|
||||
if (profile.role) setEmployeeRole(profile.role)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
apiFetch<{ employee: { role: EmployeeRole } }>('/auth/employee/me')
|
||||
.then(({ employee }) => {
|
||||
if (cancelled) return
|
||||
setEmployeeRole(employee.role)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('modal') === 'add-car') {
|
||||
setShowAddModal(true)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const canManageFleet = employeeRole === 'OWNER' || employeeRole === 'MANAGER'
|
||||
|
||||
function closeAddModal() {
|
||||
setShowAddModal(false)
|
||||
if (searchParams.get('modal') === 'add-car') {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('modal')
|
||||
router.replace(params.toString() ? `${pathname}?${params.toString()}` : pathname)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePublished = async (id: string, current: boolean) => {
|
||||
setActionError(null)
|
||||
try {
|
||||
await apiFetch(`/vehicles/${id}`, { method: 'PATCH', body: JSON.stringify({ isPublished: !current }) })
|
||||
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, isPublished: !current } : v))
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
setActionError(err.statusCode === 403 ? managerRequiredMessage : (err.message ?? 'Failed to update publication status'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,11 +654,13 @@ export default function FleetPage() {
|
||||
<h2 className="text-xl font-semibold text-slate-900">{f.heading}</h2>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{f.vehicleCount(vehicles.length)}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAddModal(true)} className="btn-primary">
|
||||
<button onClick={() => setShowAddModal(true)} className="btn-primary" disabled={!canManageFleet} title={!canManageFleet ? managerRequiredMessage : undefined}>
|
||||
<Plus className="w-4 h-4" /> {f.addVehicle}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionError ? <div className="card p-4 text-sm text-red-600">{actionError}</div> : null}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-4 flex flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-48">
|
||||
@@ -739,7 +730,14 @@ export default function FleetPage() {
|
||||
<span className="badge-gray">{f.categoryLabels[vehicle.category as keyof typeof f.categoryLabels] ?? vehicle.category}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<StatusDropdown vehicleId={vehicle.id} status={vehicle.status} onStatusChange={changeStatus} />
|
||||
<StatusDropdown
|
||||
vehicleId={vehicle.id}
|
||||
status={vehicle.status}
|
||||
onStatusChange={changeStatus}
|
||||
disabled={!canManageFleet}
|
||||
disabledReason={managerRequiredMessage}
|
||||
onError={setActionError}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-medium text-slate-900">
|
||||
{formatCurrency(vehicle.dailyRate, 'MAD')}{f.perDay}
|
||||
@@ -748,8 +746,11 @@ export default function FleetPage() {
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
|
||||
disabled={!canManageFleet}
|
||||
title={!canManageFleet ? managerRequiredMessage : undefined}
|
||||
className={[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none',
|
||||
!canManageFleet ? 'cursor-not-allowed opacity-60' : '',
|
||||
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
|
||||
].join(' ')}
|
||||
>
|
||||
@@ -790,7 +791,7 @@ export default function FleetPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddVehicleModal open={showAddModal} onClose={() => setShowAddModal(false)} onSaved={fetchVehicles} />
|
||||
<AddVehicleModal open={showAddModal} onClose={closeAddModal} onSaved={fetchVehicles} />
|
||||
{maintenanceModal && (
|
||||
<MaintenanceModal
|
||||
vehicleId={maintenanceModal.vehicleId}
|
||||
|
||||
@@ -22,6 +22,12 @@ interface OnlineReservation {
|
||||
createdAt: string
|
||||
vehicle: { make: string; model: string; year: number; licensePlate: string }
|
||||
customer: { firstName: string; lastName: string; email: string; phone: string | null }
|
||||
bookingRequest: {
|
||||
stage: string
|
||||
documentsRequired: boolean
|
||||
paymentRequired: boolean
|
||||
documentsMissing: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
@@ -42,6 +48,33 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
NO_SHOW: 'No show',
|
||||
}
|
||||
|
||||
const REQUEST_STAGE_STYLE: Record<string, string> = {
|
||||
REQUEST_SENT: 'bg-orange-100 text-orange-800',
|
||||
COMPANY_CONFIRMED: 'bg-blue-100 text-blue-800',
|
||||
DOCUMENTS_REQUIRED: 'bg-amber-100 text-amber-800',
|
||||
PAYMENT_REQUIRED: 'bg-purple-100 text-purple-800',
|
||||
BOOKING_CONFIRMED: 'bg-emerald-100 text-emerald-700',
|
||||
CANCELLED: 'bg-rose-100 text-rose-700',
|
||||
}
|
||||
|
||||
const REQUEST_STAGE_LABEL: Record<string, string> = {
|
||||
REQUEST_SENT: 'Waiting for company review',
|
||||
COMPANY_CONFIRMED: 'Availability confirmed',
|
||||
DOCUMENTS_REQUIRED: 'Driver documents missing',
|
||||
PAYMENT_REQUIRED: 'Payment or deposit pending',
|
||||
BOOKING_CONFIRMED: 'Ready to proceed',
|
||||
CANCELLED: 'Request closed',
|
||||
}
|
||||
|
||||
const MISSING_ITEM_LABEL: Record<string, string> = {
|
||||
DATE_OF_BIRTH: 'Date of birth',
|
||||
IDENTITY_DOCUMENT: 'Identity document',
|
||||
FULL_ADDRESS: 'Full address',
|
||||
DRIVER_LICENSE: 'Driver license number',
|
||||
LICENSE_IMAGE: 'License image',
|
||||
LICENSE_APPROVAL: 'License approval',
|
||||
}
|
||||
|
||||
function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: string) => void; onCancel: () => void; loading: boolean }) {
|
||||
const [reason, setReason] = useState('')
|
||||
return (
|
||||
@@ -237,6 +270,8 @@ function ReservationCard({ r, acting, onConfirm, onDecline }: {
|
||||
onDecline: () => void
|
||||
}) {
|
||||
const days = r.totalDays
|
||||
const missingItems = r.bookingRequest.documentsMissing.map((item) => MISSING_ITEM_LABEL[item] ?? item)
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden border-l-4 border-l-orange-400">
|
||||
<div className="p-5">
|
||||
@@ -267,6 +302,27 @@ function ReservationCard({ r, acting, onConfirm, onDecline }: {
|
||||
<p className="text-sm text-slate-600 italic">"{r.notes}"</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${REQUEST_STAGE_STYLE[r.bookingRequest.stage] ?? 'bg-slate-100 text-slate-700'}`}>
|
||||
{REQUEST_STAGE_LABEL[r.bookingRequest.stage] ?? r.bookingRequest.stage}
|
||||
</span>
|
||||
{r.bookingRequest.paymentRequired ? (
|
||||
<span className="inline-flex rounded-full bg-purple-100 px-2.5 py-1 text-xs font-semibold text-purple-800">
|
||||
Payment pending
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{missingItems.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{missingItems.map((item) => (
|
||||
<span key={item} className="inline-flex rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-800">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: total + actions */}
|
||||
|
||||
@@ -66,6 +66,13 @@ interface ReservationDetail {
|
||||
checkInInspectionEditable: boolean
|
||||
checkOutInspectionEditable: boolean
|
||||
}
|
||||
bookingRequest: {
|
||||
stage: string
|
||||
companyConfirmed: boolean
|
||||
documentsRequired: boolean
|
||||
paymentRequired: boolean
|
||||
documentsMissing: string[]
|
||||
}
|
||||
}
|
||||
|
||||
type EditMode = 'booking' | 'return' | null
|
||||
@@ -118,6 +125,24 @@ const detailCopy = {
|
||||
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
|
||||
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
|
||||
licenseImageLabel: 'License image',
|
||||
bookingProgress: 'Booking progress',
|
||||
paymentPending: 'Payment or deposit still pending',
|
||||
stageLabels: {
|
||||
REQUEST_SENT: 'Waiting for company review',
|
||||
COMPANY_CONFIRMED: 'Availability confirmed',
|
||||
DOCUMENTS_REQUIRED: 'Driver documents missing',
|
||||
PAYMENT_REQUIRED: 'Payment or deposit pending',
|
||||
BOOKING_CONFIRMED: 'Ready to proceed',
|
||||
CANCELLED: 'Request closed',
|
||||
},
|
||||
missingItems: {
|
||||
DATE_OF_BIRTH: 'Date of birth',
|
||||
IDENTITY_DOCUMENT: 'Identity document',
|
||||
FULL_ADDRESS: 'Full address',
|
||||
DRIVER_LICENSE: 'Driver license number',
|
||||
LICENSE_IMAGE: 'License image',
|
||||
LICENSE_APPROVAL: 'License approval',
|
||||
},
|
||||
noLicenseImage: 'No license image uploaded.',
|
||||
pickupPhotosTitle: 'Pickup photos',
|
||||
dropoffPhotosTitle: 'Return photos',
|
||||
@@ -167,6 +192,24 @@ const detailCopy = {
|
||||
checkOutReadOnly: 'L’inspection de retour devient modifiable une fois le véhicule retourné.',
|
||||
inspectionClosed: 'Cette réservation est clôturée. Les modifications d’inspection sont désactivées.',
|
||||
licenseImageLabel: 'Image du permis',
|
||||
bookingProgress: 'Progression de la réservation',
|
||||
paymentPending: 'Paiement ou dépôt encore en attente',
|
||||
stageLabels: {
|
||||
REQUEST_SENT: 'En attente de validation',
|
||||
COMPANY_CONFIRMED: 'Disponibilité confirmée',
|
||||
DOCUMENTS_REQUIRED: 'Documents conducteur manquants',
|
||||
PAYMENT_REQUIRED: 'Paiement ou dépôt en attente',
|
||||
BOOKING_CONFIRMED: 'Prêt à avancer',
|
||||
CANCELLED: 'Demande clôturée',
|
||||
},
|
||||
missingItems: {
|
||||
DATE_OF_BIRTH: 'Date de naissance',
|
||||
IDENTITY_DOCUMENT: 'Pièce d’identité',
|
||||
FULL_ADDRESS: 'Adresse complète',
|
||||
DRIVER_LICENSE: 'Numéro du permis',
|
||||
LICENSE_IMAGE: 'Image du permis',
|
||||
LICENSE_APPROVAL: 'Validation du permis',
|
||||
},
|
||||
noLicenseImage: 'Aucune image de permis téléversée.',
|
||||
pickupPhotosTitle: 'Photos de départ',
|
||||
dropoffPhotosTitle: 'Photos de retour',
|
||||
@@ -216,6 +259,24 @@ const detailCopy = {
|
||||
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
|
||||
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
|
||||
licenseImageLabel: 'صورة الرخصة',
|
||||
bookingProgress: 'تقدم الحجز',
|
||||
paymentPending: 'الدفع أو العربون ما زال معلقاً',
|
||||
stageLabels: {
|
||||
REQUEST_SENT: 'بانتظار مراجعة الشركة',
|
||||
COMPANY_CONFIRMED: 'تم تأكيد التوفر',
|
||||
DOCUMENTS_REQUIRED: 'وثائق السائق ناقصة',
|
||||
PAYMENT_REQUIRED: 'الدفع أو العربون معلق',
|
||||
BOOKING_CONFIRMED: 'جاهز للمتابعة',
|
||||
CANCELLED: 'تم إغلاق الطلب',
|
||||
},
|
||||
missingItems: {
|
||||
DATE_OF_BIRTH: 'تاريخ الميلاد',
|
||||
IDENTITY_DOCUMENT: 'وثيقة الهوية',
|
||||
FULL_ADDRESS: 'العنوان الكامل',
|
||||
DRIVER_LICENSE: 'رقم الرخصة',
|
||||
LICENSE_IMAGE: 'صورة الرخصة',
|
||||
LICENSE_APPROVAL: 'اعتماد الرخصة',
|
||||
},
|
||||
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
|
||||
pickupPhotosTitle: 'صور الاستلام',
|
||||
dropoffPhotosTitle: 'صور الإرجاع',
|
||||
@@ -534,6 +595,31 @@ export default function ReservationDetailPage() {
|
||||
|
||||
{actionError && <div className="card p-4 text-sm text-red-600">{actionError}</div>}
|
||||
|
||||
{reservation.source === 'MARKETPLACE' ? (
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.bookingProgress}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="badge-gray">
|
||||
{copy.stageLabels[reservation.bookingRequest.stage as keyof typeof copy.stageLabels] ?? reservation.bookingRequest.stage}
|
||||
</span>
|
||||
{reservation.bookingRequest.paymentRequired ? (
|
||||
<span className="rounded-full bg-purple-100 px-2.5 py-1 text-xs font-semibold text-purple-800">
|
||||
{copy.paymentPending}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{reservation.bookingRequest.documentsMissing.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{reservation.bookingRequest.documentsMissing.map((item) => (
|
||||
<span key={item} className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
|
||||
{copy.missingItems[item as keyof typeof copy.missingItems] ?? item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCustomer}</h3>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
@@ -25,18 +26,26 @@ interface ReservationRow {
|
||||
customer: { firstName: string; lastName: string; email: string }
|
||||
}
|
||||
|
||||
interface VehicleRow {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default function ReservationsPage() {
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const router = useRouter()
|
||||
const r = dict.reservations
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
|
||||
const [rows, setRows] = useState<ReservationRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [checkingNewReservation, setCheckingNewReservation] = useState(false)
|
||||
const [showNoVehiclesModal, setShowNoVehiclesModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
|
||||
.then((result) => setRows(result ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
.catch((err) => setLoadError(err.message))
|
||||
}, [])
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
@@ -52,6 +61,40 @@ export default function ReservationsPage() {
|
||||
}
|
||||
return language === 'fr' ? 'Voir la réservation' : language === 'ar' ? 'عرض الحجز' : 'View reservation'
|
||||
}
|
||||
const noVehiclesMessage =
|
||||
language === 'fr'
|
||||
? 'Ajoutez au moins un véhicule à votre flotte avant de créer une réservation.'
|
||||
: language === 'ar'
|
||||
? 'أضف مركبة واحدة على الأقل إلى الأسطول قبل إنشاء حجز.'
|
||||
: 'Add at least one vehicle to your fleet before creating a reservation.'
|
||||
|
||||
async function handleNewReservationClick() {
|
||||
setActionError(null)
|
||||
setCheckingNewReservation(true)
|
||||
try {
|
||||
const vehicles = await apiFetch<VehicleRow[]>('/vehicles?pageSize=1')
|
||||
if (!vehicles || vehicles.length === 0) {
|
||||
setShowNoVehiclesModal(true)
|
||||
return
|
||||
}
|
||||
router.push('/reservations/new')
|
||||
} catch (err: any) {
|
||||
setActionError(err.message ?? (
|
||||
language === 'fr'
|
||||
? 'Impossible de vérifier la flotte avant de créer la réservation.'
|
||||
: language === 'ar'
|
||||
? 'تعذر التحقق من الأسطول قبل إنشاء الحجز.'
|
||||
: 'Failed to verify fleet before creating the reservation.'
|
||||
))
|
||||
} finally {
|
||||
setCheckingNewReservation(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddCar() {
|
||||
setShowNoVehiclesModal(false)
|
||||
router.push('/fleet?modal=add-car')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -60,13 +103,29 @@ export default function ReservationsPage() {
|
||||
<h2 className="text-xl font-semibold text-slate-900">{r.heading}</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">{r.subtitle}</p>
|
||||
</div>
|
||||
<Link href="/reservations/new" className="btn-primary whitespace-nowrap">
|
||||
{language === 'fr' ? 'Réserver une voiture' : language === 'ar' ? 'حجز سيارة' : 'Book car'}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary whitespace-nowrap"
|
||||
onClick={handleNewReservationClick}
|
||||
disabled={checkingNewReservation}
|
||||
>
|
||||
{checkingNewReservation
|
||||
? language === 'fr'
|
||||
? 'Vérification…'
|
||||
: language === 'ar'
|
||||
? 'جارٍ التحقق…'
|
||||
: 'Checking…'
|
||||
: language === 'fr'
|
||||
? 'Réserver une voiture'
|
||||
: language === 'ar'
|
||||
? 'حجز سيارة'
|
||||
: 'Book car'}
|
||||
</button>
|
||||
</div>
|
||||
{actionError ? <div className="card p-4 text-sm text-red-600">{actionError}</div> : null}
|
||||
<div className="card overflow-hidden">
|
||||
{error ? (
|
||||
<div className="p-8 text-sm text-red-600">{error}</div>
|
||||
{loadError ? (
|
||||
<div className="p-8 text-sm text-red-600">{loadError}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -109,6 +168,29 @@ export default function ReservationsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showNoVehiclesModal ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40 p-4"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) setShowNoVehiclesModal(false)
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{language === 'fr' ? 'Ajouter un véhicule' : language === 'ar' ? 'أضف مركبة' : 'Add a car'}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-slate-600">{noVehiclesMessage}</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button type="button" className="btn-primary flex-1" onClick={handleAddCar}>
|
||||
{language === 'fr' ? 'Ajouter une voiture' : language === 'ar' ? 'إضافة مركبة' : 'Add car'}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary flex-1" onClick={() => setShowNoVehiclesModal(false)}>
|
||||
{language === 'fr' ? 'Annuler' : language === 'ar' ? 'إلغاء' : 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -218,8 +218,10 @@ function LocalSignInForm({
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const requestedPortal = searchParams.get('portal')
|
||||
const adminNext = searchParams.get('next') || '/dashboard'
|
||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
|
||||
const preferAdminAuth = requestedPortal === 'admin'
|
||||
|
||||
function redirectAdmin(token: string) {
|
||||
const hash = new URLSearchParams({ token, next: adminNext }).toString()
|
||||
@@ -232,54 +234,71 @@ function LocalSignInForm({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const empJson = await empRes.json()
|
||||
const tryAdminLogin = async () => {
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (empRes.ok && empJson?.data?.employee) {
|
||||
const targetPath = toPublicDashboardPath(employeeRedirect)
|
||||
if (empJson?.data?.employee) {
|
||||
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
|
||||
const prefLang = empJson.data.employee?.preferredLanguage
|
||||
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
|
||||
setLanguage(prefLang)
|
||||
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
if (adminRes.ok && adminJson?.data?.admin) {
|
||||
window.location.href = `${adminUrl}/dashboard`
|
||||
return true
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||
notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
|
||||
// Use a document navigation here so the authenticated dashboard bootstraps
|
||||
// from a fresh request after the token cookie and localStorage are set.
|
||||
window.location.replace(targetPath)
|
||||
|
||||
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
|
||||
setStep('totp')
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const tryEmployeeLogin = async () => {
|
||||
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const empJson = await empRes.json()
|
||||
|
||||
if (empRes.ok && empJson?.data?.employee) {
|
||||
const targetPath = toPublicDashboardPath(employeeRedirect)
|
||||
if (empJson?.data?.employee) {
|
||||
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
|
||||
const prefLang = empJson.data.employee?.preferredLanguage
|
||||
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
|
||||
setLanguage(prefLang)
|
||||
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||
notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
|
||||
// Use a document navigation here so the authenticated dashboard bootstraps
|
||||
// from a fresh request after the token cookie and localStorage are set.
|
||||
window.location.replace(targetPath)
|
||||
return true
|
||||
}
|
||||
|
||||
if (empJson?.error === 'password_not_set') {
|
||||
setError(dict.passwordNotSet)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (preferAdminAuth) {
|
||||
if (await tryAdminLogin()) return
|
||||
setError(dict.invalidCredentials)
|
||||
return
|
||||
}
|
||||
|
||||
if (empJson?.error === 'password_not_set') {
|
||||
setError(dict.passwordNotSet)
|
||||
return
|
||||
}
|
||||
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.admin) {
|
||||
window.location.href = `${adminUrl}/dashboard`
|
||||
return
|
||||
}
|
||||
|
||||
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
|
||||
setStep('totp')
|
||||
return
|
||||
}
|
||||
if (await tryEmployeeLogin()) return
|
||||
if (await tryAdminLogin()) return
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
'use client'
|
||||
|
||||
import * as React from "react";
|
||||
import PublicFooter from '@/components/layout/PublicFooter'
|
||||
import PublicHeader from '@/components/layout/PublicHeader'
|
||||
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https: wss:",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join('; '),
|
||||
},
|
||||
]
|
||||
const { buildSecurityHeaders } = require('../../config/nextSecurityHeaders')
|
||||
|
||||
// The marketplace proxies /dashboard and /admin to their own Next dev servers.
|
||||
// Allow those asset-prefix origins so proxied pages can load chunks and HMR.
|
||||
const securityHeaders = buildSecurityHeaders({
|
||||
assetSources: [process.env.DASHBOARD_ASSET_PREFIX, process.env.ADMIN_ASSET_PREFIX],
|
||||
})
|
||||
const nextConfig = {
|
||||
images: {
|
||||
domains: ['res.cloudinary.com'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
|
||||
import { type MarketplaceLanguage } from '@/lib/i18n'
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './MarketplaceShell'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('MarketplaceShell auth helpers', () => {
|
||||
it('does not report an employee profile when the browser cache is empty', () => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
getItem: vi.fn(() => null),
|
||||
},
|
||||
})
|
||||
|
||||
expect(hasCachedEmployeeProfile()).toBe(false)
|
||||
})
|
||||
|
||||
it('detects the cached employee profile marker written by dashboard sign-in', () => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
getItem: vi.fn((key: string) => (key === 'employee_profile' ? '{"id":"emp_1"}' : null)),
|
||||
},
|
||||
})
|
||||
|
||||
expect(hasCachedEmployeeProfile()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the cached employee profile marker', () => {
|
||||
const removeItem = vi.fn()
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
removeItem,
|
||||
},
|
||||
})
|
||||
|
||||
clearCachedEmployeeProfile()
|
||||
|
||||
expect(removeItem).toHaveBeenCalledWith('employee_profile')
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,8 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
|
||||
},
|
||||
}
|
||||
|
||||
const EMPLOYEE_PROFILE_KEY = 'employee_profile'
|
||||
|
||||
export const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [
|
||||
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
|
||||
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
|
||||
@@ -127,6 +129,24 @@ export function getFooterContent(language: MarketplaceLanguage): {
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCachedEmployeeProfile(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
|
||||
try {
|
||||
return Boolean(window.localStorage.getItem(EMPLOYEE_PROFILE_KEY))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCachedEmployeeProfile() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function detectBrowserLanguage(): string | null {
|
||||
if (typeof navigator === 'undefined') return null
|
||||
const langs = Array.from(navigator.languages ?? [navigator.language])
|
||||
@@ -201,12 +221,20 @@ export default function MarketplaceShell({
|
||||
}
|
||||
|
||||
async function syncCompanyBrand() {
|
||||
if (!hasCachedEmployeeProfile()) {
|
||||
setCompanyName(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/companies/me/brand`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearCachedEmployeeProfile()
|
||||
}
|
||||
setCompanyName(null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ describe('marketplace API helpers', () => {
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
await expect(marketplaceFetch('/marketplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/marketplace\/vehicles$/), { cache: 'no-store' })
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/marketplace\/vehicles$/), {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws rich marketplace API errors', async () => {
|
||||
@@ -53,6 +56,7 @@ describe('marketplace API helpers', () => {
|
||||
await expect(marketplacePost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vehicleId: 'vehicle_1' }),
|
||||
}))
|
||||
|
||||
@@ -17,8 +17,12 @@ export class MarketplaceApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export async function marketplaceFetch<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' })
|
||||
export async function marketplaceFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
...init,
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
|
||||
return json.data as T
|
||||
@@ -36,6 +40,7 @@ export async function marketplacePost<T>(path: string, body: unknown): Promise<T
|
||||
const base = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
function normalizeAssetPrefix(rawValue, basePath) {
|
||||
if (!rawValue) return undefined
|
||||
|
||||
try {
|
||||
const url = new URL(rawValue)
|
||||
const pathname = url.pathname.replace(/\/$/, '')
|
||||
if (!pathname.endsWith(basePath)) {
|
||||
url.pathname = `${pathname}${basePath}`
|
||||
}
|
||||
return url.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
const trimmed = rawValue.replace(/\/$/, '')
|
||||
return trimmed.endsWith(basePath) ? trimmed : `${trimmed}${basePath}`
|
||||
}
|
||||
}
|
||||
|
||||
function collectOrigins(assetSources) {
|
||||
const origins = new Set()
|
||||
|
||||
for (const source of assetSources) {
|
||||
if (!source) continue
|
||||
|
||||
try {
|
||||
origins.add(new URL(source).origin)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return [...origins]
|
||||
}
|
||||
|
||||
function collectWebSocketOrigins(assetSources) {
|
||||
const origins = new Set()
|
||||
|
||||
for (const source of assetSources) {
|
||||
if (!source) continue
|
||||
|
||||
try {
|
||||
const url = new URL(source)
|
||||
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
origins.add(`${protocol}//${url.host}`)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return [...origins]
|
||||
}
|
||||
|
||||
function collectBrowserApiSources() {
|
||||
const sources = [process.env.NEXT_PUBLIC_API_URL, process.env.API_URL]
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
sources.push('http://localhost:4000/api/v1')
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
function buildSecurityHeaders({ assetSources = [], connectSources = collectBrowserApiSources() } = {}) {
|
||||
const assetOrigins = collectOrigins(assetSources)
|
||||
const connectOrigins = collectOrigins(connectSources)
|
||||
const websocketOrigins = [
|
||||
...collectWebSocketOrigins(assetSources),
|
||||
...collectWebSocketOrigins(connectSources),
|
||||
]
|
||||
|
||||
const scriptSrc = ["'self'", "'unsafe-inline'"]
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
scriptSrc.push("'unsafe-eval'")
|
||||
}
|
||||
scriptSrc.push(...assetOrigins)
|
||||
|
||||
const styleSrc = ["'self'", "'unsafe-inline'", ...assetOrigins]
|
||||
const imgSrc = [...new Set(["'self'", 'data:', 'blob:', 'https:', ...assetOrigins, ...connectOrigins])]
|
||||
const fontSrc = ["'self'", 'data:', ...assetOrigins]
|
||||
const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])]
|
||||
|
||||
return [
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
`script-src ${scriptSrc.join(' ')}`,
|
||||
`style-src ${styleSrc.join(' ')}`,
|
||||
`img-src ${imgSrc.join(' ')}`,
|
||||
`font-src ${fontSrc.join(' ')}`,
|
||||
`connect-src ${connectSrc.join(' ')}`,
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join('; '),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSecurityHeaders,
|
||||
normalizeAssetPrefix,
|
||||
}
|
||||
+30
-1
@@ -81,6 +81,17 @@ services:
|
||||
- postgres_bootstrap_state:/state
|
||||
- api_uploads_dev:/var/lib/rentaldrivego/storage
|
||||
|
||||
types:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
command: ["sh", "-c", "cd /app/packages/types && exec npm run dev"]
|
||||
volumes:
|
||||
- .:/app
|
||||
- app_node_modules:/app/node_modules
|
||||
restart: unless-stopped
|
||||
profiles: ["api", "marketplace", "dashboard", "admin", "full"]
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
@@ -92,11 +103,15 @@ services:
|
||||
condition: service_started
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
types:
|
||||
condition: service_started
|
||||
env_file:
|
||||
- .env.docker.dev
|
||||
environment:
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
CHOKIDAR_INTERVAL: "1000"
|
||||
FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage
|
||||
command: ["sh", "-c", "npm run build --workspace @rentaldrivego/types && cd /app/apps/api && exec /app/node_modules/.bin/ts-node-dev --respawn --transpile-only --ignore-watch ../../packages/types/dist src/index.ts"]
|
||||
command: ["sh", "-c", "npm run build --workspace @rentaldrivego/types && cd /app/apps/api && exec /app/node_modules/.bin/ts-node-dev --respawn --transpile-only src/index.ts"]
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
@@ -112,8 +127,13 @@ services:
|
||||
dockerfile: Dockerfile.dev
|
||||
depends_on:
|
||||
- api
|
||||
- types
|
||||
env_file:
|
||||
- .env.docker.dev
|
||||
environment:
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
CHOKIDAR_INTERVAL: "1000"
|
||||
WATCHPACK_POLLING: "true"
|
||||
command:
|
||||
[
|
||||
"sh",
|
||||
@@ -135,8 +155,13 @@ services:
|
||||
dockerfile: Dockerfile.dev
|
||||
depends_on:
|
||||
- api
|
||||
- types
|
||||
env_file:
|
||||
- .env.docker.dev
|
||||
environment:
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
CHOKIDAR_INTERVAL: "1000"
|
||||
WATCHPACK_POLLING: "true"
|
||||
command:
|
||||
[
|
||||
"sh",
|
||||
@@ -158,10 +183,14 @@ services:
|
||||
dockerfile: Dockerfile.dev
|
||||
depends_on:
|
||||
- api
|
||||
- types
|
||||
env_file:
|
||||
- .env.docker.dev
|
||||
environment:
|
||||
ADMIN_ASSET_PREFIX: "http://localhost:3002"
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
CHOKIDAR_INTERVAL: "1000"
|
||||
WATCHPACK_POLLING: "true"
|
||||
command:
|
||||
[
|
||||
"sh",
|
||||
|
||||
Regular → Executable
+27
-2
@@ -1,10 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
REAL_NPM="/usr/local/bin/npm"
|
||||
SELF_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
|
||||
|
||||
if [ -n "${REAL_NPM:-}" ]; then
|
||||
:
|
||||
elif [ -x /usr/local/bin/npm ]; then
|
||||
REAL_NPM=/usr/local/bin/npm
|
||||
elif [ -x /opt/homebrew/bin/npm ]; then
|
||||
REAL_NPM=/opt/homebrew/bin/npm
|
||||
else
|
||||
PATH_WITHOUT_SELF=$(printf '%s' "$PATH" | sed "s#${SELF_DIR}:##; s#:${SELF_DIR}##; s#${SELF_DIR}\$##")
|
||||
REAL_NPM=$(PATH="$PATH_WITHOUT_SELF" command -v npm)
|
||||
fi
|
||||
|
||||
find_workspace_root() {
|
||||
dir="$PWD"
|
||||
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -f "$dir/package.json" ] && grep -q '"workspaces"' "$dir/package.json"; then
|
||||
printf '%s\n' "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir=$(dirname "$dir")
|
||||
done
|
||||
|
||||
printf '%s\n' "$PWD"
|
||||
}
|
||||
|
||||
if [ "${1-}" = "config" ] && [ "${2-}" = "get" ] && [ "${3-}" = "registry" ]; then
|
||||
exec "$REAL_NPM" --prefix "$PWD" "$@"
|
||||
exec "$REAL_NPM" --prefix "$(find_workspace_root)" config get registry
|
||||
fi
|
||||
|
||||
exec "$REAL_NPM" "$@"
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Car Booking Process Revision and Improvement Plan
|
||||
|
||||
## Problem
|
||||
|
||||
The current renter booking process is too heavy too early. It asks for identity documents, full address, and detailed license data before the renter has even submitted a reservation request. That creates unnecessary friction, lowers conversion, and makes the flow feel like paperwork instead of booking.
|
||||
|
||||
## Revision Applied
|
||||
|
||||
The booking form has been simplified into a low-friction request flow:
|
||||
|
||||
1. Trip details first: pick-up location, drop-off choice, pick-up date, return date, and estimated total.
|
||||
2. Contact details second: first name, last name, email, phone, and optional notes.
|
||||
3. Driver details moved into an optional collapsible section.
|
||||
4. Submission copy changed from a formal “reservation request” flow to a faster “request this car in 1 minute” flow.
|
||||
5. Required validation reduced to only what is necessary to start the booking conversation: dates, name, email, phone, and required locations.
|
||||
6. Optional driver fields are still supported and sent to the same backend if the renter chooses to provide them.
|
||||
7. The existing backend contract is preserved because those identity and license fields are already optional in the public reservation schema.
|
||||
|
||||
## Why This Is Better
|
||||
|
||||
The old flow forced renters to provide high-trust personal data before they had enough confidence that the booking was worth completing. That is backwards. The revised flow asks only for the information needed to check availability and contact the renter. Driver documents can be collected after the company confirms availability, pricing, and intent.
|
||||
|
||||
This reduces cognitive load, reduces privacy anxiety, and makes mobile completion more realistic. Yes, apparently people abandon forms when confronted with a small government census inside a car card. Shocking.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Frontend Simplification
|
||||
|
||||
Replace the current booking form with the revised version in:
|
||||
|
||||
`apps/marketplace/src/components/BookingForm.tsx`
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The visible default form fits into three clear sections: trip details, contact details, optional driver details.
|
||||
- Driver identity and license fields are hidden by default.
|
||||
- Required fields are only trip dates, renter name, email, phone, and required locations.
|
||||
- Estimated total still updates when valid dates are entered.
|
||||
- Existing helper behavior for total days, return location, and optional text normalization remains intact.
|
||||
|
||||
### Phase 2: Availability Confidence
|
||||
|
||||
Add a lightweight availability check before or during submission.
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- When dates are selected, call an availability endpoint for the selected vehicle and date range.
|
||||
- If unavailable, show the next available date before the renter fills contact details.
|
||||
- If available, show a small “Likely available” message, without overpromising final confirmation.
|
||||
|
||||
Backend candidate:
|
||||
|
||||
- Add or expose `GET /marketplace/:slug/vehicles/:id/availability?startDate=&endDate=`.
|
||||
- Reuse the existing `getVehicleAvailabilitySummary` service instead of inventing yet another inconsistent truth source, because apparently systems enjoy lying to themselves.
|
||||
|
||||
### Phase 3: Booking Status Transparency
|
||||
|
||||
After submission, show exactly what happens next.
|
||||
|
||||
Suggested post-submit states:
|
||||
|
||||
- Request sent.
|
||||
- Company confirms availability.
|
||||
- Renter provides documents if required.
|
||||
- Payment/deposit is completed.
|
||||
- Booking confirmed.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The renter understands that the first submission is a request, not a guaranteed reservation.
|
||||
- Staff can see incomplete document status inside the dashboard.
|
||||
- Reminder notifications are triggered if documents or payment are missing.
|
||||
|
||||
### Phase 4: Save Renter Profile
|
||||
|
||||
For logged-in renters, prefill known data.
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- Prefill name, email, and phone from the renter profile.
|
||||
- Let renters save driver details to their account after submitting once.
|
||||
- Never require repeated entry of license and identity fields for returning renters unless expired or missing.
|
||||
|
||||
### Phase 5: Measure the Flow
|
||||
|
||||
Track funnel metrics so the team stops guessing, a radical concept.
|
||||
|
||||
Recommended events:
|
||||
|
||||
- `booking_form_viewed`
|
||||
- `trip_dates_selected`
|
||||
- `contact_details_started`
|
||||
- `driver_details_expanded`
|
||||
- `booking_request_submitted`
|
||||
- `booking_request_failed`
|
||||
- `booking_request_success`
|
||||
|
||||
Primary KPIs:
|
||||
|
||||
- Form completion rate.
|
||||
- Time to submit.
|
||||
- Drop-off rate before contact details.
|
||||
- Drop-off rate after driver details expansion.
|
||||
- Request-to-confirmation conversion.
|
||||
|
||||
## Future UX Improvements
|
||||
|
||||
- Add date and time selection, not just dates, because rental handoff depends on time.
|
||||
- Add promo code validation inside the vehicle page, not only search.
|
||||
- Add a sticky mobile bottom bar with price estimate and submit button.
|
||||
- Add a progress indicator: Trip → Contact → Optional documents.
|
||||
- Add WhatsApp fallback after successful submission for companies that rely on chat.
|
||||
- Support document upload after confirmation rather than typing document numbers into a tiny form.
|
||||
|
||||
## Risk Notes
|
||||
|
||||
- Do not remove backend support for driver fields. Some companies may need them for compliance or faster approvals.
|
||||
- Do not make the first request feel like a guaranteed booking unless inventory locking and payment are implemented.
|
||||
- Do not collect sensitive identity data unless there is a clear business reason and proper privacy handling.
|
||||
- Do not bury the total price. Price uncertainty kills trust faster than bad button colors, though humans keep trying both.
|
||||
@@ -0,0 +1,724 @@
|
||||
# Plan: Fix and Simplify Car Booking Without Adding Customer Accounts
|
||||
|
||||
## Objective
|
||||
|
||||
Improve the car booking process while keeping the current guest booking model.
|
||||
|
||||
This plan does **not** add:
|
||||
|
||||
- Customer accounts
|
||||
- Customer login
|
||||
- Renter dashboard
|
||||
- Global customer profile
|
||||
- Cross-company customer sharing
|
||||
- Saved customer identity
|
||||
|
||||
The goal is to make booking smoother, safer, and simpler while keeping each company’s customer data private.
|
||||
|
||||
---
|
||||
|
||||
## Current Direction
|
||||
|
||||
The system should continue using this structure:
|
||||
|
||||
```txt
|
||||
Company
|
||||
└── Customer
|
||||
└── Reservation
|
||||
```
|
||||
|
||||
A customer who books with Company A exists as a customer under Company A.
|
||||
|
||||
If the same person books with Company B, they become a separate customer under Company B.
|
||||
|
||||
This is the correct approach for now.
|
||||
|
||||
---
|
||||
|
||||
## Core Privacy Rule
|
||||
|
||||
Customer data must always belong to one company.
|
||||
|
||||
The system must never allow Company A to view, edit, search, or infer customer data from Company B.
|
||||
|
||||
Correct model:
|
||||
|
||||
```txt
|
||||
Company A
|
||||
└── john@example.com
|
||||
|
||||
Company B
|
||||
└── john@example.com
|
||||
```
|
||||
|
||||
These are two separate customer records.
|
||||
|
||||
Do **not** create a shared global customer record.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Keep Guest Booking Only
|
||||
|
||||
### Decision
|
||||
|
||||
Do not build customer accounts now.
|
||||
|
||||
Booking should work without login.
|
||||
|
||||
The customer only submits the information needed to request a booking.
|
||||
|
||||
### Required Behavior
|
||||
|
||||
When a customer books a car:
|
||||
|
||||
1. Customer selects a car.
|
||||
2. Customer enters basic booking and contact information.
|
||||
3. Backend finds the car.
|
||||
4. Backend derives the company from the car.
|
||||
5. Backend finds or creates a customer under that company.
|
||||
6. Backend creates a reservation under that company.
|
||||
|
||||
The customer should not need to create an account to make a booking.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Simplify the Booking Form
|
||||
|
||||
### Problem
|
||||
|
||||
The current booking process asks for too much information too early.
|
||||
|
||||
That creates friction and makes the booking feel heavier than it needs to be.
|
||||
|
||||
### Fix
|
||||
|
||||
The first booking form should only ask for:
|
||||
|
||||
- Name
|
||||
- Email
|
||||
- Phone
|
||||
- Pickup date
|
||||
- Return date
|
||||
- Pickup location
|
||||
- Return location
|
||||
- Optional message
|
||||
|
||||
### Move These Fields Out of the First Step
|
||||
|
||||
Do not require these during the initial booking request:
|
||||
|
||||
- Driver license
|
||||
- License expiration date
|
||||
- Government ID
|
||||
- Passport
|
||||
- Full address
|
||||
- Document upload
|
||||
- Payment details
|
||||
|
||||
These can be requested later during verification or approval.
|
||||
|
||||
### New Booking Flow
|
||||
|
||||
```txt
|
||||
Step 1: Request Booking
|
||||
- Customer contact info
|
||||
- Trip dates
|
||||
- Pickup and return locations
|
||||
- Notes
|
||||
|
||||
Step 2: Company Reviews Request
|
||||
- Company checks availability
|
||||
- Company accepts, rejects, or asks for more details
|
||||
|
||||
Step 3: Verification If Needed
|
||||
- License
|
||||
- Address
|
||||
- Documents
|
||||
- Payment
|
||||
```
|
||||
|
||||
This keeps the first action lightweight.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Derive Company Ownership From the Car
|
||||
|
||||
### Problem
|
||||
|
||||
The frontend should not decide which company owns a reservation.
|
||||
|
||||
If the frontend sends `companyId`, it can be wrong, manipulated, or stale.
|
||||
|
||||
### Fix
|
||||
|
||||
The backend must derive `companyId` from the selected car.
|
||||
|
||||
Correct backend logic:
|
||||
|
||||
```ts
|
||||
const car = await prisma.car.findUnique({
|
||||
where: { id: input.carId },
|
||||
select: {
|
||||
id: true,
|
||||
companyId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!car) {
|
||||
throw new Error("Car not found");
|
||||
}
|
||||
|
||||
const companyId = car.companyId;
|
||||
```
|
||||
|
||||
Then use that `companyId` for both the customer and reservation.
|
||||
|
||||
The frontend may send `carId`, but the backend decides the company.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Find or Create Customer by Company and Email
|
||||
|
||||
### Required Rule
|
||||
|
||||
Customers should be unique inside each company, not globally.
|
||||
|
||||
Use:
|
||||
|
||||
```prisma
|
||||
@@unique([companyId, email])
|
||||
```
|
||||
|
||||
This allows:
|
||||
|
||||
```txt
|
||||
john@example.com under Company A
|
||||
john@example.com under Company B
|
||||
```
|
||||
|
||||
without mixing their records.
|
||||
|
||||
### Booking Logic
|
||||
|
||||
```ts
|
||||
const normalizedEmail = input.email.toLowerCase().trim();
|
||||
|
||||
const customer = await prisma.customer.upsert({
|
||||
where: {
|
||||
companyId_email: {
|
||||
companyId,
|
||||
email: normalizedEmail,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: input.name,
|
||||
phone: input.phone,
|
||||
},
|
||||
create: {
|
||||
companyId,
|
||||
email: normalizedEmail,
|
||||
name: input.name,
|
||||
phone: input.phone,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Important
|
||||
|
||||
Do not use email alone to find a customer.
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
where: {
|
||||
email: input.email
|
||||
}
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
where: {
|
||||
companyId_email: {
|
||||
companyId,
|
||||
email: normalizedEmail
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Create Reservation With Company Scope
|
||||
|
||||
Every reservation must be linked to:
|
||||
|
||||
- `companyId`
|
||||
- `customerId`
|
||||
- `carId`
|
||||
|
||||
Recommended reservation creation:
|
||||
|
||||
```ts
|
||||
const reservation = await prisma.reservation.create({
|
||||
data: {
|
||||
companyId,
|
||||
customerId: customer.id,
|
||||
carId: car.id,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
pickupLocation: input.pickupLocation,
|
||||
returnLocation: input.returnLocation,
|
||||
notes: input.notes,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Initial reservation status should be:
|
||||
|
||||
```txt
|
||||
PENDING
|
||||
```
|
||||
|
||||
The company can later change it to:
|
||||
|
||||
```txt
|
||||
APPROVED
|
||||
REJECTED
|
||||
CANCELLED
|
||||
COMPLETED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Enforce Company Privacy in Dashboard Queries
|
||||
|
||||
### Problem
|
||||
|
||||
If company dashboard routes fetch customer or reservation records only by ID, one company may accidentally access another company’s data.
|
||||
|
||||
That is a serious multi-tenant privacy bug.
|
||||
|
||||
### Fix
|
||||
|
||||
Every company-facing query must include `companyId`.
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
});
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
await prisma.customer.findFirst({
|
||||
where: {
|
||||
id: customerId,
|
||||
companyId: currentCompany.id,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
await prisma.reservation.findUnique({
|
||||
where: { id: reservationId },
|
||||
});
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
await prisma.reservation.findFirst({
|
||||
where: {
|
||||
id: reservationId,
|
||||
companyId: currentCompany.id,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Apply This Rule To
|
||||
|
||||
- Customers
|
||||
- Reservations
|
||||
- Cars
|
||||
- Documents
|
||||
- Notes
|
||||
- Payments
|
||||
- Messages
|
||||
- Invoices
|
||||
- Reviews
|
||||
|
||||
Anything owned by a company must be queried with company scope.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Prevent Cross-Company Search
|
||||
|
||||
Company staff should never be able to search all customers globally.
|
||||
|
||||
Company customer search must always filter by the company.
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
await prisma.customer.findMany({
|
||||
where: {
|
||||
companyId: currentCompany.id,
|
||||
OR: [
|
||||
{ name: { contains: search, mode: "insensitive" } },
|
||||
{ email: { contains: search, mode: "insensitive" } },
|
||||
{ phone: { contains: search, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Incorrect:
|
||||
|
||||
```ts
|
||||
await prisma.customer.findMany({
|
||||
where: {
|
||||
email: { contains: search },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The second version searches across companies and should not exist.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Improve Booking Status Feedback
|
||||
|
||||
After submission, show a clear confirmation page.
|
||||
|
||||
The customer should see:
|
||||
|
||||
- Booking reference
|
||||
- Car name
|
||||
- Pickup date
|
||||
- Return date
|
||||
- Status: Pending
|
||||
- Company name
|
||||
- Message: The company will review your request.
|
||||
|
||||
Do not leave the customer wondering whether the booking worked.
|
||||
|
||||
### Optional Confirmation Email
|
||||
|
||||
Send a confirmation email with:
|
||||
|
||||
- Booking reference
|
||||
- Car details
|
||||
- Dates
|
||||
- Company name
|
||||
- Status
|
||||
- Support or contact information
|
||||
|
||||
No customer account required.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Add Booking Reference
|
||||
|
||||
Add a public-safe booking reference to reservations.
|
||||
|
||||
Example:
|
||||
|
||||
```txt
|
||||
BK-2026-8F3K2
|
||||
```
|
||||
|
||||
This is useful for emails, support, and later booking lookup.
|
||||
|
||||
Recommended field:
|
||||
|
||||
```prisma
|
||||
bookingReference String @unique
|
||||
```
|
||||
|
||||
The reference should not expose database IDs.
|
||||
|
||||
Bad reference:
|
||||
|
||||
```txt
|
||||
cm8x9customeridreservationid
|
||||
```
|
||||
|
||||
Good reference:
|
||||
|
||||
```txt
|
||||
BK-7H92KQ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Optional Booking Lookup Without Account
|
||||
|
||||
If needed later, allow customers to check booking status without creating an account.
|
||||
|
||||
Use:
|
||||
|
||||
```txt
|
||||
booking reference + email
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```txt
|
||||
Booking Reference: BK-7H92KQ
|
||||
Email: john@example.com
|
||||
```
|
||||
|
||||
The backend should only return limited information:
|
||||
|
||||
- Status
|
||||
- Car name
|
||||
- Company name
|
||||
- Pickup date
|
||||
- Return date
|
||||
- Pickup location
|
||||
- Return location
|
||||
|
||||
Do not expose:
|
||||
|
||||
- Internal notes
|
||||
- Admin comments
|
||||
- Risk flags
|
||||
- Documents
|
||||
- Payment metadata
|
||||
- Other bookings
|
||||
- Other company records
|
||||
|
||||
This gives convenience without building full accounts.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Add Validation
|
||||
|
||||
Validate booking input on both frontend and backend.
|
||||
|
||||
### Required Validations
|
||||
|
||||
- Email must be valid.
|
||||
- Phone must be valid enough for contact.
|
||||
- Start date must be before end date.
|
||||
- Start date cannot be in the past.
|
||||
- Car must exist.
|
||||
- Car must be active/listed.
|
||||
- Car must belong to an active company.
|
||||
|
||||
### Date Validation
|
||||
|
||||
Reject:
|
||||
|
||||
- Return date before pickup date.
|
||||
- Same-day invalid ranges if not allowed.
|
||||
- Past pickup dates.
|
||||
|
||||
### Availability Validation
|
||||
|
||||
Before creating a reservation, check whether the car already has an overlapping approved or pending reservation.
|
||||
|
||||
Example logic:
|
||||
|
||||
```ts
|
||||
const overlappingReservation = await prisma.reservation.findFirst({
|
||||
where: {
|
||||
carId: car.id,
|
||||
status: {
|
||||
in: ["PENDING", "APPROVED"],
|
||||
},
|
||||
startDate: {
|
||||
lt: input.endDate,
|
||||
},
|
||||
endDate: {
|
||||
gt: input.startDate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (overlappingReservation) {
|
||||
throw new Error("Car is not available for the selected dates");
|
||||
}
|
||||
```
|
||||
|
||||
This avoids double booking.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12: Add Tests
|
||||
|
||||
Add tests for the booking flow and company privacy.
|
||||
|
||||
### Test 1: Booking creates company-scoped customer
|
||||
|
||||
Given:
|
||||
|
||||
```txt
|
||||
Company A
|
||||
Car A belongs to Company A
|
||||
Customer books Car A
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```txt
|
||||
Customer is created under Company A
|
||||
Reservation is created under Company A
|
||||
```
|
||||
|
||||
### Test 2: Same email can book with two companies
|
||||
|
||||
Given:
|
||||
|
||||
```txt
|
||||
john@example.com books with Company A
|
||||
john@example.com books with Company B
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```txt
|
||||
Two customer records exist
|
||||
One under Company A
|
||||
One under Company B
|
||||
```
|
||||
|
||||
### Test 3: Company cannot view another company’s customer
|
||||
|
||||
Given:
|
||||
|
||||
```txt
|
||||
Customer belongs to Company B
|
||||
Company A user tries to fetch that customer
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```txt
|
||||
404 or 403
|
||||
```
|
||||
|
||||
### Test 4: Company cannot view another company’s reservation
|
||||
|
||||
Given:
|
||||
|
||||
```txt
|
||||
Reservation belongs to Company B
|
||||
Company A user tries to fetch it
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```txt
|
||||
404 or 403
|
||||
```
|
||||
|
||||
### Test 5: Backend ignores frontend companyId
|
||||
|
||||
Given:
|
||||
|
||||
```txt
|
||||
Frontend sends carId for Company A
|
||||
Frontend also sends companyId for Company B
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```txt
|
||||
Reservation is created under Company A
|
||||
```
|
||||
|
||||
The backend must trust the car ownership, not the frontend payload.
|
||||
|
||||
### Test 6: Double booking is blocked
|
||||
|
||||
Given:
|
||||
|
||||
```txt
|
||||
Car has an approved reservation from June 10 to June 15
|
||||
Customer tries to book June 12 to June 14
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```txt
|
||||
Booking is rejected
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 13: Implementation Order
|
||||
|
||||
1. Audit current booking form.
|
||||
2. Classify fields as required now, optional now, move to verification, or remove.
|
||||
3. Simplify the frontend booking form.
|
||||
4. Update booking API to accept `carId` and derive `companyId` from the car.
|
||||
5. Normalize email before lookup or storage.
|
||||
6. Find or create customer using `companyId + email`.
|
||||
7. Create reservation with `companyId + customerId + carId`.
|
||||
8. Set initial reservation status to `PENDING`.
|
||||
9. Audit company dashboard routes.
|
||||
10. Add `companyId` filtering to every company-owned query.
|
||||
11. Prevent cross-company customer and reservation search.
|
||||
12. Add reservation availability check.
|
||||
13. Add booking confirmation page.
|
||||
14. Add confirmation email if email infrastructure exists.
|
||||
15. Add booking reference if not already present.
|
||||
16. Add tests for booking creation, company isolation, and double-booking prevention.
|
||||
17. Deploy carefully.
|
||||
18. Run migration only if schema changes are needed.
|
||||
|
||||
---
|
||||
|
||||
## What Not To Change Now
|
||||
|
||||
Do not add:
|
||||
|
||||
- Renter model usage
|
||||
- Customer login
|
||||
- Customer password
|
||||
- Customer dashboard
|
||||
- Shared profile
|
||||
- Cross-company booking history
|
||||
- Saved documents
|
||||
- Saved payment methods
|
||||
|
||||
Do not change customer uniqueness to global email.
|
||||
|
||||
Do not let companies search customers outside their own company.
|
||||
|
||||
Do not trust `companyId` from the frontend.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
This work is complete when:
|
||||
|
||||
- A customer can book without creating an account.
|
||||
- The booking form is shorter and easier to complete.
|
||||
- Customers are created per company using `companyId + email`.
|
||||
- The same email can exist under multiple companies.
|
||||
- Reservations are always tied to the correct company.
|
||||
- The backend derives `companyId` from `carId`.
|
||||
- Company dashboards cannot access another company’s customers.
|
||||
- Company dashboards cannot access another company’s reservations.
|
||||
- Overlapping bookings are blocked.
|
||||
- Booking confirmation is clear.
|
||||
- Sensitive information is not collected too early.
|
||||
|
||||
---
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
Keep the system guest-based for now.
|
||||
|
||||
Fix the booking process by simplifying the form, enforcing company-scoped data access, deriving company ownership from the car, and preventing double bookings.
|
||||
|
||||
This gives the product a cleaner booking process without adding customer accounts before they are actually needed.
|
||||
@@ -0,0 +1,3 @@
|
||||
ACME_EMAIL=rentaldrivego@gmail.com
|
||||
TRAEFIK_DOMAIN=rentaldrivego.ma
|
||||
TRAEFIK_DOMAIN_WWW=www.rentaldrivego.ma
|
||||
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:latest
|
||||
command:
|
||||
- --api.dashboard=false
|
||||
- --api.insecure=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.network=traefik-proxy
|
||||
- --providers.file.directory=/etc/traefik/dynamic
|
||||
- --providers.file.watch=true
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=0
|
||||
- --entrypoints.websecure.transport.respondingTimeouts.writeTimeout=0
|
||||
- --entrypoints.websecure.transport.respondingTimeouts.idleTimeout=1800s
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- traefik-letsencrypt:/letsencrypt
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./dynamic:/etc/traefik/dynamic:ro
|
||||
networks:
|
||||
- traefik-proxy
|
||||
networks:
|
||||
traefik-proxy:
|
||||
external: true
|
||||
volumes:
|
||||
traefik-letsencrypt:
|
||||
@@ -0,0 +1,598 @@
|
||||
Driver's License Expiration Validation Implementation Plan
|
||||
Document Version: 1.0
|
||||
Date: 2026-06-11
|
||||
Status: Draft
|
||||
Author: Engineering Team
|
||||
|
||||
Table of Contents
|
||||
Overview
|
||||
|
||||
Requirements
|
||||
|
||||
Architecture Design
|
||||
|
||||
Implementation Phases
|
||||
|
||||
Technical Specifications
|
||||
|
||||
Testing Strategy
|
||||
|
||||
Deployment Plan
|
||||
|
||||
Monitoring & Maintenance
|
||||
|
||||
Risk Assessment
|
||||
|
||||
Appendices
|
||||
|
||||
Overview
|
||||
Business Objective
|
||||
Enforce a mandatory validation rule requiring all renters to possess a driver's license with minimum 30-day remaining validity from the date of submission.
|
||||
|
||||
Scope
|
||||
In Scope: Frontend validation, backend API validation, database schema changes, user notifications, error handling
|
||||
|
||||
Out of Scope: Physical license verification, DMV database integration, international license translation services
|
||||
|
||||
Success Criteria
|
||||
100% of license submissions validated against 30-day rule
|
||||
|
||||
<1% false rejection rate
|
||||
|
||||
<2 second validation response time
|
||||
|
||||
99.9% API uptime for validation endpoint
|
||||
|
||||
Requirements
|
||||
Functional Requirements
|
||||
ID Requirement Priority
|
||||
FR-1 System shall validate license expiration date is ≥30 days from current date P0
|
||||
FR-2 System shall reject submissions with <30 days remaining validity P0
|
||||
FR-3 System shall display clear error messages to users P0
|
||||
FR-4 System shall support manual date entry and OCR scanning P1
|
||||
FR-5 System shall log all validation attempts with results P1
|
||||
FR-6 System shall send reminders at 60, 45, and 30 days before expiration P2
|
||||
FR-7 System shall support state-specific license formats P2
|
||||
Non-Functional Requirements
|
||||
ID Requirement Target
|
||||
NFR-1 API response time <500ms p95
|
||||
NFR-2 Validation accuracy >99.9%
|
||||
NFR-3 System availability 99.95%
|
||||
NFR-4 Data encryption at rest AES-256
|
||||
NFR-5 PII data compliance GDPR, CCPA
|
||||
Architecture Design
|
||||
System Diagram
|
||||
text
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CLIENT LAYER │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Web App │ │ Mobile App │ │ Admin Dashboard │ │
|
||||
│ │ (React) │ │ (React │ │ (Internal Tool) │ │
|
||||
│ │ │ │ Native) │ │ │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
|
||||
│ │ │ │ │
|
||||
└─────────┼─────────────────┼──────────────────────┼──────────────┘
|
||||
│ │ │
|
||||
┌─────────┼─────────────────┼──────────────────────┼──────────────┐
|
||||
│ │ API GATEWAY LAYER │ │
|
||||
│ ┌──────┴─────────────────┴──────────────────────┴───────────┐ │
|
||||
│ │ Kong / AWS API Gateway │ │
|
||||
│ │ (Rate Limiting, Authentication, Routing) │ │
|
||||
│ └──────────────────────────┬────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────┼────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ┌──────┴──────┐ ┌────────┴────────┐ ┌──────┴──────────┐ │
|
||||
│ │ License │ │ Renter │ │ Notification │ │
|
||||
│ │ Validation │ │ Service │ │ Service │ │
|
||||
│ │ Service │ │ │ │ │ │
|
||||
│ └──────┬──────┘ └────────┬────────┘ └──────┬──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────────┼────────────────────┘ │
|
||||
│ │ │
|
||||
│ DATA LAYER│ │
|
||||
│ ┌───────────────────────────┴──────────────────────────────┐ │
|
||||
│ │ PostgreSQL (Primary DB) │ │
|
||||
│ └───────────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────┴──────────────────────────────┐ │
|
||||
│ │ Redis (Cache + Rate Limiting) │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
Data Flow Sequence
|
||||
Implementation Phases
|
||||
Phase 1: Foundation (Week 1-2)
|
||||
Week 1: Setup & Core Logic
|
||||
Day 1-2: Project setup and repository configuration
|
||||
|
||||
Initialize Git repository with branch protection rules
|
||||
|
||||
Set up CI/CD pipeline (GitHub Actions/Jenkins)
|
||||
|
||||
Configure development, staging, and production environments
|
||||
|
||||
Day 3-4: Database schema design
|
||||
|
||||
sql
|
||||
-- Migration: Create license_validations table
|
||||
CREATE TABLE license_validations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
renter_id UUID NOT NULL REFERENCES renters(id),
|
||||
license_number_encrypted TEXT NOT NULL,
|
||||
expiration_date DATE NOT NULL,
|
||||
issuing_state VARCHAR(2),
|
||||
validation_status VARCHAR(20) NOT NULL,
|
||||
days_remaining INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
validated_by VARCHAR(50) -- 'SYSTEM' or 'MANUAL'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_license_expiration ON license_validations(expiration_date);
|
||||
CREATE INDEX idx_license_status ON license_validations(validation_status);
|
||||
Day 5: Core validation service implementation
|
||||
|
||||
Build LicenseValidator class with business logic
|
||||
|
||||
Implement date parsing with multiple format support
|
||||
|
||||
Add unit tests for all validation scenarios
|
||||
|
||||
Week 2: API & Frontend
|
||||
Day 1-3: REST API endpoint development
|
||||
|
||||
yaml
|
||||
# OpenAPI Specification
|
||||
/api/v1/licenses/validate:
|
||||
post:
|
||||
summary: Validate driver's license expiration
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- expiration_date
|
||||
properties:
|
||||
expiration_date:
|
||||
type: string
|
||||
format: date
|
||||
example: "2027-06-11"
|
||||
license_number:
|
||||
type: string
|
||||
state:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: License is valid
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
valid:
|
||||
type: boolean
|
||||
days_remaining:
|
||||
type: integer
|
||||
message:
|
||||
type: string
|
||||
'422':
|
||||
description: License validation failed
|
||||
Day 4-5: Frontend component development
|
||||
|
||||
Build LicenseUpload component with date picker
|
||||
|
||||
Implement real-time validation feedback
|
||||
|
||||
Add accessibility features (ARIA labels, keyboard navigation)
|
||||
|
||||
Phase 2: Enhancement (Week 3-4)
|
||||
Week 3: OCR Integration & Advanced Validation
|
||||
Day 1-3: OCR service integration
|
||||
|
||||
python
|
||||
class OCRLicenseProcessor:
|
||||
def __init__(self):
|
||||
self.textract_client = boto3.client('textract')
|
||||
|
||||
async def extract_license_data(self, image_bytes: bytes) -> dict:
|
||||
"""Extract license data using AWS Textract"""
|
||||
response = await self.textract_client.analyze_document(
|
||||
Document={'Bytes': image_bytes},
|
||||
FeatureTypes=['FORMS']
|
||||
)
|
||||
return self._parse_license_fields(response)
|
||||
|
||||
def _parse_license_fields(self, textract_response: dict) -> dict:
|
||||
"""Parse extracted fields into structured data"""
|
||||
# Extract expiration date using regex patterns
|
||||
# Map to standard format YYYY-MM-DD
|
||||
pass
|
||||
Day 4: State-specific validation rules
|
||||
|
||||
Implement validation patterns for all 50 US states
|
||||
|
||||
Handle temporary licenses and permits
|
||||
|
||||
Add military ID and passport support
|
||||
|
||||
Day 5: Error handling and edge cases
|
||||
|
||||
Graceful handling of corrupted images
|
||||
|
||||
Invalid date formats
|
||||
|
||||
Network timeouts and retries
|
||||
|
||||
Week 4: Notifications & Admin Tools
|
||||
Day 1-2: Notification system
|
||||
|
||||
python
|
||||
class LicenseReminderService:
|
||||
REMINDER_SCHEDULE = [60, 45, 30] # Days before expiration
|
||||
|
||||
async def schedule_reminders(self, renter_id: str, expiration_date: date):
|
||||
for days_before in self.REMINDER_SCHEDULE:
|
||||
reminder_date = expiration_date - timedelta(days=days_before)
|
||||
await self.notification_queue.enqueue(
|
||||
'send_license_reminder',
|
||||
renter_id=renter_id,
|
||||
scheduled_at=reminder_date,
|
||||
days_remaining=days_before
|
||||
)
|
||||
Day 3-4: Admin dashboard
|
||||
|
||||
Build validation queue for manual review
|
||||
|
||||
Analytics dashboard (rejection rates, common errors)
|
||||
|
||||
Bulk update and override capabilities
|
||||
|
||||
Day 5: Documentation and API docs
|
||||
|
||||
Update API documentation with Swagger/OpenAPI
|
||||
|
||||
Create internal wiki for support team
|
||||
|
||||
Write runbook for common issues
|
||||
|
||||
Phase 3: Testing & Hardening (Week 5-6)
|
||||
Week 5: Comprehensive Testing
|
||||
Day 1-3: Integration testing
|
||||
|
||||
javascript
|
||||
// Integration test example
|
||||
describe('License Validation API', () => {
|
||||
test('Should reject license expiring in 15 days', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/licenses/validate')
|
||||
.send({
|
||||
expiration_date: '2026-06-26',
|
||||
license_number: 'DL123456'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.valid).toBe(false);
|
||||
expect(response.body.message).toContain('30 days');
|
||||
});
|
||||
|
||||
test('Should accept license expiring in 60 days', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/licenses/validate')
|
||||
.send({
|
||||
expiration_date: '2026-08-10',
|
||||
license_number: 'DL789012'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.valid).toBe(true);
|
||||
expect(response.body.days_remaining).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
Day 4: Performance testing
|
||||
|
||||
Load test with 1000 concurrent requests
|
||||
|
||||
Stress test OCR processing pipeline
|
||||
|
||||
Database query optimization
|
||||
|
||||
Day 5: Security audit
|
||||
|
||||
PII encryption verification
|
||||
|
||||
SQL injection and XSS testing
|
||||
|
||||
Authentication/authorization testing
|
||||
|
||||
Week 6: UAT & Deployment Preparation
|
||||
Day 1-2: User acceptance testing
|
||||
|
||||
Test with real license images (anonymized)
|
||||
|
||||
Validate all error messages and flows
|
||||
|
||||
Cross-browser compatibility testing
|
||||
|
||||
Day 3: Staging deployment
|
||||
|
||||
Deploy to staging environment
|
||||
|
||||
Run smoke tests
|
||||
|
||||
Monitor for 24 hours
|
||||
|
||||
Day 4-5: Production deployment
|
||||
|
||||
Database migration execution
|
||||
|
||||
Blue-green deployment strategy
|
||||
|
||||
Gradual traffic shifting (10% → 50% → 100%)
|
||||
|
||||
Technical Specifications
|
||||
Validation Rules Matrix
|
||||
Scenario Condition Action Response Code
|
||||
Valid License ≥30 days remaining Accept 200
|
||||
Warning Zone 30-60 days remaining Accept + Schedule Reminder 200 + Warning
|
||||
Expiring Soon 1-29 days remaining Reject 422
|
||||
Expired ≤0 days remaining Reject 422
|
||||
Invalid Format Date parse error Reject 400
|
||||
Future Date >20 years from now Reject 400
|
||||
Missing Date No date provided Reject 400
|
||||
API Response Examples
|
||||
Success Response
|
||||
json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"valid": true,
|
||||
"days_remaining": 365,
|
||||
"expiration_date": "2027-06-11",
|
||||
"message": "License verified successfully",
|
||||
"next_reminder": "2027-04-12"
|
||||
}
|
||||
}
|
||||
Error Response
|
||||
json
|
||||
{
|
||||
"status": "error",
|
||||
"error": {
|
||||
"code": "LICENSE_EXPIRING_SOON",
|
||||
"message": "License must have at least 30 days remaining validity. Your license expires in 15 days on 2026-06-26.",
|
||||
"details": {
|
||||
"expiration_date": "2026-06-26",
|
||||
"days_remaining": 15,
|
||||
"minimum_required": 30,
|
||||
"suggested_action": "Please upload a valid license with more than 30 days remaining."
|
||||
}
|
||||
}
|
||||
}
|
||||
Environment Variables Configuration
|
||||
bash
|
||||
# .env.example
|
||||
LICENSE_MIN_VALIDITY_DAYS=30
|
||||
LICENSE_WARNING_THRESHOLD_DAYS=60
|
||||
OCR_ENABLED=true
|
||||
OCR_PROVIDER=aws_textract
|
||||
REDIS_URL=redis://localhost:6379
|
||||
DATABASE_URL=postgresql://user:pass@localhost:5432/rental_db
|
||||
ENCRYPTION_KEY=your-256-bit-key
|
||||
LOG_LEVEL=info
|
||||
Testing Strategy
|
||||
Test Pyramid
|
||||
text
|
||||
┌──────┐
|
||||
│ E2E │ 10% - Critical user journeys
|
||||
│ │
|
||||
┌┴──────┴┐
|
||||
│Integrat│ 30% - API contracts, DB interactions
|
||||
│ ion │
|
||||
│ │
|
||||
┌┴────────┴┐
|
||||
│ Unit │ 60% - Business logic, validation rules
|
||||
│ │
|
||||
└──────────┘
|
||||
Test Cases
|
||||
Unit Tests (60% coverage)
|
||||
Test ID Description Input Expected Output
|
||||
UT-01 Date exactly 30 days from now today + 30 days valid: true
|
||||
UT-02 Date 29 days from now today + 29 days valid: false
|
||||
UT-03 Date in past 2020-01-01 valid: false
|
||||
UT-04 Invalid date string "invalid" Error handled
|
||||
UT-05 Leap year date 2028-02-29 Correctly calculated
|
||||
UT-06 Timezone edge case Various timezones Consistent results
|
||||
Integration Tests
|
||||
API returns correct status codes for all scenarios
|
||||
|
||||
Database correctly stores encrypted license data
|
||||
|
||||
Redis cache properly invalidates
|
||||
|
||||
Notification queue receives correct messages
|
||||
|
||||
OCR pipeline processes images end-to-end
|
||||
|
||||
End-to-End Tests
|
||||
User uploads valid license → Success flow
|
||||
|
||||
User uploads expired license → Error flow
|
||||
|
||||
User receives reminder notification
|
||||
|
||||
Admin can manually override validation
|
||||
|
||||
Deployment Plan
|
||||
Pre-Deployment Checklist
|
||||
All tests passing (unit, integration, E2E)
|
||||
|
||||
Security scan completed (no critical/high vulnerabilities)
|
||||
|
||||
Database migration scripts reviewed and tested
|
||||
|
||||
Rollback plan documented and tested
|
||||
|
||||
Monitoring dashboards configured
|
||||
|
||||
Alert thresholds set up
|
||||
|
||||
Support team trained on new features
|
||||
|
||||
API documentation updated
|
||||
|
||||
Performance benchmarks met
|
||||
|
||||
Data backup completed
|
||||
|
||||
Deployment Steps
|
||||
Phase 1: Database Migration (30 min downtime)
|
||||
bash
|
||||
# 1. Take database snapshot
|
||||
pg_dump -h production-db -U admin rental_db > backup_20260611.sql
|
||||
|
||||
# 2. Run migrations
|
||||
npm run migrate:production
|
||||
|
||||
# 3. Verify migration
|
||||
SELECT * FROM schema_migrations ORDER BY version DESC LIMIT 5;
|
||||
Phase 2: Backend Deployment (Blue-Green)
|
||||
bash
|
||||
# 1. Deploy to green environment
|
||||
kubectl apply -f deployment-green.yaml
|
||||
|
||||
# 2. Run health checks
|
||||
curl https://green-api.example.com/health
|
||||
|
||||
# 3. Switch traffic (10% → 50% → 100%)
|
||||
# Update load balancer configuration
|
||||
|
||||
# 4. Monitor for 15 minutes
|
||||
# Watch logs: kubectl logs -f deployment/license-validation-green
|
||||
Phase 3: Frontend Deployment
|
||||
bash
|
||||
# 1. Build and deploy to CDN
|
||||
npm run build:production
|
||||
aws s3 sync dist/ s3://rental-app-prod/ --cache-control "max-age=3600"
|
||||
|
||||
# 2. Invalidate CloudFront cache
|
||||
aws cloudfront create-invalidation --distribution-id E1234567890 --paths "/*"
|
||||
Rollback Plan
|
||||
bash
|
||||
# Immediate rollback if error rate >5% or p95 latency >2s
|
||||
# 1. Revert load balancer to blue environment
|
||||
# 2. Scale down green deployment
|
||||
kubectl scale deployment license-validation-green --replicas=0
|
||||
|
||||
# 3. Rollback database (if schema changes)
|
||||
npm run migrate:rollback:production
|
||||
|
||||
# 4. Notify on-call engineer
|
||||
# 5. Create incident postmortem
|
||||
Monitoring & Maintenance
|
||||
Key Metrics Dashboard
|
||||
yaml
|
||||
Dashboard: License Validation Service
|
||||
Metrics:
|
||||
- name: license_validation_total
|
||||
type: Counter
|
||||
description: Total validation attempts
|
||||
labels: [status, source]
|
||||
|
||||
- name: license_validation_duration_seconds
|
||||
type: Histogram
|
||||
description: Validation processing time
|
||||
buckets: [0.1, 0.5, 1, 2, 5]
|
||||
|
||||
- name: license_rejection_rate
|
||||
type: Gauge
|
||||
description: Percentage of rejected licenses
|
||||
alert: ">20% for 5 minutes"
|
||||
|
||||
- name: ocr_processing_errors
|
||||
type: Counter
|
||||
description: OCR processing failures
|
||||
alert: ">10 errors in 5 minutes"
|
||||
Alert Configuration
|
||||
yaml
|
||||
alerts:
|
||||
- name: HighRejectionRate
|
||||
condition: license_rejection_rate > 0.20
|
||||
duration: 5m
|
||||
severity: warning
|
||||
channel: "#engineering-alerts"
|
||||
|
||||
- name: ValidationServiceDown
|
||||
condition: up{job="license-validation"} == 0
|
||||
duration: 1m
|
||||
severity: critical
|
||||
channel: "#on-call"
|
||||
|
||||
- name: SlowValidationResponses
|
||||
condition: histogram_quantile(0.95, license_validation_duration) > 2
|
||||
duration: 5m
|
||||
severity: warning
|
||||
channel: "#engineering-alerts"
|
||||
Maintenance Tasks
|
||||
Task Frequency Owner Duration
|
||||
Database index optimization Monthly DBA 1 hour
|
||||
SSL certificate renewal Annually DevOps 30 min
|
||||
Dependency updates Bi-weekly Engineering 2 hours
|
||||
Security patches As needed Security team Varies
|
||||
Log rotation Automated System N/A
|
||||
Risk Assessment
|
||||
Risk Matrix
|
||||
Risk Impact Probability Mitigation
|
||||
OCR misreads date High Medium Manual review fallback, confidence scoring
|
||||
Timezone mismatch Medium Low Store all dates in UTC, convert at presentation layer
|
||||
Database connection failure High Low Connection pooling, retry logic, circuit breaker
|
||||
Third-party OCR service outage Medium Low Implement fallback to manual entry, cache provider
|
||||
PII data breach Critical Low Encryption at rest/transit, access controls, audit logging
|
||||
Performance degradation under load Medium Medium Auto-scaling, caching, load testing
|
||||
Contingency Plans
|
||||
Scenario 1: OCR Service Unavailable
|
||||
|
||||
Automatically switch to manual entry mode
|
||||
|
||||
Display clear instructions to user
|
||||
|
||||
Queue OCR processing for retry when service recovers
|
||||
|
||||
Scenario 2: High False Rejection Rate
|
||||
|
||||
Temporarily adjust threshold to 15 days
|
||||
|
||||
Notify support team to expect inquiries
|
||||
|
||||
Investigate root cause (date parsing, timezone, etc.)
|
||||
|
||||
Deploy hotfix if needed
|
||||
|
||||
Appendices
|
||||
Appendix A: Supported License Formats
|
||||
Country/State Format Example Notes
|
||||
US Standard MM/DD/YYYY 06/11/2027 Varies by state
|
||||
EU Standard DD/MM/YYYY 11/06/2027
|
||||
ISO 8601 YYYY-MM-DD 2027-06-11 Preferred internal format
|
||||
Military ID DD-MMM-YYYY 11-JUN-2027
|
||||
Appendix B: Error Code Reference
|
||||
Code HTTP Status Description
|
||||
LICENSE_EXPIRED 422 License has expired
|
||||
LICENSE_EXPIRING_SOON 422 Less than 30 days remaining
|
||||
LICENSE_INVALID_FORMAT 400 Date format not recognized
|
||||
LICENSE_FUTURE_DATE 400 Date too far in future
|
||||
LICENSE_MISSING_DATE 400 No date provided
|
||||
OCR_PROCESSING_FAILED 500 Could not extract from image
|
||||
LICENSE_ALREADY_EXISTS 409 Duplicate submission
|
||||
Appendix C: Team Contacts
|
||||
Role Name Contact
|
||||
Product Owner [Name] [Email/Phone]
|
||||
Tech Lead [Name] [Email/Phone]
|
||||
Backend Developer [Name] [Email/Phone]
|
||||
Frontend Developer [Name] [Email/Phone]
|
||||
DevOps Engineer [Name] [Email/Phone]
|
||||
Security Engineer [Name] [Email/Phone]
|
||||
On-Call Rotation Schedule [PagerDuty Link]
|
||||
Document Control
|
||||
Version Date Author Changes
|
||||
1.0 2026-06-11 Engineering Team Initial creation
|
||||
@@ -0,0 +1,331 @@
|
||||
# User Input Validation and Formatting Plan
|
||||
|
||||
## Table of Contents
|
||||
1. [Global Rules](#global-rules)
|
||||
2. [Field-Specific Rules](#field-specific-rules)
|
||||
3. [Implementation Logic](#implementation-logic)
|
||||
4. [Formatting Functions](#formatting-functions)
|
||||
5. [Error Handling](#error-handling)
|
||||
6. [Processing Order](#processing-order)
|
||||
7. [Field Configuration Reference](#field-configuration-reference)
|
||||
8. [Phone Validation](#phone-validation)
|
||||
|
||||
---
|
||||
|
||||
## Global Rules
|
||||
|
||||
### Allowed Characters
|
||||
All fields accept only:
|
||||
- Letters (`A-Z`, `a-z`)
|
||||
- Numbers (`0-9`)
|
||||
- Special characters: `@`, `-`, `/`
|
||||
- Spaces (where applicable)
|
||||
|
||||
**Global Regex Pattern:**
|
||||
```regex
|
||||
^[a-zA-Z0-9@\-\/\s]*$
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field-Specific Rules
|
||||
|
||||
### Group 1: Title Case Fields (Uppercase First Letter, Lowercase Rest)
|
||||
**Fields:** Name, Nationality, Street Address, City, Pick-up Location, Return Location, Country
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Format | Uppercase first letter, lowercase rest |
|
||||
| Max Characters | 50 (Name, Nationality, City, Locations, Country) / 100 (Street Address) |
|
||||
| Allowed Characters | Letters, spaces, `-` (Street Address also allows numbers and `/`; Pick-up/Return Location also allows numbers, `/`) |
|
||||
| Transformation | `toTitleCase()` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
"john doe" → "John Doe"
|
||||
|
||||
"MARIA" → "Maria"
|
||||
|
||||
"new york" → "New York"
|
||||
|
||||
"123 main st." → "123 Main St"
|
||||
|
||||
### Group 2: Title Case All Words Fields
|
||||
**Fields:** Full Address, Commercial Name, Legal Company Name
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Format | Uppercase first letter of every word |
|
||||
| Max Characters | 200 (Full Address) / 100 (Company Names) |
|
||||
| Allowed Characters | Letters, numbers, spaces, `-`, `/`, `,` |
|
||||
| Transformation | `toTitleCaseAll()` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
"123 main street, apt 4b" → "123 Main Street, Apt 4b"
|
||||
|
||||
"acme corporation" → "Acme Corporation"
|
||||
|
||||
### Group 3: Lowercase Fields
|
||||
**Fields:** Email
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Format | All lowercase |
|
||||
| Max Characters | 100 |
|
||||
| Allowed Characters | Letters, numbers, `@`, `.`, `_`, `%`, `+`, `-` |
|
||||
| Transformation | `toLowerCase()` |
|
||||
| Validation | Must match email format |
|
||||
|
||||
**Validation Regex:**
|
||||
```regex
|
||||
^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
"John.Doe@Example.COM" → "john.doe@example.com"
|
||||
|
||||
### Group 4: Uppercase Fields
|
||||
**Fields:** License Plate, VIN, CIN, Passport Number, International Permit Number, Driver License Number, License Category, ICE Number, Commercial Registry (RC)
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Format | ALL UPPERCASE letters, numbers unchanged |
|
||||
| Max Characters | 30 |
|
||||
| Allowed Characters | Letters, numbers, `-` |
|
||||
| Transformation | `toUpperCase()` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
"abc-123" → "ABC-123"
|
||||
|
||||
"ab123cd" → "AB123CD"
|
||||
|
||||
### Group 5: Car Fields
|
||||
**Fields:** Car Mark, Car Model, Car Color
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Format | Uppercase first letter, lowercase rest |
|
||||
| Max Characters | 30 |
|
||||
| Allowed Characters | Letters, numbers, spaces, `-` |
|
||||
| Transformation | `toTitleCase()` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
"TOYOTA" → "Toyota"
|
||||
|
||||
"midnight-blue" → "Midnight-Blue"
|
||||
|
||||
### Group 6: Preserved Format Fields
|
||||
**Fields:** ZIP Code
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Format | As entered (no case transformation) |
|
||||
| Max Characters | 10 |
|
||||
| Allowed Characters | Letters, numbers, spaces, `-` |
|
||||
| Transformation | `preserveOriginal()` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
"12345" → "12345"
|
||||
|
||||
"SW1A 1AA" → "SW1A 1AA"
|
||||
|
||||
---
|
||||
|
||||
## Phone Validation
|
||||
|
||||
### Morocco Phone Numbers
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Format | As entered (digits, spaces, `+` preserved) |
|
||||
| Max Characters | n/a (constrained by regex match) |
|
||||
| Allowed Characters | Digits, spaces, `+` |
|
||||
| Validation | Must match Morocco phone format |
|
||||
|
||||
**Validation Regex:**
|
||||
```regex
|
||||
^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$
|
||||
```
|
||||
|
||||
**Accepted formats:**
|
||||
|
||||
"+212 6 00 00 00 00" → "+212 6 00 00 00 00"
|
||||
|
||||
"00212600000000" → "00212600000000"
|
||||
|
||||
"0600000000" → "0600000000"
|
||||
|
||||
"+212700000000" → "+212700000000"
|
||||
|
||||
**Rejected formats:**
|
||||
|
||||
International codes other than +212
|
||||
|
||||
Numbers not starting with 5, 6, or 7 after the prefix
|
||||
|
||||
Wrong digit count
|
||||
|
||||
---
|
||||
|
||||
## Implementation Logic
|
||||
|
||||
### Main Processing Function
|
||||
```javascript
|
||||
function sanitizeAndFormatInput(value, fieldType) {
|
||||
// Step 1: Remove disallowed characters
|
||||
let sanitized = removeDisallowedChars(value, fieldType);
|
||||
|
||||
// Step 2: Trim leading/trailing whitespace
|
||||
sanitized = sanitized.trim();
|
||||
|
||||
// Step 3: Validate format (if applicable)
|
||||
if (fieldType === 'email' && !isValidEmail(sanitized)) {
|
||||
throw new Error('Invalid email format');
|
||||
}
|
||||
|
||||
// Step 4: Apply field-specific formatting
|
||||
let formatted = applyFormatting(sanitized, fieldType);
|
||||
|
||||
// Step 5: Truncate to max length
|
||||
formatted = truncateToMaxLength(formatted, fieldType);
|
||||
|
||||
return formatted;
|
||||
}
|
||||
```
|
||||
|
||||
### Processing Order
|
||||
1. **Sanitize** — Remove invalid characters
|
||||
2. **Trim** — Remove leading/trailing whitespace
|
||||
3. **Validate** — Check format (email, phone, required fields)
|
||||
4. **Format** — Apply case transformation
|
||||
5. **Truncate** — Enforce max length
|
||||
6. **Return** — Output formatted value
|
||||
|
||||
---
|
||||
|
||||
## Formatting Functions
|
||||
|
||||
| Function | Description | Used For |
|
||||
|---|---|---|
|
||||
| `toTitleCase()` | First letter uppercase, rest lowercase per word | Name, Nationality, Street Address, City, Locations, Country, Car fields |
|
||||
| `toTitleCaseAll()` | First letter of every word uppercase | Full Address, Commercial Name, Legal Company Name |
|
||||
| `toLowerCase()` | All characters to lowercase | Email |
|
||||
| `toUpperCase()` | All letters to uppercase, numbers unchanged | License Plate, VIN, CIN, Passport, Permit, Driver License, License Category, ICE, RC |
|
||||
| `preserveOriginal()` | No transformation | ZIP Code |
|
||||
|
||||
### Function Implementations
|
||||
```javascript
|
||||
function toTitleCase(str) {
|
||||
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
|
||||
}
|
||||
|
||||
function toTitleCaseAll(str) {
|
||||
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
|
||||
}
|
||||
|
||||
function toLowerCase(str) {
|
||||
return str.toLowerCase();
|
||||
}
|
||||
|
||||
function toUpperCase(str) {
|
||||
return str.replace(/[a-zA-Z]/g, (char) => char.toUpperCase());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Condition | Error Message |
|
||||
|---|---|
|
||||
| Invalid characters | "Only letters, numbers, @, -, and / are allowed" |
|
||||
| Invalid email format | "Please enter a valid email address" |
|
||||
| Invalid phone format | "Please enter a valid Morocco phone number" |
|
||||
| Required field empty | "This field is required" |
|
||||
| Exceeds max length | "Maximum [X] characters allowed" |
|
||||
|
||||
### Validation Flow
|
||||
```text
|
||||
User Input
|
||||
│
|
||||
▼
|
||||
Character Validation ────► Invalid ────► Error Message
|
||||
│
|
||||
▼
|
||||
Format Validation ────► Invalid ────► Error Message
|
||||
│
|
||||
▼
|
||||
Apply Transformation
|
||||
│
|
||||
▼
|
||||
Check Max Length ────► Exceeds ────► Truncate / Warning
|
||||
│
|
||||
▼
|
||||
Return Formatted Value
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field Configuration Reference
|
||||
|
||||
| # | Field | Max Length | Format | Transformation |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Name | 50 | Title Case | `toTitleCase()` |
|
||||
| 2 | Nationality | 50 | Title Case | `toTitleCase()` |
|
||||
| 3 | Street Address | 100 | Title Case | `toTitleCase()` |
|
||||
| 4 | City | 50 | Title Case | `toTitleCase()` |
|
||||
| 5 | Pick-up Location | 50 | Title Case | `toTitleCase()` |
|
||||
| 6 | Return Location | 50 | Title Case | `toTitleCase()` |
|
||||
| 7 | Country | 50 | Title Case | `toTitleCase()` |
|
||||
| 8 | Full Address | 200 | Title Case All | `toTitleCaseAll()` |
|
||||
| 9 | Email | 100 | Lowercase | `toLowerCase()` |
|
||||
| 10 | Car Mark | 30 | Title Case | `toTitleCase()` |
|
||||
| 11 | Car Model | 30 | Title Case | `toTitleCase()` |
|
||||
| 12 | Car Color | 30 | Title Case | `toTitleCase()` |
|
||||
| 13 | License Plate | 30 | Uppercase | `toUpperCase()` |
|
||||
| 14 | VIN | 30 | Uppercase | `toUpperCase()` |
|
||||
| 15 | CIN | 30 | Uppercase | `toUpperCase()` |
|
||||
| 16 | Passport Number | 30 | Uppercase | `toUpperCase()` |
|
||||
| 17 | International Permit Number | 30 | Uppercase | `toUpperCase()` |
|
||||
| 18 | Driver License Number | 30 | Uppercase | `toUpperCase()` |
|
||||
| 19 | License Category | 30 | Uppercase | `toUpperCase()` |
|
||||
| 20 | ICE Number | 30 | Uppercase | `toUpperCase()` |
|
||||
| 21 | Commercial Registry (RC) | 30 | Uppercase | `toUpperCase()` |
|
||||
| 22 | Commercial Name | 100 | Title Case All | `toTitleCaseAll()` |
|
||||
| 23 | Legal Company Name | 100 | Title Case All | `toTitleCaseAll()` |
|
||||
| 24 | ZIP Code | 10 | Preserved | `preserveOriginal()` |
|
||||
| 25 | Phone | — | Preserved | Morocco regex validation |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference Card
|
||||
|
||||
### Email Validation
|
||||
```regex
|
||||
^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$
|
||||
```
|
||||
|
||||
### Morocco Phone Validation
|
||||
```regex
|
||||
^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$
|
||||
```
|
||||
|
||||
### Global Character Allowlist
|
||||
```regex
|
||||
^[a-zA-Z0-9@\-\/\s]*$
|
||||
```
|
||||
|
||||
### Rules Summary
|
||||
|
||||
| Group | Fields | Transformation |
|
||||
|---|---|---|
|
||||
| Title Case | Name, Nationality, Address, City, Locations, Country | "John Doe" |
|
||||
| Title Case All | Full Address, Company Names | "Acme Corporation Ltd." |
|
||||
| Lowercase | Email | "user@domain.com" |
|
||||
| Uppercase | IDs, Licenses, Documents | "ABC-123" |
|
||||
| Preserved | ZIP Code, Phone | As entered |
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
ALTER TYPE "NotificationType" ADD VALUE IF NOT EXISTS 'DOCUMENTS_REQUIRED';
|
||||
ALTER TYPE "NotificationType" ADD VALUE IF NOT EXISTS 'PAYMENT_REQUIRED';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "marketplace_funnel_events" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"eventName" TEXT NOT NULL,
|
||||
"companyId" TEXT,
|
||||
"companySlug" TEXT NOT NULL,
|
||||
"vehicleId" TEXT NOT NULL,
|
||||
"renterId" TEXT,
|
||||
"sessionId" TEXT,
|
||||
"path" TEXT,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "marketplace_funnel_events_eventName_createdAt_idx"
|
||||
ON "marketplace_funnel_events"("eventName", "createdAt");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "marketplace_funnel_events_companyId_createdAt_idx"
|
||||
ON "marketplace_funnel_events"("companyId", "createdAt");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "marketplace_funnel_events_companySlug_createdAt_idx"
|
||||
ON "marketplace_funnel_events"("companySlug", "createdAt");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "marketplace_funnel_events_vehicleId_createdAt_idx"
|
||||
ON "marketplace_funnel_events"("vehicleId", "createdAt");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "marketplace_funnel_events_renterId_createdAt_idx"
|
||||
ON "marketplace_funnel_events"("renterId", "createdAt");
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "reservations"
|
||||
ADD COLUMN IF NOT EXISTS "bookingReference" TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "reservations_bookingReference_key"
|
||||
ON "reservations"("bookingReference");
|
||||
@@ -422,6 +422,8 @@ enum NotificationType {
|
||||
BOOKING_CANCELLED
|
||||
PAYMENT_RECEIVED
|
||||
PAYMENT_FAILED
|
||||
DOCUMENTS_REQUIRED
|
||||
PAYMENT_REQUIRED
|
||||
SUBSCRIPTION_TRIAL_ENDING
|
||||
SUBSCRIPTION_SUSPENDED
|
||||
VEHICLE_MAINTENANCE_DUE
|
||||
@@ -1293,6 +1295,7 @@ model Reservation {
|
||||
notes String?
|
||||
cancelReason String?
|
||||
cancelledBy CancelledBy?
|
||||
bookingReference String? @unique
|
||||
contractNumber String? @unique
|
||||
invoiceNumber String? @unique
|
||||
reviewToken String? @unique
|
||||
@@ -1345,6 +1348,26 @@ model ReservationPublicAccess {
|
||||
@@map("reservation_public_access")
|
||||
}
|
||||
|
||||
model MarketplaceFunnelEvent {
|
||||
id String @id @default(cuid())
|
||||
eventName String
|
||||
companyId String?
|
||||
companySlug String
|
||||
vehicleId String
|
||||
renterId String?
|
||||
sessionId String?
|
||||
path String?
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([eventName, createdAt])
|
||||
@@index([companyId, createdAt])
|
||||
@@index([companySlug, createdAt])
|
||||
@@index([vehicleId, createdAt])
|
||||
@@index([renterId, createdAt])
|
||||
@@map("marketplace_funnel_events")
|
||||
}
|
||||
|
||||
|
||||
model WebhookEvent {
|
||||
id String @id @default(cuid())
|
||||
|
||||
Vendored
+2
@@ -9,6 +9,8 @@ export type NotificationType =
|
||||
| 'BOOKING_CANCELLED'
|
||||
| 'PAYMENT_RECEIVED'
|
||||
| 'PAYMENT_FAILED'
|
||||
| 'DOCUMENTS_REQUIRED'
|
||||
| 'PAYMENT_REQUIRED'
|
||||
| 'SUBSCRIPTION_TRIAL_ENDING'
|
||||
| 'SUBSCRIPTION_SUSPENDED'
|
||||
| 'VEHICLE_MAINTENANCE_DUE'
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --preserveWatchOutput --watchFile dynamicprioritypolling --watchDirectory dynamicprioritypolling",
|
||||
"build": "tsc",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user