refractor code,
This commit is contained in:
+124
-11
@@ -1,28 +1,141 @@
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
||||
DOCKER_IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
|
||||
DOCKERFILE_PATH: Dockerfile.production
|
||||
# Required: disable TLS so the docker client can reach the dind daemon
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
|
||||
build:
|
||||
# ====================================
|
||||
# TEST STAGE
|
||||
# Runs on every push and merge request
|
||||
# ====================================
|
||||
unit_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
before_script:
|
||||
- npm ci
|
||||
- npm run db:generate
|
||||
script:
|
||||
- npm run type-check
|
||||
- npm run test:api
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
|
||||
integration_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
services:
|
||||
- name: postgres:16-alpine
|
||||
alias: postgres
|
||||
variables:
|
||||
POSTGRES_DB: rentaldrivego_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
- name: redis:7-alpine
|
||||
alias: redis
|
||||
variables:
|
||||
# Used by db:deploy (Prisma migrations) and any code reading DATABASE_URL directly
|
||||
DATABASE_URL: "postgresql://postgres:password@postgres:5432/rentaldrivego_test"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
NODE_ENV: test
|
||||
before_script:
|
||||
- npm ci
|
||||
- npm run db:generate
|
||||
# Overwrite the local .env.test so vitest integration config gets CI service hostnames
|
||||
# (the file uses postgres/redis aliases, not localhost)
|
||||
- |
|
||||
cat > apps/api/.env.test << 'EOF'
|
||||
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test
|
||||
REDIS_URL=redis://redis:6379
|
||||
JWT_SECRET=test-secret
|
||||
JWT_EXPIRY=8h
|
||||
NODE_ENV=test
|
||||
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
|
||||
EOF
|
||||
- npm run db:deploy
|
||||
script:
|
||||
- npm run test:api:integration
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
|
||||
# ====================================
|
||||
# BUILD STAGE
|
||||
# ====================================
|
||||
build_image:
|
||||
stage: build
|
||||
image: docker:24
|
||||
services:
|
||||
- docker:dind
|
||||
script:
|
||||
- name: docker:24-dind
|
||||
alias: docker
|
||||
before_script:
|
||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
- docker build -t $DOCKER_IMAGE .
|
||||
script:
|
||||
- echo "--- Building Docker Image ---"
|
||||
# NEXT_PUBLIC_* vars are inlined into Next.js bundles at build time — must be passed as ARGs
|
||||
- |
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
||||
--build-arg NEXT_PUBLIC_MARKETPLACE_URL=$NEXT_PUBLIC_MARKETPLACE_URL \
|
||||
--build-arg NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
|
||||
-t $DOCKER_IMAGE \
|
||||
-t $DOCKER_IMAGE_LATEST \
|
||||
-f $DOCKERFILE_PATH .
|
||||
- echo "--- Pushing to Registry ---"
|
||||
- docker push $DOCKER_IMAGE
|
||||
only:
|
||||
- main
|
||||
- docker push $DOCKER_IMAGE_LATEST
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
|
||||
deploy:
|
||||
# ====================================
|
||||
# DEPLOY STAGE
|
||||
# ====================================
|
||||
deploy_to_vps:
|
||||
stage: deploy
|
||||
image: alpine:latest
|
||||
needs:
|
||||
- build_image
|
||||
before_script:
|
||||
- apk add --no-cache openssh-client
|
||||
# Load the SSH private key stored in the CI variable SSH_PRIVATE_KEY
|
||||
- eval $(ssh-agent -s)
|
||||
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
|
||||
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
# Scan and trust the VPS host key (avoids manual known_hosts management)
|
||||
- ssh-keyscan -H $VPS_IP >> ~/.ssh/known_hosts
|
||||
- chmod 644 ~/.ssh/known_hosts
|
||||
script:
|
||||
- ssh $VPS_USER@$VPS_IP "docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY"
|
||||
- ssh $VPS_USER@$VPS_IP "docker pull $DOCKER_IMAGE"
|
||||
- ssh $VPS_USER@$VPS_IP "docker stop myapp || true"
|
||||
- ssh $VPS_USER@$VPS_IP "docker run -d --name myapp -p 80:3000 $DOCKER_IMAGE"
|
||||
- echo "--- Deploying $DOCKER_IMAGE to VPS ---"
|
||||
- |
|
||||
ssh $VPS_USER@$VPS_IP "
|
||||
set -e
|
||||
cd /opt/rentaldrivego
|
||||
|
||||
echo '-- Pulling latest code --'
|
||||
git pull
|
||||
|
||||
echo '-- Logging into registry --'
|
||||
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
|
||||
echo '-- Pulling pre-built image --'
|
||||
docker pull $DOCKER_IMAGE
|
||||
|
||||
echo '-- Restarting services --'
|
||||
APP_VERSION=$CI_COMMIT_SHORT_SHA \
|
||||
docker compose -p rentaldrivego-prod \
|
||||
--env-file .env.docker.production \
|
||||
-f docker-compose.production.yml \
|
||||
up -d --no-build
|
||||
|
||||
echo '-- Pruning old images --'
|
||||
docker image prune -f
|
||||
"
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
|
||||
Binary file not shown.
@@ -103,6 +103,7 @@ The test container runs:
|
||||
- `npm run db:generate`
|
||||
- `npm run type-check`
|
||||
- `npm run build`
|
||||
- `npm run test:api:integration`
|
||||
|
||||
### Production
|
||||
|
||||
@@ -240,6 +241,70 @@ npm run docker:prod:logs
|
||||
npm run docker:prod:logs:api
|
||||
```
|
||||
|
||||
#### Backup production data
|
||||
|
||||
Create a timestamped backup directory under `./backups`:
|
||||
|
||||
```bash
|
||||
npm run docker:prod:backup
|
||||
```
|
||||
|
||||
Or choose a different parent directory:
|
||||
|
||||
```bash
|
||||
bash scripts/docker-prod-backup.sh /srv/rentaldrivego-backups
|
||||
```
|
||||
|
||||
Each backup contains:
|
||||
|
||||
- `postgres.dump` — logical PostgreSQL backup in custom format
|
||||
- `api-uploads.tar.gz` — uploaded files from `/var/lib/rentaldrivego/storage`
|
||||
- `traefik-letsencrypt.tar.gz` — Traefik ACME certificate state, when available
|
||||
- `volumes/*.tar.gz` — raw Docker volume archives for the default production volumes
|
||||
- `manifest.txt` — basic metadata
|
||||
|
||||
By default, the backup also archives these named production volumes when they exist:
|
||||
|
||||
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_api_uploads`
|
||||
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_pgmanage_prod_data`
|
||||
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_postgres_prod_data`
|
||||
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_redis_prod_data`
|
||||
|
||||
To include extra named volumes from the same server, pass them as additional arguments:
|
||||
|
||||
```bash
|
||||
bash scripts/docker-prod-backup.sh /srv/rentaldrivego-backups \
|
||||
gitlab-l3gq_gitlab-config \
|
||||
gitlab-l3gq_gitlab-data \
|
||||
gitlab-l3gq_gitlab-logs \
|
||||
pgmanage_data \
|
||||
traefik-gcjk_portainer_data \
|
||||
traefik-gcjk_traefik-letsencrypt
|
||||
```
|
||||
|
||||
You can also provide extra volume names through `DOCKER_EXTRA_BACKUP_VOLUMES`.
|
||||
|
||||
#### Restore production data
|
||||
|
||||
Restore is destructive: it overwrites the production database, uploaded files, and, when present in the backup, Traefik ACME state.
|
||||
|
||||
```bash
|
||||
bash scripts/docker-prod-restore.sh ./backups/rentaldrivego-prod-YYYYMMDDTHHMMSSZ --yes
|
||||
```
|
||||
|
||||
The restore script:
|
||||
|
||||
1. Stops public app services
|
||||
2. Restores the PostgreSQL dump
|
||||
3. Replaces uploaded files
|
||||
4. Restores Traefik ACME state if the archive exists
|
||||
5. Restores raw volume archives from `volumes/` except the ones already covered by the database and upload restore steps
|
||||
6. Starts Traefik and the production stack again
|
||||
|
||||
If the backup contains raw archives for volumes used by other stacks such as GitLab or Portainer, stop those containers before running restore. The script will refuse to overwrite a volume that is currently attached to a running container.
|
||||
|
||||
Before running restore on a live server, take a fresh backup first.
|
||||
|
||||
#### Stop the stack
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -12,4 +12,4 @@ RUN npm install
|
||||
|
||||
ENV NODE_ENV=test
|
||||
|
||||
CMD ["sh", "-c", "npm run db:generate && npm run type-check && npm run build"]
|
||||
CMD ["sh", "-c", "npm run db:deploy && npm run db:generate && npm run type-check && npm run build && npm run test:api:integration"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL=postgresql://postgres:password@localhost:5432/rentaldrivego_test
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=test-secret
|
||||
JWT_EXPIRY=8h
|
||||
NODE_ENV=test
|
||||
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
|
||||
@@ -9,7 +9,11 @@
|
||||
"build": "tsc",
|
||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||
"start": "node dist/index.js",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:integration:watch": "vitest --config vitest.integration.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^3.4.3",
|
||||
@@ -50,7 +54,10 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import morgan from 'morgan'
|
||||
import { getLegacyStorageRoots, getStorageRoot } from './lib/storage'
|
||||
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
|
||||
|
||||
// ─── Module routes ────────────────────────────────────────────
|
||||
import webhookRouter from './modules/webhooks/webhook.routes'
|
||||
import companyAuthRouter from './modules/auth/auth.company.routes'
|
||||
import employeeAuthRouter from './modules/auth/auth.employee.routes'
|
||||
import renterAuthRouter from './modules/auth/auth.renter.routes'
|
||||
import teamRouter from './modules/team/team.routes'
|
||||
import offersRouter from './modules/offers/offer.routes'
|
||||
import analyticsRouter from './modules/analytics/analytics.routes'
|
||||
import notificationsRouter from './modules/notifications/notification.routes'
|
||||
import adminRouter from './modules/admin/admin.routes'
|
||||
import subscriptionsRouter from './modules/subscriptions/subscription.routes'
|
||||
import paymentsRouter from './modules/payments/payment.routes'
|
||||
import customersRouter from './modules/customers/customer.routes'
|
||||
import vehiclesRouter from './modules/vehicles/vehicle.routes'
|
||||
import companiesRouter from './modules/companies/company.routes'
|
||||
import reservationsRouter from './modules/reservations/reservation.routes'
|
||||
import marketplaceRouter from './modules/marketplace/marketplace.routes'
|
||||
import siteRouter from './modules/site/site.routes'
|
||||
|
||||
// ─── Centralized error handling ───────────────────────────────
|
||||
import { errorMiddleware } from './http/errors/errorMiddleware'
|
||||
|
||||
const v1 = '/api/v1'
|
||||
|
||||
const defaultCorsOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3002',
|
||||
'http://localhost:3003',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://127.0.0.1:3001',
|
||||
'http://127.0.0.1:3002',
|
||||
'http://127.0.0.1:3003',
|
||||
]
|
||||
|
||||
export const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
|
||||
: defaultCorsOrigins
|
||||
|
||||
const routeDocs = [
|
||||
{ method: 'GET', path: '/health', description: 'Health check' },
|
||||
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
|
||||
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
|
||||
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
|
||||
{ method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' },
|
||||
{ method: 'GET', path: `${v1}/reservations`, description: 'List reservations' },
|
||||
{ method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' },
|
||||
{ method: 'GET', path: `${v1}/customers`, description: 'List customers' },
|
||||
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
|
||||
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
|
||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/cities`, description: 'Marketplace cities' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
|
||||
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
|
||||
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
|
||||
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
|
||||
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
|
||||
]
|
||||
|
||||
export function createApp() {
|
||||
const app = express()
|
||||
const corsMiddleware = cors({ origin: corsOrigins, credentials: true })
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.set('trust proxy', 1)
|
||||
}
|
||||
|
||||
app.use(corsMiddleware)
|
||||
|
||||
// Customer identity documents must never be anonymously retrievable from the
|
||||
// public storage mount, even if an older raw storage URL leaks.
|
||||
app.use('/storage/companies/:companyId/customers/:customerId', (_req, res) => {
|
||||
res.status(404).end()
|
||||
})
|
||||
|
||||
// Serve uploaded assets from the configured storage root, with a legacy fallback
|
||||
// for older files that were written under the previous API-local path.
|
||||
app.use('/storage', express.static(getStorageRoot()))
|
||||
for (const legacyRoot of getLegacyStorageRoots()) {
|
||||
app.use('/storage', express.static(legacyRoot))
|
||||
}
|
||||
|
||||
// Webhook must use raw body BEFORE express.json()
|
||||
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
|
||||
|
||||
app.use(helmet())
|
||||
if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// ─── API Routes ─────────────────────────────────────────────
|
||||
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
|
||||
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
||||
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
|
||||
|
||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||
|
||||
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
|
||||
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
||||
|
||||
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
|
||||
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
|
||||
app.use(`${v1}/team`, apiLimiter, teamRouter)
|
||||
app.use(`${v1}/customers`, apiLimiter, customersRouter)
|
||||
app.use(`${v1}/offers`, apiLimiter, offersRouter)
|
||||
app.use(`${v1}/analytics`, apiLimiter, analyticsRouter)
|
||||
app.use(`${v1}/notifications`, apiLimiter, notificationsRouter)
|
||||
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
||||
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||
|
||||
// ─── Health / Docs ──────────────────────────────────────────
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
app.get(`${v1}/docs`, (_req, res) => {
|
||||
res.json({ name: 'rentaldrivego-api', version: '1.0.0', baseUrl: v1, docsUrl: '/docs', routes: routeDocs })
|
||||
})
|
||||
|
||||
app.get('/docs', (_req, res) => {
|
||||
const rows = routeDocs
|
||||
.map((r) => `<tr><td>${r.method}</td><td><code>${r.path}</code></td><td>${r.description}</td></tr>`)
|
||||
.join('')
|
||||
res.type('html').send(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>RentalDriveGo API Docs</title></head><body><h1>RentalDriveGo API</h1><table><thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead><tbody>${rows}</tbody></table></body></html>`)
|
||||
})
|
||||
|
||||
// ─── Error handler ──────────────────────────────────────────
|
||||
app.use(errorMiddleware)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { AppError } from './index'
|
||||
|
||||
export function errorMiddleware(err: any, _req: Request, res: Response, _next: NextFunction) {
|
||||
if (err.name === 'ZodError') {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Invalid request body', issues: err.issues, statusCode: 400 })
|
||||
}
|
||||
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Resource not found', statusCode: 404 })
|
||||
}
|
||||
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json({ error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 })
|
||||
}
|
||||
|
||||
if (err instanceof AppError) {
|
||||
if (err.statusCode >= 500) console.error('[API Error]', err)
|
||||
return res.status(err.statusCode).json({ error: err.error, message: err.message, statusCode: err.statusCode, ...err.data })
|
||||
}
|
||||
|
||||
const statusCode = err.statusCode ?? 500
|
||||
const message = err.message ?? 'Internal server error'
|
||||
const code = err.code ?? 'internal_error'
|
||||
|
||||
if (statusCode >= 500) console.error('[API Error]', err)
|
||||
|
||||
res.status(statusCode).json({ error: code, message, statusCode })
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export class AppError extends Error {
|
||||
readonly statusCode: number
|
||||
readonly error: string
|
||||
readonly data?: Record<string, unknown>
|
||||
|
||||
constructor(message: string, statusCode: number, error: string, data?: Record<string, unknown>) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
this.error = error
|
||||
this.data = data
|
||||
this.name = this.constructor.name
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message = 'Validation error') {
|
||||
super(message, 400, 'validation_error')
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message = 'Resource not found') {
|
||||
super(message, 404, 'not_found')
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message = 'A resource with this value already exists') {
|
||||
super(message, 409, 'conflict')
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(message, 403, 'forbidden')
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(message, 401, 'unauthorized')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Response } from 'express'
|
||||
|
||||
export function ok<T>(res: Response, data: T): void {
|
||||
res.json({ data })
|
||||
}
|
||||
|
||||
export function created<T>(res: Response, data: T): void {
|
||||
res.status(201).json({ data })
|
||||
}
|
||||
|
||||
export function noContent(res: Response): void {
|
||||
res.status(204).end()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import multer from 'multer'
|
||||
import { ValidationError } from '../errors'
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
/**
|
||||
* Shared multer instance used by all upload endpoints.
|
||||
* Files are kept in memory so services receive a Buffer — no temp-file cleanup needed.
|
||||
*/
|
||||
export const imageUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
})
|
||||
|
||||
/**
|
||||
* Asserts that a file was provided and is an image MIME type.
|
||||
* Call inside the route handler after the multer middleware runs.
|
||||
*/
|
||||
export function assertImageFile(
|
||||
file: Express.Multer.File | undefined,
|
||||
fieldLabel = 'file',
|
||||
): asserts file is Express.Multer.File {
|
||||
if (!file) throw new ValidationError(`A ${fieldLabel} is required`)
|
||||
if (!file.mimetype.startsWith('image/')) throw new ValidationError('Only image uploads are supported')
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that at least one file was provided and all files are images.
|
||||
*/
|
||||
export function assertImageFiles(files: Express.Multer.File[], fieldLabel = 'photos'): void {
|
||||
if (!files || files.length === 0) throw new ValidationError(`At least one ${fieldLabel} file is required`)
|
||||
const nonImage = files.find((f) => !f.mimetype.startsWith('image/'))
|
||||
if (nonImage) throw new ValidationError(`All uploaded files must be images — "${nonImage.originalname}" is not`)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Request } from 'express'
|
||||
import type { ZodTypeAny, output } from 'zod'
|
||||
|
||||
export function parseBody<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
|
||||
return schema.parse(req.body)
|
||||
}
|
||||
|
||||
export function parseQuery<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
|
||||
return schema.parse(req.query)
|
||||
}
|
||||
|
||||
export function parseParams<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
|
||||
return schema.parse(req.params)
|
||||
}
|
||||
+4
-194
@@ -1,7 +1,3 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import morgan from 'morgan'
|
||||
import http from 'http'
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import cron from 'node-cron'
|
||||
@@ -9,72 +5,12 @@ import jwt from 'jsonwebtoken'
|
||||
import { z } from 'zod'
|
||||
import { redis } from './lib/redis'
|
||||
import { prisma } from './lib/prisma'
|
||||
import { getStorageRoot } from './lib/storage'
|
||||
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
|
||||
import { assertStorageConfiguration } from './lib/storage'
|
||||
import { createApp, corsOrigins } from './app'
|
||||
|
||||
// ─── Routes ───────────────────────────────────────────────────
|
||||
import webhookRouter from './routes/webhooks'
|
||||
import companyAuthRouter from './routes/auth.company'
|
||||
import employeeAuthRouter from './routes/auth.employee'
|
||||
import renterAuthRouter from './routes/auth.renter'
|
||||
import vehiclesRouter from './routes/vehicles'
|
||||
import reservationsRouter from './routes/reservations'
|
||||
import teamRouter from './routes/team'
|
||||
import customersRouter from './routes/customers'
|
||||
import offersRouter from './routes/offers'
|
||||
import analyticsRouter from './routes/analytics'
|
||||
import notificationsRouter from './routes/notifications'
|
||||
import marketplaceRouter from './routes/marketplace'
|
||||
import adminRouter from './routes/admin'
|
||||
import companiesRouter from './routes/companies'
|
||||
import subscriptionsRouter from './routes/subscriptions'
|
||||
import siteRouter from './routes/site'
|
||||
import paymentsRouter from './routes/payments'
|
||||
|
||||
const app = express()
|
||||
const app = createApp()
|
||||
const server = http.createServer(app)
|
||||
|
||||
// Trust the first hop from a reverse proxy so req.ip and rate-limiting
|
||||
// use the real client IP (from X-Forwarded-For) rather than the proxy's IP.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.set('trust proxy', 1)
|
||||
}
|
||||
const v1 = '/api/v1'
|
||||
const defaultCorsOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3002',
|
||||
'http://localhost:3003',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://127.0.0.1:3001',
|
||||
'http://127.0.0.1:3002',
|
||||
'http://127.0.0.1:3003',
|
||||
]
|
||||
const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
|
||||
: defaultCorsOrigins
|
||||
|
||||
const routeDocs = [
|
||||
{ method: 'GET', path: '/health', description: 'Health check' },
|
||||
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
|
||||
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
|
||||
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
|
||||
{ method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' },
|
||||
{ method: 'GET', path: `${v1}/reservations`, description: 'List reservations' },
|
||||
{ method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' },
|
||||
{ method: 'GET', path: `${v1}/customers`, description: 'List customers' },
|
||||
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
|
||||
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
|
||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/cities`, description: 'Marketplace cities' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
|
||||
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
|
||||
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
|
||||
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
|
||||
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
|
||||
]
|
||||
assertStorageConfiguration()
|
||||
|
||||
// ─── Socket.io ────────────────────────────────────────────────
|
||||
const io = new SocketIOServer(server, {
|
||||
@@ -122,132 +58,6 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Middleware ────────────────────────────────────────────────
|
||||
// Serve uploaded assets from the configured storage root.
|
||||
app.use('/storage', express.static(getStorageRoot()))
|
||||
|
||||
// Webhook must use raw body BEFORE express.json()
|
||||
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
|
||||
|
||||
app.use(helmet())
|
||||
app.use(cors({ origin: corsOrigins, credentials: true }))
|
||||
app.use(morgan('combined'))
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// ─── API Routes ───────────────────────────────────────────────
|
||||
// Auth routes: strict brute-force protection
|
||||
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
|
||||
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
||||
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
|
||||
|
||||
// Admin routes: dedicated limit
|
||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||
|
||||
// Public unauthenticated routes
|
||||
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
|
||||
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
||||
|
||||
// Authenticated company/renter routes
|
||||
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
|
||||
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
|
||||
app.use(`${v1}/team`, apiLimiter, teamRouter)
|
||||
app.use(`${v1}/customers`, apiLimiter, customersRouter)
|
||||
app.use(`${v1}/offers`, apiLimiter, offersRouter)
|
||||
app.use(`${v1}/analytics`, apiLimiter, analyticsRouter)
|
||||
app.use(`${v1}/notifications`, apiLimiter, notificationsRouter)
|
||||
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
||||
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||
|
||||
// ─── Health check ─────────────────────────────────────────────
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
app.get(`${v1}/docs`, (_req, res) => {
|
||||
res.json({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: v1,
|
||||
docsUrl: '/docs',
|
||||
routes: routeDocs,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/docs', (_req, res) => {
|
||||
const rows = routeDocs
|
||||
.map(
|
||||
(route) => `
|
||||
<tr>
|
||||
<td>${route.method}</td>
|
||||
<td><code>${route.path}</code></td>
|
||||
<td>${route.description}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
res.type('html').send(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>RentalDriveGo API Docs</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; color: #0f172a; background: #f8fafc; }
|
||||
h1 { margin-bottom: 8px; }
|
||||
p { color: #475569; }
|
||||
.meta { margin-bottom: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 12px; overflow: hidden; }
|
||||
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid #e2e8f0; }
|
||||
th { background: #e2e8f0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
code { background: #f1f5f9; padding: 2px 6px; border-radius: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RentalDriveGo API</h1>
|
||||
<p class="meta">Base URL: <code>${v1}</code> · JSON index: <code>${v1}/docs</code></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Path</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`)
|
||||
})
|
||||
|
||||
// ─── Error handler ────────────────────────────────────────────
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
const statusCode = err.statusCode ?? 500
|
||||
const message = err.message ?? 'Internal server error'
|
||||
const code = err.code ?? 'internal_error'
|
||||
|
||||
if (statusCode >= 500) {
|
||||
console.error('[API Error]', err)
|
||||
}
|
||||
|
||||
// Zod validation error
|
||||
if (err.name === 'ZodError') {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Invalid request body', issues: err.issues, statusCode: 400 })
|
||||
}
|
||||
|
||||
// Prisma not found
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Resource not found', statusCode: 404 })
|
||||
}
|
||||
|
||||
// Prisma unique constraint
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json({ error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 })
|
||||
}
|
||||
|
||||
res.status(statusCode).json({ error: code, message, statusCode })
|
||||
})
|
||||
|
||||
// ─── Scheduled jobs ───────────────────────────────────────────
|
||||
|
||||
// Daily: flag expiring/expired licenses
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function isDatabaseUnavailableError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const candidate = error as { code?: string; message?: string }
|
||||
return candidate.code === 'P1001' || candidate.message?.includes("Can't reach database server") === true
|
||||
}
|
||||
+76
-10
@@ -2,7 +2,13 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const DEFAULT_STORAGE_ROOT = path.resolve(__dirname, '..', '..', 'storage')
|
||||
const APP_PACKAGE_ROOT = path.resolve(__dirname, '..', '..', '..')
|
||||
const APP_SOURCE_ROOT = path.join(APP_PACKAGE_ROOT, 'src')
|
||||
const APP_DIST_ROOT = path.join(APP_PACKAGE_ROOT, 'dist')
|
||||
const DEFAULT_STORAGE_ROOT = path.join(APP_PACKAGE_ROOT, 'storage')
|
||||
const LEGACY_STORAGE_ROOTS = [
|
||||
path.join(APP_SOURCE_ROOT, 'lib', 'storage'),
|
||||
]
|
||||
|
||||
export function getStorageRoot(): string {
|
||||
return process.env.FILE_STORAGE_ROOT
|
||||
@@ -10,8 +16,37 @@ export function getStorageRoot(): string {
|
||||
: DEFAULT_STORAGE_ROOT
|
||||
}
|
||||
|
||||
function ensureStorageRoot(): string {
|
||||
export function getLegacyStorageRoots(): string[] {
|
||||
return LEGACY_STORAGE_ROOTS
|
||||
}
|
||||
|
||||
function isWithinPath(targetPath: string, parentPath: string): boolean {
|
||||
const relative = path.relative(parentPath, targetPath)
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
export function assertStorageConfiguration(): string {
|
||||
const storageRoot = getStorageRoot()
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT) {
|
||||
throw new Error('FILE_STORAGE_ROOT must be set in production so uploads are stored on the mounted volume.')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const forbiddenRoots = [APP_PACKAGE_ROOT, APP_SOURCE_ROOT, APP_DIST_ROOT]
|
||||
const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root))
|
||||
if (invalidRoot) {
|
||||
throw new Error(
|
||||
`FILE_STORAGE_ROOT must point outside the API app tree in production. Received ${storageRoot}, which is inside ${invalidRoot}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return storageRoot
|
||||
}
|
||||
|
||||
function ensureStorageRoot(): string {
|
||||
const storageRoot = assertStorageConfiguration()
|
||||
fs.mkdirSync(storageRoot, { recursive: true })
|
||||
return storageRoot
|
||||
}
|
||||
@@ -20,6 +55,25 @@ function getApiBase(): string {
|
||||
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function getDashboardBase(): string {
|
||||
return (
|
||||
process.env.DASHBOARD_URL ??
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ??
|
||||
'http://localhost:3001/dashboard'
|
||||
).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function getStorageRelativePath(imageUrl: string): string | null {
|
||||
const marker = '/storage/'
|
||||
const idx = imageUrl.indexOf(marker)
|
||||
if (idx === -1) return null
|
||||
return imageUrl.slice(idx + marker.length)
|
||||
}
|
||||
|
||||
export function getProtectedCustomerLicenseImageUrl(customerId: string): string {
|
||||
return `${getDashboardBase()}/api/v1/customers/${customerId}/license-image`
|
||||
}
|
||||
|
||||
export async function uploadImage(
|
||||
buffer: Buffer,
|
||||
folder: string,
|
||||
@@ -38,12 +92,24 @@ export async function uploadImage(
|
||||
return `${getApiBase()}/storage/${folder}/${filename}`
|
||||
}
|
||||
|
||||
export async function deleteImage(imageUrl: string): Promise<void> {
|
||||
const storageRoot = getStorageRoot()
|
||||
const marker = '/storage/'
|
||||
const idx = imageUrl.indexOf(marker)
|
||||
if (idx === -1) return
|
||||
const relative = imageUrl.slice(idx + marker.length)
|
||||
const filePath = path.join(storageRoot, relative)
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
|
||||
export function resolveStoredFilePath(imageUrl: string): string | null {
|
||||
const relative = getStorageRelativePath(imageUrl)
|
||||
if (!relative) return null
|
||||
|
||||
const roots = [assertStorageConfiguration(), ...getLegacyStorageRoots()]
|
||||
for (const root of roots) {
|
||||
const filePath = path.join(root, relative)
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function deleteImage(imageUrl: string): Promise<void> {
|
||||
const filePath = resolveStoredFilePath(imageUrl)
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
/**
|
||||
* Sends a uniform auth-error JSON response.
|
||||
* All auth middleware must go through this function so the shape is identical
|
||||
* to what the errorMiddleware produces for AppError instances.
|
||||
*/
|
||||
export function sendUnauthorized(res: Response, error: string, message: string) {
|
||||
return res.status(401).json({ error, message, statusCode: 401 })
|
||||
}
|
||||
|
||||
export function getCookie(req: Request, name: string): string | null {
|
||||
const cookieHeader = req.headers.cookie
|
||||
if (!cookieHeader) return null
|
||||
|
||||
for (const chunk of cookieHeader.split(';')) {
|
||||
const [rawName, ...rawValue] = chunk.trim().split('=')
|
||||
if (rawName === name) {
|
||||
return decodeURIComponent(rawValue.join('='))
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function sendForbidden(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
|
||||
return res.status(403).json({ error, message, statusCode: 403, ...extra })
|
||||
}
|
||||
|
||||
export function sendPaymentRequired(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
|
||||
return res.status(402).json({ error, message, statusCode: 402, ...extra })
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { AdminRole } from '@rentaldrivego/database'
|
||||
import { sendUnauthorized, sendForbidden } from './authHelpers'
|
||||
|
||||
const ROLE_RANK: Record<AdminRole, number> = {
|
||||
SUPER_ADMIN: 5,
|
||||
@@ -11,48 +12,52 @@ const ROLE_RANK: Record<AdminRole, number> = {
|
||||
VIEWER: 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a valid admin Bearer token.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.admin — the full AdminUser record
|
||||
*/
|
||||
export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||
}
|
||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||
|
||||
let payload: { sub: string; type: string }
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type !== 'admin') {
|
||||
return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 })
|
||||
}
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
|
||||
if (!admin || !admin.isActive) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 })
|
||||
}
|
||||
|
||||
req.admin = admin
|
||||
next()
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired admin token')
|
||||
}
|
||||
|
||||
if (payload.type !== 'admin') {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
|
||||
}
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
|
||||
if (!admin || !admin.isActive) {
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
|
||||
}
|
||||
|
||||
req.admin = admin
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the authenticated admin to have at least `minimumRole`.
|
||||
* Must be applied after `requireAdminAuth`.
|
||||
*/
|
||||
export function requireAdminRole(minimumRole: AdminRole) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const admin = req.admin
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||
}
|
||||
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||
|
||||
const rank = ROLE_RANK[admin.role] ?? 0
|
||||
const required = ROLE_RANK[minimumRole] ?? 99
|
||||
const rank = ROLE_RANK[admin.role] ?? 0
|
||||
const required = ROLE_RANK[minimumRole] ?? 99
|
||||
|
||||
if (rank < required) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: `This action requires the ${minimumRole} role or higher`,
|
||||
statusCode: 403,
|
||||
})
|
||||
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`)
|
||||
}
|
||||
|
||||
next()
|
||||
|
||||
@@ -1,45 +1,54 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { getCookie, sendUnauthorized } from './authHelpers'
|
||||
|
||||
/**
|
||||
* Validates a company-scoped Bearer token and loads the employee + company onto `req`.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.employee — full employee record (with company relation)
|
||||
* req.company — the employee's company
|
||||
* req.companyId — string shorthand for req.company.id
|
||||
*/
|
||||
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction, allowCookie = false) {
|
||||
const authHeader = req.headers.authorization
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
const cookieToken = allowCookie ? getCookie(req, 'employee_token') : null
|
||||
const token = bearerToken ?? cookieToken
|
||||
|
||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
||||
|
||||
let payload: { sub: string; type: string }
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
} catch {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired session token')
|
||||
}
|
||||
|
||||
if (payload.type !== 'employee') {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type for this endpoint')
|
||||
}
|
||||
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: payload.sub },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
if (!employee || !employee.isActive) {
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Employee account not found or inactive')
|
||||
}
|
||||
|
||||
req.employee = employee
|
||||
req.company = employee.company
|
||||
req.companyId = employee.companyId
|
||||
next()
|
||||
}
|
||||
|
||||
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const sessionToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!sessionToken) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthenticated',
|
||||
message: 'Authentication required',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(sessionToken, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type === 'employee') {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: payload.sub },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
if (!employee || !employee.isActive) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthenticated',
|
||||
message: 'Employee account not found or inactive',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
|
||||
req.employee = employee
|
||||
req.company = employee.company
|
||||
req.companyId = employee.companyId
|
||||
return next()
|
||||
}
|
||||
} catch {
|
||||
return res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
message: 'Invalid or expired session token',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
return authenticateCompanyRequest(req, res, next)
|
||||
}
|
||||
|
||||
export async function requireCompanyDocumentAuth(req: Request, res: Response, next: NextFunction) {
|
||||
return authenticateCompanyRequest(req, res, next, true)
|
||||
}
|
||||
|
||||
@@ -1,46 +1,57 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendUnauthorized } from './authHelpers'
|
||||
|
||||
/**
|
||||
* Requires a valid renter Bearer token.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.renterId — the authenticated renter's id
|
||||
*/
|
||||
export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Renter authentication required', statusCode: 401 })
|
||||
}
|
||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Renter authentication required')
|
||||
|
||||
let payload: { sub: string; type: string }
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type !== 'renter') {
|
||||
return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 })
|
||||
}
|
||||
|
||||
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
|
||||
if (!renter || !renter.isActive) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Renter account not found or inactive', statusCode: 401 })
|
||||
}
|
||||
|
||||
req.renterId = renter.id
|
||||
next()
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired token')
|
||||
}
|
||||
|
||||
if (payload.type !== 'renter') {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
|
||||
}
|
||||
|
||||
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
|
||||
if (!renter || !renter.isActive) {
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Renter account not found or inactive')
|
||||
}
|
||||
|
||||
req.renterId = renter.id
|
||||
next()
|
||||
}
|
||||
|
||||
export async function optionalRenterAuth(req: Request, res: Response, next: NextFunction) {
|
||||
/**
|
||||
* Optionally extracts renter identity from a Bearer token.
|
||||
* Never blocks the request — invalid or absent tokens are silently ignored.
|
||||
*
|
||||
* Sets on success:
|
||||
* req.renterId — the authenticated renter's id (if token is valid)
|
||||
*/
|
||||
export async function optionalRenterAuth(req: Request, _res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!token) return next()
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type === 'renter') {
|
||||
req.renterId = payload.sub
|
||||
}
|
||||
if (payload.type === 'renter') req.renterId = payload.sub
|
||||
} catch {
|
||||
// Optional — ignore invalid tokens
|
||||
// Optional — silently ignore invalid tokens
|
||||
}
|
||||
|
||||
next()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { EmployeeRole } from '@rentaldrivego/database'
|
||||
import { sendUnauthorized, sendForbidden } from './authHelpers'
|
||||
|
||||
const ROLE_RANK: Record<EmployeeRole, number> = {
|
||||
OWNER: 3,
|
||||
@@ -7,21 +8,20 @@ const ROLE_RANK: Record<EmployeeRole, number> = {
|
||||
AGENT: 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the authenticated employee to have at least `minimumRole`.
|
||||
* Must be applied after `requireCompanyAuth`.
|
||||
*/
|
||||
export function requireRole(minimumRole: EmployeeRole) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const employee = req.employee
|
||||
if (!employee) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
|
||||
}
|
||||
if (!employee) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
||||
|
||||
const employeeRank = ROLE_RANK[employee.role] ?? 0
|
||||
const requiredRank = ROLE_RANK[minimumRole] ?? 99
|
||||
const requiredRank = ROLE_RANK[minimumRole] ?? 99
|
||||
|
||||
if (employeeRank < requiredRank) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: `This action requires the ${minimumRole} role or higher`,
|
||||
statusCode: 403,
|
||||
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`, {
|
||||
requiredRole: minimumRole,
|
||||
yourRole: employee.role,
|
||||
})
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
|
||||
|
||||
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
|
||||
|
||||
/**
|
||||
* Blocks requests for companies with lapsed or unactivated subscriptions.
|
||||
* Must be applied after `requireTenant`.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.company.status is not SUSPENDED or PENDING
|
||||
*/
|
||||
export function requireSubscription(req: Request, res: Response, next: NextFunction) {
|
||||
const company = req.company
|
||||
if (!company) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
|
||||
}
|
||||
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
|
||||
|
||||
if (BLOCKED_STATUSES.includes(company.status)) {
|
||||
return res.status(402).json({
|
||||
error: 'subscription_' + company.status.toLowerCase(),
|
||||
message:
|
||||
company.status === 'SUSPENDED'
|
||||
? 'Your account has been suspended. Please contact support or renew your subscription.'
|
||||
: 'Your account is pending activation. Please complete your subscription setup.',
|
||||
statusCode: 402,
|
||||
billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing`,
|
||||
})
|
||||
return sendPaymentRequired(
|
||||
res,
|
||||
`subscription_${company.status.toLowerCase()}`,
|
||||
company.status === 'SUSPENDED'
|
||||
? 'Your account has been suspended. Please contact support or renew your subscription.'
|
||||
: 'Your account is pending activation. Please complete your subscription setup.',
|
||||
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing` },
|
||||
)
|
||||
}
|
||||
|
||||
next()
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendUnauthorized } from './authHelpers'
|
||||
|
||||
/**
|
||||
* Loads the company record and validates it exists.
|
||||
* Must be applied after `requireCompanyAuth`.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.company — full Company record
|
||||
* req.companyId — string id (already set by requireCompanyAuth)
|
||||
*/
|
||||
export async function requireTenant(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.companyId) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthenticated',
|
||||
message: 'Tenant context missing — requireCompanyAuth must run first',
|
||||
statusCode: 401,
|
||||
})
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Tenant context missing — requireCompanyAuth must run first')
|
||||
}
|
||||
|
||||
const company = await prisma.company.findUnique({ where: { id: req.companyId } })
|
||||
if (!company) {
|
||||
return res.status(401).json({
|
||||
error: 'company_not_found',
|
||||
message: 'Company not found',
|
||||
statusCode: 401,
|
||||
})
|
||||
return sendUnauthorized(res, 'company_not_found', 'Company not found')
|
||||
}
|
||||
|
||||
req.company = company
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export function presentAdminUser<T extends Record<string, any>>(admin: T) {
|
||||
const { passwordHash, totpSecret, ...safe } = admin
|
||||
return safe
|
||||
}
|
||||
|
||||
export function presentAdminSession<T extends Record<string, any>>(admin: T, token: string) {
|
||||
return {
|
||||
token,
|
||||
admin: presentAdminUser(admin),
|
||||
}
|
||||
}
|
||||
|
||||
export function presentPaginated<T>(
|
||||
data: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const companyListInclude = {
|
||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
|
||||
subscription: { select: { plan: true, status: true } },
|
||||
_count: { select: { employees: true, vehicles: true } },
|
||||
} as const
|
||||
|
||||
const companyDetailInclude = {
|
||||
brand: true,
|
||||
contractSettings: true,
|
||||
accountingSettings: true,
|
||||
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
|
||||
employees: true,
|
||||
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
|
||||
} as const
|
||||
|
||||
const billingInclude = {
|
||||
company: { select: { id: true, name: true, email: true, slug: true, status: true } },
|
||||
invoices: {
|
||||
select: { id: true, amount: true, currency: true, status: true, paidAt: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
},
|
||||
_count: { select: { invoices: true } },
|
||||
} as const
|
||||
|
||||
function parseOptionalDate(value?: string | null) {
|
||||
if (!value) return null
|
||||
return new Date(value)
|
||||
}
|
||||
|
||||
export function findAdminByEmail(email: string) {
|
||||
return prisma.adminUser.findUnique({ where: { email } })
|
||||
}
|
||||
|
||||
export function findAdminByIdOrThrow(id: string) {
|
||||
return prisma.adminUser.findUniqueOrThrow({ where: { id } })
|
||||
}
|
||||
|
||||
export function updateAdminLastLogin(id: string) {
|
||||
return prisma.adminUser.update({ where: { id }, data: { lastLoginAt: new Date() } })
|
||||
}
|
||||
|
||||
export function updateAdminTotpSecret(id: string, secret: string) {
|
||||
return prisma.adminUser.update({ where: { id }, data: { totpSecret: secret } })
|
||||
}
|
||||
|
||||
export function enableAdminTotp(id: string) {
|
||||
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
|
||||
}
|
||||
|
||||
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
|
||||
return prisma.adminUser.update({
|
||||
where: { id },
|
||||
data: { passwordResetToken: token, passwordResetExpiresAt: expiresAt },
|
||||
})
|
||||
}
|
||||
|
||||
export function findAdminByResetToken(token: string) {
|
||||
return prisma.adminUser.findFirst({
|
||||
where: {
|
||||
passwordResetToken: token,
|
||||
passwordResetExpiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAdminPassword(id: string, passwordHash: string) {
|
||||
return prisma.adminUser.update({
|
||||
where: { id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordResetToken: null,
|
||||
passwordResetExpiresAt: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createAuditLog(data: Record<string, unknown>) {
|
||||
return prisma.auditLog.create({ data: data as any })
|
||||
}
|
||||
|
||||
export async function listCompaniesPage(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
|
||||
const where: any = {}
|
||||
if (query.status) where.status = query.status
|
||||
if (query.q) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.q, mode: 'insensitive' } },
|
||||
{ email: { contains: query.q, mode: 'insensitive' } },
|
||||
{ slug: { contains: query.q, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
if (query.plan) where.subscription = { plan: query.plan }
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.company.findMany({
|
||||
where,
|
||||
include: companyListInclude,
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.company.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
export function getCompanyDetail(id: string) {
|
||||
return prisma.company.findUniqueOrThrow({ where: { id }, include: companyDetailInclude })
|
||||
}
|
||||
|
||||
export function getCompanyUpdateSnapshot(id: string) {
|
||||
return prisma.company.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { brand: true, contractSettings: true, accountingSettings: true, subscription: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function applyCompanyUpdate(id: string, body: any, current: { name: string; slug: string; brand?: { paymentMethodsEnabled?: any[] | null } | null }) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
if (body.company) await tx.company.update({ where: { id }, data: body.company })
|
||||
|
||||
if (body.subscription) {
|
||||
const sub = body.subscription
|
||||
await tx.subscription.upsert({
|
||||
where: { companyId: id },
|
||||
update: {
|
||||
...sub,
|
||||
trialStartAt: parseOptionalDate(sub.trialStartAt),
|
||||
trialEndAt: parseOptionalDate(sub.trialEndAt),
|
||||
currentPeriodStart: parseOptionalDate(sub.currentPeriodStart),
|
||||
currentPeriodEnd: parseOptionalDate(sub.currentPeriodEnd),
|
||||
cancelledAt: parseOptionalDate(sub.cancelledAt),
|
||||
},
|
||||
create: {
|
||||
companyId: id,
|
||||
plan: sub.plan ?? 'STARTER',
|
||||
billingPeriod: sub.billingPeriod ?? 'MONTHLY',
|
||||
status: sub.status ?? 'TRIALING',
|
||||
currency: sub.currency ?? 'MAD',
|
||||
trialStartAt: parseOptionalDate(sub.trialStartAt),
|
||||
trialEndAt: parseOptionalDate(sub.trialEndAt),
|
||||
currentPeriodStart: parseOptionalDate(sub.currentPeriodStart),
|
||||
currentPeriodEnd: parseOptionalDate(sub.currentPeriodEnd),
|
||||
cancelledAt: parseOptionalDate(sub.cancelledAt),
|
||||
cancelAtPeriodEnd: sub.cancelAtPeriodEnd ?? false,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.brand) {
|
||||
await tx.brandSettings.upsert({
|
||||
where: { companyId: id },
|
||||
update: body.brand,
|
||||
create: {
|
||||
companyId: id,
|
||||
displayName: body.brand.displayName ?? current.name,
|
||||
subdomain: body.brand.subdomain ?? current.slug,
|
||||
paymentMethodsEnabled: current.brand?.paymentMethodsEnabled ?? [],
|
||||
...body.brand,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.contractSettings) {
|
||||
await tx.contractSettings.upsert({
|
||||
where: { companyId: id },
|
||||
update: body.contractSettings,
|
||||
create: { companyId: id, ...body.contractSettings } as any,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.accountingSettings) {
|
||||
await tx.accountingSettings.upsert({
|
||||
where: { companyId: id },
|
||||
update: body.accountingSettings,
|
||||
create: { companyId: id, ...body.accountingSettings } as any,
|
||||
})
|
||||
}
|
||||
|
||||
return tx.company.findUniqueOrThrow({ where: { id }, include: companyDetailInclude })
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCompanyStatus(id: string, status: string) {
|
||||
return prisma.company.update({ where: { id }, data: { status: status as any } })
|
||||
}
|
||||
|
||||
export function deleteCompany(id: string) {
|
||||
return prisma.company.delete({ where: { id } })
|
||||
}
|
||||
|
||||
export function getCompanyForImpersonation(id: string) {
|
||||
return prisma.company.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { employees: { where: { role: 'OWNER' } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function listRentersPage(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
|
||||
const where: any = {}
|
||||
if (query.blocked !== undefined) where.isActive = query.blocked === 'false'
|
||||
if (query.q) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: query.q, mode: 'insensitive' } },
|
||||
{ email: { contains: query.q, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.renter.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
_count: { select: { reservations: true } },
|
||||
},
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.renter.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
export function updateRenterActive(id: string, isActive: boolean) {
|
||||
return prisma.renter.update({ where: { id }, data: { isActive } })
|
||||
}
|
||||
|
||||
export async function getPlatformMetricCounts() {
|
||||
const [
|
||||
totalCompanies,
|
||||
activeCompanies,
|
||||
trialingCompanies,
|
||||
suspendedCompanies,
|
||||
totalRenters,
|
||||
totalReservations,
|
||||
] = await Promise.all([
|
||||
prisma.company.count(),
|
||||
prisma.company.count({ where: { status: 'ACTIVE' } }),
|
||||
prisma.company.count({ where: { status: 'TRIALING' } }),
|
||||
prisma.company.count({ where: { status: 'SUSPENDED' } }),
|
||||
prisma.renter.count(),
|
||||
prisma.reservation.count(),
|
||||
])
|
||||
|
||||
return {
|
||||
totalCompanies,
|
||||
activeCompanies,
|
||||
trialingCompanies,
|
||||
suspendedCompanies,
|
||||
totalRenters,
|
||||
totalReservations,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAuditLogsPage(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
|
||||
const where: any = {}
|
||||
if (query.adminId) where.adminUserId = query.adminId
|
||||
if (query.action) where.action = { contains: query.action }
|
||||
if (query.companyId) where.companyId = query.companyId
|
||||
if (query.entityId) where.resourceId = query.entityId
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
include: { adminUser: { select: { firstName: true, lastName: true, email: true } } },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.auditLog.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
export function listAdmins() {
|
||||
return prisma.adminUser.findMany({
|
||||
include: { permissions: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function createAdmin(data: {
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
passwordHash: string
|
||||
permissions?: any[]
|
||||
}) {
|
||||
return prisma.adminUser.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
role: data.role as any,
|
||||
passwordHash: data.passwordHash,
|
||||
permissions: data.permissions ? { create: data.permissions } : undefined,
|
||||
},
|
||||
include: { permissions: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAdminRole(id: string, role: string) {
|
||||
return prisma.adminUser.update({ where: { id }, data: { role: role as any } })
|
||||
}
|
||||
|
||||
export async function replaceAdminPermissions(id: string, permissions: any[]) {
|
||||
await prisma.adminUser.findUniqueOrThrow({ where: { id } })
|
||||
await prisma.$transaction([
|
||||
prisma.adminPermission.deleteMany({ where: { adminUserId: id } }),
|
||||
prisma.adminPermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
adminUserId: id,
|
||||
resource: permission.resource,
|
||||
actions: permission.actions,
|
||||
})) as any,
|
||||
}),
|
||||
])
|
||||
return prisma.adminUser.findUniqueOrThrow({ where: { id }, include: { permissions: true } })
|
||||
}
|
||||
|
||||
export async function listBillingPage(query: { status?: string; plan?: string; page: number; pageSize: number }) {
|
||||
const where: any = {}
|
||||
if (query.status) where.status = query.status
|
||||
if (query.plan) where.plan = query.plan
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where,
|
||||
include: billingInclude,
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.subscription.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
export function listActiveSubscriptionsForMrr() {
|
||||
return prisma.subscription.findMany({
|
||||
where: { status: { in: ['ACTIVE', 'TRIALING'] as any[] } },
|
||||
select: { plan: true, billingPeriod: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBillingStatusCounts() {
|
||||
const [activeCount, trialingCount, pastDueCount, cancelledCount] = await Promise.all([
|
||||
prisma.subscription.count({ where: { status: 'ACTIVE' } }),
|
||||
prisma.subscription.count({ where: { status: 'TRIALING' } }),
|
||||
prisma.subscription.count({ where: { status: 'PAST_DUE' } }),
|
||||
prisma.subscription.count({ where: { status: 'CANCELLED' } }),
|
||||
])
|
||||
|
||||
return { activeCount, trialingCount, pastDueCount, cancelledCount }
|
||||
}
|
||||
|
||||
export async function listCompanyInvoicesPage(companyId: string, query: { page: number; pageSize: number }) {
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.subscriptionInvoice.findMany({
|
||||
where: { companyId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
prisma.subscriptionInvoice.count({ where: { companyId } }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
export function getInvoicePdfRecord(invoiceId: string) {
|
||||
return prisma.subscriptionInvoice.findUniqueOrThrow({
|
||||
where: { id: invoiceId },
|
||||
include: {
|
||||
company: { select: { name: true, email: true, phone: true, address: true } },
|
||||
subscription: {
|
||||
select: {
|
||||
plan: true,
|
||||
billingPeriod: true,
|
||||
currency: true,
|
||||
currentPeriodStart: true,
|
||||
currentPeriodEnd: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAdminAuth, requireAdminRole } from '../../middleware/requireAdminAuth'
|
||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import * as service from './admin.service'
|
||||
import { presentAdminUser } from './admin.presenter'
|
||||
import {
|
||||
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
|
||||
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema,
|
||||
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
|
||||
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
|
||||
homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema,
|
||||
} from './admin.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── Auth (public) ─────────────────────────────────────────────
|
||||
|
||||
router.post('/auth/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, totpCode } = parseBody(loginSchema, req)
|
||||
const result = await service.login(email, password, totpCode)
|
||||
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
||||
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
|
||||
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
|
||||
ok(res, { data: result })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/auth/forgot-password', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = parseBody(forgotPasswordSchema, req)
|
||||
await service.forgotPassword(email)
|
||||
ok(res, { data: { message: 'If that email is registered, a reset link has been sent.' } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/auth/reset-password', async (req, res, next) => {
|
||||
try {
|
||||
const { token, password } = parseBody(resetPasswordSchema, req)
|
||||
const ok2 = await service.resetPassword(token, password)
|
||||
if (!ok2) return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 })
|
||||
ok(res, { data: { message: 'Password updated successfully. You can now sign in.' } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Auth (protected) ──────────────────────────────────────────
|
||||
|
||||
router.get('/auth/me', requireAdminAuth, (req, res) => {
|
||||
ok(res, { data: presentAdminUser(req.admin as any) })
|
||||
})
|
||||
|
||||
router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.setupTotp(req.admin.id, req.admin.email) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { code } = parseBody(totpVerifySchema, req)
|
||||
const valid = await service.verifyTotp(req.admin.id, code)
|
||||
if (!valid) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Companies ─────────────────────────────────────────────────
|
||||
|
||||
router.get('/companies', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.listCompanies(parseQuery(companiesQuerySchema, req)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/companies/:id', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, { data: await service.getCompany(id) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(adminCompanyUpdateSchema, req)
|
||||
ok(res, { data: await service.updateCompany(id, body, req.admin.id, req.ip) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { status, reason } = parseBody(companyStatusSchema, req)
|
||||
ok(res, { data: await service.setCompanyStatus(id, status, reason, req.admin.id, req.ip) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deleteCompany(id, req.admin.id, req.ip)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, { data: await service.impersonateCompany(id, req.admin.id, req.ip) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Renters ───────────────────────────────────────────────────
|
||||
|
||||
router.get('/renters', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.listRenters(parseQuery(rentersQuerySchema, req)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/renters/:id/block', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.setRenterActive(id, false)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/renters/:id/unblock', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.setRenterActive(id, true)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Metrics ───────────────────────────────────────────────────
|
||||
|
||||
router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getPlatformMetrics() })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Audit logs ────────────────────────────────────────────────
|
||||
|
||||
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getAuditLogs(parseQuery(auditLogQuerySchema, req)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Admin users ───────────────────────────────────────────────
|
||||
|
||||
router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.listAdmins() })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
created(res, { data: await service.createAdmin(parseBody(createAdminSchema, req)) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { role } = parseBody(adminRoleSchema, req)
|
||||
await service.updateAdminRole(id, role)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { permissions } = parseBody(adminPermissionsSchema, req)
|
||||
ok(res, { data: await service.updateAdminPermissions(id, permissions) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Billing ───────────────────────────────────────────────────
|
||||
|
||||
router.get('/billing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getBilling(parseQuery(billingQuerySchema, req)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/:companyId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { companyId } = parseParams(companyIdParamSchema, req)
|
||||
ok(res, await service.getCompanyInvoices(companyId, parseQuery(invoicesQuerySchema, req)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
|
||||
const { pdfBuffer, invoiceNumber } = await service.getInvoicePdf(invoiceId)
|
||||
res.setHeader('Content-Type', 'application/pdf')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${invoiceNumber}.pdf"`)
|
||||
res.setHeader('Content-Length', pdfBuffer.length)
|
||||
res.end(pdfBuffer)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Site config ───────────────────────────────────────────────
|
||||
|
||||
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getMarketplaceHomepage() })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { homepage } = parseBody(homepageUpdateSchema, req)
|
||||
ok(res, { data: await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,176 @@
|
||||
import { z } from 'zod'
|
||||
import type { MarketplaceHomepageContent, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep } from '@rentaldrivego/types'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
password: z.string().max(128),
|
||||
totpCode: z.string().length(6).optional(),
|
||||
})
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
})
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8).max(128),
|
||||
})
|
||||
|
||||
export const totpVerifySchema = z.object({
|
||||
code: z.string().length(6),
|
||||
})
|
||||
|
||||
export const companiesQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
plan: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const rentersQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
blocked: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const auditLogQuerySchema = z.object({
|
||||
adminId: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
companyId: z.string().optional(),
|
||||
entityId: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
|
||||
export const billingQuerySchema = z.object({
|
||||
status: z.string().optional(),
|
||||
plan: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const invoicesQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const permissionSchema = z.object({
|
||||
resource: z.enum(['COMPANIES', 'COMPANY_EMPLOYEES', 'SUBSCRIPTIONS', 'PAYMENTS', 'OFFERS', 'RENTERS', 'ADMIN_USERS', 'AUDIT_LOGS', 'PLATFORM_METRICS']),
|
||||
actions: z.array(z.enum(['READ', 'CREATE', 'UPDATE', 'DELETE', 'SUSPEND', 'IMPERSONATE'])).min(1),
|
||||
})
|
||||
|
||||
export const createAdminSchema = z.object({
|
||||
email: z.string().email(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
password: z.string().min(8),
|
||||
permissions: z.array(permissionSchema).optional(),
|
||||
})
|
||||
|
||||
export const adminRoleSchema = z.object({
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
})
|
||||
|
||||
export const adminPermissionsSchema = z.object({
|
||||
permissions: z.array(permissionSchema),
|
||||
})
|
||||
|
||||
export const companyStatusSchema = z.object({
|
||||
status: z.enum(['ACTIVE', 'SUSPENDED', 'CANCELLED']),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
const nullableString = z.union([z.string(), z.null()]).optional()
|
||||
const nullableEmail = z.union([z.string().email(), z.null()]).optional()
|
||||
const nullableUrl = z.union([z.string().url(), z.null()]).optional()
|
||||
const nullableDate = z.union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.null()]).optional()
|
||||
|
||||
export const adminCompanyUpdateSchema = z.object({
|
||||
company: z.object({
|
||||
name: z.string().min(1).optional(), slug: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(), phone: nullableString,
|
||||
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
||||
subscriptionPaymentRef: nullableString,
|
||||
}).optional(),
|
||||
subscription: z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
|
||||
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
trialStartAt: nullableDate, trialEndAt: nullableDate,
|
||||
currentPeriodStart: nullableDate, currentPeriodEnd: nullableDate,
|
||||
cancelledAt: nullableDate, cancelAtPeriodEnd: z.boolean().optional(),
|
||||
}).optional(),
|
||||
brand: z.object({
|
||||
displayName: z.string().min(1).optional(), tagline: nullableString,
|
||||
subdomain: z.string().min(1).optional(), customDomain: nullableString,
|
||||
publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString,
|
||||
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
|
||||
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
|
||||
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: z.any().optional(),
|
||||
menuConfig: z.any().optional(),
|
||||
}).optional(),
|
||||
contractSettings: z.object({
|
||||
legalName: nullableString, registrationNumber: nullableString, taxId: nullableString,
|
||||
terms: z.string().optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
lateFeePerHour: z.number().int().nullable().optional(), taxRate: z.number().nullable().optional(),
|
||||
signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(),
|
||||
}).optional(),
|
||||
accountingSettings: z.object({
|
||||
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
|
||||
fiscalYearStart: z.number().int().min(1).max(12).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
accountantEmail: nullableEmail, accountantName: nullableString,
|
||||
autoSendReport: z.boolean().optional(),
|
||||
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
|
||||
const marketplacePillarSchema: z.ZodType<MarketplaceHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
|
||||
const marketplaceStepSchema: z.ZodType<MarketplaceHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
|
||||
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'steps', 'closing'])
|
||||
|
||||
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
|
||||
sections: z.array(marketplaceSectionSchema).min(1),
|
||||
heroKicker: z.string().min(1), heroTitle: z.string().min(1), heroBody: z.string().min(1),
|
||||
startTrial: z.string().min(1), exploreVehicles: z.string().min(1),
|
||||
surfaceLabel: z.string().min(1), surfaceTitle: z.string().min(1), surfaceBody: z.string().min(1),
|
||||
liveLabel: z.string().min(1), trustedFleets: z.string().min(1), brandedFlows: z.string().min(1), multiTenant: z.string().min(1),
|
||||
companyKicker: z.string().min(1), companyTitle: z.string().min(1), companyBody: z.string().min(1),
|
||||
renterKicker: z.string().min(1), renterTitle: z.string().min(1), renterBody: z.string().min(1),
|
||||
pillars: z.array(marketplacePillarSchema).length(3),
|
||||
metrics: z.array(marketplaceMetricSchema).length(3),
|
||||
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1),
|
||||
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().min(1),
|
||||
readyKicker: z.string().min(1), readyTitle: z.string().min(1), readyBody: z.string().min(1),
|
||||
viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
|
||||
})
|
||||
|
||||
export const marketplaceHomepageConfigSchema = z.object({
|
||||
en: marketplaceHomepageContentSchema,
|
||||
fr: marketplaceHomepageContentSchema,
|
||||
ar: marketplaceHomepageContentSchema,
|
||||
})
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
export const companyIdParamSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const invoiceIdParamSchema = z.object({
|
||||
invoiceId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const homepageUpdateSchema = z.object({
|
||||
homepage: marketplaceHomepageConfigSchema,
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import crypto from 'crypto'
|
||||
import { authenticator } from 'otplib'
|
||||
import qrcode from 'qrcode'
|
||||
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
|
||||
import { generateInvoicePdf, buildInvoiceNumber } from '../../services/invoicePdfService'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import * as presenter from './admin.presenter'
|
||||
import * as repo from './admin.repo'
|
||||
|
||||
const ADMIN_RESET_TTL_MINUTES = 60
|
||||
|
||||
const PLAN_MONTHLY_AMOUNT: Record<string, number> = {
|
||||
STARTER: 29900,
|
||||
GROWTH: 59900,
|
||||
PRO: 99900,
|
||||
}
|
||||
|
||||
function signAdminToken(adminId: string) {
|
||||
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
|
||||
}
|
||||
|
||||
function toAuditJson<T>(value: T) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
function ensureAdminBasePath(baseUrl: string) {
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
const pathname = url.pathname.replace(/\/$/, '')
|
||||
if (!pathname.endsWith('/admin')) url.pathname = `${pathname}/admin`
|
||||
return url.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
const trimmed = baseUrl.replace(/\/$/, '')
|
||||
return trimmed.endsWith('/admin') ? trimmed : `${trimmed}/admin`
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string, totpCode?: string) {
|
||||
const admin = await repo.findAdminByEmail(email)
|
||||
if (!admin || !admin.isActive) return null
|
||||
|
||||
const valid = await bcrypt.compare(password, admin.passwordHash)
|
||||
if (!valid) return null
|
||||
|
||||
if (admin.totpEnabled) {
|
||||
if (!totpCode) return { totpRequired: true } as const
|
||||
if (!authenticator.verify({ token: totpCode, secret: admin.totpSecret! })) {
|
||||
return { invalidTotp: true } as const
|
||||
}
|
||||
}
|
||||
|
||||
await repo.updateAdminLastLogin(admin.id)
|
||||
await repo.createAuditLog({
|
||||
adminUserId: admin.id,
|
||||
action: 'ADMIN_LOGIN',
|
||||
resource: 'AdminUser',
|
||||
resourceId: admin.id,
|
||||
})
|
||||
|
||||
return presenter.presentAdminSession(admin, signAdminToken(admin.id))
|
||||
}
|
||||
|
||||
export async function setupTotp(adminId: string, email: string) {
|
||||
const secret = authenticator.generateSecret()
|
||||
await repo.updateAdminTotpSecret(adminId, secret)
|
||||
const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
|
||||
const qrCode = await qrcode.toDataURL(otpauth)
|
||||
return { secret, qrCode }
|
||||
}
|
||||
|
||||
export async function verifyTotp(adminId: string, code: string) {
|
||||
const admin = await repo.findAdminByIdOrThrow(adminId)
|
||||
if (!admin.totpSecret) return false
|
||||
|
||||
const valid = authenticator.verify({ token: code, secret: admin.totpSecret })
|
||||
if (valid) await repo.enableAdminTotp(adminId)
|
||||
return valid
|
||||
}
|
||||
|
||||
export async function forgotPassword(email: string) {
|
||||
const admin = await repo.findAdminByEmail(email)
|
||||
if (!admin || !admin.isActive) return
|
||||
|
||||
const rawToken = crypto.randomBytes(32).toString('hex')
|
||||
const expiresAt = new Date(Date.now() + ADMIN_RESET_TTL_MINUTES * 60 * 1000)
|
||||
await repo.setAdminPasswordReset(admin.id, rawToken, expiresAt)
|
||||
|
||||
const adminUrl = ensureAdminBasePath(
|
||||
process.env.ADMIN_URL ?? process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002/admin',
|
||||
)
|
||||
const resetUrl = `${adminUrl}/reset-password?token=${rawToken}`
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: email,
|
||||
subject: 'Reset your RentalDriveGo admin password',
|
||||
html: `<p>Hi ${admin.firstName},</p><p><a href="${resetUrl}">Reset password</a></p><p>Expires in ${ADMIN_RESET_TTL_MINUTES} minutes.</p>`,
|
||||
text: `Hi ${admin.firstName},\n\nReset here: ${resetUrl}\n\nExpires in ${ADMIN_RESET_TTL_MINUTES} minutes.`,
|
||||
}).catch((err) => console.error('[AdminForgotPassword]', err?.message))
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, password: string) {
|
||||
const admin = await repo.findAdminByResetToken(token)
|
||||
if (!admin) return false
|
||||
|
||||
await repo.updateAdminPassword(admin.id, await bcrypt.hash(password, 12))
|
||||
return true
|
||||
}
|
||||
|
||||
export async function listCompanies(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
|
||||
const { data, total } = await repo.listCompaniesPage(query)
|
||||
return presenter.presentPaginated(data, total, query.page, query.pageSize)
|
||||
}
|
||||
|
||||
export function getCompany(id: string) {
|
||||
return repo.getCompanyDetail(id)
|
||||
}
|
||||
|
||||
export async function updateCompany(id: string, body: any, adminId: string, ip?: string) {
|
||||
const before = await repo.getCompanyUpdateSnapshot(id)
|
||||
const updated = await repo.applyCompanyUpdate(id, body, before)
|
||||
await repo.createAuditLog({
|
||||
adminUserId: adminId,
|
||||
action: 'UPDATE_COMPANY',
|
||||
resource: 'Company',
|
||||
resourceId: id,
|
||||
companyId: id,
|
||||
before: toAuditJson(before),
|
||||
after: toAuditJson(body),
|
||||
ipAddress: ip,
|
||||
})
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function setCompanyStatus(id: string, status: string, reason: string | undefined, adminId: string, ip?: string) {
|
||||
const before = await repo.getCompanyUpdateSnapshot(id)
|
||||
const updated = await repo.updateCompanyStatus(id, status)
|
||||
await repo.createAuditLog({
|
||||
adminUserId: adminId,
|
||||
action: `SET_COMPANY_STATUS_${status}`,
|
||||
resource: 'Company',
|
||||
resourceId: id,
|
||||
companyId: id,
|
||||
before: { status: before.status },
|
||||
after: { status },
|
||||
note: reason,
|
||||
ipAddress: ip,
|
||||
})
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function deleteCompany(id: string, adminId: string, ip?: string) {
|
||||
await repo.deleteCompany(id)
|
||||
await repo.createAuditLog({
|
||||
adminUserId: adminId,
|
||||
action: 'DELETE_COMPANY',
|
||||
resource: 'Company',
|
||||
resourceId: id,
|
||||
ipAddress: ip,
|
||||
})
|
||||
}
|
||||
|
||||
export async function impersonateCompany(id: string, adminId: string, ip?: string) {
|
||||
const company = await repo.getCompanyForImpersonation(id)
|
||||
const token = jwt.sign(
|
||||
{
|
||||
sub: company.employees[0]?.id,
|
||||
companyId: company.id,
|
||||
isImpersonation: true,
|
||||
type: 'employee',
|
||||
},
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '30m' },
|
||||
)
|
||||
|
||||
await repo.createAuditLog({
|
||||
adminUserId: adminId,
|
||||
action: 'IMPERSONATE_COMPANY',
|
||||
resource: 'Company',
|
||||
resourceId: id,
|
||||
companyId: id,
|
||||
ipAddress: ip,
|
||||
})
|
||||
|
||||
return { token, expiresIn: 1800 }
|
||||
}
|
||||
|
||||
export async function listRenters(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
|
||||
const { data, total } = await repo.listRentersPage(query)
|
||||
return presenter.presentPaginated(data, total, query.page, query.pageSize)
|
||||
}
|
||||
|
||||
export function setRenterActive(id: string, isActive: boolean) {
|
||||
return repo.updateRenterActive(id, isActive)
|
||||
}
|
||||
|
||||
export function getPlatformMetrics() {
|
||||
return repo.getPlatformMetricCounts()
|
||||
}
|
||||
|
||||
export async function getAuditLogs(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
|
||||
const { data, total } = await repo.listAuditLogsPage(query)
|
||||
return presenter.presentPaginated(data, total, query.page, query.pageSize)
|
||||
}
|
||||
|
||||
export async function listAdmins() {
|
||||
const admins = await repo.listAdmins()
|
||||
return admins.map((admin) => presenter.presentAdminUser(admin))
|
||||
}
|
||||
|
||||
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
|
||||
const admin = await repo.createAdmin({
|
||||
...body,
|
||||
passwordHash: await bcrypt.hash(body.password, 12),
|
||||
})
|
||||
return presenter.presentAdminUser(admin)
|
||||
}
|
||||
|
||||
export function updateAdminRole(id: string, role: string) {
|
||||
return repo.updateAdminRole(id, role)
|
||||
}
|
||||
|
||||
export async function updateAdminPermissions(id: string, permissions: any[]) {
|
||||
const admin = await repo.replaceAdminPermissions(id, permissions)
|
||||
return presenter.presentAdminUser(admin)
|
||||
}
|
||||
|
||||
export async function getBilling(query: { status?: string; plan?: string; page: number; pageSize: number }) {
|
||||
const [{ data, total }, activeSubscriptions, stats] = await Promise.all([
|
||||
repo.listBillingPage(query),
|
||||
repo.listActiveSubscriptionsForMrr(),
|
||||
repo.getBillingStatusCounts(),
|
||||
])
|
||||
|
||||
const mrr = activeSubscriptions.reduce((acc, subscription) => {
|
||||
const monthlyAmount = PLAN_MONTHLY_AMOUNT[subscription.plan] ?? 0
|
||||
return acc + (subscription.billingPeriod === 'ANNUAL' ? Math.round(monthlyAmount * 10 / 12) : monthlyAmount)
|
||||
}, 0)
|
||||
|
||||
return presenter.presentPaginated(data, total, query.page, query.pageSize, {
|
||||
stats: { mrr, ...stats },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCompanyInvoices(companyId: string, query: { page: number; pageSize: number }) {
|
||||
const { data, total } = await repo.listCompanyInvoicesPage(companyId, query)
|
||||
return presenter.presentPaginated(data, total, query.page, query.pageSize)
|
||||
}
|
||||
|
||||
export async function getInvoicePdf(invoiceId: string) {
|
||||
const invoice = await repo.getInvoicePdfRecord(invoiceId)
|
||||
const invoiceNumber = buildInvoiceNumber(invoice.id, invoice.createdAt)
|
||||
const transactionId = invoice.amanpayTransactionId ?? invoice.paypalCaptureId ?? null
|
||||
const pdfBuffer = await generateInvoicePdf({
|
||||
invoiceNumber,
|
||||
issueDate: invoice.createdAt.toISOString(),
|
||||
dueDate: invoice.status === 'PENDING' ? invoice.createdAt.toISOString() : null,
|
||||
company: {
|
||||
name: invoice.company.name,
|
||||
email: invoice.company.email,
|
||||
phone: invoice.company.phone,
|
||||
address: invoice.company.address,
|
||||
},
|
||||
subscription: {
|
||||
plan: invoice.subscription.plan,
|
||||
billingPeriod: invoice.subscription.billingPeriod,
|
||||
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
|
||||
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
|
||||
currency: invoice.subscription.currency,
|
||||
},
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status,
|
||||
paymentProvider: invoice.paymentProvider,
|
||||
transactionId,
|
||||
paidAt: invoice.paidAt?.toISOString(),
|
||||
})
|
||||
|
||||
return { pdfBuffer, invoiceNumber }
|
||||
}
|
||||
|
||||
export function getMarketplaceHomepage() {
|
||||
return getMarketplaceHomepageContent()
|
||||
}
|
||||
|
||||
export async function updateMarketplaceHomepage(homepage: any, adminId: string, ip?: string) {
|
||||
const saved = await saveMarketplaceHomepageContent(homepage)
|
||||
await repo.createAuditLog({
|
||||
adminUserId: adminId,
|
||||
action: 'UPDATE',
|
||||
resource: 'MarketplaceHomepage',
|
||||
after: toAuditJson(saved),
|
||||
ipAddress: ip,
|
||||
userAgent: undefined,
|
||||
})
|
||||
return saved
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseQuery } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import * as service from './analytics.service'
|
||||
import { summaryQuerySchema, reportQuerySchema } from './analytics.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/summary', async (req, res, next) => {
|
||||
try {
|
||||
const { period } = parseQuery(summaryQuerySchema, req)
|
||||
ok(res, { data: await service.getSummary(req.companyId, period) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/dashboard', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getDashboard(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/sources', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getSources(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/report', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const query = parseQuery(reportQuerySchema, req)
|
||||
const result = await service.getReport(req.companyId, query)
|
||||
if (result.format === 'CSV') {
|
||||
const start = result.startDate.toISOString().slice(0, 10)
|
||||
const end = result.endDate.toISOString().slice(0, 10)
|
||||
res.setHeader('Content-Type', 'text/csv')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="report-${start}-${end}.csv"`)
|
||||
return res.send(result.csv)
|
||||
}
|
||||
ok(res, { data: result.report })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const summaryQuerySchema = z.object({
|
||||
period: z.string().default('30d'),
|
||||
})
|
||||
|
||||
export const reportQuerySchema = z.object({
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
format: z.string().default('JSON'),
|
||||
period: z.string().optional(),
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { generateFinancialReport, toCsv } from '../../services/financialReportService'
|
||||
|
||||
function getRangeFromPeriod(period: string) {
|
||||
const now = new Date()
|
||||
switch (period) {
|
||||
case 'WEEKLY': return { from: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), to: now }
|
||||
case 'QUARTERLY': return { from: new Date(now.getFullYear(), now.getMonth() - 2, 1), to: now }
|
||||
case 'ANNUAL': return { from: new Date(now.getFullYear(), 0, 1), to: now }
|
||||
default: return { from: new Date(now.getFullYear(), now.getMonth(), 1), to: now }
|
||||
}
|
||||
}
|
||||
|
||||
function pct(current: number, previous: number) {
|
||||
if (previous === 0) return current > 0 ? 100 : 0
|
||||
return Math.round(((current - previous) / previous) * 100)
|
||||
}
|
||||
|
||||
export async function getSummary(companyId: string, period: string) {
|
||||
const days = parseInt(period.replace('d', '')) || 30
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||
const [totalReservations, activeVehicles, totalRevenue, totalCustomers] = await Promise.all([
|
||||
prisma.reservation.count({ where: { companyId, createdAt: { gte: since } } }),
|
||||
prisma.vehicle.count({ where: { companyId, status: 'AVAILABLE' } }),
|
||||
prisma.reservation.aggregate({ where: { companyId, status: { in: ['COMPLETED'] }, createdAt: { gte: since } }, _sum: { totalAmount: true } }),
|
||||
prisma.customer.count({ where: { companyId } }),
|
||||
])
|
||||
return { totalReservations, activeVehicles, totalRevenue: totalRevenue._sum.totalAmount ?? 0, totalCustomers, period }
|
||||
}
|
||||
|
||||
export async function getDashboard(companyId: string) {
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const prevMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
const prevMonthEnd = new Date(monthStart.getTime() - 1)
|
||||
|
||||
const [
|
||||
totalBookings, previousBookings, activeVehicles, previousActiveVehicles,
|
||||
totalCustomers, previousCustomers, monthlyRevenueAgg, previousRevenueAgg,
|
||||
recentReservations, sourceGroups, subscription,
|
||||
] = await Promise.all([
|
||||
prisma.reservation.count({ where: { companyId, createdAt: { gte: monthStart } } }),
|
||||
prisma.reservation.count({ where: { companyId, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } } }),
|
||||
prisma.vehicle.count({ where: { companyId, status: { in: ['AVAILABLE', 'RENTED'] } } }),
|
||||
prisma.vehicle.count({ where: { companyId, createdAt: { lte: prevMonthEnd }, status: { in: ['AVAILABLE', 'RENTED'] } } }),
|
||||
prisma.customer.count({ where: { companyId } }),
|
||||
prisma.customer.count({ where: { companyId, createdAt: { lte: prevMonthEnd } } }),
|
||||
prisma.reservation.aggregate({ where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: monthStart } }, _sum: { totalAmount: true } }),
|
||||
prisma.reservation.aggregate({ where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } }, _sum: { totalAmount: true } }),
|
||||
prisma.reservation.findMany({ where: { companyId }, include: { customer: true, vehicle: true }, orderBy: { createdAt: 'desc' }, take: 8 }),
|
||||
prisma.reservation.groupBy({ by: ['source'], where: { companyId }, _count: { id: true }, _sum: { totalAmount: true } }),
|
||||
prisma.subscription.findUnique({ where: { companyId } }),
|
||||
])
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
totalBookings,
|
||||
activeVehicles,
|
||||
monthlyRevenue: monthlyRevenueAgg._sum.totalAmount ?? 0,
|
||||
totalCustomers,
|
||||
bookingsChange: pct(totalBookings, previousBookings),
|
||||
vehiclesChange: pct(activeVehicles, previousActiveVehicles),
|
||||
revenueChange: pct(monthlyRevenueAgg._sum.totalAmount ?? 0, previousRevenueAgg._sum.totalAmount ?? 0),
|
||||
customersChange: pct(totalCustomers, previousCustomers),
|
||||
},
|
||||
recentReservations: (recentReservations as any[]).map((r) => ({
|
||||
id: r.id,
|
||||
bookingRef: r.contractNumber ?? r.id.slice(-8).toUpperCase(),
|
||||
customerName: `${r.customer.firstName} ${r.customer.lastName}`,
|
||||
vehicleName: `${r.vehicle.make} ${r.vehicle.model}`,
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate,
|
||||
status: r.status,
|
||||
totalAmount: r.totalAmount,
|
||||
})),
|
||||
sourceBreakdown: (sourceGroups as any[]).map((g) => ({
|
||||
source: g.source,
|
||||
count: g._count.id,
|
||||
revenue: g._sum.totalAmount ?? 0,
|
||||
})),
|
||||
subscription: subscription ? {
|
||||
status: subscription.status,
|
||||
planName: subscription.plan,
|
||||
trialEndsAt: subscription.trialEndAt,
|
||||
} : null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSources(companyId: string) {
|
||||
return prisma.reservation.groupBy({ by: ['source'], where: { companyId }, _count: { id: true } })
|
||||
}
|
||||
|
||||
export async function getReport(companyId: string, query: { from?: string; to?: string; format: string; period?: string }) {
|
||||
const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId } })
|
||||
const derivedRange = getRangeFromPeriod(query.period || accountingSettings?.reportingPeriod || 'MONTHLY')
|
||||
const startDate = query.from ? new Date(query.from) : derivedRange.from
|
||||
const endDate = query.to ? new Date(query.to) : derivedRange.to
|
||||
const report = await generateFinancialReport(companyId, startDate, endDate)
|
||||
return { report, startDate, endDate, format: query.format, csv: query.format === 'CSV' ? toCsv(report.rows) : null }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
type DbClient = Pick<typeof prisma, 'company' | 'brandSettings' | 'contractSettings' | 'subscription' | 'employee'>
|
||||
|
||||
type CompanySignupInput = {
|
||||
companyName: string
|
||||
slug: string
|
||||
email: string
|
||||
companyPhone: string
|
||||
streetAddress: string
|
||||
city: string
|
||||
country: string
|
||||
zipCode: string
|
||||
legalForm: string
|
||||
managerName: string
|
||||
fax?: string
|
||||
yearsActive: string
|
||||
currency: 'MAD' | 'USD' | 'EUR'
|
||||
registrationNumber: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
preferredLanguage: 'en' | 'fr' | 'ar'
|
||||
firstName: string
|
||||
lastName: string
|
||||
passwordHash: string
|
||||
now: Date
|
||||
trialEndAt: Date
|
||||
}
|
||||
|
||||
export function findCompanyBySlug(slug: string) {
|
||||
return prisma.company.findUnique({ where: { slug } })
|
||||
}
|
||||
|
||||
export function findCompanyByEmail(email: string) {
|
||||
return prisma.company.findUnique({ where: { email } })
|
||||
}
|
||||
|
||||
export function findEmployeeByEmail(email: string) {
|
||||
return prisma.employee.findFirst({ where: { email } })
|
||||
}
|
||||
|
||||
export async function createCompanySignup(db: DbClient, input: CompanySignupInput) {
|
||||
const company = await db.company.create({
|
||||
data: {
|
||||
name: input.companyName,
|
||||
slug: input.slug,
|
||||
email: input.email,
|
||||
phone: input.companyPhone,
|
||||
address: {
|
||||
streetAddress: input.streetAddress,
|
||||
city: input.city,
|
||||
country: input.country,
|
||||
zipCode: input.zipCode,
|
||||
legalForm: input.legalForm,
|
||||
managerName: input.managerName,
|
||||
fax: input.fax || null,
|
||||
yearsActive: input.yearsActive,
|
||||
},
|
||||
status: 'TRIALING',
|
||||
},
|
||||
})
|
||||
|
||||
await db.brandSettings.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
displayName: input.companyName,
|
||||
subdomain: input.slug,
|
||||
publicEmail: input.email,
|
||||
publicPhone: input.companyPhone,
|
||||
publicAddress: input.streetAddress,
|
||||
publicCity: input.city,
|
||||
publicCountry: input.country,
|
||||
defaultCurrency: input.currency,
|
||||
},
|
||||
})
|
||||
|
||||
await db.contractSettings.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
legalName: input.companyName,
|
||||
registrationNumber: input.registrationNumber,
|
||||
},
|
||||
})
|
||||
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
plan: input.plan,
|
||||
billingPeriod: input.billingPeriod,
|
||||
currency: input.currency,
|
||||
status: 'TRIALING',
|
||||
trialStartAt: input.now,
|
||||
trialEndAt: input.trialEndAt,
|
||||
currentPeriodStart: input.now,
|
||||
currentPeriodEnd: input.trialEndAt,
|
||||
},
|
||||
})
|
||||
|
||||
const employee = await db.employee.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
clerkUserId: `local_owner_${company.id}`,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
email: input.email,
|
||||
phone: input.companyPhone,
|
||||
passwordHash: input.passwordHash,
|
||||
role: 'OWNER',
|
||||
preferredLanguage: input.preferredLanguage,
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
return { company, employee }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Router } from 'express'
|
||||
import { parseBody } from '../../http/validate'
|
||||
import { created } from '../../http/respond'
|
||||
import { companySignupSchema } from './auth.company.schemas'
|
||||
import * as service from './auth.company.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/signup', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(companySignupSchema, req)
|
||||
created(res, await service.signup(body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/complete-signup', (_req, res) => {
|
||||
service.completeSignupDisabled()
|
||||
res.end()
|
||||
})
|
||||
|
||||
router.post('/verify-email', (_req, res) => {
|
||||
service.verifyEmailDisabled()
|
||||
res.end()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const companySignupSchema = z.object({
|
||||
firstName: z.string().min(1).max(80),
|
||||
lastName: z.string().min(1).max(80),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(128),
|
||||
companyName: z.string().min(2).max(120),
|
||||
legalForm: z.string().min(1).max(80),
|
||||
registrationNumber: z.string().min(1).max(120),
|
||||
streetAddress: z.string().min(1).max(200),
|
||||
city: z.string().min(1).max(120),
|
||||
country: z.string().min(1).max(120),
|
||||
zipCode: z.string().min(1).max(40),
|
||||
companyPhone: z.string().min(1).max(80),
|
||||
fax: z.string().max(80).optional(),
|
||||
yearsActive: z.string().min(1).max(80),
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { sendNotification } from '../../services/notificationService'
|
||||
import { signupEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import { presentCompanySignup } from './auth.presenter'
|
||||
import * as repo from './auth.company.repo'
|
||||
import { companySignupSchema } from './auth.company.schemas'
|
||||
import type { output } from 'zod'
|
||||
|
||||
type CompanySignupInput = output<typeof companySignupSchema>
|
||||
|
||||
function slugify(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
|
||||
}
|
||||
|
||||
async function generateUniqueSlug(baseName: string) {
|
||||
const base = slugify(baseName)
|
||||
|
||||
for (let attempt = 0; attempt < 25; attempt++) {
|
||||
const slug = attempt === 0 ? base : `${base}-${attempt + 1}`
|
||||
const existing = await repo.findCompanyBySlug(slug)
|
||||
if (!existing) return slug
|
||||
}
|
||||
|
||||
return `${base}-${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
export async function signup(body: CompanySignupInput) {
|
||||
if (await repo.findCompanyByEmail(body.email)) {
|
||||
throw new AppError('A company account with this email already exists', 409, 'email_taken')
|
||||
}
|
||||
|
||||
if (await repo.findEmployeeByEmail(body.email)) {
|
||||
throw new AppError('An employee account with this email already exists', 409, 'email_taken')
|
||||
}
|
||||
|
||||
const slug = await generateUniqueSlug(body.companyName)
|
||||
const now = new Date()
|
||||
const trialEndAt = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000)
|
||||
const passwordHash = await bcrypt.hash(body.password, 12)
|
||||
|
||||
const result = await prisma.$transaction((tx) => repo.createCompanySignup(tx, {
|
||||
companyName: body.companyName,
|
||||
slug,
|
||||
email: body.email,
|
||||
companyPhone: body.companyPhone,
|
||||
streetAddress: body.streetAddress,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
zipCode: body.zipCode,
|
||||
legalForm: body.legalForm,
|
||||
managerName: `${body.firstName} ${body.lastName}`.trim(),
|
||||
fax: body.fax,
|
||||
yearsActive: body.yearsActive,
|
||||
currency: body.currency,
|
||||
registrationNumber: body.registrationNumber,
|
||||
plan: body.plan,
|
||||
billingPeriod: body.billingPeriod,
|
||||
preferredLanguage: body.preferredLanguage,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
passwordHash,
|
||||
now,
|
||||
trialEndAt,
|
||||
}))
|
||||
|
||||
const lang = body.preferredLanguage as Lang
|
||||
const emailResult = await sendNotification({
|
||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||
title: signupEmail.subject(lang),
|
||||
body: signupEmail.text({
|
||||
firstName: body.firstName,
|
||||
companyName: body.companyName,
|
||||
plan: body.plan,
|
||||
billingPeriod: body.billingPeriod,
|
||||
currency: body.currency,
|
||||
paymentProvider: body.paymentProvider,
|
||||
trialEnd: trialEndAt,
|
||||
}, lang),
|
||||
companyId: result.company.id,
|
||||
employeeId: result.employee.id,
|
||||
email: body.email,
|
||||
channels: ['EMAIL', 'IN_APP'],
|
||||
locale: lang,
|
||||
}).catch(() => [])
|
||||
|
||||
const emailDelivery = (emailResult as Array<{ channel?: string }>).find((entry) => entry.channel === 'EMAIL')
|
||||
|
||||
return presentCompanySignup({
|
||||
company: result.company,
|
||||
trialEndAt,
|
||||
emailDelivery,
|
||||
})
|
||||
}
|
||||
|
||||
export function completeSignupDisabled() {
|
||||
throw new AppError('Clerk-based signup has been removed. Use /auth/company/signup instead.', 410, 'disabled')
|
||||
}
|
||||
|
||||
export function verifyEmailDisabled() {
|
||||
throw new AppError('Email verification resend via Clerk has been removed from this project.', 410, 'disabled')
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export function findEmployeeWithCompanyById(id: string) {
|
||||
return prisma.employee.findUnique({
|
||||
where: { id },
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findEmployeeWithCompanyByEmail(email: string) {
|
||||
return prisma.employee.findFirst({
|
||||
where: { email },
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findActiveEmployeeByEmail(email: string) {
|
||||
return prisma.employee.findFirst({
|
||||
where: { email, isActive: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function setPasswordResetToken(id: string, passwordResetToken: string, passwordResetExpiresAt: Date) {
|
||||
return prisma.employee.update({
|
||||
where: { id },
|
||||
data: { passwordResetToken, passwordResetExpiresAt },
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePreferredLanguage(id: string, preferredLanguage: 'en' | 'fr' | 'ar') {
|
||||
return prisma.employee.update({
|
||||
where: { id },
|
||||
data: { preferredLanguage },
|
||||
})
|
||||
}
|
||||
|
||||
export function findEmployeeByResetToken(token: string) {
|
||||
return prisma.employee.findFirst({
|
||||
where: {
|
||||
passwordResetToken: token,
|
||||
passwordResetExpiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function resetPassword(id: string, passwordHash: string) {
|
||||
return prisma.employee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordResetToken: null,
|
||||
passwordResetExpiresAt: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { parseBody } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import {
|
||||
employeeForgotPasswordSchema,
|
||||
employeeLanguageSchema,
|
||||
employeeLoginSchema,
|
||||
employeeResetPasswordSchema,
|
||||
} from './auth.employee.schemas'
|
||||
import * as service from './auth.employee.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/me', requireCompanyAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getMe(req.employee.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(employeeLoginSchema, req)
|
||||
ok(res, await service.login(body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/forgot-password', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = parseBody(employeeForgotPasswordSchema, req)
|
||||
ok(res, await service.forgotPassword(email))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/language', requireCompanyAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { language } = parseBody(employeeLanguageSchema, req)
|
||||
ok(res, await service.updateLanguage(req.employee.id, language))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reset-password', async (req, res, next) => {
|
||||
try {
|
||||
const { token, password } = parseBody(employeeResetPasswordSchema, req)
|
||||
ok(res, await service.resetPassword(token, password))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const employeeLoginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
password: z.string().max(128),
|
||||
})
|
||||
|
||||
export const employeeForgotPasswordSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
})
|
||||
|
||||
export const employeeLanguageSchema = z.object({
|
||||
language: z.enum(['en', 'fr', 'ar']),
|
||||
})
|
||||
|
||||
export const employeeResetPasswordSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8).max(128),
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import crypto from 'crypto'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import { presentEmployeeSession } from './auth.presenter'
|
||||
import * as repo from './auth.employee.repo'
|
||||
import { employeeLanguageSchema, employeeLoginSchema } from './auth.employee.schemas'
|
||||
import type { output } from 'zod'
|
||||
|
||||
const RESET_TOKEN_TTL_MINUTES = 60
|
||||
|
||||
type EmployeeLoginInput = output<typeof employeeLoginSchema>
|
||||
type EmployeeLanguageInput = output<typeof employeeLanguageSchema>
|
||||
|
||||
function signEmployeeToken(employeeId: string) {
|
||||
return jwt.sign(
|
||||
{ sub: employeeId, type: 'employee' },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'] },
|
||||
)
|
||||
}
|
||||
|
||||
function ensureDashboardBasePath(baseUrl: string) {
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
const pathname = url.pathname.replace(/\/$/, '')
|
||||
if (!pathname.endsWith('/dashboard')) url.pathname = `${pathname}/dashboard`
|
||||
return url.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
const trimmed = baseUrl.replace(/\/$/, '')
|
||||
return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard`
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMe(employeeId: string) {
|
||||
const employee = await repo.findEmployeeWithCompanyById(employeeId)
|
||||
|
||||
if (!employee || !employee.isActive) {
|
||||
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
|
||||
}
|
||||
|
||||
return presentEmployeeSession(employee)
|
||||
}
|
||||
|
||||
export async function login(body: EmployeeLoginInput) {
|
||||
const employee = await repo.findEmployeeWithCompanyByEmail(body.email)
|
||||
|
||||
if (!employee || !employee.isActive) {
|
||||
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
|
||||
}
|
||||
|
||||
if (!employee.passwordHash) {
|
||||
throw new AppError('This account does not have a password yet. Use the invitation or reset email to set one first.', 401, 'password_not_set')
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(body.password, employee.passwordHash)
|
||||
if (!validPassword) {
|
||||
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
|
||||
}
|
||||
|
||||
return presentEmployeeSession(employee, signEmployeeToken(employee.id))
|
||||
}
|
||||
|
||||
export async function forgotPassword(email: string) {
|
||||
const employee = await repo.findActiveEmployeeByEmail(email)
|
||||
|
||||
if (employee) {
|
||||
const rawToken = crypto.randomBytes(32).toString('hex')
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000)
|
||||
await repo.setPasswordResetToken(employee.id, rawToken, expiresAt)
|
||||
|
||||
const dashboardUrl = ensureDashboardBasePath(
|
||||
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard',
|
||||
)
|
||||
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
|
||||
const lang = (employee.preferredLanguage as Lang) ?? 'fr'
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: email,
|
||||
subject: resetPasswordEmail.subject(lang),
|
||||
html: resetPasswordEmail.html(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
||||
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
||||
}).catch((err) => console.error('[ForgotPassword]', err?.message))
|
||||
}
|
||||
|
||||
return { message: 'If that email is registered, a reset link has been sent.' }
|
||||
}
|
||||
|
||||
export async function updateLanguage(employeeId: string, language: EmployeeLanguageInput['language']) {
|
||||
await repo.updatePreferredLanguage(employeeId, language)
|
||||
return { language }
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, password: string) {
|
||||
const employee = await repo.findEmployeeByResetToken(token)
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError('Reset link is invalid or has expired.', 400, 'invalid_token')
|
||||
}
|
||||
|
||||
await repo.resetPassword(employee.id, await bcrypt.hash(password, 12))
|
||||
return { message: 'Password updated successfully. You can now sign in.' }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
type EmployeeWithCompany = {
|
||||
id: string
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
preferredLanguage?: string | null
|
||||
companyId: string
|
||||
company: {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
|
||||
type CompanySignupResult = {
|
||||
company: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
trialEndAt: Date
|
||||
emailDelivery?: unknown
|
||||
}
|
||||
|
||||
type RenterProfile = {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
preferredLocale: string | null
|
||||
preferredCurrency: string | null
|
||||
emailVerified: boolean
|
||||
savedCompanies: Array<{ companyId: string }>
|
||||
}
|
||||
|
||||
type SavedCompany = {
|
||||
id: string
|
||||
brand: {
|
||||
displayName: string | null
|
||||
subdomain: string | null
|
||||
logoUrl: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export function presentEmployeeSession(employee: EmployeeWithCompany, token?: string) {
|
||||
const data = {
|
||||
employee: {
|
||||
id: employee.id,
|
||||
email: employee.email,
|
||||
firstName: employee.firstName,
|
||||
lastName: employee.lastName,
|
||||
role: employee.role,
|
||||
preferredLanguage: employee.preferredLanguage,
|
||||
companyId: employee.companyId,
|
||||
companyName: employee.company.name,
|
||||
companySlug: employee.company.slug,
|
||||
},
|
||||
}
|
||||
|
||||
return token ? { token, ...data } : data
|
||||
}
|
||||
|
||||
export function presentCompanySignup(result: CompanySignupResult) {
|
||||
return {
|
||||
companyId: result.company.id,
|
||||
companyName: result.company.name,
|
||||
slug: result.company.slug,
|
||||
invitationId: null,
|
||||
trialEndsAt: result.trialEndAt.toISOString(),
|
||||
nextStep: 'workspace_created',
|
||||
emailDelivery: result.emailDelivery ?? { attempted: false, success: false, error: null },
|
||||
}
|
||||
}
|
||||
|
||||
export function presentRenterProfile(renter: RenterProfile, companies: SavedCompany[]) {
|
||||
const companyMap = new Map(companies.map((company) => [company.id, company]))
|
||||
|
||||
return {
|
||||
...renter,
|
||||
savedCompanies: renter.savedCompanies.map((savedCompany) => ({
|
||||
id: savedCompany.companyId,
|
||||
brand: companyMap.get(savedCompany.companyId)?.brand ?? null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import type { output } from 'zod'
|
||||
import { renterUpdateSchema } from './auth.renter.schemas'
|
||||
|
||||
type RenterUpdateInput = output<typeof renterUpdateSchema>
|
||||
|
||||
export function findRenterProfile(id: string) {
|
||||
return prisma.renter.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
preferredLocale: true,
|
||||
preferredCurrency: true,
|
||||
emailVerified: true,
|
||||
savedCompanies: { select: { companyId: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function findSavedCompanies(companyIds: string[]) {
|
||||
return companyIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: prisma.company.findMany({
|
||||
where: { id: { in: companyIds } },
|
||||
select: {
|
||||
id: true,
|
||||
brand: {
|
||||
select: { displayName: true, subdomain: true, logoUrl: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRenterProfile(id: string, data: RenterUpdateInput) {
|
||||
return prisma.renter.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRenterFcmToken(id: string, fcmToken: string) {
|
||||
return prisma.renter.update({
|
||||
where: { id },
|
||||
data: { fcmToken },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Router } from 'express'
|
||||
import { requireRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
|
||||
import * as service from './auth.renter.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/signup', (_req, res) => {
|
||||
service.signupDisabled()
|
||||
res.end()
|
||||
})
|
||||
|
||||
router.post('/login', (_req, res) => {
|
||||
service.loginDisabled()
|
||||
res.end()
|
||||
})
|
||||
|
||||
router.get('/me', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getMe(req.renterId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(renterUpdateSchema, req)
|
||||
ok(res, await service.updateMe(req.renterId, body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/fcm-token', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { fcmToken } = parseBody(renterFcmTokenSchema, req)
|
||||
ok(res, await service.updateFcmToken(req.renterId, fcmToken))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const renterUpdateSchema = z.object({
|
||||
firstName: z.string().min(1).max(100).trim().optional(),
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
phone: z.string().max(30).trim().optional(),
|
||||
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
|
||||
preferredCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
})
|
||||
|
||||
export const renterFcmTokenSchema = z.object({
|
||||
fcmToken: z.string(),
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AppError } from '../../http/errors'
|
||||
import { presentRenterProfile } from './auth.presenter'
|
||||
import * as repo from './auth.renter.repo'
|
||||
import type { output } from 'zod'
|
||||
import { renterUpdateSchema } from './auth.renter.schemas'
|
||||
|
||||
type RenterUpdateInput = output<typeof renterUpdateSchema>
|
||||
|
||||
export function signupDisabled() {
|
||||
throw new AppError('Renter account creation is disabled. Only company owners can sign in to the platform.', 403, 'renter_signup_disabled')
|
||||
}
|
||||
|
||||
export function loginDisabled() {
|
||||
throw new AppError('Renter sign-in is disabled. Only company owners can sign in to the platform.', 403, 'renter_login_disabled')
|
||||
}
|
||||
|
||||
export async function getMe(renterId: string) {
|
||||
const renter = await repo.findRenterProfile(renterId)
|
||||
const savedCompanies = await repo.findSavedCompanies(renter.savedCompanies.map((savedCompany) => savedCompany.companyId))
|
||||
return presentRenterProfile(renter, savedCompanies)
|
||||
}
|
||||
|
||||
export function updateMe(renterId: string, body: RenterUpdateInput) {
|
||||
return repo.updateRenterProfile(renterId, body)
|
||||
}
|
||||
|
||||
export async function updateFcmToken(renterId: string, fcmToken: string) {
|
||||
await repo.updateRenterFcmToken(renterId, fcmToken)
|
||||
return { success: true }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function presentCompany(company: any) {
|
||||
return company
|
||||
}
|
||||
|
||||
export function presentBrand(brand: any) {
|
||||
return brand
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export async function findCompany(companyId: string) {
|
||||
return prisma.company.findUniqueOrThrow({
|
||||
where: { id: companyId },
|
||||
include: {
|
||||
brand: true,
|
||||
subscription: true,
|
||||
_count: { select: { vehicles: true, customers: true, reservations: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateCompany(companyId: string, data: any) {
|
||||
return prisma.company.update({ where: { id: companyId }, data })
|
||||
}
|
||||
|
||||
export async function findBrand(companyId: string) {
|
||||
return prisma.brandSettings.findUnique({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function upsertBrand(companyId: string, updateData: any, createData: any) {
|
||||
return prisma.brandSettings.upsert({
|
||||
where: { companyId },
|
||||
update: updateData,
|
||||
create: { companyId, ...createData },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findContractSettings(companyId: string) {
|
||||
return prisma.contractSettings.findUnique({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function upsertContractSettings(companyId: string, data: any) {
|
||||
return prisma.contractSettings.upsert({
|
||||
where: { companyId },
|
||||
update: data,
|
||||
create: { companyId, ...data },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findInsurancePolicies(companyId: string) {
|
||||
return prisma.insurancePolicy.findMany({
|
||||
where: { companyId },
|
||||
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function createInsurancePolicy(companyId: string, data: any) {
|
||||
return prisma.insurancePolicy.create({ data: { companyId, ...data } })
|
||||
}
|
||||
|
||||
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
|
||||
return prisma.insurancePolicy.updateMany({ where: { id, companyId }, data })
|
||||
}
|
||||
|
||||
export async function findInsurancePolicyOrThrow(id: string) {
|
||||
return prisma.insurancePolicy.findUniqueOrThrow({ where: { id } })
|
||||
}
|
||||
|
||||
export async function deleteInsurancePolicy(id: string, companyId: string) {
|
||||
return prisma.insurancePolicy.deleteMany({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export async function findPricingRules(companyId: string) {
|
||||
return prisma.pricingRule.findMany({
|
||||
where: { companyId },
|
||||
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function createPricingRule(companyId: string, data: any) {
|
||||
return prisma.pricingRule.create({ data: { companyId, ...data } })
|
||||
}
|
||||
|
||||
export async function updatePricingRule(id: string, companyId: string, data: any) {
|
||||
return prisma.pricingRule.updateMany({ where: { id, companyId }, data })
|
||||
}
|
||||
|
||||
export async function findPricingRuleOrThrow(id: string) {
|
||||
return prisma.pricingRule.findUniqueOrThrow({ where: { id } })
|
||||
}
|
||||
|
||||
export async function deletePricingRule(id: string, companyId: string) {
|
||||
return prisma.pricingRule.deleteMany({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export async function findAccountingSettings(companyId: string) {
|
||||
return prisma.accountingSettings.findUnique({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function upsertAccountingSettings(companyId: string, data: any) {
|
||||
return prisma.accountingSettings.upsert({
|
||||
where: { companyId },
|
||||
update: data,
|
||||
create: { companyId, ...data },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findApiKey(companyId: string) {
|
||||
return prisma.company.findUniqueOrThrow({ where: { id: companyId }, select: { apiKey: true } })
|
||||
}
|
||||
|
||||
export async function regenerateApiKey(companyId: string) {
|
||||
return prisma.company.update({
|
||||
where: { id: companyId },
|
||||
data: { apiKey: `api_${Math.random().toString(36).slice(2)}${Date.now()}` },
|
||||
select: { apiKey: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findBrandBySubdomain(subdomain: string, excludeCompanyId: string) {
|
||||
return prisma.brandSettings.findFirst({ where: { subdomain, companyId: { not: excludeCompanyId } } })
|
||||
}
|
||||
|
||||
export async function findBrandByCustomDomain(customDomain: string, excludeCompanyId: string) {
|
||||
return prisma.brandSettings.findFirst({ where: { customDomain, companyId: { not: excludeCompanyId } } })
|
||||
}
|
||||
|
||||
export async function clearCustomDomain(companyId: string) {
|
||||
return prisma.brandSettings.updateMany({
|
||||
where: { companyId },
|
||||
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody, parseParams } from '../../http/validate'
|
||||
import { ok, created, noContent } from '../../http/respond'
|
||||
import { imageUpload, assertImageFile } from '../../http/upload'
|
||||
import * as service from './company.service'
|
||||
import {
|
||||
companySchema, brandSchema, contractSettingsSchema,
|
||||
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
|
||||
subdomainSchema, customDomainSchema, idParamSchema,
|
||||
} from './company.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
const company = await service.getCompany(req.companyId)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(companySchema, req)
|
||||
const company = await service.updateCompany(req.companyId, body)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/brand', async (req, res, next) => {
|
||||
try {
|
||||
const brand = await service.getBrand(req.companyId)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(brandSchema, req)
|
||||
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
assertImageFile(req.file, 'logo')
|
||||
const brand = await service.uploadLogo(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
assertImageFile(req.file, 'hero image')
|
||||
const brand = await service.uploadHeroImage(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { subdomain } = parseBody(subdomainSchema, req)
|
||||
const result = await service.checkSubdomainAvailability(subdomain, req.companyId)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { customDomain } = parseBody(customDomainSchema, req)
|
||||
const brand = await service.setCustomDomain(req.companyId, req.company.name, req.company.slug, customDomain)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/brand/custom-domain/status', async (req, res, next) => {
|
||||
try {
|
||||
const status = await service.getCustomDomainStatus(req.companyId)
|
||||
ok(res, status)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await service.removeCustomDomain(req.companyId)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/contract-settings', async (req, res, next) => {
|
||||
try {
|
||||
const settings = await service.getContractSettings(req.companyId)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/contract-settings', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(contractSettingsSchema, req)
|
||||
const settings = await service.updateContractSettings(req.companyId, body)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/insurance-policies', async (req, res, next) => {
|
||||
try {
|
||||
const policies = await service.getInsurancePolicies(req.companyId)
|
||||
ok(res, policies)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(insurancePolicySchema, req)
|
||||
const policy = await service.createInsurancePolicy(req.companyId, body)
|
||||
created(res, policy)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(insurancePolicySchema.partial(), req)
|
||||
const policy = await service.updateInsurancePolicy(id, req.companyId, body)
|
||||
ok(res, policy)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deleteInsurancePolicy(id, req.companyId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/pricing-rules', async (req, res, next) => {
|
||||
try {
|
||||
const rules = await service.getPricingRules(req.companyId)
|
||||
ok(res, rules)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(pricingRuleSchema, req)
|
||||
const rule = await service.createPricingRule(req.companyId, body)
|
||||
created(res, rule)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(pricingRuleSchema.partial(), req)
|
||||
const rule = await service.updatePricingRule(id, req.companyId, body)
|
||||
ok(res, rule)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/pricing-rules/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deletePricingRule(id, req.companyId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/accounting-settings', async (req, res, next) => {
|
||||
try {
|
||||
const settings = await service.getAccountingSettings(req.companyId)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/accounting-settings', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(accountingSettingsSchema, req)
|
||||
const settings = await service.updateAccountingSettings(req.companyId, body)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/api-key', async (req, res, next) => {
|
||||
try {
|
||||
const company = await service.getApiKey(req.companyId)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/api-key/regenerate', async (req, res, next) => {
|
||||
try {
|
||||
const company = await service.regenerateApiKey(req.companyId)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,158 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const companySchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
address: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export const brandSchema = z.object({
|
||||
displayName: z.string().min(1).optional(),
|
||||
tagline: z.string().optional(),
|
||||
primaryColor: z.string().optional(),
|
||||
accentColor: z.string().optional(),
|
||||
publicEmail: z.string().email().optional(),
|
||||
publicPhone: z.string().optional(),
|
||||
publicAddress: z.string().optional(),
|
||||
publicCity: z.string().optional(),
|
||||
publicCountry: z.string().optional(),
|
||||
websiteUrl: z.string().url().optional(),
|
||||
whatsappNumber: z.string().optional(),
|
||||
defaultLocale: z.string().optional(),
|
||||
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
amanpayMerchantId: z.string().optional(),
|
||||
amanpaySecretKey: z.string().optional(),
|
||||
paypalEmail: z.string().email().optional(),
|
||||
paypalMerchantId: z.string().optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: z.object({
|
||||
heroTitle: z.union([z.string(), z.null()]).optional(),
|
||||
heroDescription: z.union([z.string(), z.null()]).optional(),
|
||||
viewOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
viewPricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactCompanyLabel: z.union([z.string(), z.null()]).optional(),
|
||||
activeOffersTitle: z.union([z.string(), z.null()]).optional(),
|
||||
seeAllOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
noActiveOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
publishedVehiclesTitle: z.union([z.string(), z.null()]).optional(),
|
||||
viewVehicleLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingEyebrow: z.union([z.string(), z.null()]).optional(),
|
||||
pricingTitle: z.union([z.string(), z.null()]).optional(),
|
||||
pricingDescription: z.union([z.string(), z.null()]).optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
layout: z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(['hero', 'offers', 'vehicles', 'pricing']),
|
||||
x: z.number().int().min(1),
|
||||
y: z.number().int().min(1),
|
||||
w: z.number().int().min(1),
|
||||
h: z.number().int().min(1),
|
||||
})),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
menuConfig: z.object({
|
||||
aboutLabel: z.union([z.string(), z.null()]).optional(),
|
||||
vehiclesLabel: z.union([z.string(), z.null()]).optional(),
|
||||
offersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
blogLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactLabel: z.union([z.string(), z.null()]).optional(),
|
||||
bookCarLabel: z.union([z.string(), z.null()]).optional(),
|
||||
siteNavigationLabel: z.union([z.string(), z.null()]).optional(),
|
||||
showAbout: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
showBlog: z.boolean().optional(),
|
||||
showContact: z.boolean().optional(),
|
||||
pageSections: z.object({
|
||||
home: z.object({
|
||||
hero: z.boolean().optional(), offers: z.boolean().optional(),
|
||||
vehicles: z.boolean().optional(), pricing: z.boolean().optional(),
|
||||
}).optional(),
|
||||
about: z.object({
|
||||
hero: z.boolean().optional(), highlights: z.boolean().optional(), details: z.boolean().optional(),
|
||||
}).optional(),
|
||||
offers: z.object({ header: z.boolean().optional(), grid: z.boolean().optional() }).optional(),
|
||||
pricing: z.object({
|
||||
hero: z.boolean().optional(), plans: z.boolean().optional(),
|
||||
payments: z.boolean().optional(), faq: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicles: z.object({
|
||||
header: z.boolean().optional(), filters: z.boolean().optional(), grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicleDetail: z.object({ gallery: z.boolean().optional(), summary: z.boolean().optional() }).optional(),
|
||||
blog: z.object({ hero: z.boolean().optional(), posts: z.boolean().optional() }).optional(),
|
||||
contact: z.object({ content: z.boolean().optional() }).optional(),
|
||||
booking: z.object({ content: z.boolean().optional() }).optional(),
|
||||
bookingConfirmation: z.object({
|
||||
hero: z.boolean().optional(), summary: z.boolean().optional(), actions: z.boolean().optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
export const contractSettingsSchema = z.object({
|
||||
legalName: z.string().optional(),
|
||||
registrationNumber: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
terms: z.string().optional(),
|
||||
fuelPolicy: z.string().optional(),
|
||||
depositPolicy: z.string().optional(),
|
||||
lateFeePolicy: z.string().optional(),
|
||||
damagePolicy: z.string().optional(),
|
||||
contractFooterNote: z.string().optional(),
|
||||
invoiceFooterNote: z.string().optional(),
|
||||
signatureRequired: z.boolean().optional(),
|
||||
showTax: z.boolean().optional(),
|
||||
taxRate: z.number().optional(),
|
||||
taxLabel: z.string().optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
fuelPolicyNote: z.string().optional(),
|
||||
fuelChargePerLiter: z.number().int().optional(),
|
||||
fuelShortfallFee: z.number().int().optional(),
|
||||
lateFeePerHour: z.number().int().optional(),
|
||||
additionalDriverCharge: z.enum(['FREE', 'PER_DAY', 'FLAT']).optional(),
|
||||
additionalDriverDailyRate: z.number().int().optional(),
|
||||
additionalDriverFlatRate: z.number().int().optional(),
|
||||
})
|
||||
|
||||
export const insurancePolicySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'FULL', 'BASIC', 'ROADSIDE', 'PERSONAL', 'CUSTOM']),
|
||||
chargeType: z.enum(['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL']),
|
||||
chargeValue: z.number().int().min(0),
|
||||
isRequired: z.boolean().default(false),
|
||||
isActive: z.boolean().default(true),
|
||||
sortOrder: z.number().int().default(0),
|
||||
})
|
||||
|
||||
export const pricingRuleSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.enum(['SURCHARGE', 'DISCOUNT']),
|
||||
condition: z.enum(['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN']),
|
||||
conditionValue: z.number().int(),
|
||||
adjustmentType: z.enum(['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL']),
|
||||
adjustmentValue: z.number().int(),
|
||||
isActive: z.boolean().default(true),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export const accountingSettingsSchema = z.object({
|
||||
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
|
||||
fiscalYearStart: z.number().int().min(1).max(12).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
accountantEmail: z.string().email().optional(),
|
||||
accountantName: z.string().optional(),
|
||||
autoSendReport: z.boolean().optional(),
|
||||
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
|
||||
})
|
||||
|
||||
export const subdomainSchema = z.object({ subdomain: z.string().min(3) })
|
||||
export const customDomainSchema = z.object({ customDomain: z.string().min(3) })
|
||||
export const idParamSchema = z.object({ id: z.string() })
|
||||
@@ -0,0 +1,153 @@
|
||||
import { uploadImage } from '../../lib/storage'
|
||||
import { ConflictError, NotFoundError } from '../../http/errors'
|
||||
import { presentCompany, presentBrand } from './company.presenter'
|
||||
import * as repo from './company.repo'
|
||||
|
||||
function buildPaymentMethodsEnabled(input: {
|
||||
amanpayMerchantId?: string | null
|
||||
amanpaySecretKey?: string | null
|
||||
paypalEmail?: string | null
|
||||
}) {
|
||||
const methods: Array<'AMANPAY' | 'PAYPAL'> = []
|
||||
if (input.amanpayMerchantId && input.amanpaySecretKey) methods.push('AMANPAY')
|
||||
if (input.paypalEmail) methods.push('PAYPAL')
|
||||
return methods
|
||||
}
|
||||
|
||||
export async function getCompany(companyId: string) {
|
||||
return presentCompany(await repo.findCompany(companyId))
|
||||
}
|
||||
|
||||
export async function updateCompany(companyId: string, data: any) {
|
||||
return presentCompany(await repo.updateCompany(companyId, data))
|
||||
}
|
||||
|
||||
export async function getBrand(companyId: string) {
|
||||
return presentBrand(await repo.findBrand(companyId))
|
||||
}
|
||||
|
||||
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
|
||||
const current = await repo.findBrand(companyId)
|
||||
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
|
||||
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
|
||||
amanpaySecretKey: body.amanpaySecretKey ?? current?.amanpaySecretKey,
|
||||
paypalEmail: body.paypalEmail ?? current?.paypalEmail,
|
||||
})
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
companyId,
|
||||
{ ...body, paymentMethodsEnabled },
|
||||
{ displayName: body.displayName ?? companyName, subdomain: companySlug, paymentMethodsEnabled, ...body },
|
||||
))
|
||||
}
|
||||
|
||||
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
companyId,
|
||||
{ logoUrl: url },
|
||||
{ displayName: companyName, subdomain: companySlug, logoUrl: url },
|
||||
))
|
||||
}
|
||||
|
||||
export async function uploadHeroImage(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
companyId,
|
||||
{ heroImageUrl: url },
|
||||
{ displayName: companyName, subdomain: companySlug, heroImageUrl: url },
|
||||
))
|
||||
}
|
||||
|
||||
export async function checkSubdomainAvailability(subdomain: string, companyId: string) {
|
||||
const existing = await repo.findBrandBySubdomain(subdomain, companyId)
|
||||
return { available: !existing }
|
||||
}
|
||||
|
||||
export async function setCustomDomain(companyId: string, companyName: string, companySlug: string, customDomain: string) {
|
||||
const normalized = customDomain.toLowerCase().trim()
|
||||
const existing = await repo.findBrandByCustomDomain(normalized, companyId)
|
||||
if (existing) throw new ConflictError('This custom domain is already in use')
|
||||
return repo.upsertBrand(
|
||||
companyId,
|
||||
{ customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date() },
|
||||
{ displayName: companyName, subdomain: companySlug, customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date() },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getCustomDomainStatus(companyId: string) {
|
||||
const brand = await repo.findBrand(companyId)
|
||||
const customDomain = brand?.customDomain ?? null
|
||||
return {
|
||||
customDomain,
|
||||
verified: brand?.customDomainVerified ?? false,
|
||||
status: customDomain ? (brand?.customDomainVerified ? 'verified' : 'pending_dns') : 'not_configured',
|
||||
dnsTarget: process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET ?? 'cname.vercel-dns.com',
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCustomDomain(companyId: string) {
|
||||
const result = await repo.clearCustomDomain(companyId)
|
||||
return { success: result.count > 0 }
|
||||
}
|
||||
|
||||
export async function getContractSettings(companyId: string) {
|
||||
return repo.findContractSettings(companyId)
|
||||
}
|
||||
|
||||
export async function updateContractSettings(companyId: string, data: any) {
|
||||
return repo.upsertContractSettings(companyId, data)
|
||||
}
|
||||
|
||||
export async function getInsurancePolicies(companyId: string) {
|
||||
return repo.findInsurancePolicies(companyId)
|
||||
}
|
||||
|
||||
export async function createInsurancePolicy(companyId: string, data: any) {
|
||||
return repo.createInsurancePolicy(companyId, data)
|
||||
}
|
||||
|
||||
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
|
||||
const result = await repo.updateInsurancePolicy(id, companyId, data)
|
||||
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
||||
return repo.findInsurancePolicyOrThrow(id)
|
||||
}
|
||||
|
||||
export async function deleteInsurancePolicy(id: string, companyId: string) {
|
||||
const result = await repo.deleteInsurancePolicy(id, companyId)
|
||||
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
||||
}
|
||||
|
||||
export async function getPricingRules(companyId: string) {
|
||||
return repo.findPricingRules(companyId)
|
||||
}
|
||||
|
||||
export async function createPricingRule(companyId: string, data: any) {
|
||||
return repo.createPricingRule(companyId, data)
|
||||
}
|
||||
|
||||
export async function updatePricingRule(id: string, companyId: string, data: any) {
|
||||
const result = await repo.updatePricingRule(id, companyId, data)
|
||||
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
||||
return repo.findPricingRuleOrThrow(id)
|
||||
}
|
||||
|
||||
export async function deletePricingRule(id: string, companyId: string) {
|
||||
const result = await repo.deletePricingRule(id, companyId)
|
||||
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
||||
}
|
||||
|
||||
export async function getAccountingSettings(companyId: string) {
|
||||
return repo.findAccountingSettings(companyId)
|
||||
}
|
||||
|
||||
export async function updateAccountingSettings(companyId: string, data: any) {
|
||||
return repo.upsertAccountingSettings(companyId, data)
|
||||
}
|
||||
|
||||
export async function getApiKey(companyId: string) {
|
||||
return repo.findApiKey(companyId)
|
||||
}
|
||||
|
||||
export async function regenerateApiKey(companyId: string) {
|
||||
return repo.regenerateApiKey(companyId)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as repo from './company.repo'
|
||||
import * as service from './company.service'
|
||||
|
||||
vi.mock('./company.repo')
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/companies/comp_1/brand/logo.jpg'),
|
||||
}))
|
||||
|
||||
const mockCompany = {
|
||||
id: 'comp_1',
|
||||
name: 'Test Rentals',
|
||||
slug: 'test-rentals',
|
||||
email: 'info@test.com',
|
||||
brand: null,
|
||||
subscription: null,
|
||||
_count: { vehicles: 5, customers: 10, reservations: 20 },
|
||||
}
|
||||
|
||||
const mockBrand = {
|
||||
id: 'brand_1',
|
||||
companyId: 'comp_1',
|
||||
displayName: 'Test Rentals',
|
||||
subdomain: 'test-rentals',
|
||||
logoUrl: null,
|
||||
heroImageUrl: null,
|
||||
customDomain: null,
|
||||
customDomainVerified: false,
|
||||
amanpayMerchantId: null,
|
||||
amanpaySecretKey: null,
|
||||
paypalEmail: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('company.service', () => {
|
||||
describe('getCompany', () => {
|
||||
it('returns company with brand and subscription', async () => {
|
||||
vi.mocked(repo.findCompany).mockResolvedValue(mockCompany as any)
|
||||
const result = await service.getCompany('comp_1')
|
||||
expect(result).toEqual(mockCompany)
|
||||
expect(repo.findCompany).toHaveBeenCalledWith('comp_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCompany', () => {
|
||||
it('updates company profile', async () => {
|
||||
vi.mocked(repo.updateCompany).mockResolvedValue({ ...mockCompany, name: 'Updated Rentals' } as any)
|
||||
const result = await service.updateCompany('comp_1', { name: 'Updated Rentals' })
|
||||
expect(repo.updateCompany).toHaveBeenCalledWith('comp_1', { name: 'Updated Rentals' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadLogo', () => {
|
||||
it('uploads logo and upserts brand', async () => {
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' } as any)
|
||||
const result = await service.uploadLogo('comp_1', 'Test Rentals', 'test-rentals', Buffer.from(''))
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'comp_1',
|
||||
expect.objectContaining({ logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' }),
|
||||
expect.objectContaining({ logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadHeroImage', () => {
|
||||
it('uploads hero image and upserts brand', async () => {
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, heroImageUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' } as any)
|
||||
await service.uploadHeroImage('comp_1', 'Test Rentals', 'test-rentals', Buffer.from(''))
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'comp_1',
|
||||
expect.objectContaining({ heroImageUrl: expect.any(String) }),
|
||||
expect.objectContaining({ heroImageUrl: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkSubdomainAvailability', () => {
|
||||
it('returns available=true when subdomain is free', async () => {
|
||||
vi.mocked(repo.findBrandBySubdomain).mockResolvedValue(null)
|
||||
const result = await service.checkSubdomainAvailability('my-rental', 'comp_1')
|
||||
expect(result.available).toBe(true)
|
||||
})
|
||||
|
||||
it('returns available=false when subdomain is taken', async () => {
|
||||
vi.mocked(repo.findBrandBySubdomain).mockResolvedValue(mockBrand as any)
|
||||
const result = await service.checkSubdomainAvailability('taken-rental', 'comp_1')
|
||||
expect(result.available).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCustomDomain', () => {
|
||||
it('throws ConflictError when custom domain is already in use', async () => {
|
||||
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(mockBrand as any)
|
||||
await expect(service.setCustomDomain('comp_1', 'Test Rentals', 'test-rentals', 'taken.com')).rejects.toThrow('This custom domain is already in use')
|
||||
})
|
||||
|
||||
it('sets custom domain when available', async () => {
|
||||
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(null)
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, customDomain: 'my-rental.com' } as any)
|
||||
await service.setCustomDomain('comp_1', 'Test Rentals', 'test-rentals', 'My-Rental.com')
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'comp_1',
|
||||
expect.objectContaining({ customDomain: 'my-rental.com' }),
|
||||
expect.objectContaining({ customDomain: 'my-rental.com' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateInsurancePolicy', () => {
|
||||
it('throws NotFoundError when policy does not belong to company', async () => {
|
||||
vi.mocked(repo.updateInsurancePolicy).mockResolvedValue({ count: 0 } as any)
|
||||
await expect(service.updateInsurancePolicy('pol_1', 'comp_1', { name: 'CDW' })).rejects.toThrow('Insurance policy not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePricingRule', () => {
|
||||
it('throws NotFoundError when rule does not belong to company', async () => {
|
||||
vi.mocked(repo.deletePricingRule).mockResolvedValue({ count: 0 } as any)
|
||||
await expect(service.deletePricingRule('rule_1', 'comp_1')).rejects.toThrow('Pricing rule not found')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getProtectedCustomerLicenseImageUrl } from '../../lib/storage'
|
||||
|
||||
export function presentCustomer(customer: any) {
|
||||
return {
|
||||
...customer,
|
||||
licenseImageUrl: customer?.licenseImageUrl
|
||||
? getProtectedCustomerLicenseImageUrl(customer.id)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function presentCustomerList(customers: any[], meta: { total: number; page: number; pageSize: number; totalPages: number }) {
|
||||
return { data: customers, ...meta }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export async function findMany(where: any, skip: number, take: number) {
|
||||
return Promise.all([
|
||||
prisma.customer.findMany({ where, skip, take, orderBy: { createdAt: 'desc' } }),
|
||||
prisma.customer.count({ where }),
|
||||
])
|
||||
}
|
||||
|
||||
export async function findById(id: string, companyId: string) {
|
||||
return prisma.customer.findFirst({
|
||||
where: { id, companyId },
|
||||
include: {
|
||||
reservations: { include: { vehicle: true }, orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findByIdSimple(id: string, companyId: string) {
|
||||
return prisma.customer.findFirst({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export async function create(data: any) {
|
||||
return prisma.customer.create({ data })
|
||||
}
|
||||
|
||||
export async function updateMany(id: string, companyId: string, data: any) {
|
||||
return prisma.customer.updateMany({ where: { id, companyId }, data })
|
||||
}
|
||||
|
||||
export async function updateById(id: string, data: any) {
|
||||
return prisma.customer.update({ where: { id }, data })
|
||||
}
|
||||
|
||||
export async function findUniqueOrThrow(id: string) {
|
||||
return prisma.customer.findUniqueOrThrow({ where: { id } })
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth, requireCompanyDocumentAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import { imageUpload, assertImageFile } from '../../http/upload'
|
||||
import * as service from './customer.service'
|
||||
import { customerSchema, listQuerySchema, approveLicenseSchema, flagSchema, idParamSchema } from './customer.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/:id/license-image', requireCompanyDocumentAuth, requireTenant, requireSubscription, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const filePath = await service.getLicenseImageFile(id, req.companyId)
|
||||
res.sendFile(filePath)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const query = parseQuery(listQuerySchema, req)
|
||||
const result = await service.listCustomers(req.companyId, query)
|
||||
res.json(result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(customerSchema, req)
|
||||
const customer = await service.createCustomer(body, req.companyId)
|
||||
created(res, customer)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const customer = await service.getCustomer(id, req.companyId)
|
||||
ok(res, customer)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(customerSchema.partial(), req)
|
||||
const customer = await service.updateCustomer(id, req.companyId, body)
|
||||
ok(res, customer)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { reason } = parseBody(flagSchema, req)
|
||||
await service.flagCustomer(id, req.companyId, reason)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.unflagCustomer(id, req.companyId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/validate-license', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const result = await service.validateCustomerLicense(id, req.companyId)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/license-image', imageUpload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
assertImageFile(req.file, 'license image')
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const updated = await service.uploadLicenseImage(id, req.companyId, req.file)
|
||||
ok(res, updated)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/approve-license', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { decision, note } = parseBody(approveLicenseSchema, req)
|
||||
const approverName = `${req.employee.firstName} ${req.employee.lastName}`
|
||||
const updated = await service.approveLicense(id, req.companyId, decision, note, approverName)
|
||||
ok(res, updated)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
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(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().max(100).trim().optional(),
|
||||
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(),
|
||||
})
|
||||
|
||||
export const listQuerySchema = paginationSchema.extend({
|
||||
q: z.string().max(100).optional(),
|
||||
flagged: z.enum(['true', 'false']).optional(),
|
||||
})
|
||||
|
||||
export const approveLicenseSchema = z.object({
|
||||
decision: z.enum(['APPROVE', 'DENY']),
|
||||
note: z.string().optional(),
|
||||
})
|
||||
|
||||
export const flagSchema = z.object({
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export const idParamSchema = z.object({ id: z.string() })
|
||||
@@ -0,0 +1,100 @@
|
||||
import { validateAndFlagLicense } from '../../services/licenseValidationService'
|
||||
import { uploadImage, deleteImage, resolveStoredFilePath } from '../../lib/storage'
|
||||
import { NotFoundError } from '../../http/errors'
|
||||
import { presentCustomer, presentCustomerList } from './customer.presenter'
|
||||
import * as repo from './customer.repo'
|
||||
|
||||
export async function listCustomers(companyId: string, query: { page?: number; pageSize?: number; q?: string; flagged?: string }) {
|
||||
const page = query.page ?? 1
|
||||
const pageSize = query.pageSize ?? 20
|
||||
const { q, flagged } = query
|
||||
const safeQ = q ? q.trim().slice(0, 100) : undefined
|
||||
const where: any = { companyId }
|
||||
if (flagged !== undefined) where.flagged = flagged === 'true'
|
||||
if (safeQ) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: safeQ, mode: 'insensitive' } },
|
||||
{ lastName: { contains: safeQ, mode: 'insensitive' } },
|
||||
{ email: { contains: safeQ, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
const [customers, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize)
|
||||
return presentCustomerList(customers.map(presentCustomer), { total, page, pageSize, totalPages: Math.ceil(total / pageSize) })
|
||||
}
|
||||
|
||||
export async function getCustomer(id: string, companyId: string) {
|
||||
const customer = await repo.findById(id, companyId)
|
||||
if (!customer) throw new NotFoundError('Customer not found')
|
||||
return presentCustomer(customer)
|
||||
}
|
||||
|
||||
export async function createCustomer(data: any, companyId: string) {
|
||||
const customer = await repo.create({
|
||||
...data,
|
||||
companyId,
|
||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
|
||||
licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : null,
|
||||
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : null,
|
||||
})
|
||||
if (data.licenseExpiry) {
|
||||
await validateAndFlagLicense(customer.id).catch(() => null)
|
||||
}
|
||||
return presentCustomer(customer)
|
||||
}
|
||||
|
||||
export async function updateCustomer(id: string, companyId: string, data: any) {
|
||||
const result = await repo.updateMany(id, companyId, {
|
||||
...data,
|
||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : undefined,
|
||||
licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : undefined,
|
||||
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : undefined,
|
||||
})
|
||||
if (result.count === 0) throw new NotFoundError('Customer not found')
|
||||
if (data.licenseExpiry) await validateAndFlagLicense(id).catch(() => null)
|
||||
return presentCustomer(await repo.findUniqueOrThrow(id))
|
||||
}
|
||||
|
||||
export async function flagCustomer(id: string, companyId: string, reason?: string) {
|
||||
await repo.updateMany(id, companyId, { flagged: true, flagReason: reason ?? null })
|
||||
}
|
||||
|
||||
export async function unflagCustomer(id: string, companyId: string) {
|
||||
await repo.updateMany(id, companyId, { flagged: false, flagReason: null })
|
||||
}
|
||||
|
||||
export async function validateCustomerLicense(id: string, companyId: string) {
|
||||
const customer = await repo.findByIdSimple(id, companyId)
|
||||
if (!customer) throw new NotFoundError('Customer not found')
|
||||
return validateAndFlagLicense(customer.id)
|
||||
}
|
||||
|
||||
export async function uploadLicenseImage(id: string, companyId: string, file: Express.Multer.File) {
|
||||
const customer = await repo.findByIdSimple(id, companyId)
|
||||
if (!customer) throw new NotFoundError('Customer not found')
|
||||
const url = await uploadImage(file.buffer, `companies/${companyId}/customers/${customer.id}`)
|
||||
if (customer.licenseImageUrl) {
|
||||
await deleteImage(customer.licenseImageUrl).catch(() => null)
|
||||
}
|
||||
return presentCustomer(await repo.updateById(customer.id, { licenseImageUrl: url }))
|
||||
}
|
||||
|
||||
export async function getLicenseImageFile(id: string, companyId: string) {
|
||||
const customer = await repo.findByIdSimple(id, companyId)
|
||||
if (!customer || !customer.licenseImageUrl) throw new NotFoundError('License image not found')
|
||||
|
||||
const filePath = resolveStoredFilePath(customer.licenseImageUrl)
|
||||
if (!filePath) throw new NotFoundError('License image not found')
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
export async function approveLicense(id: string, companyId: string, decision: 'APPROVE' | 'DENY', note: string | undefined, approverName: string) {
|
||||
const customer = await repo.findByIdSimple(id, companyId)
|
||||
if (!customer) throw new NotFoundError('Customer not found')
|
||||
return presentCustomer(await repo.updateById(customer.id, {
|
||||
licenseValidationStatus: decision === 'APPROVE' ? 'APPROVED' : 'DENIED',
|
||||
licenseApprovedBy: approverName,
|
||||
licenseApprovedAt: new Date(),
|
||||
licenseApprovalNote: note ?? null,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as repo from './customer.repo'
|
||||
import * as service from './customer.service'
|
||||
|
||||
vi.mock('./customer.repo')
|
||||
vi.mock('../../services/licenseValidationService', () => ({
|
||||
validateAndFlagLicense: vi.fn().mockResolvedValue({ status: 'VALID', daysUntilExpiry: 365, requiresApproval: false, message: 'Valid' }),
|
||||
}))
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/test.jpg'),
|
||||
deleteImage: vi.fn().mockResolvedValue(undefined),
|
||||
resolveStoredFilePath: vi.fn().mockReturnValue('/tmp/license.jpg'),
|
||||
getProtectedCustomerLicenseImageUrl: vi.fn((customerId: string) => `http://localhost:3001/dashboard/api/v1/customers/${customerId}/license-image`),
|
||||
}))
|
||||
|
||||
const mockCustomer = {
|
||||
id: 'cust_1',
|
||||
companyId: 'comp_1',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john@example.com',
|
||||
licenseImageUrl: null,
|
||||
flagged: false,
|
||||
flagReason: null,
|
||||
licenseExpiry: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('customer.service', () => {
|
||||
describe('listCustomers', () => {
|
||||
it('returns paginated customer list', async () => {
|
||||
vi.mocked(repo.findMany).mockResolvedValue([[mockCustomer], 1] as any)
|
||||
const result = await service.listCustomers('comp_1', { page: 1, pageSize: 20 })
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.data).toHaveLength(1)
|
||||
expect(result.totalPages).toBe(1)
|
||||
})
|
||||
|
||||
it('filters by search query', async () => {
|
||||
vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any)
|
||||
await service.listCustomers('comp_1', { page: 1, pageSize: 20, q: 'john' })
|
||||
expect(repo.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ OR: expect.arrayContaining([expect.objectContaining({ firstName: expect.anything() })]) }),
|
||||
0, 20,
|
||||
)
|
||||
})
|
||||
|
||||
it('filters by flagged status', async () => {
|
||||
vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any)
|
||||
await service.listCustomers('comp_1', { page: 1, pageSize: 20, flagged: 'true' })
|
||||
expect(repo.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ flagged: true }),
|
||||
0, 20,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCustomer', () => {
|
||||
it('creates customer and returns it', async () => {
|
||||
vi.mocked(repo.create).mockResolvedValue(mockCustomer as any)
|
||||
const result = await service.createCustomer({ firstName: 'John', lastName: 'Doe', email: 'john@example.com' }, 'comp_1')
|
||||
expect(result).toEqual({
|
||||
...mockCustomer,
|
||||
licenseImageUrl: null,
|
||||
})
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCustomer', () => {
|
||||
it('throws NotFoundError when customer does not belong to company', async () => {
|
||||
vi.mocked(repo.updateMany).mockResolvedValue({ count: 0 } as any)
|
||||
await expect(service.updateCustomer('cust_1', 'comp_1', { firstName: 'Jane' })).rejects.toThrow('Customer not found')
|
||||
})
|
||||
|
||||
it('updates customer and returns refreshed record', async () => {
|
||||
vi.mocked(repo.updateMany).mockResolvedValue({ count: 1 } as any)
|
||||
vi.mocked(repo.findUniqueOrThrow).mockResolvedValue({ ...mockCustomer, firstName: 'Jane' } as any)
|
||||
const result = await service.updateCustomer('cust_1', 'comp_1', { firstName: 'Jane' })
|
||||
expect(result.firstName).toBe('Jane')
|
||||
expect(result.licenseImageUrl).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('flagCustomer', () => {
|
||||
it('sets flagged=true with reason', async () => {
|
||||
vi.mocked(repo.updateMany).mockResolvedValue({ count: 1 } as any)
|
||||
await service.flagCustomer('cust_1', 'comp_1', 'Suspicious behavior')
|
||||
expect(repo.updateMany).toHaveBeenCalledWith('cust_1', 'comp_1', { flagged: true, flagReason: 'Suspicious behavior' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('unflagCustomer', () => {
|
||||
it('clears flag and reason', async () => {
|
||||
vi.mocked(repo.updateMany).mockResolvedValue({ count: 1 } as any)
|
||||
await service.unflagCustomer('cust_1', 'comp_1')
|
||||
expect(repo.updateMany).toHaveBeenCalledWith('cust_1', 'comp_1', { flagged: false, flagReason: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadLicenseImage', () => {
|
||||
it('uploads image and updates customer record', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(mockCustomer as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...mockCustomer, licenseImageUrl: 'http://localhost:4000/storage/test.jpg' } as any)
|
||||
const result = await service.uploadLicenseImage('cust_1', 'comp_1', { buffer: Buffer.from(''), mimetype: 'image/jpeg' } as any)
|
||||
expect(result.licenseImageUrl).toBe('http://localhost:3001/dashboard/api/v1/customers/cust_1/license-image')
|
||||
})
|
||||
|
||||
it('resolves a stored file path for protected downloads', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({ ...mockCustomer, licenseImageUrl: 'http://localhost:4000/storage/test.jpg' } as any)
|
||||
const result = await service.getLicenseImageFile('cust_1', 'comp_1')
|
||||
expect(result).toBe('/tmp/license.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('approveLicense', () => {
|
||||
it('sets APPROVED status', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(mockCustomer as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...mockCustomer, licenseValidationStatus: 'APPROVED' } as any)
|
||||
const result = await service.approveLicense('cust_1', 'comp_1', 'APPROVE', undefined, 'Admin User')
|
||||
expect(repo.updateById).toHaveBeenCalledWith('cust_1', expect.objectContaining({ licenseValidationStatus: 'APPROVED' }))
|
||||
})
|
||||
|
||||
it('sets DENIED status', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(mockCustomer as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...mockCustomer, licenseValidationStatus: 'DENIED' } as any)
|
||||
await service.approveLicense('cust_1', 'comp_1', 'DENY', 'Expired document', 'Admin User')
|
||||
expect(repo.updateById).toHaveBeenCalledWith('cust_1', expect.objectContaining({ licenseValidationStatus: 'DENIED' }))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
export function presentVehicleWithAvailability<T extends { id: string }>(
|
||||
vehicle: T,
|
||||
availability: { available: boolean; status: string; nextAvailableAt: Date | null },
|
||||
) {
|
||||
return {
|
||||
...vehicle,
|
||||
availability: availability.available,
|
||||
availabilityStatus: availability.status,
|
||||
nextAvailableAt: availability.nextAvailableAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export async function findPublicOffers() {
|
||||
return prisma.offer.findMany({
|
||||
where: { isPublic: true, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
include: { company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, primaryColor: true } } } } },
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
take: 50,
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCitiesFromCompanies() {
|
||||
return prisma.company.findMany({
|
||||
where: {
|
||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
|
||||
},
|
||||
select: { brand: { select: { publicCity: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findListedCompanies(where: any, skip: number, take: number) {
|
||||
return prisma.company.findMany({
|
||||
where,
|
||||
include: {
|
||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
|
||||
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
})
|
||||
}
|
||||
|
||||
export async function findPublishedVehicles(where: any, skip: number, take: number) {
|
||||
return prisma.vehicle.findMany({
|
||||
where,
|
||||
include: {
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
orderBy: { dailyRate: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleForMarketplace(vehicleId: string, companySlug: string) {
|
||||
return prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
email: string; firstName: string; lastName: string; phone?: string
|
||||
}) {
|
||||
return prisma.customer.upsert({
|
||||
where: { companyId_email: { companyId, email: data.email } },
|
||||
create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone },
|
||||
update: { firstName: data.firstName, lastName: data.lastName, ...(data.phone ? { phone: data.phone } : {}) },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createMarketplaceReservation(data: {
|
||||
companyId: string; vehicleId: string; customerId: string
|
||||
startDate: Date; endDate: Date; dailyRate: number; totalDays: number; totalAmount: number; notes?: string
|
||||
}) {
|
||||
return prisma.reservation.create({
|
||||
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyPage(slug: string) {
|
||||
return prisma.company.findFirst({
|
||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
include: {
|
||||
brand: true,
|
||||
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
|
||||
offers: { where: { isPublic: true, isActive: true, validUntil: { gte: new Date() } }, orderBy: { isFeatured: 'desc' } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyBySlug(slug: string) {
|
||||
return prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
}
|
||||
|
||||
export async function findCompanyReviews(companyId: string) {
|
||||
return prisma.review.findMany({
|
||||
where: { companyId, isPublished: true },
|
||||
include: { renter: { select: { firstName: true, lastName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyVehicles(companyId: string) {
|
||||
return prisma.vehicle.findMany({
|
||||
where: { companyId, isPublished: true, status: 'AVAILABLE' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleById(slug: string, vehicleId: string) {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
return prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyOffers(companyId: string) {
|
||||
return prisma.offer.findMany({
|
||||
where: { companyId, isPublic: true, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function findReservationByReviewToken(token: string) {
|
||||
return prisma.reservation.findUnique({
|
||||
where: { reviewToken: token },
|
||||
include: {
|
||||
vehicle: { select: { make: true, model: true, year: true, photos: true } },
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true } } } },
|
||||
review: { select: { id: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findReservationForReviewSubmit(token: string) {
|
||||
return prisma.reservation.findUnique({
|
||||
where: { reviewToken: token },
|
||||
include: { review: { select: { id: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createReview(data: {
|
||||
reservationId: string; companyId: string; renterId?: string | null
|
||||
overallRating: number; vehicleRating?: number; serviceRating?: number; comment?: string
|
||||
}) {
|
||||
return prisma.review.create({
|
||||
data: { ...data, renterId: data.renterId ?? undefined, isPublished: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function invalidateReviewToken(reservationId: string) {
|
||||
return prisma.reservation.update({ where: { id: reservationId }, data: { reviewToken: null } })
|
||||
}
|
||||
|
||||
export async function findOfferByCode(code: string) {
|
||||
return prisma.offer.findFirst({
|
||||
where: { promoCode: code, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Router } from 'express'
|
||||
import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
|
||||
import * as service from './marketplace.service'
|
||||
import {
|
||||
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema,
|
||||
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
|
||||
} from './marketplace.schemas'
|
||||
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
|
||||
router.get('/offers', async (_req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getPublicOffers())
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/cities', async (_req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getCities())
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/companies', async (req, res, next) => {
|
||||
try {
|
||||
const { city, hasOffer } = parseQuery(companiesQuerySchema, req)
|
||||
const { page, pageSize } = parseQuery(paginationSchema, req)
|
||||
ok(res, await service.getListedCompanies({ city, hasOffer, page, pageSize }))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/search', async (req, res, next) => {
|
||||
try {
|
||||
const filters = parseQuery(searchSchema, req)
|
||||
const pagination = parseQuery(paginationSchema, req)
|
||||
ok(res, await service.searchVehicles({ ...filters, ...pagination }))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/reservations', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(marketplaceReservationSchema, req)
|
||||
const result = await service.createMarketplaceReservation(body)
|
||||
created(res, result)
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
ok(res, await service.getReviewContext(token))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
const body = parseBody(reviewBodySchema, req)
|
||||
const result = await service.submitReview(token, body)
|
||||
created(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/offers/:code/validate', async (req, res, next) => {
|
||||
try {
|
||||
const { code } = parseParams(offerCodeParamSchema, req)
|
||||
ok(res, await service.validateOfferCode(code))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Offer validation is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyPage(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/reviews', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyReviews(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyVehicles(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(vehicleParamSchema, req)
|
||||
ok(res, await service.getVehicleDetail(slug, id))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyOffers(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const searchSchema = z.object({
|
||||
city: z.string().trim().max(100).optional(),
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
category: z.string().trim().max(50).optional(),
|
||||
maxPrice: z.coerce.number().int().min(0).max(1_000_000).optional(),
|
||||
transmission: z.string().trim().max(20).optional(),
|
||||
make: z.string().trim().max(60).optional(),
|
||||
model: z.string().trim().max(60).optional(),
|
||||
})
|
||||
|
||||
export const companiesQuerySchema = z.object({
|
||||
city: z.string().trim().max(100).optional(),
|
||||
hasOffer: z.string().optional(),
|
||||
})
|
||||
|
||||
export const marketplaceReservationSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
companySlug: z.string().trim().max(100),
|
||||
firstName: z.string().min(1).max(100),
|
||||
lastName: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
phone: z.string().max(30).optional(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
notes: z.string().max(500).optional(),
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
})
|
||||
|
||||
export const reviewBodySchema = z.object({
|
||||
overallRating: z.number().int().min(1).max(5),
|
||||
vehicleRating: z.number().int().min(1).max(5).optional(),
|
||||
serviceRating: z.number().int().min(1).max(5).optional(),
|
||||
comment: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
export const slugParamSchema = z.object({ slug: z.string() })
|
||||
export const vehicleParamSchema = z.object({ slug: z.string(), id: z.string() })
|
||||
export const reviewTokenSchema = z.object({ token: z.string() })
|
||||
export const offerCodeParamSchema = z.object({ code: z.string() })
|
||||
@@ -0,0 +1,213 @@
|
||||
import { AppError, NotFoundError, ConflictError } from '../../http/errors'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { marketplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import * as repo from './marketplace.repo'
|
||||
|
||||
export async function getPublicOffers() {
|
||||
return repo.findPublicOffers()
|
||||
}
|
||||
|
||||
export async function getCities() {
|
||||
const rows = await repo.findCitiesFromCompanies()
|
||||
const map = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
const city = row.brand?.publicCity?.trim()
|
||||
if (!city) continue
|
||||
const key = city.toLocaleLowerCase()
|
||||
if (!map.has(key)) map.set(key, city)
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||
}
|
||||
|
||||
export async function getListedCompanies(params: {
|
||||
city?: string; hasOffer?: string; page?: number; pageSize?: number
|
||||
}) {
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }
|
||||
if (params.city) where.brand = { ...where.brand, publicCity: { contains: params.city, mode: 'insensitive' } }
|
||||
if (params.hasOffer === 'true') {
|
||||
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
|
||||
}
|
||||
return repo.findListedCompanies(where, (page - 1) * pageSize, pageSize)
|
||||
}
|
||||
|
||||
export async function searchVehicles(params: {
|
||||
city?: string; startDate?: string; endDate?: string; category?: string
|
||||
maxPrice?: number; transmission?: string; make?: string; model?: string
|
||||
page?: number; pageSize?: number
|
||||
}) {
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const where: any = {
|
||||
isPublished: true,
|
||||
status: 'AVAILABLE',
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
|
||||
}
|
||||
if (params.category) where.category = params.category
|
||||
if (params.maxPrice !== undefined) where.dailyRate = { lte: params.maxPrice }
|
||||
if (params.transmission) where.transmission = params.transmission
|
||||
if (params.make) where.make = { contains: params.make, mode: 'insensitive' }
|
||||
if (params.model) where.model = { contains: params.model, mode: 'insensitive' }
|
||||
if (params.city) {
|
||||
where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
|
||||
}
|
||||
|
||||
const vehicles = await repo.findPublishedVehicles(where, (page - 1) * pageSize, pageSize)
|
||||
|
||||
const availability = await Promise.all(
|
||||
vehicles.map(async (v) => {
|
||||
const a = await getVehicleAvailabilitySummary(v.id, {
|
||||
range: params.startDate && params.endDate
|
||||
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
|
||||
: undefined,
|
||||
})
|
||||
return [v.id, a] as const
|
||||
}),
|
||||
)
|
||||
const availMap = new Map(availability)
|
||||
|
||||
return vehicles.map((v) => ({
|
||||
...v,
|
||||
availability: availMap.get(v.id)?.available ?? null,
|
||||
availabilityStatus: availMap.get(v.id)?.status ?? 'AVAILABLE',
|
||||
nextAvailableAt: availMap.get(v.id)?.nextAvailableAt ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createMarketplaceReservation(body: {
|
||||
vehicleId: string; companySlug: string; firstName: string; lastName: string
|
||||
email: string; phone?: string; startDate: string; endDate: string
|
||||
notes?: string; language?: string
|
||||
}) {
|
||||
const startDate = new Date(body.startDate)
|
||||
const endDate = new Date(body.endDate)
|
||||
|
||||
if (endDate <= startDate) {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
|
||||
const vehicle = await repo.findVehicleForMarketplace(body.vehicleId, body.companySlug)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(body.vehicleId, { range: { startDate, endDate } })
|
||||
if (!availability.available) {
|
||||
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, {
|
||||
email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone,
|
||||
})
|
||||
|
||||
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||
const totalAmount = vehicle.dailyRate * totalDays
|
||||
|
||||
const reservation = await repo.createMarketplaceReservation({
|
||||
companyId: vehicle.companyId, vehicleId: body.vehicleId, customerId: customer.id,
|
||||
startDate, endDate, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes,
|
||||
})
|
||||
|
||||
const lang = (body.language ?? 'fr') as Lang
|
||||
const companyName = vehicle.company.brand?.displayName ?? (vehicle.company as any).name
|
||||
const emailOpts = {
|
||||
firstName: body.firstName, vehicleYear: vehicle.year, vehicleMake: vehicle.make, vehicleModel: vehicle.model,
|
||||
companyName, startDate, endDate, totalDays,
|
||||
rateDisplay: (vehicle.dailyRate / 100).toFixed(2),
|
||||
totalDisplay: (totalAmount / 100).toFixed(2),
|
||||
email: body.email, phone: body.phone,
|
||||
}
|
||||
|
||||
sendTransactionalEmail({
|
||||
to: body.email,
|
||||
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||
html: marketplaceReservationEmail.html(emailOpts, lang),
|
||||
text: marketplaceReservationEmail.text(emailOpts, lang),
|
||||
}).catch(() => null)
|
||||
|
||||
sendNotification({
|
||||
type: 'NEW_BOOKING',
|
||||
title: 'New Marketplace Reservation Request',
|
||||
body: `${body.firstName} ${body.lastName} requested ${vehicle.make} ${vehicle.model} from ${startDate.toISOString().slice(0, 10)} to ${endDate.toISOString().slice(0, 10)}.`,
|
||||
companyId: vehicle.companyId,
|
||||
channels: ['IN_APP'],
|
||||
}).catch(() => null)
|
||||
|
||||
return { reservationId: reservation.id }
|
||||
}
|
||||
|
||||
export async function getCompanyPage(slug: string) {
|
||||
const company = await repo.findCompanyPage(slug)
|
||||
if (!company) throw new NotFoundError('Company not found')
|
||||
|
||||
const vehicles = await Promise.all(
|
||||
company.vehicles.map(async (v) => {
|
||||
const a = await getVehicleAvailabilitySummary(v.id)
|
||||
return { ...v, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
|
||||
}),
|
||||
)
|
||||
return { ...company, vehicles }
|
||||
}
|
||||
|
||||
export async function getCompanyReviews(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return repo.findCompanyReviews(company.id)
|
||||
}
|
||||
|
||||
export async function getCompanyVehicles(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return repo.findCompanyVehicles(company.id)
|
||||
}
|
||||
|
||||
export async function getVehicleDetail(slug: string, vehicleId: string) {
|
||||
const vehicle = await repo.findVehicleById(slug, vehicleId)
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
return { ...vehicle, availability: availability.available, availabilityStatus: availability.status, nextAvailableAt: availability.nextAvailableAt }
|
||||
}
|
||||
|
||||
export async function getCompanyOffers(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return repo.findCompanyOffers(company.id)
|
||||
}
|
||||
|
||||
export async function getReviewContext(token: string) {
|
||||
const reservation = await repo.findReservationByReviewToken(token)
|
||||
if (!reservation) throw new NotFoundError('Review link is invalid or has expired')
|
||||
if (reservation.review) throw new ConflictError('A review has already been submitted for this reservation')
|
||||
|
||||
return {
|
||||
reservationId: reservation.id,
|
||||
companyName: reservation.company.brand?.displayName ?? (reservation.company as any).name,
|
||||
companyLogoUrl: reservation.company.brand?.logoUrl ?? null,
|
||||
vehicle: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`,
|
||||
vehiclePhoto: reservation.vehicle.photos[0] ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitReview(token: string, body: {
|
||||
overallRating: number; vehicleRating?: number; serviceRating?: number; comment?: string
|
||||
}) {
|
||||
const reservation = await repo.findReservationForReviewSubmit(token)
|
||||
if (!reservation) throw new NotFoundError('Review link is invalid or has expired')
|
||||
if (reservation.review) throw new ConflictError('A review has already been submitted for this reservation')
|
||||
|
||||
const review = await repo.createReview({
|
||||
reservationId: reservation.id,
|
||||
companyId: reservation.companyId,
|
||||
renterId: reservation.renterId,
|
||||
...body,
|
||||
})
|
||||
await repo.invalidateReviewToken(reservation.id)
|
||||
return { reviewId: review.id }
|
||||
}
|
||||
|
||||
export async function validateOfferCode(code: string) {
|
||||
const offer = await repo.findOfferByCode(code)
|
||||
if (!offer) throw new NotFoundError('Promo code not found or expired')
|
||||
if (offer.maxRedemptions && offer.redemptionCount >= offer.maxRedemptions) {
|
||||
throw new ConflictError('Promo code has reached its maximum redemptions')
|
||||
}
|
||||
return offer
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
|
||||
|
||||
vi.mock('./marketplace.repo', () => ({
|
||||
findPublicOffers: vi.fn(),
|
||||
findCitiesFromCompanies: vi.fn(),
|
||||
findListedCompanies: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
findVehicleForMarketplace: vi.fn(),
|
||||
upsertMarketplaceCustomer: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
findCompanyPage: vi.fn(),
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyReviews: vi.fn(),
|
||||
findCompanyVehicles: vi.fn(),
|
||||
findVehicleById: vi.fn(),
|
||||
findCompanyOffers: vi.fn(),
|
||||
findReservationByReviewToken: vi.fn(),
|
||||
findReservationForReviewSubmit: vi.fn(),
|
||||
createReview: vi.fn(),
|
||||
invalidateReviewToken: vi.fn(),
|
||||
findOfferByCode: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({
|
||||
getVehicleAvailabilitySummary: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/notificationService', () => ({
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/emailTranslations', () => ({
|
||||
marketplaceReservationEmail: {
|
||||
subject: vi.fn(() => 'Subject'),
|
||||
html: vi.fn(() => '<html>'),
|
||||
text: vi.fn(() => 'text'),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as repo from './marketplace.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import {
|
||||
getCities, searchVehicles, createMarketplaceReservation,
|
||||
getReviewContext, submitReview, validateOfferCode,
|
||||
} from './marketplace.service'
|
||||
|
||||
const SLUG = 'test-company'
|
||||
|
||||
function makeVehicle(overrides: object = {}) {
|
||||
return {
|
||||
id: 'v-1', dailyRate: 10000, year: 2022, make: 'Toyota', model: 'Camry',
|
||||
isPublished: true, status: 'AVAILABLE', companyId: 'co-1',
|
||||
company: { name: 'Test Co', brand: { displayName: 'Test Co' } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('getCities', () => {
|
||||
it('deduplicates and sorts cities case-insensitively', async () => {
|
||||
vi.mocked(repo.findCitiesFromCompanies).mockResolvedValue([
|
||||
{ brand: { publicCity: 'Casablanca' } },
|
||||
{ brand: { publicCity: 'casablanca' } },
|
||||
{ brand: { publicCity: 'Rabat' } },
|
||||
{ brand: null },
|
||||
] as any)
|
||||
|
||||
const result = await getCities()
|
||||
expect(result).toEqual(['Casablanca', 'Rabat'])
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('searchVehicles', () => {
|
||||
it('returns vehicles enriched with availability data', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await searchVehicles({ city: 'Casablanca' })
|
||||
|
||||
expect(repo.findPublishedVehicles).toHaveBeenCalledOnce()
|
||||
expect(result[0].availability).toBe(true)
|
||||
expect(result[0].availabilityStatus).toBe('AVAILABLE')
|
||||
})
|
||||
|
||||
it('passes date range to availability service when provided', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: new Date('2025-07-01'), blockingReason: 'RESERVATION' })
|
||||
|
||||
const result = await searchVehicles({ startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z' })
|
||||
|
||||
expect(getVehicleAvailabilitySummary).toHaveBeenCalledWith('v-1', {
|
||||
range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') },
|
||||
})
|
||||
expect(result[0].availability).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('createMarketplaceReservation', () => {
|
||||
const baseBody = {
|
||||
vehicleId: 'v-1', companySlug: SLUG,
|
||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||
startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z',
|
||||
}
|
||||
|
||||
it('creates reservation and returns reservationId', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplace).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)
|
||||
|
||||
const result = await createMarketplaceReservation(baseBody)
|
||||
expect(result.reservationId).toBe('r-1')
|
||||
})
|
||||
|
||||
it('throws NotFoundError when vehicle not found', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplace).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(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||
|
||||
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||
})
|
||||
|
||||
it('throws AppError for invalid date range', async () => {
|
||||
await expect(
|
||||
createMarketplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
|
||||
).rejects.toMatchObject({ error: 'invalid_dates' })
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('getReviewContext', () => {
|
||||
it('returns review context for a valid unreviewd token', async () => {
|
||||
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue({
|
||||
id: 'r-1', companyId: 'co-1', renterId: null, reviewToken: 'tok',
|
||||
vehicle: { year: 2022, make: 'Toyota', model: 'Camry', photos: ['photo.jpg'] },
|
||||
company: { name: 'Test Co', brand: { displayName: 'Test Co', logoUrl: 'logo.png' } },
|
||||
review: null,
|
||||
} as any)
|
||||
|
||||
const result = await getReviewContext('tok')
|
||||
expect(result.reservationId).toBe('r-1')
|
||||
expect(result.vehiclePhoto).toBe('photo.jpg')
|
||||
})
|
||||
|
||||
it('throws NotFoundError for unknown token', async () => {
|
||||
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue(null)
|
||||
await expect(getReviewContext('bad')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws ConflictError when already reviewed', async () => {
|
||||
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue({
|
||||
id: 'r-1', companyId: 'co-1', renterId: null,
|
||||
vehicle: { year: 2022, make: 'Toyota', model: 'Camry', photos: [] },
|
||||
company: { name: 'Test Co', brand: null },
|
||||
review: { id: 'rev-1' },
|
||||
} as any)
|
||||
|
||||
await expect(getReviewContext('tok')).rejects.toBeInstanceOf(ConflictError)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('submitReview', () => {
|
||||
it('creates review and invalidates token', async () => {
|
||||
vi.mocked(repo.findReservationForReviewSubmit).mockResolvedValue({ id: 'r-1', companyId: 'co-1', renterId: null, review: null } as any)
|
||||
vi.mocked(repo.createReview).mockResolvedValue({ id: 'rev-1' } as any)
|
||||
vi.mocked(repo.invalidateReviewToken).mockResolvedValue(undefined as any)
|
||||
|
||||
const result = await submitReview('tok', { overallRating: 5 })
|
||||
|
||||
expect(repo.createReview).toHaveBeenCalledOnce()
|
||||
expect(repo.invalidateReviewToken).toHaveBeenCalledWith('r-1')
|
||||
expect(result.reviewId).toBe('rev-1')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('validateOfferCode', () => {
|
||||
it('returns offer when code is valid and not exhausted', async () => {
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ id: 'o-1', maxRedemptions: null, redemptionCount: 0 } as any)
|
||||
const result = await validateOfferCode('PROMO10')
|
||||
expect((result as any).id).toBe('o-1')
|
||||
})
|
||||
|
||||
it('throws NotFoundError for unknown code', async () => {
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue(null)
|
||||
await expect(validateOfferCode('BAD')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws ConflictError when max redemptions reached', async () => {
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ id: 'o-1', maxRedemptions: 10, redemptionCount: 10 } as any)
|
||||
await expect(validateOfferCode('PROMO10')).rejects.toBeInstanceOf(ConflictError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
|
||||
|
||||
// ─── Company notifications ────────────────────────────────────
|
||||
|
||||
export function findCompany(companyId: string, unread?: string) {
|
||||
const where: any = { companyId, channel: 'IN_APP' }
|
||||
if (unread === 'true') where.readAt = null
|
||||
return prisma.notification.findMany({ where, orderBy: { createdAt: 'desc' }, take: 50 })
|
||||
}
|
||||
|
||||
export function countUnread(companyId: string) {
|
||||
return prisma.notification.count({ where: { companyId, channel: 'IN_APP', readAt: null } })
|
||||
}
|
||||
|
||||
export function markRead(id: string, companyId: string) {
|
||||
return prisma.notification.updateMany({ where: { id, companyId }, data: { readAt: new Date(), status: 'READ' } })
|
||||
}
|
||||
|
||||
export function markAllRead(companyId: string) {
|
||||
return prisma.notification.updateMany({ where: { companyId, readAt: null }, data: { readAt: new Date(), status: 'READ' } })
|
||||
}
|
||||
|
||||
export function findEmployeePreferences(employeeId: string) {
|
||||
return prisma.notificationPreference.findMany({ where: { employeeId } })
|
||||
}
|
||||
|
||||
export async function upsertEmployeePreferences(employeeId: string, prefs: Array<{ notificationType: string; channel: string; enabled: boolean }>) {
|
||||
for (const pref of prefs) {
|
||||
await prisma.notificationPreference.upsert({
|
||||
where: { employeeId_notificationType_channel: { employeeId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel } },
|
||||
create: { employeeId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel, enabled: pref.enabled },
|
||||
update: { enabled: pref.enabled },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Renter notifications ─────────────────────────────────────
|
||||
|
||||
export function findRenter(renterId: string) {
|
||||
return prisma.notification.findMany({ where: { renterId, channel: 'IN_APP' }, orderBy: { createdAt: 'desc' }, take: 50 })
|
||||
}
|
||||
|
||||
export function markRenterRead(id: string, renterId: string) {
|
||||
return prisma.notification.updateMany({ where: { id, renterId }, data: { readAt: new Date(), status: 'READ' } })
|
||||
}
|
||||
|
||||
export function markAllRenterRead(renterId: string) {
|
||||
return prisma.notification.updateMany({ where: { renterId, channel: 'IN_APP', readAt: null }, data: { readAt: new Date(), status: 'READ' } })
|
||||
}
|
||||
|
||||
export function findRenterPreferences(renterId: string) {
|
||||
return prisma.notificationPreference.findMany({ where: { renterId } })
|
||||
}
|
||||
|
||||
export async function upsertRenterPreferences(renterId: string, prefs: Array<{ notificationType: string; channel: string; enabled: boolean }>) {
|
||||
for (const pref of prefs) {
|
||||
await prisma.notificationPreference.upsert({
|
||||
where: { renterId_notificationType_channel: { renterId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel } },
|
||||
create: { renterId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel, enabled: pref.enabled },
|
||||
update: { enabled: pref.enabled },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import * as service from './notification.service'
|
||||
import { preferencesSchema, idParamSchema, unreadQuerySchema } from './notification.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const companyAuth = [requireCompanyAuth, requireTenant, requireSubscription] as const
|
||||
|
||||
// ─── Company notifications ────────────────────────────────────
|
||||
|
||||
router.get('/company', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { unread } = parseQuery(unreadQuerySchema, req)
|
||||
ok(res, { data: await service.listCompany(req.companyId, unread) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/unread-count', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: { unread: await service.countUnread(req.companyId) } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/company/:id/read', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.markRead(id, req.companyId)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/company/read-all', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
await service.markAllRead(req.companyId)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/company/preferences', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getPreferences(req.employee.id) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/company/preferences', ...companyAuth, async (req, res, next) => {
|
||||
try {
|
||||
const prefs = parseBody(preferencesSchema, req)
|
||||
await service.setPreferences(req.employee.id, prefs)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Renter notifications ─────────────────────────────────────
|
||||
|
||||
router.get('/renter', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.listRenter(req.renterId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/renter/:id/read', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.markRenterRead(id, req.renterId)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/renter/read-all', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
await service.markAllRenterRead(req.renterId)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/renter/preferences', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getRenterPreferences(req.renterId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/renter/preferences', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const prefs = parseBody(preferencesSchema, req)
|
||||
await service.setRenterPreferences(req.renterId, prefs)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const preferenceItemSchema = z.object({
|
||||
notificationType: z.string(),
|
||||
channel: z.string(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
export const preferencesSchema = z.array(preferenceItemSchema)
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
export const unreadQuerySchema = z.object({
|
||||
unread: z.string().optional(),
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as repo from './notification.repo'
|
||||
|
||||
export const listCompany = (companyId: string, unread?: string) => repo.findCompany(companyId, unread)
|
||||
export const countUnread = (companyId: string) => repo.countUnread(companyId)
|
||||
export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId)
|
||||
export const markAllRead = (companyId: string) => repo.markAllRead(companyId)
|
||||
export const getPreferences = (employeeId: string) => repo.findEmployeePreferences(employeeId)
|
||||
export const setPreferences = (employeeId: string, prefs: any[]) => repo.upsertEmployeePreferences(employeeId, prefs)
|
||||
|
||||
export const listRenter = (renterId: string) => repo.findRenter(renterId)
|
||||
export const markRenterRead = (id: string, renterId: string) => repo.markRenterRead(id, renterId)
|
||||
export const markAllRenterRead = (renterId: string) => repo.markAllRenterRead(renterId)
|
||||
export const getRenterPreferences = (renterId: string) => repo.findRenterPreferences(renterId)
|
||||
export const setRenterPreferences = (renterId: string, prefs: any[]) => repo.upsertRenterPreferences(renterId, prefs)
|
||||
@@ -0,0 +1,63 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export function findMany(companyId: string, filters: { active?: string; public?: string }) {
|
||||
const where: any = { companyId }
|
||||
if (filters.active !== undefined) where.isActive = filters.active === 'true'
|
||||
if (filters.public !== undefined) where.isPublic = filters.public === 'true'
|
||||
return prisma.offer.findMany({ where, orderBy: { createdAt: 'desc' } })
|
||||
}
|
||||
|
||||
export function findByIdOrThrow(id: string, companyId: string) {
|
||||
return prisma.offer.findFirstOrThrow({ where: { id, companyId }, include: { vehicles: true } })
|
||||
}
|
||||
|
||||
export function findFirst(id: string, companyId: string) {
|
||||
return prisma.offer.findFirst({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export async function create(companyId: string, data: any, vehicleIds: string[]) {
|
||||
return prisma.offer.create({
|
||||
data: {
|
||||
...data,
|
||||
companyId,
|
||||
validFrom: new Date(data.validFrom),
|
||||
validUntil: new Date(data.validUntil),
|
||||
vehicles: vehicleIds.length > 0 ? { create: vehicleIds.map((id) => ({ vehicleId: id })) } : undefined,
|
||||
},
|
||||
include: { vehicles: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function update(id: string, data: any, vehicleIds?: string[]) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.offer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
validFrom: data.validFrom ? new Date(data.validFrom) : undefined,
|
||||
validUntil: data.validUntil ? new Date(data.validUntil) : undefined,
|
||||
},
|
||||
})
|
||||
if (vehicleIds !== undefined) {
|
||||
await tx.offerVehicle.deleteMany({ where: { offerId: id } })
|
||||
if (vehicleIds.length > 0) {
|
||||
await tx.offerVehicle.createMany({ data: vehicleIds.map((vehicleId) => ({ offerId: id, vehicleId })) })
|
||||
}
|
||||
}
|
||||
return tx.offer.findUniqueOrThrow({ where: { id }, include: { vehicles: true } })
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMany(id: string, companyId: string) {
|
||||
return prisma.offer.deleteMany({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export function setActive(id: string, companyId: string, isActive: boolean) {
|
||||
return prisma.offer.updateMany({ where: { id, companyId }, data: { isActive } })
|
||||
}
|
||||
|
||||
export async function getStats(id: string, companyId: string) {
|
||||
const offer = await prisma.offer.findFirstOrThrow({ where: { id, companyId } })
|
||||
const reservations = await prisma.reservation.count({ where: { offerId: offer.id } })
|
||||
return { redemptions: offer.redemptionCount, reservations }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import * as service from './offer.service'
|
||||
import { offerSchema, listQuerySchema, idParamSchema } from './offer.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const query = parseQuery(listQuerySchema, req)
|
||||
ok(res, { data: await service.listOffers(req.companyId, query) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { vehicleIds, ...body } = parseBody(offerSchema, req)
|
||||
created(res, { data: await service.createOffer(req.companyId, body, vehicleIds) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, { data: await service.getOffer(id, req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { vehicleIds, ...body } = parseBody(offerSchema.partial(), req)
|
||||
ok(res, { data: await service.updateOffer(id, req.companyId, body, vehicleIds) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deleteOffer(id, req.companyId)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/activate', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.setOfferActive(id, req.companyId, true)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/deactivate', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.setOfferActive(id, req.companyId, false)
|
||||
ok(res, { data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id/stats', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, { data: await service.getOfferStats(id, req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const offerSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
termsAndConds: z.string().optional(),
|
||||
type: z.enum(['PERCENTAGE', 'FIXED_AMOUNT', 'FREE_DAY', 'SPECIAL_RATE']),
|
||||
discountValue: z.number().int().min(0),
|
||||
specialRate: z.number().int().optional(),
|
||||
appliesToAll: z.boolean().default(true),
|
||||
categories: z.array(z.enum(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'])).default([]),
|
||||
minRentalDays: z.number().int().optional(),
|
||||
maxRentalDays: z.number().int().optional(),
|
||||
promoCode: z.string().optional(),
|
||||
maxRedemptions: z.number().int().optional(),
|
||||
validFrom: z.string().datetime(),
|
||||
validUntil: z.string().datetime(),
|
||||
isActive: z.boolean().default(true),
|
||||
isPublic: z.boolean().default(true),
|
||||
isFeatured: z.boolean().default(false),
|
||||
vehicleIds: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
export const listQuerySchema = z.object({
|
||||
active: z.string().optional(),
|
||||
public: z.string().optional(),
|
||||
})
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NotFoundError } from '../../http/errors'
|
||||
import * as repo from './offer.repo'
|
||||
|
||||
export function listOffers(companyId: string, filters: { active?: string; public?: string }) {
|
||||
return repo.findMany(companyId, filters)
|
||||
}
|
||||
|
||||
export function getOffer(id: string, companyId: string) {
|
||||
return repo.findByIdOrThrow(id, companyId)
|
||||
}
|
||||
|
||||
export function createOffer(companyId: string, data: any, vehicleIds: string[]) {
|
||||
return repo.create(companyId, data, vehicleIds)
|
||||
}
|
||||
|
||||
export async function updateOffer(id: string, companyId: string, data: any, vehicleIds?: string[]) {
|
||||
const existing = await repo.findFirst(id, companyId)
|
||||
if (!existing) throw new NotFoundError('Offer not found')
|
||||
return repo.update(id, data, vehicleIds)
|
||||
}
|
||||
|
||||
export function deleteOffer(id: string, companyId: string) {
|
||||
return repo.deleteMany(id, companyId)
|
||||
}
|
||||
|
||||
export function setOfferActive(id: string, companyId: string, isActive: boolean) {
|
||||
return repo.setActive(id, companyId, isActive)
|
||||
}
|
||||
|
||||
export function getOfferStats(id: string, companyId: string) {
|
||||
return repo.getStats(id, companyId)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export function findByCompany(companyId: string) {
|
||||
return prisma.rentalPayment.findMany({
|
||||
where: { companyId },
|
||||
include: { reservation: { include: { customer: true, vehicle: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
})
|
||||
}
|
||||
|
||||
export function findByReservation(reservationId: string, companyId: string) {
|
||||
return prisma.rentalPayment.findMany({ where: { reservationId, companyId }, orderBy: { createdAt: 'desc' } })
|
||||
}
|
||||
|
||||
export function findByAmanpay(transactionId: string) {
|
||||
return prisma.rentalPayment.findFirst({ where: { amanpayTransactionId: transactionId } })
|
||||
}
|
||||
|
||||
export function findByPaypal(captureId: string) {
|
||||
return prisma.rentalPayment.findFirst({ where: { paypalCaptureId: captureId } })
|
||||
}
|
||||
|
||||
export function findByPaypalForCompany(paypalOrderId: string, companyId: string) {
|
||||
return prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId } })
|
||||
}
|
||||
|
||||
export function findPaymentOrThrow(paymentId: string, companyId: string, reservationId: string) {
|
||||
return prisma.rentalPayment.findFirstOrThrow({ where: { id: paymentId, companyId, reservationId } })
|
||||
}
|
||||
|
||||
export function findReservationOrThrow(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id, companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findReservation(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export function markPaymentSucceeded(id: string) {
|
||||
return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date() } })
|
||||
}
|
||||
|
||||
export function markPaymentFailed(query: { amanpayTransactionId?: string; paypalCaptureId?: string }) {
|
||||
return prisma.rentalPayment.updateMany({ where: query, data: { status: 'FAILED' } })
|
||||
}
|
||||
|
||||
export function incrementReservationPaid(reservationId: string, amount: number) {
|
||||
return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
|
||||
}
|
||||
|
||||
export function createPayment(data: {
|
||||
companyId: string; reservationId: string; amount: number; currency: string
|
||||
status: string; type: string; paymentProvider: string
|
||||
amanpayTransactionId?: string | null; paypalCaptureId?: string | null; paymentMethod?: string; paidAt?: Date
|
||||
}) {
|
||||
return prisma.rentalPayment.create({ data: data as any })
|
||||
}
|
||||
|
||||
export function updatePaypalCapture(id: string, captureId: string) {
|
||||
return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } })
|
||||
}
|
||||
|
||||
export function setReservationPaidAmount(reservationId: string, paidAmount: number, paymentStatus: string) {
|
||||
return prisma.reservation.update({ where: { id: reservationId }, data: { paidAmount, paymentStatus: paymentStatus as any } })
|
||||
}
|
||||
|
||||
export function setReservationRefunded(reservationId: string) {
|
||||
return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'REFUNDED' } })
|
||||
}
|
||||
|
||||
export function setPaymentRefunded(id: string, partial: boolean) {
|
||||
return prisma.rentalPayment.update({ where: { id }, data: { status: partial ? 'PARTIALLY_REFUNDED' : 'REFUNDED' } })
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody, parseParams } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as service from './payment.service'
|
||||
import { chargeSchema, manualPaymentSchema, refundSchema, capturePaypalSchema, reservationParamSchema, paymentParamSchema } from './payment.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── Webhooks (no auth) ────────────────────────────────────────
|
||||
|
||||
router.post('/webhooks/amanpay', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const signature = (req.headers['x-amanpay-signature'] as string) ?? ''
|
||||
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
await service.handleAmanpayWebhook(req.body)
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/webhooks/paypal', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const isValid = await paypal.verifyWebhookEvent(req.headers as Record<string, string>, rawBody)
|
||||
if (paypal.isConfigured() && !isValid) return res.status(401).json({ error: 'invalid_signature' })
|
||||
await service.handlePaypalWebhook(req.body)
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Authenticated ────────────────────────────────────────────
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/company', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.listByCompany(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/reservations/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
ok(res, { data: await service.listByReservation(id, req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/charge', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
const body = parseBody(chargeSchema, req)
|
||||
ok(res, { data: await service.initCharge(id, req.companyId, body) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
const { paypalOrderId } = parseBody(capturePaypalSchema, req)
|
||||
ok(res, { data: await service.capturePaypal(id, req.companyId, paypalOrderId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/manual', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
const body = parseBody(manualPaymentSchema, req)
|
||||
ok(res, { data: await service.recordManualPayment(id, req.companyId, body) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:reservationId/payments/:paymentId/refund', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { reservationId, paymentId } = parseParams(paymentParamSchema, req)
|
||||
const { amount, reason } = parseBody(refundSchema, req)
|
||||
ok(res, { data: await service.refundPayment(reservationId, paymentId, req.companyId, amount, reason) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const chargeSchema = z.object({
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
export const manualPaymentSchema = z.object({
|
||||
amount: z.number().int().positive(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
|
||||
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
|
||||
paymentMethod: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL']),
|
||||
})
|
||||
|
||||
export const refundSchema = z.object({
|
||||
amount: z.number().int().positive().optional(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export const capturePaypalSchema = z.object({
|
||||
paypalOrderId: z.string(),
|
||||
})
|
||||
|
||||
export const reservationParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
export const paymentParamSchema = z.object({
|
||||
reservationId: z.string().min(1),
|
||||
paymentId: z.string().min(1),
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ConflictError, ValidationError } from '../../http/errors'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as repo from './payment.repo'
|
||||
|
||||
export function listByCompany(companyId: string) {
|
||||
return repo.findByCompany(companyId)
|
||||
}
|
||||
|
||||
export function listByReservation(reservationId: string, companyId: string) {
|
||||
return repo.findByReservation(reservationId, companyId)
|
||||
}
|
||||
|
||||
export async function handleAmanpayWebhook(event: any) {
|
||||
const transactionId = event.transaction_id ?? event.id
|
||||
const status = event.status?.toUpperCase()
|
||||
if (status === 'PAID' || status === 'SUCCEEDED') {
|
||||
const payment = await repo.findByAmanpay(transactionId)
|
||||
if (payment) {
|
||||
await repo.markPaymentSucceeded(payment.id)
|
||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||
}
|
||||
} else if (status === 'FAILED') {
|
||||
await repo.markPaymentFailed({ amanpayTransactionId: transactionId })
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePaypalWebhook(event: any) {
|
||||
const eventType = event.event_type as string
|
||||
if (eventType === 'PAYMENT.CAPTURE.COMPLETED') {
|
||||
const captureId = event.resource?.id as string
|
||||
const payment = await repo.findByPaypal(captureId)
|
||||
if (payment) {
|
||||
await repo.markPaymentSucceeded(payment.id)
|
||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||
}
|
||||
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
|
||||
await repo.markPaymentFailed({ paypalCaptureId: event.resource?.id })
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCharge(reservationId: string, companyId: string, body: {
|
||||
provider: 'AMANPAY' | 'PAYPAL'; type: 'CHARGE' | 'DEPOSIT'
|
||||
currency: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
|
||||
if (reservation.paymentStatus === 'PAID') throw new ConflictError('Reservation is already fully paid')
|
||||
|
||||
const amount = body.type === 'DEPOSIT' ? reservation.depositAmount : reservation.totalAmount
|
||||
const description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
const orderId = `${reservationId}-${body.type}-${Date.now()}`
|
||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||
|
||||
let checkoutUrl: string
|
||||
let amanpayTransactionId: string | null = null
|
||||
let paypalCaptureId: string | null = null
|
||||
|
||||
if (body.provider === 'AMANPAY') {
|
||||
if (!amanpay.isConfigured()) throw new ValidationError('AmanPay is not configured')
|
||||
const result = await amanpay.createCheckout({
|
||||
amount, currency: body.currency, orderId, description,
|
||||
customerEmail: reservation.customer.email,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
successUrl: body.successUrl, failureUrl: body.failureUrl,
|
||||
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
amanpayTransactionId = result.transactionId
|
||||
} else {
|
||||
if (!paypal.isConfigured()) throw new ValidationError('PayPal is not configured')
|
||||
const result = await paypal.createOrder({ amount, currency: body.currency, orderId, description, returnUrl: body.successUrl, cancelUrl: body.failureUrl })
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
|
||||
const payment = await repo.createPayment({ companyId, reservationId, amount, currency: body.currency, status: 'PENDING', type: body.type, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId })
|
||||
return { payment, checkoutUrl }
|
||||
}
|
||||
|
||||
export async function capturePaypal(reservationId: string, companyId: string, paypalOrderId: string) {
|
||||
const payment = await repo.findByPaypalForCompany(paypalOrderId, companyId)
|
||||
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||
const updated = await repo.updatePaypalCapture(payment.id, captureId)
|
||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function recordManualPayment(reservationId: string, companyId: string, body: {
|
||||
amount: number; currency: string; type: string; paymentMethod: string
|
||||
}) {
|
||||
const reservation = await repo.findReservation(reservationId, companyId)
|
||||
const remaining = Math.max(reservation.totalAmount - reservation.paidAmount, 0)
|
||||
if (remaining <= 0) throw new ConflictError('Reservation is already fully paid')
|
||||
if (body.amount > remaining) throw new ValidationError('Payment amount exceeds remaining balance')
|
||||
|
||||
const payment = await repo.createPayment({ companyId, reservationId, amount: body.amount, currency: body.currency, status: 'SUCCEEDED', type: body.type, paymentProvider: body.paymentMethod === 'PAYPAL' ? 'PAYPAL' : 'AMANPAY', paymentMethod: body.paymentMethod, paidAt: new Date() })
|
||||
const newPaidAmount = reservation.paidAmount + body.amount
|
||||
await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL')
|
||||
return payment
|
||||
}
|
||||
|
||||
export async function refundPayment(reservationId: string, paymentId: string, companyId: string, amount?: number, reason?: string) {
|
||||
const payment = await repo.findPaymentOrThrow(paymentId, companyId, reservationId)
|
||||
if (payment.status !== 'SUCCEEDED') throw new ValidationError('Only succeeded payments can be refunded')
|
||||
if (!payment.amanpayTransactionId && !payment.paypalCaptureId) throw new ValidationError('Manual payments must be refunded outside the online gateway flow')
|
||||
|
||||
const refundAmount = amount ?? payment.amount
|
||||
if (payment.paymentProvider === 'AMANPAY') {
|
||||
if (!payment.amanpayTransactionId) throw new Error('No AmanPay transaction ID')
|
||||
await amanpay.refundTransaction(payment.amanpayTransactionId, refundAmount, reason)
|
||||
} else {
|
||||
if (!payment.paypalCaptureId) throw new Error('No PayPal capture ID')
|
||||
await paypal.refundCapture(payment.paypalCaptureId, refundAmount, payment.currency, reason)
|
||||
}
|
||||
|
||||
const isPartial = refundAmount < payment.amount
|
||||
const updated = await repo.setPaymentRefunded(payment.id, isPartial)
|
||||
if (!isPartial) await repo.setReservationRefunded(payment.reservationId)
|
||||
return updated
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { applyAdditionalDriversToReservation, calculateAdditionalDriverCharge } from '../../services/additionalDriverService'
|
||||
|
||||
import * as repo from './reservation.repo'
|
||||
|
||||
export async function approveAdditionalDriver(
|
||||
reservationId: string,
|
||||
driverId: string,
|
||||
companyId: string,
|
||||
approved: boolean,
|
||||
note: string | undefined,
|
||||
approvedBy: string,
|
||||
) {
|
||||
const driver = await repo.findAdditionalDriver(driverId, reservationId, companyId)
|
||||
return repo.updateAdditionalDriver(driver.id, {
|
||||
approvedAt: approved ? new Date() : null,
|
||||
approvedBy: approved ? approvedBy : null,
|
||||
approvalNote: note ?? driver.approvalNote,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './reservation.repo'
|
||||
|
||||
export function formatDocumentNumber(prefix: string, sequence: number): string {
|
||||
return `${prefix}-${String(sequence).padStart(6, '0')}`
|
||||
}
|
||||
|
||||
export async function ensureReservationDocumentNumbers(companyId: string, reservationId: string) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const reservation = await tx.reservation.findFirstOrThrow({
|
||||
where: { id: reservationId, companyId },
|
||||
select: { id: true, contractNumber: true, invoiceNumber: true },
|
||||
})
|
||||
if (reservation.contractNumber && reservation.invoiceNumber) return reservation
|
||||
|
||||
const settings = await tx.contractSettings.upsert({
|
||||
where: { companyId },
|
||||
update: {},
|
||||
create: { companyId },
|
||||
})
|
||||
|
||||
const data: { contractNumber?: string; invoiceNumber?: string } = {}
|
||||
const settingsUpdate: { lastContractSeq?: number; lastInvoiceSeq?: number } = {}
|
||||
|
||||
if (!reservation.contractNumber) {
|
||||
const nextSeq = settings.lastContractSeq + 1
|
||||
data.contractNumber = formatDocumentNumber(settings.contractPrefix, nextSeq)
|
||||
settingsUpdate.lastContractSeq = nextSeq
|
||||
}
|
||||
if (!reservation.invoiceNumber) {
|
||||
const nextSeq = settings.lastInvoiceSeq + 1
|
||||
data.invoiceNumber = formatDocumentNumber(settings.invoicePrefix, nextSeq)
|
||||
settingsUpdate.lastInvoiceSeq = nextSeq
|
||||
}
|
||||
|
||||
if (Object.keys(settingsUpdate).length > 0) {
|
||||
await tx.contractSettings.update({ where: { companyId }, data: settingsUpdate })
|
||||
}
|
||||
|
||||
return tx.reservation.update({
|
||||
where: { id: reservationId },
|
||||
data,
|
||||
select: { id: true, contractNumber: true, invoiceNumber: true },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function buildReservationInvoiceLineItems(reservation: {
|
||||
dailyRate: number
|
||||
totalDays: number
|
||||
discountAmount: number
|
||||
depositAmount: number
|
||||
pricingRulesApplied: Array<{ name?: string; amount?: number; type?: string }> | null
|
||||
insurances: Array<{ id: string; policyName: string; chargeType: string; chargeValue: number; totalCharge: number }>
|
||||
additionalDrivers: Array<{ id: string; firstName: string; lastName: string; totalCharge: number }>
|
||||
vehicle: { year: number; make: string; model: string }
|
||||
}) {
|
||||
const baseAmount = reservation.dailyRate * reservation.totalDays
|
||||
return [
|
||||
{
|
||||
description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`,
|
||||
qty: reservation.totalDays,
|
||||
unitPrice: reservation.dailyRate,
|
||||
total: baseAmount,
|
||||
category: 'RENTAL',
|
||||
},
|
||||
...reservation.insurances.map((ins) => ({
|
||||
description: ins.policyName,
|
||||
qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1,
|
||||
unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge,
|
||||
total: ins.totalCharge,
|
||||
category: 'INSURANCE',
|
||||
})),
|
||||
...reservation.additionalDrivers
|
||||
.filter((d) => d.totalCharge > 0)
|
||||
.map((d) => ({
|
||||
description: `Additional driver - ${d.firstName} ${d.lastName}`,
|
||||
qty: 1,
|
||||
unitPrice: d.totalCharge,
|
||||
total: d.totalCharge,
|
||||
category: 'ADDITIONAL_DRIVER',
|
||||
})),
|
||||
...((reservation.pricingRulesApplied ?? []).map((rule, i) => ({
|
||||
description: rule.name?.trim() || `Pricing adjustment ${i + 1}`,
|
||||
qty: 1,
|
||||
unitPrice: Number(rule.amount ?? 0),
|
||||
total: Number(rule.amount ?? 0),
|
||||
category: 'PRICING_RULE',
|
||||
}))),
|
||||
...(reservation.discountAmount > 0 ? [{
|
||||
description: 'Discount',
|
||||
qty: 1,
|
||||
unitPrice: -reservation.discountAmount,
|
||||
total: -reservation.discountAmount,
|
||||
category: 'DISCOUNT',
|
||||
}] : []),
|
||||
...(reservation.depositAmount > 0 ? [{
|
||||
description: 'Security deposit',
|
||||
qty: 1,
|
||||
unitPrice: reservation.depositAmount,
|
||||
total: reservation.depositAmount,
|
||||
category: 'DEPOSIT',
|
||||
}] : []),
|
||||
]
|
||||
}
|
||||
|
||||
export async function getContract(id: string, companyId: string) {
|
||||
await ensureReservationDocumentNumbers(companyId, id)
|
||||
|
||||
const reservation = await repo.findForContract(id, companyId)
|
||||
const contractSettings = reservation.company.contractSettings ?? await prisma.contractSettings.upsert({
|
||||
where: { companyId },
|
||||
update: {},
|
||||
create: { companyId },
|
||||
})
|
||||
|
||||
const extras = reservation.extras && typeof reservation.extras === 'object' && !Array.isArray(reservation.extras)
|
||||
? (reservation.extras as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
const invoiceLineItems = buildReservationInvoiceLineItems({
|
||||
dailyRate: reservation.dailyRate,
|
||||
totalDays: reservation.totalDays,
|
||||
discountAmount: reservation.discountAmount,
|
||||
depositAmount: reservation.depositAmount,
|
||||
pricingRulesApplied: Array.isArray(reservation.pricingRulesApplied)
|
||||
? (reservation.pricingRulesApplied as any)
|
||||
: [],
|
||||
insurances: reservation.insurances,
|
||||
additionalDrivers: reservation.additionalDrivers,
|
||||
vehicle: reservation.vehicle,
|
||||
})
|
||||
|
||||
const subtotal = invoiceLineItems.reduce((s, i) => s + i.total, 0)
|
||||
const taxes = contractSettings.showTax && contractSettings.taxRate
|
||||
? [{ label: contractSettings.taxLabel?.trim() || 'Tax', rate: contractSettings.taxRate, amount: Math.round(subtotal * contractSettings.taxRate / 100) }]
|
||||
: []
|
||||
const taxTotal = taxes.reduce((s, t) => s + t.amount, 0)
|
||||
const grandTotal = subtotal + taxTotal
|
||||
const amountPaid = reservation.rentalPayments
|
||||
.filter((p: any) => p.status === 'SUCCEEDED')
|
||||
.reduce((s: number, p: any) => s + p.amount, 0)
|
||||
|
||||
const checkInInspection = reservation.inspections.find((i: any) => i.type === 'CHECKIN') ?? null
|
||||
const checkOutInspection = reservation.inspections.find((i: any) => i.type === 'CHECKOUT') ?? null
|
||||
|
||||
return {
|
||||
reservationId: reservation.id,
|
||||
contractLocale: reservation.company.brand?.defaultLocale ?? 'en',
|
||||
contractNumber: reservation.contractNumber,
|
||||
invoiceNumber: reservation.invoiceNumber,
|
||||
generatedAt: new Date().toISOString(),
|
||||
status: reservation.status,
|
||||
paymentStatus: reservation.paymentStatus,
|
||||
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
||||
notes: reservation.notes,
|
||||
company: {
|
||||
name: reservation.company.brand?.displayName ?? reservation.company.name,
|
||||
legalName: contractSettings.legalName || reservation.company.name,
|
||||
email: reservation.company.brand?.publicEmail ?? reservation.company.email,
|
||||
phone: reservation.company.brand?.publicPhone ?? reservation.company.phone,
|
||||
address: reservation.company.brand?.publicAddress ?? reservation.company.address ?? null,
|
||||
city: reservation.company.brand?.publicCity ?? null,
|
||||
country: reservation.company.brand?.publicCountry ?? null,
|
||||
registrationNumber: contractSettings.registrationNumber,
|
||||
taxId: contractSettings.taxId,
|
||||
logoUrl: reservation.company.brand?.logoUrl ?? null,
|
||||
},
|
||||
driver: {
|
||||
firstName: reservation.customer.firstName,
|
||||
lastName: reservation.customer.lastName,
|
||||
email: reservation.customer.email,
|
||||
phone: reservation.customer.phone,
|
||||
dateOfBirth: reservation.customer.dateOfBirth,
|
||||
driverLicense: reservation.customer.driverLicense,
|
||||
licenseCountry: reservation.customer.licenseCountry,
|
||||
licenseCategory: reservation.customer.licenseCategory,
|
||||
licenseIssuedAt: reservation.customer.licenseIssuedAt,
|
||||
licenseExpiry: reservation.customer.licenseExpiry,
|
||||
nationality: reservation.customer.nationality,
|
||||
},
|
||||
additionalDrivers: reservation.additionalDrivers.map((d: any) => ({
|
||||
id: d.id, firstName: d.firstName, lastName: d.lastName,
|
||||
email: d.email, phone: d.phone, driverLicense: d.driverLicense,
|
||||
licenseIssuedAt: d.licenseIssuedAt, licenseExpiry: d.licenseExpiry,
|
||||
licenseCountry: d.nationality, dateOfBirth: d.dateOfBirth, totalCharge: d.totalCharge,
|
||||
})),
|
||||
vehicle: {
|
||||
make: reservation.vehicle.make, model: reservation.vehicle.model, year: reservation.vehicle.year,
|
||||
color: reservation.vehicle.color, licensePlate: reservation.vehicle.licensePlate,
|
||||
vin: reservation.vehicle.vin, category: reservation.vehicle.category,
|
||||
},
|
||||
rentalPeriod: {
|
||||
startDate: reservation.startDate, endDate: reservation.endDate, totalDays: reservation.totalDays,
|
||||
pickupLocation: reservation.pickupLocation, returnLocation: reservation.returnLocation,
|
||||
},
|
||||
insurance: {
|
||||
total: reservation.insuranceTotal,
|
||||
policies: reservation.insurances.map((ins: any) => ({
|
||||
id: ins.id, name: ins.policyName, chargeType: ins.chargeType,
|
||||
unitPrice: ins.chargeValue, totalCharge: ins.totalCharge,
|
||||
})),
|
||||
},
|
||||
inspections: { checkIn: checkInInspection, checkOut: checkOutInspection },
|
||||
terms: {
|
||||
terms: contractSettings.terms,
|
||||
fuelPolicy: contractSettings.fuelPolicy,
|
||||
fuelPolicyType: contractSettings.fuelPolicyType,
|
||||
depositPolicy: contractSettings.depositPolicy,
|
||||
lateFeePolicy: contractSettings.lateFeePolicy,
|
||||
damagePolicy: contractSettings.damagePolicy,
|
||||
footerNote: contractSettings.contractFooterNote,
|
||||
signatureRequired: contractSettings.signatureRequired,
|
||||
},
|
||||
invoice: {
|
||||
currency: reservation.company.accountingSettings?.currency ?? reservation.company.brand?.defaultCurrency ?? 'MAD',
|
||||
lineItems: invoiceLineItems,
|
||||
subtotal, taxes, taxTotal, total: grandTotal, amountPaid,
|
||||
balanceDue: grandTotal - amountPaid,
|
||||
payments: reservation.rentalPayments.map((p: any) => ({
|
||||
id: p.id, amount: p.amount, currency: p.currency, type: p.type,
|
||||
status: p.status, provider: p.paymentProvider,
|
||||
paymentMethod: p.paymentMethod, paidAt: p.paidAt, createdAt: p.createdAt,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBilling(id: string, companyId: string) {
|
||||
const reservation = await repo.findForBilling(id, companyId)
|
||||
const baseAmount = reservation.dailyRate * reservation.totalDays
|
||||
const lineItems = [
|
||||
{
|
||||
description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model} — ${reservation.totalDays} day(s)`,
|
||||
qty: reservation.totalDays, unitPrice: reservation.dailyRate, total: baseAmount, category: 'RENTAL',
|
||||
},
|
||||
...reservation.insurances.map((ins: any) => ({
|
||||
description: ins.policyName,
|
||||
qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1,
|
||||
unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge,
|
||||
total: ins.totalCharge, category: 'INSURANCE',
|
||||
})),
|
||||
...reservation.additionalDrivers
|
||||
.filter((d: any) => d.totalCharge > 0)
|
||||
.map((d: any) => ({
|
||||
description: `Additional Driver — ${d.firstName} ${d.lastName}`,
|
||||
qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER',
|
||||
})),
|
||||
...(reservation.depositAmount > 0 ? [{
|
||||
description: 'Security Deposit (refundable)',
|
||||
qty: 1, unitPrice: reservation.depositAmount, total: reservation.depositAmount, category: 'DEPOSIT',
|
||||
}] : []),
|
||||
]
|
||||
const grandTotal = lineItems.reduce((s, i) => s + i.total, 0) - reservation.discountAmount
|
||||
|
||||
return {
|
||||
lineItems,
|
||||
discountAmount: reservation.discountAmount,
|
||||
pricingRulesApplied: reservation.pricingRulesApplied,
|
||||
pricingRulesTotal: reservation.pricingRulesTotal,
|
||||
grandTotal,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { buildReservationWorkflow } from './reservation.presenter'
|
||||
import * as repo from './reservation.repo'
|
||||
|
||||
export async function getInspections(reservationId: string, companyId: string) {
|
||||
return repo.findInspections(reservationId, companyId)
|
||||
}
|
||||
|
||||
export async function upsertInspection(
|
||||
reservationId: string,
|
||||
companyId: string,
|
||||
type: 'CHECKIN' | 'CHECKOUT',
|
||||
body: {
|
||||
mileage?: number
|
||||
fuelLevel: string
|
||||
fuelCharge?: number
|
||||
generalCondition?: string
|
||||
employeeNotes?: string
|
||||
customerAgreed?: boolean
|
||||
damagePoints?: Array<{
|
||||
viewType: string; x: number; y: number; damageType: string
|
||||
severity?: string; description?: string; isPreExisting?: boolean
|
||||
}>
|
||||
},
|
||||
inspectedBy: string,
|
||||
) {
|
||||
const reservation = await repo.findForInspection(reservationId, companyId)
|
||||
const workflow = buildReservationWorkflow(reservation)
|
||||
|
||||
if (workflow.closed) throw new AppError('This reservation has already been closed', 400, 'reservation_closed')
|
||||
if (type === 'CHECKIN' && !workflow.checkInInspectionEditable) {
|
||||
throw new AppError('Check-in inspection is not editable at this stage', 400, 'invalid_status')
|
||||
}
|
||||
if (type === 'CHECKOUT' && !workflow.checkOutInspectionEditable) {
|
||||
throw new AppError('Check-out inspection is only editable after the vehicle is returned', 400, 'invalid_status')
|
||||
}
|
||||
|
||||
const inspection = await prisma.damageInspection.upsert({
|
||||
where: { reservationId_type: { reservationId, type } },
|
||||
update: {
|
||||
mileage: body.mileage ?? null,
|
||||
fuelLevel: body.fuelLevel as any,
|
||||
fuelCharge: body.fuelCharge ?? null,
|
||||
generalCondition: body.generalCondition ?? null,
|
||||
employeeNotes: body.employeeNotes ?? null,
|
||||
customerAgreed: body.customerAgreed ?? false,
|
||||
inspectedBy,
|
||||
inspectedAt: new Date(),
|
||||
damagePoints: { deleteMany: {}, create: body.damagePoints as any },
|
||||
},
|
||||
create: {
|
||||
reservationId, companyId, type,
|
||||
mileage: body.mileage ?? null,
|
||||
fuelLevel: body.fuelLevel as any,
|
||||
fuelCharge: body.fuelCharge ?? null,
|
||||
generalCondition: body.generalCondition ?? null,
|
||||
employeeNotes: body.employeeNotes ?? null,
|
||||
customerAgreed: body.customerAgreed ?? false,
|
||||
inspectedBy,
|
||||
damagePoints: { create: body.damagePoints as any },
|
||||
},
|
||||
include: { damagePoints: true },
|
||||
})
|
||||
|
||||
const damageData = (body.damagePoints ?? []).map((p) => ({
|
||||
viewType: p.viewType, x: p.x, y: p.y, damageType: p.damageType,
|
||||
severity: p.severity ?? 'MINOR', note: p.description ?? '', isPreExisting: p.isPreExisting ?? false,
|
||||
}))
|
||||
|
||||
await prisma.damageReport.upsert({
|
||||
where: { reservationId_type: { reservationId, type } },
|
||||
update: {
|
||||
damages: damageData,
|
||||
fuelLevel: body.fuelLevel as any,
|
||||
mileage: body.mileage ?? null,
|
||||
inspectedAt: new Date(),
|
||||
inspectedBy,
|
||||
customerSignedAt: (body.customerAgreed ?? false) ? new Date() : null,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
},
|
||||
create: {
|
||||
reservationId, companyId, type,
|
||||
damages: damageData,
|
||||
photos: [],
|
||||
fuelLevel: body.fuelLevel as any,
|
||||
mileage: body.mileage ?? null,
|
||||
inspectedAt: new Date(),
|
||||
inspectedBy,
|
||||
customerSignedAt: (body.customerAgreed ?? false) ? new Date() : null,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.reservation.update({
|
||||
where: { id: reservationId },
|
||||
data: type === 'CHECKIN'
|
||||
? { checkInMileage: body.mileage ?? null, checkInFuelLevel: body.fuelLevel }
|
||||
: { checkOutMileage: body.mileage ?? null, checkOutFuelLevel: body.fuelLevel },
|
||||
})
|
||||
|
||||
return inspection
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { applyInsurancesToReservation, calculateInsuranceCharge } from '../../services/insuranceService'
|
||||
@@ -0,0 +1,135 @@
|
||||
import crypto from 'crypto'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { validateLicense } from '../../services/licenseValidationService'
|
||||
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { bookingConfirmedNotif, reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
|
||||
import * as repo from './reservation.repo'
|
||||
|
||||
async function assertLicenseCompliance(reservationId: string, companyId: string) {
|
||||
const reservation = await repo.findForLicenseCheck(reservationId, companyId)
|
||||
|
||||
const customerLicense = validateLicense(reservation.customer.licenseExpiry)
|
||||
const customerDenied = ['DENIED', 'EXPIRED'].includes(reservation.customer.licenseValidationStatus)
|
||||
if (customerDenied || customerLicense.status === 'EXPIRED') {
|
||||
throw new AppError('Primary driver license is not valid for this reservation', 400, 'invalid_primary_license')
|
||||
}
|
||||
if (customerLicense.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') {
|
||||
throw new AppError('Primary driver license requires manager approval before this reservation can proceed', 400, 'primary_license_requires_approval')
|
||||
}
|
||||
|
||||
const blockedDriver = reservation.additionalDrivers.find((d: any) => {
|
||||
if (d.licenseExpired) return true
|
||||
return d.requiresApproval && !d.approvedAt
|
||||
})
|
||||
if (blockedDriver) {
|
||||
throw new AppError(
|
||||
`Additional driver ${blockedDriver.firstName} ${blockedDriver.lastName} requires approval before this reservation can proceed`,
|
||||
400,
|
||||
'additional_driver_requires_approval',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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' })
|
||||
|
||||
const [customer, brand] = await Promise.all([
|
||||
repo.findCustomerById(reservation.customerId),
|
||||
repo.findBrandLocale(companyId),
|
||||
])
|
||||
const lang = (brand?.defaultLocale ?? 'fr') as Lang
|
||||
await sendNotification({
|
||||
type: 'BOOKING_CONFIRMED',
|
||||
title: bookingConfirmedNotif.title(lang),
|
||||
body: bookingConfirmedNotif.body(lang),
|
||||
companyId,
|
||||
email: customer.email,
|
||||
channels: ['EMAIL', 'IN_APP'],
|
||||
locale: lang,
|
||||
}).catch(() => null)
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function checkinReservation(id: string, companyId: string, mileage?: number) {
|
||||
const reservation = await repo.findByIdSimple(id, companyId)
|
||||
if (reservation.status !== 'CONFIRMED') throw new AppError('Only CONFIRMED reservations can be checked in', 400, 'invalid_status')
|
||||
await assertLicenseCompliance(reservation.id, companyId)
|
||||
|
||||
const updated = await repo.updateById(id, {
|
||||
status: 'ACTIVE',
|
||||
checkedInAt: new Date(),
|
||||
checkInMileage: mileage ?? null,
|
||||
})
|
||||
await repo.updateVehicleStatus(reservation.vehicleId, 'RENTED')
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function checkoutReservation(id: string, companyId: string, mileage?: number) {
|
||||
const reservation = await repo.findByIdForCheckout(id, companyId)
|
||||
if (reservation.status !== 'ACTIVE') throw new AppError('Only ACTIVE reservations can be checked out', 400, 'invalid_status')
|
||||
|
||||
const reviewToken = crypto.randomBytes(32).toString('hex')
|
||||
const updated = await repo.updateById(id, {
|
||||
status: 'COMPLETED',
|
||||
checkedOutAt: new Date(),
|
||||
checkOutMileage: mileage ?? null,
|
||||
reviewToken,
|
||||
})
|
||||
await repo.updateVehicleStatus(reservation.vehicleId, 'AVAILABLE', mileage)
|
||||
|
||||
const [customer, company] = await Promise.all([
|
||||
repo.findCustomerById(reservation.customerId),
|
||||
repo.findCompanyWithBrand(companyId),
|
||||
])
|
||||
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
|
||||
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
||||
const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
const reviewUrl = `${process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reviewToken}`
|
||||
|
||||
sendTransactionalEmail({
|
||||
to: customer.email,
|
||||
subject: reviewRequestEmail.subject(vehicleLabel, lang),
|
||||
html: reviewRequestEmail.html({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
|
||||
text: reviewRequestEmail.text({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
|
||||
}).catch(() => null)
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function closeReservation(id: string, companyId: string, closedBy: string) {
|
||||
const reservation = await repo.findForClose(id, companyId)
|
||||
const workflow = buildReservationWorkflow(reservation)
|
||||
|
||||
if (reservation.status !== 'COMPLETED') throw new AppError('Only completed reservations can be closed', 400, 'invalid_status')
|
||||
if (workflow.closed) throw new AppError('This reservation is already closed', 400, 'already_closed')
|
||||
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
extras.reservationClosedAt = new Date().toISOString()
|
||||
extras.reservationClosedBy = closedBy
|
||||
|
||||
const updated = await prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: { extras: extras as any },
|
||||
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
|
||||
})
|
||||
return serializeReservationForDashboard(updated)
|
||||
}
|
||||
|
||||
export async function cancelReservation(id: string, companyId: string, reason?: string) {
|
||||
const reservation = await repo.findByIdSimple(id, companyId)
|
||||
if (['COMPLETED', 'CANCELLED'].includes(reservation.status)) {
|
||||
throw new AppError('Reservation cannot be cancelled', 400, 'invalid_status')
|
||||
}
|
||||
const updated = await repo.updateById(id, { status: 'CANCELLED', cancelReason: reason ?? null, cancelledBy: 'COMPANY' })
|
||||
if (['CONFIRMED', 'ACTIVE'].includes(reservation.status)) {
|
||||
await repo.updateVehicleStatus(reservation.vehicleId, 'AVAILABLE')
|
||||
}
|
||||
return updated
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { serializeReservationForDashboard } from './reservation.presenter'
|
||||
|
||||
describe('serializeReservationForDashboard', () => {
|
||||
it('rewrites customer license image URLs to the protected route', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation-1',
|
||||
status: 'CONFIRMED',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
extras: {},
|
||||
customer: {
|
||||
id: 'customer-1',
|
||||
licenseImageUrl: 'http://localhost:4000/storage/companies/company-1/customers/customer-1/license.jpg',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.customer?.licenseImageUrl).toBe('http://localhost:3001/dashboard/api/v1/customers/customer-1/license-image')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { getProtectedCustomerLicenseImageUrl } from '../../lib/storage'
|
||||
|
||||
export function parseReservationExtras(extras: unknown): Record<string, unknown> {
|
||||
if (!extras || typeof extras !== 'object' || Array.isArray(extras)) return {}
|
||||
return { ...(extras as Record<string, unknown>) }
|
||||
}
|
||||
|
||||
export function normalizeOptionalString(value: string | null | undefined): string | null {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : value
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
export function buildReservationWorkflow(reservation: {
|
||||
status: string
|
||||
contractNumber: string | null
|
||||
invoiceNumber: string | null
|
||||
extras: unknown
|
||||
}) {
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
const closedAt = typeof extras.reservationClosedAt === 'string' ? extras.reservationClosedAt : null
|
||||
const contractGenerated = Boolean(reservation.contractNumber || reservation.invoiceNumber)
|
||||
const closed = Boolean(closedAt)
|
||||
|
||||
return {
|
||||
contractGenerated,
|
||||
closed,
|
||||
closedAt,
|
||||
closedBy: typeof extras.reservationClosedBy === 'string' ? extras.reservationClosedBy : null,
|
||||
coreEditable: !closed && !contractGenerated && ['DRAFT', 'CONFIRMED'].includes(reservation.status),
|
||||
returnEditable: !closed && reservation.status === 'COMPLETED',
|
||||
checkInInspectionEditable: !closed && ['CONFIRMED', 'ACTIVE'].includes(reservation.status),
|
||||
checkOutInspectionEditable: !closed && reservation.status === 'COMPLETED',
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeReservationForDashboard<T extends {
|
||||
extras: unknown
|
||||
status: string
|
||||
contractNumber: string | null
|
||||
invoiceNumber: string | null
|
||||
customer?: {
|
||||
id: string
|
||||
licenseImageUrl?: string | null
|
||||
} | null
|
||||
}>(reservation: T): T & { paymentMode: string | null; workflow: ReturnType<typeof buildReservationWorkflow> } {
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
const customer =
|
||||
reservation.customer
|
||||
? {
|
||||
...reservation.customer,
|
||||
licenseImageUrl: reservation.customer.licenseImageUrl
|
||||
? getProtectedCustomerLicenseImageUrl(reservation.customer.id)
|
||||
: null,
|
||||
}
|
||||
: reservation.customer
|
||||
|
||||
return {
|
||||
...reservation,
|
||||
...(customer !== undefined ? { customer } : {}),
|
||||
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
||||
workflow: buildReservationWorkflow(reservation),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export { applyPricingRules } from '../../services/pricingRuleService'
|
||||
|
||||
export function calculateUpdatedInsuranceCharge(
|
||||
chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL',
|
||||
chargeValue: number,
|
||||
totalDays: number,
|
||||
baseRentalAmount: number,
|
||||
): number {
|
||||
switch (chargeType) {
|
||||
case 'PER_DAY': return chargeValue * totalDays
|
||||
case 'PER_RENTAL': return chargeValue
|
||||
case 'PERCENTAGE_OF_RENTAL': return Math.round(baseRentalAmount * chargeValue / 100)
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateUpdatedAdditionalDriverCharge(
|
||||
chargeType: 'PER_DAY' | 'FLAT' | 'FREE',
|
||||
chargeValue: number,
|
||||
totalDays: number,
|
||||
): number {
|
||||
switch (chargeType) {
|
||||
case 'PER_DAY': return chargeValue * totalDays
|
||||
case 'FLAT': return chargeValue
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const FULL_INCLUDE = {
|
||||
vehicle: true,
|
||||
customer: true,
|
||||
insurances: true,
|
||||
additionalDrivers: true,
|
||||
rentalPayments: true,
|
||||
damageReports: true,
|
||||
}
|
||||
|
||||
export async function findMany(where: any, skip: number, take: number) {
|
||||
return Promise.all([
|
||||
prisma.reservation.findMany({
|
||||
where,
|
||||
include: { vehicle: true, customer: true },
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.reservation.count({ where }),
|
||||
])
|
||||
}
|
||||
|
||||
export async function findById(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({ where: { id, companyId }, include: FULL_INCLUDE })
|
||||
}
|
||||
|
||||
export async function findByIdSimple(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export async function findByIdWithRelations(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id, companyId },
|
||||
include: { insurances: true, additionalDrivers: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findByIdForCheckout(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id, companyId },
|
||||
include: { vehicle: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findForLicenseCheck(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id, companyId },
|
||||
include: { customer: true, additionalDrivers: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findForContract(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id, companyId },
|
||||
include: {
|
||||
vehicle: true,
|
||||
customer: true,
|
||||
insurances: true,
|
||||
additionalDrivers: true,
|
||||
rentalPayments: { orderBy: { createdAt: 'asc' } },
|
||||
inspections: { include: { damagePoints: true }, orderBy: { inspectedAt: 'asc' } },
|
||||
company: { include: { brand: true, contractSettings: true, accountingSettings: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findForBilling(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id, companyId },
|
||||
include: { vehicle: true, insurances: true, additionalDrivers: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findForClose(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export async function findForInspection(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id, companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findConflict(vehicleId: string, start: Date, end: Date, excludeId?: string) {
|
||||
return prisma.reservation.findFirst({
|
||||
where: {
|
||||
vehicleId,
|
||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: end },
|
||||
endDate: { gt: start },
|
||||
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function create(data: any) {
|
||||
return prisma.reservation.create({ data, include: { vehicle: true, customer: true } })
|
||||
}
|
||||
|
||||
export async function updateById(id: string, data: any) {
|
||||
return prisma.reservation.update({ where: { id }, data })
|
||||
}
|
||||
|
||||
export async function findActiveOffer(companyId: string, promoCode: string) {
|
||||
return prisma.offer.findFirst({
|
||||
where: { companyId, promoCode, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function incrementOfferRedemption(offerId: string) {
|
||||
return prisma.offer.update({ where: { id: offerId }, data: { redemptionCount: { increment: 1 } } })
|
||||
}
|
||||
|
||||
export async function findVehicle(vehicleId: string, companyId: string) {
|
||||
return prisma.vehicle.findFirstOrThrow({ where: { id: vehicleId, companyId } })
|
||||
}
|
||||
|
||||
export async function findCustomer(customerId: string, companyId: string) {
|
||||
return prisma.customer.findFirstOrThrow({ where: { id: customerId, companyId } })
|
||||
}
|
||||
|
||||
export async function findCustomerById(customerId: string) {
|
||||
return prisma.customer.findUniqueOrThrow({ where: { id: customerId } })
|
||||
}
|
||||
|
||||
export async function updateVehicleStatus(vehicleId: string, status: string, mileage?: number) {
|
||||
return prisma.vehicle.update({
|
||||
where: { id: vehicleId },
|
||||
data: { status: status as any, ...(mileage !== undefined ? { mileage } : {}) },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCompanyWithBrand(companyId: string) {
|
||||
return prisma.company.findUnique({
|
||||
where: { id: companyId },
|
||||
include: { brand: { select: { displayName: true, defaultLocale: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findBrandLocale(companyId: string) {
|
||||
return prisma.brandSettings.findUnique({ where: { companyId }, select: { defaultLocale: true } })
|
||||
}
|
||||
|
||||
export async function findInspections(reservationId: string, companyId: string) {
|
||||
return prisma.damageInspection.findMany({
|
||||
where: { reservationId, companyId },
|
||||
include: { damagePoints: true },
|
||||
orderBy: { inspectedAt: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findAdditionalDriver(driverId: string, reservationId: string, companyId: string) {
|
||||
return prisma.additionalDriver.findFirstOrThrow({ where: { id: driverId, reservationId, companyId } })
|
||||
}
|
||||
|
||||
export async function updateAdditionalDriver(id: string, data: any) {
|
||||
return prisma.additionalDriver.update({ where: { id }, data })
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import * as service from './reservation.service'
|
||||
import * as lifecycle from './reservation.lifecycle.service'
|
||||
import * as inspectionService from './reservation.inspection.service'
|
||||
import * as additionalDriverService from './reservation.additional-driver.service'
|
||||
import { getContract, getBilling } from './reservation.document.service'
|
||||
import {
|
||||
createSchema, updateSchema, listQuerySchema,
|
||||
checkinSchema, checkoutSchema, cancelSchema, approvalSchema,
|
||||
inspectionSchema, inspectionTypeParam,
|
||||
idParamSchema, driverParamSchema, inspectionParamSchema,
|
||||
} from './reservation.schemas'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const query = parseQuery(listQuerySchema, req)
|
||||
const result = await service.listReservations(req.companyId, query)
|
||||
res.json(result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(createSchema, req)
|
||||
const reservation = await service.createReservation(req.companyId, body)
|
||||
created(res, reservation)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const reservation = await service.getReservation(id, req.companyId)
|
||||
ok(res, reservation)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(updateSchema, req)
|
||||
const reservation = await service.updateReservation(id, req.companyId, body)
|
||||
ok(res, reservation)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id/contract', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const contract = await getContract(id, req.companyId)
|
||||
ok(res, contract)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id/billing', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const billing = await getBilling(id, req.companyId)
|
||||
ok(res, billing)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/confirm', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const updated = await lifecycle.confirmReservation(id, req.companyId)
|
||||
ok(res, updated)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/checkin', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { mileage } = parseBody(checkinSchema, req)
|
||||
const updated = await lifecycle.checkinReservation(id, req.companyId, mileage)
|
||||
ok(res, updated)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/checkout', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { mileage } = parseBody(checkoutSchema, req)
|
||||
const updated = await lifecycle.checkoutReservation(id, req.companyId, mileage)
|
||||
ok(res, updated)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/close', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const closedBy = `${req.employee.firstName} ${req.employee.lastName}`
|
||||
const reservation = await lifecycle.closeReservation(id, req.companyId, closedBy)
|
||||
ok(res, reservation)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/cancel', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { reason } = parseBody(cancelSchema, req)
|
||||
const updated = await lifecycle.cancelReservation(id, req.companyId, reason)
|
||||
ok(res, updated)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id/inspections', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const inspections = await inspectionService.getInspections(id, req.companyId)
|
||||
ok(res, inspections)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:id/inspections/:type', async (req, res, next) => {
|
||||
try {
|
||||
const { id, type: rawType } = parseParams(inspectionParamSchema, req)
|
||||
const type = inspectionTypeParam.parse(rawType.toUpperCase())
|
||||
const body = parseBody(inspectionSchema, req)
|
||||
const inspectedBy = `${req.employee.firstName} ${req.employee.lastName}`
|
||||
const result = await inspectionService.upsertInspection(id, req.companyId, type, body, inspectedBy)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next) => {
|
||||
try {
|
||||
const { id, driverId } = parseParams(driverParamSchema, req)
|
||||
const { approved, note } = parseBody(approvalSchema, req)
|
||||
const approvedBy = `${req.employee.firstName} ${req.employee.lastName}`
|
||||
const updated = await additionalDriverService.approveAdditionalDriver(id, driverId, req.companyId, approved, note, approvedBy)
|
||||
ok(res, updated)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
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),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
})
|
||||
|
||||
export const inspectionSchema = z.object({
|
||||
mileage: z.number().int().optional(),
|
||||
fuelLevel: z.enum(['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']),
|
||||
fuelCharge: z.number().int().min(0).optional(),
|
||||
generalCondition: z.string().optional(),
|
||||
employeeNotes: z.string().optional(),
|
||||
customerAgreed: z.boolean().default(false),
|
||||
damagePoints: z.array(z.object({
|
||||
viewType: z.enum(['TOP', 'FRONT', 'REAR', 'LEFT', 'RIGHT']),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
damageType: z.enum(['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']),
|
||||
severity: z.enum(['MINOR', 'MODERATE', 'MAJOR']).default('MINOR'),
|
||||
description: z.string().optional(),
|
||||
isPreExisting: z.boolean().default(false),
|
||||
})).default([]),
|
||||
})
|
||||
|
||||
export const createSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
customerId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
pickupLocation: z.string().optional(),
|
||||
returnLocation: z.string().optional(),
|
||||
offerId: z.string().cuid().optional(),
|
||||
promoCodeUsed: z.string().optional(),
|
||||
depositAmount: z.number().int().min(0).default(0),
|
||||
paymentMode: z.string().max(50).optional(),
|
||||
notes: z.string().optional(),
|
||||
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
||||
additionalDrivers: z.array(additionalDriverSchema).default([]),
|
||||
})
|
||||
|
||||
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(),
|
||||
depositAmount: z.number().int().min(0).optional(),
|
||||
notes: z.string().optional().nullable(),
|
||||
paymentMode: z.string().max(50).optional().nullable(),
|
||||
damageChargeAmount: z.number().int().min(0).optional(),
|
||||
damageChargeNote: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
export const listQuerySchema = z.object({
|
||||
status: z.string().optional(),
|
||||
vehicleId: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const checkinSchema = z.object({ mileage: z.number().int().optional() })
|
||||
export const checkoutSchema = z.object({ mileage: z.number().int().optional() })
|
||||
export const cancelSchema = z.object({ reason: z.string().optional() })
|
||||
export const approvalSchema = z.object({ approved: z.boolean(), note: z.string().optional() })
|
||||
export const inspectionTypeParam = z.enum(['CHECKIN', 'CHECKOUT'])
|
||||
|
||||
export const idParamSchema = z.object({ id: z.string() })
|
||||
export const driverParamSchema = z.object({ id: z.string(), driverId: z.string() })
|
||||
export const inspectionParamSchema = z.object({ id: z.string(), type: z.string() })
|
||||
@@ -0,0 +1,216 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { validateAndFlagLicense } from '../../services/licenseValidationService'
|
||||
import { applyPricingRules, calculateUpdatedInsuranceCharge, calculateUpdatedAdditionalDriverCharge } from './reservation.pricing.service'
|
||||
import { applyInsurancesToReservation } from './reservation.insurance.service'
|
||||
import { applyAdditionalDriversToReservation } from './reservation.additional-driver.service'
|
||||
import { parseReservationExtras, normalizeOptionalString, serializeReservationForDashboard, buildReservationWorkflow } from './reservation.presenter'
|
||||
import * as repo from './reservation.repo'
|
||||
|
||||
export async function listReservations(companyId: string, query: {
|
||||
status?: string; vehicleId?: string; source?: string
|
||||
startDate?: string; endDate?: string; page?: number; pageSize?: number
|
||||
}) {
|
||||
const page = query.page ?? 1
|
||||
const pageSize = query.pageSize ?? 20
|
||||
const where: any = { companyId }
|
||||
if (query.status) where.status = query.status
|
||||
if (query.vehicleId) where.vehicleId = query.vehicleId
|
||||
if (query.source) where.source = query.source
|
||||
if (query.startDate) where.startDate = { gte: new Date(query.startDate) }
|
||||
if (query.endDate) where.endDate = { lte: new Date(query.endDate) }
|
||||
|
||||
const [reservations, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize)
|
||||
return {
|
||||
data: reservations.map(serializeReservationForDashboard),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReservation(id: string, companyId: string) {
|
||||
const reservation = await repo.findById(id, companyId)
|
||||
return serializeReservationForDashboard(reservation)
|
||||
}
|
||||
|
||||
export async function createReservation(companyId: string, body: {
|
||||
vehicleId: string; customerId: string; startDate: string; endDate: string
|
||||
pickupLocation?: string; returnLocation?: string; offerId?: string
|
||||
promoCodeUsed?: string; depositAmount?: number; paymentMode?: string; notes?: string
|
||||
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
|
||||
}) {
|
||||
const vehicle = await repo.findVehicle(body.vehicleId, companyId)
|
||||
await repo.findCustomer(body.customerId, companyId)
|
||||
|
||||
const start = new Date(body.startDate)
|
||||
const end = new Date(body.endDate)
|
||||
const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
const conflict = await repo.findConflict(body.vehicleId, start, end)
|
||||
if (conflict) throw new AppError('Vehicle is not available for the selected dates', 409, 'vehicle_unavailable')
|
||||
|
||||
let discountAmount = 0
|
||||
let offerId: string | null = body.offerId ?? null
|
||||
|
||||
if (body.promoCodeUsed) {
|
||||
const offer = await repo.findActiveOffer(companyId, body.promoCodeUsed)
|
||||
if (offer) {
|
||||
offerId = offer.id
|
||||
const base = vehicle.dailyRate * totalDays
|
||||
if (offer.type === 'PERCENTAGE') discountAmount = Math.round(base * offer.discountValue / 100)
|
||||
else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue
|
||||
else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue
|
||||
await repo.incrementOfferRedemption(offer.id)
|
||||
}
|
||||
}
|
||||
|
||||
const depositAmount = body.depositAmount ?? 0
|
||||
const additionalDriversList = body.additionalDrivers ?? []
|
||||
const baseAmount = vehicle.dailyRate * totalDays
|
||||
const { applied, total: pricingTotal } = await applyPricingRules(companyId, body.customerId, additionalDriversList, vehicle.dailyRate, totalDays)
|
||||
const totalAmount = baseAmount - discountAmount + pricingTotal + depositAmount
|
||||
|
||||
const reservation = await repo.create({
|
||||
companyId,
|
||||
vehicleId: body.vehicleId,
|
||||
customerId: body.customerId,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
pickupLocation: body.pickupLocation ?? null,
|
||||
returnLocation: body.returnLocation ?? null,
|
||||
offerId,
|
||||
promoCodeUsed: body.promoCodeUsed ?? null,
|
||||
source: 'DASHBOARD',
|
||||
dailyRate: vehicle.dailyRate,
|
||||
discountAmount,
|
||||
totalDays,
|
||||
totalAmount,
|
||||
depositAmount,
|
||||
notes: body.notes ?? null,
|
||||
extras: body.paymentMode ? { paymentMode: body.paymentMode } : undefined,
|
||||
pricingRulesApplied: applied,
|
||||
pricingRulesTotal: pricingTotal,
|
||||
})
|
||||
|
||||
const insuranceIds = body.selectedInsurancePolicyIds ?? []
|
||||
const additionalDrivers = body.additionalDrivers ?? []
|
||||
if (insuranceIds.length > 0) {
|
||||
await applyInsurancesToReservation(reservation.id, companyId, insuranceIds, totalDays, baseAmount)
|
||||
}
|
||||
if (additionalDrivers.length > 0) {
|
||||
await applyAdditionalDriversToReservation(reservation.id, companyId, additionalDrivers, totalDays)
|
||||
}
|
||||
|
||||
await validateAndFlagLicense(body.customerId).catch(() => null)
|
||||
return reservation
|
||||
}
|
||||
|
||||
export async function updateReservation(id: string, companyId: string, body: {
|
||||
startDate?: string; endDate?: string; pickupLocation?: string | null
|
||||
returnLocation?: string | null; depositAmount?: number; notes?: string | null
|
||||
paymentMode?: string | null; damageChargeAmount?: number; damageChargeNote?: string | null
|
||||
}) {
|
||||
const reservation = await repo.findByIdWithRelations(id, companyId)
|
||||
|
||||
if (reservation.status === 'CANCELLED' || reservation.status === 'NO_SHOW') {
|
||||
throw new AppError('This reservation can no longer be edited', 400, 'invalid_status')
|
||||
}
|
||||
|
||||
const workflow = buildReservationWorkflow(reservation)
|
||||
const requested = Object.keys(body).filter((k) => body[k as keyof typeof body] !== undefined)
|
||||
const bookingFields = ['startDate', 'endDate', 'pickupLocation', 'returnLocation', 'depositAmount', 'notes', 'paymentMode']
|
||||
const returnFields = ['returnLocation', 'damageChargeAmount', 'damageChargeNote']
|
||||
|
||||
if (!workflow.coreEditable && !workflow.returnEditable) {
|
||||
throw new AppError('This reservation is locked for editing', 400, 'reservation_locked')
|
||||
}
|
||||
|
||||
if (workflow.coreEditable) {
|
||||
const invalid = requested.filter((f) => !bookingFields.includes(f))
|
||||
if (invalid.length > 0) throw new AppError('Only booking details can be edited at this stage', 400, 'invalid_fields')
|
||||
|
||||
const nextStart = body.startDate ? new Date(body.startDate) : reservation.startDate
|
||||
const nextEnd = body.endDate ? new Date(body.endDate) : reservation.endDate
|
||||
const totalDays = Math.ceil((nextEnd.getTime() - nextStart.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (nextEnd <= nextStart || totalDays <= 0) throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
|
||||
if (body.startDate || body.endDate) {
|
||||
const conflict = await repo.findConflict(reservation.vehicleId, nextStart, nextEnd, reservation.id)
|
||||
if (conflict) throw new AppError('Vehicle is not available for the selected dates', 409, 'vehicle_unavailable')
|
||||
}
|
||||
|
||||
const baseAmount = reservation.dailyRate * totalDays
|
||||
const { applied, total: pricingRulesTotal } = await applyPricingRules(
|
||||
companyId,
|
||||
reservation.customerId,
|
||||
reservation.additionalDrivers.map((d: any) => ({ dateOfBirth: d.dateOfBirth, licenseIssuedAt: d.licenseIssuedAt })),
|
||||
reservation.dailyRate,
|
||||
totalDays,
|
||||
)
|
||||
|
||||
const insuranceUpdates = reservation.insurances.map((ins: any) => ({
|
||||
id: ins.id,
|
||||
totalCharge: calculateUpdatedInsuranceCharge(ins.chargeType, ins.chargeValue, totalDays, baseAmount),
|
||||
}))
|
||||
const driverUpdates = reservation.additionalDrivers.map((d: any) => ({
|
||||
id: d.id,
|
||||
totalCharge: calculateUpdatedAdditionalDriverCharge(d.chargeType, d.chargeValue, totalDays),
|
||||
}))
|
||||
|
||||
const insuranceTotal = insuranceUpdates.reduce((s, i) => s + i.totalCharge, 0)
|
||||
const additionalDriverTotal = driverUpdates.reduce((s, d) => s + d.totalCharge, 0)
|
||||
const depositAmount = body.depositAmount ?? reservation.depositAmount
|
||||
const totalAmount = baseAmount - reservation.discountAmount + pricingRulesTotal + insuranceTotal + additionalDriverTotal + depositAmount
|
||||
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
if (body.paymentMode !== undefined) {
|
||||
const next = normalizeOptionalString(body.paymentMode)
|
||||
if (next) extras.paymentMode = next
|
||||
else delete extras.paymentMode
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: {
|
||||
startDate: nextStart,
|
||||
endDate: nextEnd,
|
||||
totalDays,
|
||||
pickupLocation: body.pickupLocation !== undefined ? normalizeOptionalString(body.pickupLocation) : reservation.pickupLocation,
|
||||
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
|
||||
depositAmount,
|
||||
notes: body.notes !== undefined ? normalizeOptionalString(body.notes) : reservation.notes,
|
||||
pricingRulesApplied: applied,
|
||||
pricingRulesTotal,
|
||||
insuranceTotal,
|
||||
additionalDriverTotal,
|
||||
totalAmount,
|
||||
extras: (Object.keys(extras).length > 0 ? extras : {}) as any,
|
||||
},
|
||||
}),
|
||||
...insuranceUpdates.map((ins) =>
|
||||
prisma.reservationInsurance.update({ where: { id: ins.id }, data: { totalCharge: ins.totalCharge } })
|
||||
),
|
||||
...driverUpdates.map((d) =>
|
||||
prisma.additionalDriver.update({ where: { id: d.id }, data: { totalCharge: d.totalCharge } })
|
||||
),
|
||||
])
|
||||
} else {
|
||||
const invalid = requested.filter((f) => !returnFields.includes(f))
|
||||
if (invalid.length > 0) throw new AppError('Only return details can be edited after vehicle return', 400, 'invalid_fields')
|
||||
|
||||
await prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: {
|
||||
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
|
||||
damageChargeAmount: body.damageChargeAmount !== undefined ? body.damageChargeAmount : reservation.damageChargeAmount,
|
||||
damageChargeNote: body.damageChargeNote !== undefined ? normalizeOptionalString(body.damageChargeNote) : reservation.damageChargeNote,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return repo.findById(id, companyId).then(serializeReservationForDashboard)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { AppError } from '../../http/errors'
|
||||
|
||||
// ── repo mock ──────────────────────────────────────────────────────────────
|
||||
vi.mock('./reservation.repo', () => ({
|
||||
findMany: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByIdSimple: vi.fn(),
|
||||
findByIdWithRelations: vi.fn(),
|
||||
findByIdForCheckout: vi.fn(),
|
||||
findForLicenseCheck: vi.fn(),
|
||||
findForClose: vi.fn(),
|
||||
findForInspection: vi.fn(),
|
||||
findConflict: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
findActiveOffer: vi.fn(),
|
||||
incrementOfferRedemption: vi.fn(),
|
||||
findVehicle: vi.fn(),
|
||||
findCustomer: vi.fn(),
|
||||
findCustomerById: vi.fn(),
|
||||
updateVehicleStatus: vi.fn(),
|
||||
findCompanyWithBrand: vi.fn(),
|
||||
findBrandLocale: vi.fn(),
|
||||
findInspections: vi.fn(),
|
||||
findAdditionalDriver: vi.fn(),
|
||||
updateAdditionalDriver: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./reservation.pricing.service', () => ({
|
||||
applyPricingRules: vi.fn(),
|
||||
calculateUpdatedInsuranceCharge: vi.fn(),
|
||||
calculateUpdatedAdditionalDriverCharge: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./reservation.insurance.service', () => ({
|
||||
applyInsurancesToReservation: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./reservation.additional-driver.service', () => ({
|
||||
applyAdditionalDriversToReservation: vi.fn(),
|
||||
approveAdditionalDriver: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/licenseValidationService', () => ({
|
||||
validateAndFlagLicense: vi.fn().mockResolvedValue(undefined),
|
||||
validateLicense: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/notificationService', () => ({
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
reservation: { update: vi.fn() },
|
||||
damageInspection: { upsert: vi.fn() },
|
||||
damageReport: { upsert: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./reservation.presenter', () => ({
|
||||
serializeReservationForDashboard: vi.fn((r) => r),
|
||||
parseReservationExtras: vi.fn(() => ({})),
|
||||
buildReservationWorkflow: vi.fn(),
|
||||
normalizeOptionalString: vi.fn((s) => s),
|
||||
}))
|
||||
|
||||
import * as repo from './reservation.repo'
|
||||
import * as pricingService from './reservation.pricing.service'
|
||||
import * as insuranceService from './reservation.insurance.service'
|
||||
import * as additionalDriverService from './reservation.additional-driver.service'
|
||||
import { validateLicense } from '../../services/licenseValidationService'
|
||||
import { buildReservationWorkflow } from './reservation.presenter'
|
||||
import { createReservation } from './reservation.service'
|
||||
import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service'
|
||||
import { approveAdditionalDriver } from './reservation.additional-driver.service'
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const RES_ID = 'reservation-1'
|
||||
|
||||
function makeVehicle(overrides: object = {}) {
|
||||
return { id: 'vehicle-1', dailyRate: 100, status: 'AVAILABLE', isPublished: true, mileage: 50000, ...overrides }
|
||||
}
|
||||
|
||||
function makeReservation(overrides: object = {}) {
|
||||
return {
|
||||
id: RES_ID,
|
||||
companyId: COMPANY,
|
||||
vehicleId: 'vehicle-1',
|
||||
customerId: 'customer-1',
|
||||
status: 'DRAFT',
|
||||
startDate: new Date('2025-06-01'),
|
||||
endDate: new Date('2025-06-04'),
|
||||
totalDays: 3,
|
||||
dailyRate: 100,
|
||||
depositAmount: 0,
|
||||
discountAmount: 0,
|
||||
totalAmount: 300,
|
||||
additionalDrivers: [],
|
||||
insurances: [],
|
||||
customer: { firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', licenseExpiry: null, licenseValidationStatus: 'APPROVED' },
|
||||
vehicle: { year: 2022, make: 'Toyota', model: 'Camry' },
|
||||
extras: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('createReservation', () => {
|
||||
it('creates a reservation and returns it', async () => {
|
||||
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle())
|
||||
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
|
||||
vi.mocked(repo.findConflict).mockResolvedValue(null)
|
||||
vi.mocked(repo.findActiveOffer).mockResolvedValue(null)
|
||||
vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
|
||||
vi.mocked(repo.create).mockResolvedValue(makeReservation() as any)
|
||||
|
||||
const result = await createReservation(COMPANY, {
|
||||
vehicleId: 'vehicle-1',
|
||||
customerId: 'customer-1',
|
||||
startDate: '2025-06-01T00:00:00.000Z',
|
||||
endDate: '2025-06-04T00:00:00.000Z',
|
||||
depositAmount: 0,
|
||||
selectedInsurancePolicyIds: [],
|
||||
additionalDrivers: [],
|
||||
})
|
||||
|
||||
expect(repo.create).toHaveBeenCalledOnce()
|
||||
expect(result.id).toBe(RES_ID)
|
||||
})
|
||||
|
||||
it('throws conflict error when vehicle is unavailable', async () => {
|
||||
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle())
|
||||
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
|
||||
vi.mocked(repo.findConflict).mockResolvedValue({ id: 'other-res' } as any)
|
||||
|
||||
await expect(
|
||||
createReservation(COMPANY, {
|
||||
vehicleId: 'vehicle-1',
|
||||
customerId: 'customer-1',
|
||||
startDate: '2025-06-01T00:00:00.000Z',
|
||||
endDate: '2025-06-04T00:00:00.000Z',
|
||||
selectedInsurancePolicyIds: [],
|
||||
additionalDrivers: [],
|
||||
}),
|
||||
).rejects.toThrow('Vehicle is not available for the selected dates')
|
||||
|
||||
expect(repo.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('confirmReservation', () => {
|
||||
it('confirms a DRAFT reservation', async () => {
|
||||
const res = makeReservation({ status: 'DRAFT' })
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(res as any)
|
||||
vi.mocked(repo.findForLicenseCheck).mockResolvedValue({
|
||||
...res,
|
||||
customer: { ...res.customer, licenseExpiry: null, licenseValidationStatus: 'APPROVED' },
|
||||
additionalDrivers: [],
|
||||
} as any)
|
||||
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'CONFIRMED' } as any)
|
||||
vi.mocked(repo.findCustomerById).mockResolvedValue({ email: 'ali@test.com', firstName: 'Ali' } as any)
|
||||
vi.mocked(repo.findBrandLocale).mockResolvedValue({ defaultLocale: 'fr' } as any)
|
||||
|
||||
const result = await confirmReservation(RES_ID, COMPANY)
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith(RES_ID, { status: 'CONFIRMED' })
|
||||
expect((result as any).status).toBe('CONFIRMED')
|
||||
})
|
||||
|
||||
it('rejects non-DRAFT reservation', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(makeReservation({ status: 'CONFIRMED' }) as any)
|
||||
|
||||
await expect(confirmReservation(RES_ID, COMPANY)).rejects.toThrow('Only DRAFT reservations can be confirmed')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('checkinReservation', () => {
|
||||
it('transitions CONFIRMED → ACTIVE and marks vehicle RENTED', async () => {
|
||||
const res = makeReservation({ status: 'CONFIRMED' })
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(res as any)
|
||||
vi.mocked(repo.findForLicenseCheck).mockResolvedValue({
|
||||
...res,
|
||||
customer: { ...res.customer, licenseExpiry: null, licenseValidationStatus: 'APPROVED' },
|
||||
additionalDrivers: [],
|
||||
} as any)
|
||||
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'ACTIVE' } as any)
|
||||
vi.mocked(repo.updateVehicleStatus).mockResolvedValue(undefined as any)
|
||||
|
||||
await checkinReservation(RES_ID, COMPANY, 55000)
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith(RES_ID, expect.objectContaining({ status: 'ACTIVE', checkInMileage: 55000 }))
|
||||
expect(repo.updateVehicleStatus).toHaveBeenCalledWith('vehicle-1', 'RENTED')
|
||||
})
|
||||
|
||||
it('rejects non-CONFIRMED reservation', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue(makeReservation({ status: 'ACTIVE' }) as any)
|
||||
|
||||
await expect(checkinReservation(RES_ID, COMPANY)).rejects.toThrow('Only CONFIRMED reservations can be checked in')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('checkoutReservation', () => {
|
||||
it('transitions ACTIVE → COMPLETED and marks vehicle AVAILABLE', async () => {
|
||||
const res = makeReservation({ status: 'ACTIVE' })
|
||||
vi.mocked(repo.findByIdForCheckout).mockResolvedValue(res as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'COMPLETED' } as any)
|
||||
vi.mocked(repo.updateVehicleStatus).mockResolvedValue(undefined as any)
|
||||
vi.mocked(repo.findCustomerById).mockResolvedValue({ email: 'ali@test.com', firstName: 'Ali' } as any)
|
||||
vi.mocked(repo.findCompanyWithBrand).mockResolvedValue({ name: 'TestCo', brand: { displayName: 'TestCo', defaultLocale: 'fr' } } as any)
|
||||
|
||||
await checkoutReservation(RES_ID, COMPANY, 58000)
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith(RES_ID, expect.objectContaining({ status: 'COMPLETED', checkOutMileage: 58000 }))
|
||||
expect(repo.updateVehicleStatus).toHaveBeenCalledWith('vehicle-1', 'AVAILABLE', 58000)
|
||||
})
|
||||
|
||||
it('rejects non-ACTIVE reservation', async () => {
|
||||
vi.mocked(repo.findByIdForCheckout).mockResolvedValue(makeReservation({ status: 'CONFIRMED' }) as any)
|
||||
|
||||
await expect(checkoutReservation(RES_ID, COMPANY)).rejects.toThrow('Only ACTIVE reservations can be checked out')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('closeReservation', () => {
|
||||
it('closes a COMPLETED reservation', async () => {
|
||||
const res = makeReservation({ status: 'COMPLETED' })
|
||||
vi.mocked(repo.findForClose).mockResolvedValue(res as any)
|
||||
vi.mocked(buildReservationWorkflow).mockReturnValue({ closed: false } as any)
|
||||
const { prisma } = await import('../../lib/prisma')
|
||||
vi.mocked(prisma.reservation.update).mockResolvedValue({ ...res, extras: { reservationClosedBy: 'Admin User' } } as any)
|
||||
|
||||
await closeReservation(RES_ID, COMPANY, 'Admin User')
|
||||
|
||||
expect(prisma.reservation.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: RES_ID } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects already closed reservation', async () => {
|
||||
vi.mocked(repo.findForClose).mockResolvedValue(makeReservation({ status: 'COMPLETED' }) as any)
|
||||
vi.mocked(buildReservationWorkflow).mockReturnValue({ closed: true } as any)
|
||||
|
||||
await expect(closeReservation(RES_ID, COMPANY, 'Admin')).rejects.toThrow('already closed')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('approveAdditionalDriver', () => {
|
||||
it('calls through to the additional driver service', async () => {
|
||||
vi.mocked(additionalDriverService.approveAdditionalDriver).mockResolvedValue({ id: 'driver-1', approvedAt: new Date() } as any)
|
||||
|
||||
const result = await approveAdditionalDriver(RES_ID, 'driver-1', COMPANY, true, 'Looks good', 'Manager')
|
||||
|
||||
expect(additionalDriverService.approveAdditionalDriver).toHaveBeenCalledWith(RES_ID, 'driver-1', COMPANY, true, 'Looks good', 'Manager')
|
||||
expect((result as any).id).toBe('driver-1')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('pricing rule application', () => {
|
||||
it('applies pricing rules and adjusts totalAmount', async () => {
|
||||
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle({ dailyRate: 100 }))
|
||||
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
|
||||
vi.mocked(repo.findConflict).mockResolvedValue(null)
|
||||
vi.mocked(repo.findActiveOffer).mockResolvedValue(null)
|
||||
vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [{ id: 'rule-1' }], total: 50 })
|
||||
vi.mocked(repo.create).mockResolvedValue(makeReservation({ totalAmount: 350 }) as any)
|
||||
|
||||
await createReservation(COMPANY, {
|
||||
vehicleId: 'vehicle-1',
|
||||
customerId: 'customer-1',
|
||||
startDate: '2025-06-01T00:00:00.000Z',
|
||||
endDate: '2025-06-04T00:00:00.000Z',
|
||||
selectedInsurancePolicyIds: [],
|
||||
additionalDrivers: [],
|
||||
})
|
||||
|
||||
expect(pricingService.applyPricingRules).toHaveBeenCalledOnce()
|
||||
const createCall = vi.mocked(repo.create).mock.calls[0][0] as any
|
||||
expect(createCall.pricingRulesApplied).toEqual([{ id: 'rule-1' }])
|
||||
expect(createCall.totalAmount).toBe(350)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('insurance application', () => {
|
||||
it('calls applyInsurancesToReservation when insurance IDs are provided', async () => {
|
||||
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle())
|
||||
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
|
||||
vi.mocked(repo.findConflict).mockResolvedValue(null)
|
||||
vi.mocked(repo.findActiveOffer).mockResolvedValue(null)
|
||||
vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
|
||||
vi.mocked(repo.create).mockResolvedValue(makeReservation() as any)
|
||||
vi.mocked(insuranceService.applyInsurancesToReservation).mockResolvedValue(undefined as any)
|
||||
|
||||
await createReservation(COMPANY, {
|
||||
vehicleId: 'vehicle-1',
|
||||
customerId: 'customer-1',
|
||||
startDate: '2025-06-01T00:00:00.000Z',
|
||||
endDate: '2025-06-04T00:00:00.000Z',
|
||||
selectedInsurancePolicyIds: ['policy-1', 'policy-2'],
|
||||
additionalDrivers: [],
|
||||
})
|
||||
|
||||
expect(insuranceService.applyInsurancesToReservation).toHaveBeenCalledWith(
|
||||
RES_ID, COMPANY, ['policy-1', 'policy-2'], 3, 300,
|
||||
)
|
||||
})
|
||||
|
||||
it('skips insurance call when no IDs provided', async () => {
|
||||
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle())
|
||||
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
|
||||
vi.mocked(repo.findConflict).mockResolvedValue(null)
|
||||
vi.mocked(repo.findActiveOffer).mockResolvedValue(null)
|
||||
vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
|
||||
vi.mocked(repo.create).mockResolvedValue(makeReservation() as any)
|
||||
|
||||
await createReservation(COMPANY, {
|
||||
vehicleId: 'vehicle-1',
|
||||
customerId: 'customer-1',
|
||||
startDate: '2025-06-01T00:00:00.000Z',
|
||||
endDate: '2025-06-04T00:00:00.000Z',
|
||||
selectedInsurancePolicyIds: [],
|
||||
additionalDrivers: [],
|
||||
})
|
||||
|
||||
expect(insuranceService.applyInsurancesToReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
export function presentBrand(company: {
|
||||
id: string; slug: string; name: string; phone: string | null; brand: any
|
||||
}) {
|
||||
const brand = company.brand
|
||||
return {
|
||||
company: { id: company.id, slug: company.slug, name: company.name, phone: company.phone },
|
||||
brand: brand ? {
|
||||
displayName: brand.displayName,
|
||||
tagline: brand.tagline,
|
||||
logoUrl: brand.logoUrl,
|
||||
faviconUrl: brand.faviconUrl,
|
||||
heroImageUrl: brand.heroImageUrl,
|
||||
primaryColor: brand.primaryColor,
|
||||
accentColor: brand.accentColor,
|
||||
subdomain: brand.subdomain,
|
||||
customDomain: brand.customDomain,
|
||||
customDomainVerified: brand.customDomainVerified,
|
||||
customDomainAddedAt: brand.customDomainAddedAt,
|
||||
publicEmail: brand.publicEmail,
|
||||
publicPhone: brand.publicPhone,
|
||||
publicAddress: brand.publicAddress,
|
||||
publicCity: brand.publicCity,
|
||||
publicCountry: brand.publicCountry,
|
||||
websiteUrl: brand.websiteUrl,
|
||||
whatsappNumber: brand.whatsappNumber,
|
||||
facebookUrl: brand.facebookUrl,
|
||||
instagramUrl: brand.instagramUrl,
|
||||
defaultLocale: brand.defaultLocale,
|
||||
defaultCurrency: brand.defaultCurrency,
|
||||
paypalEmail: brand.paypalEmail,
|
||||
paypalMerchantId: brand.paypalMerchantId,
|
||||
paymentMethodsEnabled: brand.paymentMethodsEnabled,
|
||||
isListedOnMarketplace: brand.isListedOnMarketplace,
|
||||
marketplaceRating: brand.marketplaceRating,
|
||||
homePageConfig: brand.homePageConfig,
|
||||
menuConfig: brand.menuConfig,
|
||||
} : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export async function findCompanyBySlug(slug: string) {
|
||||
return prisma.company.findFirstOrThrow({
|
||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE', 'SUSPENDED'] } },
|
||||
include: { brand: true, contractSettings: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findPublishedVehicles(companyId: string) {
|
||||
return prisma.vehicle.findMany({
|
||||
where: { companyId, isPublished: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleById(vehicleId: string, companyId: string) {
|
||||
return prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: vehicleId, companyId, isPublished: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findActiveOffers(companyId: string) {
|
||||
return prisma.offer.findMany({
|
||||
where: { companyId, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function findInsurancePolicies(companyId: string) {
|
||||
return prisma.insurancePolicy.findMany({
|
||||
where: { companyId, isActive: true },
|
||||
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function findOfferByPromoCode(companyId: string, code: string) {
|
||||
return prisma.offer.findFirst({
|
||||
where: { companyId, promoCode: code, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function upsertCustomer(companyId: string, data: {
|
||||
email: string; firstName: string; lastName: string; phone?: string | null
|
||||
driverLicense?: string | null; dateOfBirth?: string | null; licenseExpiry?: string | null
|
||||
licenseIssuedAt?: string | null; nationality?: string | null
|
||||
}) {
|
||||
const parsed = {
|
||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
|
||||
licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : null,
|
||||
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : null,
|
||||
}
|
||||
return prisma.customer.upsert({
|
||||
where: { companyId_email: { companyId, email: data.email } },
|
||||
create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed },
|
||||
update: { firstName: data.firstName, lastName: data.lastName, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createReservation(data: object) {
|
||||
return prisma.reservation.create({ data: data as any })
|
||||
}
|
||||
|
||||
export async function findReservationWithDetails(reservationId: string) {
|
||||
return prisma.reservation.findUniqueOrThrow({
|
||||
where: { id: reservationId },
|
||||
include: { insurances: true, additionalDrivers: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findBooking(reservationId: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id: reservationId, companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findReservationForPayment(reservationId: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id: reservationId, companyId },
|
||||
include: { vehicle: true, customer: true, additionalDrivers: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createRentalPayment(data: {
|
||||
companyId: string; reservationId: string; amount: number; currency: string
|
||||
paymentProvider: string; amanpayTransactionId?: string | null; paypalCaptureId?: string | null
|
||||
}) {
|
||||
return prisma.rentalPayment.create({
|
||||
data: { ...data, paymentProvider: data.paymentProvider as any, status: 'PENDING', type: 'CHARGE' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findPaymentByPaypalOrderId(paypalOrderId: string, companyId: string) {
|
||||
return prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId } })
|
||||
}
|
||||
|
||||
export async function capturePaypalPayment(paymentId: string, captureId: string, reservationId: string, amount: number) {
|
||||
await prisma.rentalPayment.update({ where: { id: paymentId }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } })
|
||||
await prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Router } from 'express'
|
||||
import { parseBody, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
|
||||
import * as service from './site.service'
|
||||
import {
|
||||
slugParamSchema, bookingParamSchema,
|
||||
availabilitySchema, validateCodeSchema, bookSchema, paySchema, capturePaypalSchema, contactSchema,
|
||||
} from './site.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/platform/homepage', async (_req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getPlatformHomepage())
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:slug/brand', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getBrand(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.json({ data: { company: { id: 'demo', slug: req.params.slug, name: 'Demo Company', phone: null }, brand: null } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getPublicVehicles(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(bookingParamSchema, req)
|
||||
ok(res, await service.getVehicleDetail(slug, id))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getOffers(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/booking-options', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getBookingOptions(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: { insurancePolicies: [], contractSettings: null } })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/availability', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
const { vehicleId, startDate, endDate } = parseBody(availabilitySchema, req)
|
||||
ok(res, await service.checkAvailability(slug, vehicleId, 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.post('/:slug/book/validate-code', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
const { code } = parseBody(validateCodeSchema, req)
|
||||
ok(res, await service.validatePromoCode(slug, code))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Promo code validation is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/book', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
const body = parseBody(bookSchema, req)
|
||||
const result = await service.createBooking(slug, body)
|
||||
created(res, result)
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Booking is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/booking/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(bookingParamSchema, req)
|
||||
ok(res, await service.getBooking(slug, id))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Booking details are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/booking/:id/pay', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(bookingParamSchema, req)
|
||||
const body = parseBody(paySchema, req)
|
||||
ok(res, await service.initPayment(slug, id, body))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Payment initiation is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/booking/:id/capture-paypal', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
const { paypalOrderId } = parseBody(capturePaypalSchema, req)
|
||||
ok(res, await service.capturePaypal(slug, paypalOrderId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:slug/contact', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
const body = parseBody(contactSchema, req)
|
||||
ok(res, await service.handleContact(slug, body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const slugParamSchema = z.object({ slug: z.string() })
|
||||
export const bookingParamSchema = z.object({ slug: z.string(), id: z.string() })
|
||||
|
||||
export const availabilitySchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
})
|
||||
|
||||
export const validateCodeSchema = z.object({ code: z.string().min(1) })
|
||||
|
||||
export const bookSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z.string().optional(),
|
||||
driverLicense: z.string().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
offerId: z.string().cuid().optional(),
|
||||
promoCodeUsed: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
source: z.enum(['PUBLIC_SITE', 'MARKETPLACE']).default('PUBLIC_SITE'),
|
||||
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
||||
additionalDrivers: z.array(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),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
})).default([]),
|
||||
})
|
||||
|
||||
export const paySchema = z.object({
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
export const capturePaypalSchema = z.object({ paypalOrderId: z.string() })
|
||||
|
||||
export const contactSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
message: z.string().min(1),
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
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 { getMarketplaceHomepageContent } from '../../services/platformContentService'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as repo from './site.repo'
|
||||
import { presentBrand } from './site.presenter'
|
||||
|
||||
export async function getPlatformHomepage() {
|
||||
return getMarketplaceHomepageContent()
|
||||
}
|
||||
|
||||
export async function getBrand(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return presentBrand(company)
|
||||
}
|
||||
|
||||
export async function getPublicVehicles(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const vehicles = await repo.findPublishedVehicles(company.id)
|
||||
return Promise.all(
|
||||
vehicles.map(async (v) => {
|
||||
const a = await getVehicleAvailabilitySummary(v.id)
|
||||
return { ...v, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getVehicleDetail(slug: string, vehicleId: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const vehicle = await repo.findVehicleById(vehicleId, company.id)
|
||||
const a = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
return { ...vehicle, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
|
||||
}
|
||||
|
||||
export async function getOffers(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return repo.findActiveOffers(company.id)
|
||||
}
|
||||
|
||||
export async function getBookingOptions(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const insurancePolicies = await repo.findInsurancePolicies(company.id)
|
||||
return { insurancePolicies, contractSettings: company.contractSettings }
|
||||
}
|
||||
|
||||
export async function checkAvailability(slug: string, vehicleId: string, startDate: string, endDate: string) {
|
||||
await repo.findCompanyBySlug(slug)
|
||||
const availability = await getVehicleAvailabilitySummary(vehicleId, {
|
||||
range: { startDate: new Date(startDate), endDate: new Date(endDate) },
|
||||
})
|
||||
return { available: availability.available, nextAvailableAt: availability.nextAvailableAt }
|
||||
}
|
||||
|
||||
export async function validatePromoCode(slug: string, code: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const offer = await repo.findOfferByPromoCode(company.id, code)
|
||||
if (!offer) throw new NotFoundError('Promo code not found or expired')
|
||||
return offer
|
||||
}
|
||||
|
||||
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
|
||||
offerId?: string; promoCodeUsed?: string; notes?: string; source?: string
|
||||
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
|
||||
}) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const vehicle = await repo.findVehicleById(body.vehicleId, company.id)
|
||||
|
||||
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')
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, { range: { startDate: start, endDate: end } })
|
||||
if (!availability.available) {
|
||||
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const customer = await repo.upsertCustomer(company.id, {
|
||||
email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone,
|
||||
driverLicense: body.driverLicense, dateOfBirth: body.dateOfBirth,
|
||||
licenseExpiry: body.licenseExpiry, licenseIssuedAt: body.licenseIssuedAt, nationality: body.nationality,
|
||||
})
|
||||
|
||||
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86_400_000))
|
||||
const baseAmount = vehicle.dailyRate * totalDays
|
||||
|
||||
let discountAmount = 0
|
||||
if (body.promoCodeUsed) {
|
||||
const offer = await repo.findOfferByPromoCode(company.id, body.promoCodeUsed)
|
||||
if (offer) {
|
||||
if (offer.type === 'PERCENTAGE') discountAmount = Math.round(baseAmount * offer.discountValue / 100)
|
||||
else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue
|
||||
else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue
|
||||
}
|
||||
}
|
||||
|
||||
const additionalDriversList = body.additionalDrivers ?? []
|
||||
const { applied, total: pricingRulesTotal } = await applyPricingRules(
|
||||
company.id, customer.id, additionalDriversList as any[], 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,
|
||||
customerId: customer.id,
|
||||
offerId: body.offerId ?? null,
|
||||
promoCodeUsed: body.promoCodeUsed ?? null,
|
||||
vehicleCategory: vehicle.category,
|
||||
source: body.source ?? 'PUBLIC_SITE',
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
dailyRate: vehicle.dailyRate,
|
||||
totalDays,
|
||||
totalAmount: baseAmount - discountAmount + pricingRulesTotal,
|
||||
discountAmount,
|
||||
pricingRulesApplied: applied,
|
||||
pricingRulesTotal,
|
||||
notes: body.notes ?? null,
|
||||
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 refreshed = await repo.findReservationWithDetails(reservation.id)
|
||||
return {
|
||||
...refreshed,
|
||||
requiresManualApproval:
|
||||
primaryLicenseResult.requiresApproval ||
|
||||
refreshed.additionalDrivers.some((d) => d.requiresApproval),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBooking(slug: string, reservationId: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return repo.findBooking(reservationId, company.id)
|
||||
}
|
||||
|
||||
export async function initPayment(slug: string, reservationId: string, body: {
|
||||
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const reservation = await repo.findReservationForPayment(reservationId, company.id)
|
||||
|
||||
if (reservation.paymentStatus === 'PAID') {
|
||||
throw new AppError('This reservation is already paid', 409, 'already_paid')
|
||||
}
|
||||
|
||||
const customerLicenseResult = validateLicense(reservation.customer.licenseExpiry)
|
||||
const licenseBlocked =
|
||||
reservation.customer.licenseValidationStatus === 'DENIED' ||
|
||||
customerLicenseResult.status === 'EXPIRED' ||
|
||||
(customerLicenseResult.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') ||
|
||||
reservation.additionalDrivers.some((d) => d.licenseExpired || (d.requiresApproval && !d.approvedAt))
|
||||
|
||||
if (licenseBlocked) {
|
||||
throw new AppError('This reservation requires license review before payment can be processed', 409, 'license_review_required')
|
||||
}
|
||||
|
||||
const currency = body.currency ?? 'MAD'
|
||||
const amount = reservation.totalAmount
|
||||
const description = `Rental: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
const orderId = `res-${reservation.id}-${Date.now()}`
|
||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||
|
||||
let checkoutUrl: string
|
||||
let amanpayTransactionId: string | null = null
|
||||
let paypalCaptureId: string | null = null
|
||||
|
||||
if (body.provider === 'AMANPAY') {
|
||||
if (!amanpay.isConfigured()) {
|
||||
throw new AppError('Online payment is not available for this company', 503, 'provider_not_configured')
|
||||
}
|
||||
const brand = company.brand as any
|
||||
const merchantId = brand?.amanpayMerchantId ?? process.env.AMANPAY_MERCHANT_ID ?? ''
|
||||
const secretKey = brand?.amanpaySecretKey ?? process.env.AMANPAY_SECRET_KEY ?? ''
|
||||
if (!merchantId || !secretKey) {
|
||||
throw new AppError('AmanPay is not configured for this company', 503, 'provider_not_configured')
|
||||
}
|
||||
const result = await amanpay.createCheckout({
|
||||
amount, currency, orderId, description,
|
||||
customerEmail: reservation.customer.email,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
successUrl: body.successUrl,
|
||||
failureUrl: body.failureUrl,
|
||||
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
amanpayTransactionId = result.transactionId
|
||||
} else {
|
||||
if (!paypal.isConfigured()) {
|
||||
throw new AppError('PayPal is not available for this company', 503, 'provider_not_configured')
|
||||
}
|
||||
const result = await paypal.createOrder({
|
||||
amount, currency, orderId, description,
|
||||
returnUrl: body.successUrl,
|
||||
cancelUrl: body.failureUrl,
|
||||
})
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
|
||||
await repo.createRentalPayment({
|
||||
companyId: company.id,
|
||||
reservationId: reservation.id,
|
||||
amount,
|
||||
currency,
|
||||
paymentProvider: body.provider,
|
||||
amanpayTransactionId,
|
||||
paypalCaptureId,
|
||||
})
|
||||
|
||||
return { checkoutUrl }
|
||||
}
|
||||
|
||||
export async function capturePaypal(slug: string, paypalOrderId: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const payment = await repo.findPaymentByPaypalOrderId(paypalOrderId, company.id)
|
||||
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||
await repo.capturePaypalPayment(payment.id, captureId, payment.reservationId, payment.amount)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function handleContact(slug: string, body: { name: string; email: string; message: string }) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return {
|
||||
success: true,
|
||||
deliveredTo: company.brand?.publicEmail ?? company.email,
|
||||
preview: body,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { AppError, NotFoundError } from '../../http/errors'
|
||||
|
||||
vi.mock('./site.repo', () => ({
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
findVehicleById: vi.fn(),
|
||||
findActiveOffers: vi.fn(),
|
||||
findInsurancePolicies: vi.fn(),
|
||||
findOfferByPromoCode: vi.fn(),
|
||||
upsertCustomer: vi.fn(),
|
||||
createReservation: vi.fn(),
|
||||
findReservationWithDetails: vi.fn(),
|
||||
findBooking: vi.fn(),
|
||||
findReservationForPayment: vi.fn(),
|
||||
createRentalPayment: vi.fn(),
|
||||
findPaymentByPaypalOrderId: vi.fn(),
|
||||
capturePaypalPayment: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({
|
||||
getVehicleAvailabilitySummary: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/insuranceService', () => ({
|
||||
applyInsurancesToReservation: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/additionalDriverService', () => ({
|
||||
applyAdditionalDriversToReservation: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
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(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/paypalService', () => ({
|
||||
isConfigured: vi.fn(),
|
||||
createOrder: vi.fn(),
|
||||
captureOrder: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/platformContentService', () => ({
|
||||
getMarketplaceHomepageContent: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as repo from './site.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { applyPricingRules } from '../../services/pricingRuleService'
|
||||
import { validateLicense } from '../../services/licenseValidationService'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypalSvc from '../../services/paypalService'
|
||||
import {
|
||||
getBrand, getPublicVehicles, checkAvailability, validatePromoCode,
|
||||
createBooking, initPayment,
|
||||
} from './site.service'
|
||||
|
||||
const SLUG = 'test-company'
|
||||
|
||||
function makeCompany(overrides: object = {}) {
|
||||
return {
|
||||
id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com',
|
||||
brand: { publicEmail: null }, contractSettings: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeVehicle(overrides: object = {}) {
|
||||
return {
|
||||
id: 'v-1', dailyRate: 10000, year: 2022, make: 'Toyota', model: 'Camry',
|
||||
isPublished: true, status: 'AVAILABLE', companyId: 'co-1', category: 'SEDAN',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('getBrand', () => {
|
||||
it('returns company and public brand data only', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany({
|
||||
brand: {
|
||||
displayName: 'Test Co',
|
||||
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
|
||||
amanpayMerchantId: 'merchant-id',
|
||||
amanpaySecretKey: 'top-secret',
|
||||
paypalMerchantId: 'paypal-merchant-id',
|
||||
},
|
||||
}) as any)
|
||||
const result = await getBrand(SLUG)
|
||||
|
||||
expect(result.company.id).toBe('co-1')
|
||||
expect(result.brand).toMatchObject({
|
||||
displayName: 'Test Co',
|
||||
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
|
||||
paypalMerchantId: 'paypal-merchant-id',
|
||||
})
|
||||
expect(result.brand).not.toHaveProperty('amanpayMerchantId')
|
||||
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('getPublicVehicles', () => {
|
||||
it('returns vehicles enriched with availability', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await getPublicVehicles(SLUG)
|
||||
expect(result[0].availability).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('checkAvailability', () => {
|
||||
it('returns availability result for given date range', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await checkAvailability(SLUG, 'v-1', '2025-06-01T00:00:00.000Z', '2025-06-04T00:00:00.000Z')
|
||||
expect(result.available).toBe(true)
|
||||
expect(getVehicleAvailabilitySummary).toHaveBeenCalledWith('v-1', { range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') } })
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('validatePromoCode', () => {
|
||||
it('returns offer when code is valid', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'o-1' } as any)
|
||||
const result = await validatePromoCode(SLUG, 'SUMMER10')
|
||||
expect((result as any).id).toBe('o-1')
|
||||
})
|
||||
|
||||
it('throws NotFoundError for unknown promo code', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue(null)
|
||||
await expect(validatePromoCode(SLUG, 'BADCODE')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('createBooking', () => {
|
||||
const baseBody = {
|
||||
vehicleId: 'v-1',
|
||||
startDate: '2025-06-01T00:00:00.000Z',
|
||||
endDate: '2025-06-04T00:00:00.000Z',
|
||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(repo.findVehicleById).mockResolvedValue(makeVehicle() as any)
|
||||
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)
|
||||
})
|
||||
|
||||
it('creates a booking and returns reservation with requiresManualApproval flag', async () => {
|
||||
const result = await createBooking(SLUG, baseBody)
|
||||
expect(repo.createReservation).toHaveBeenCalledOnce()
|
||||
expect((result as any).requiresManualApproval).toBe(false)
|
||||
})
|
||||
|
||||
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' }),
|
||||
).rejects.toMatchObject({ error: 'invalid_dates' })
|
||||
})
|
||||
|
||||
it('throws AppError when vehicle unavailable', async () => {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('initPayment — payment guard paths', () => {
|
||||
const payBody = { provider: 'PAYPAL' as const, successUrl: 'http://ok', failureUrl: 'http://fail' }
|
||||
|
||||
it('throws AppError when reservation is already paid', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
|
||||
paymentStatus: 'PAID', totalAmount: 100,
|
||||
customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'A', lastName: 'B' },
|
||||
additionalDrivers: [],
|
||||
vehicle: { make: 'Toyota', model: 'Camry' },
|
||||
} as any)
|
||||
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
|
||||
|
||||
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'already_paid' })
|
||||
})
|
||||
|
||||
it('throws AppError when license review is required', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
|
||||
paymentStatus: 'UNPAID', totalAmount: 100,
|
||||
customer: { licenseExpiry: null, licenseValidationStatus: 'DENIED', email: 'a@b.com', firstName: 'A', lastName: 'B' },
|
||||
additionalDrivers: [],
|
||||
vehicle: { make: 'Toyota', model: 'Camry' },
|
||||
} as any)
|
||||
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
|
||||
|
||||
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'license_review_required' })
|
||||
})
|
||||
|
||||
it('throws AppError when PayPal is not configured', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
|
||||
paymentStatus: 'UNPAID', totalAmount: 30000,
|
||||
customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'Ali', lastName: 'Ben' },
|
||||
additionalDrivers: [],
|
||||
vehicle: { make: 'Toyota', model: 'Camry' },
|
||||
} as any)
|
||||
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
|
||||
vi.mocked(paypalSvc.isConfigured).mockReturnValue(false)
|
||||
|
||||
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'provider_not_configured' })
|
||||
})
|
||||
|
||||
it('returns checkoutUrl when PayPal is configured', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
|
||||
id: 'r-1', paymentStatus: 'UNPAID', totalAmount: 30000, companyId: 'co-1',
|
||||
customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'Ali', lastName: 'Ben' },
|
||||
additionalDrivers: [],
|
||||
vehicle: { make: 'Toyota', model: 'Camry' },
|
||||
} as any)
|
||||
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
|
||||
vi.mocked(paypalSvc.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(paypalSvc.createOrder).mockResolvedValue({ approveUrl: 'https://paypal.com/approve', orderId: 'pp-1' } as any)
|
||||
vi.mocked(repo.createRentalPayment).mockResolvedValue(undefined as any)
|
||||
|
||||
const result = await initPayment(SLUG, 'r-1', payBody)
|
||||
expect(result.checkoutUrl).toBe('https://paypal.com/approve')
|
||||
expect(repo.createRentalPayment).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export function findByCompany(companyId: string) {
|
||||
return prisma.subscription.findUnique({
|
||||
where: { companyId },
|
||||
include: { invoices: { orderBy: { createdAt: 'desc' }, take: 12 } },
|
||||
})
|
||||
}
|
||||
|
||||
export function findInvoices(companyId: string) {
|
||||
return prisma.subscriptionInvoice.findMany({ where: { companyId }, orderBy: { createdAt: 'desc' }, take: 50 })
|
||||
}
|
||||
|
||||
export function findInvoiceByAmanpay(transactionId: string) {
|
||||
return prisma.subscriptionInvoice.findFirst({ where: { amanpayTransactionId: transactionId }, include: { subscription: true } })
|
||||
}
|
||||
|
||||
export function findInvoiceByPaypal(captureId: string) {
|
||||
return prisma.subscriptionInvoice.findFirst({ where: { paypalCaptureId: captureId }, include: { subscription: true } })
|
||||
}
|
||||
|
||||
export function findInvoiceByPaypalForCompany(paypalOrderId: string, companyId: string) {
|
||||
return prisma.subscriptionInvoice.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId }, include: { subscription: true } })
|
||||
}
|
||||
|
||||
export function markInvoicePaid(id: string) {
|
||||
return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date() } })
|
||||
}
|
||||
|
||||
export function activateSubscription(id: string, periodEnd: Date) {
|
||||
return prisma.subscription.update({
|
||||
where: { id },
|
||||
data: { status: 'ACTIVE', currentPeriodStart: new Date(), currentPeriodEnd: periodEnd },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findOrCreateSubscription(companyId: string, plan: string, billingPeriod: string, currency: string) {
|
||||
const existing = await prisma.subscription.findUnique({ where: { companyId } })
|
||||
if (existing) return existing
|
||||
return prisma.subscription.create({ data: { companyId, plan: plan as any, billingPeriod: billingPeriod as any, currency, status: 'PENDING' as any } })
|
||||
}
|
||||
|
||||
export function createInvoice(data: {
|
||||
companyId: string; subscriptionId: string; amount: number; currency: string
|
||||
paymentProvider: string; amanpayTransactionId?: string | null; paypalCaptureId?: string | null
|
||||
}) {
|
||||
return prisma.subscriptionInvoice.create({ data: { ...data, status: 'PENDING', paymentProvider: data.paymentProvider as any } })
|
||||
}
|
||||
|
||||
export function updateInvoicePaypal(id: string, captureId: string) {
|
||||
return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId } })
|
||||
}
|
||||
|
||||
export function updatePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) {
|
||||
return prisma.subscription.update({ where: { companyId }, data })
|
||||
}
|
||||
|
||||
export function setCancelAtPeriodEnd(companyId: string, value: boolean) {
|
||||
return prisma.subscription.update({ where: { companyId }, data: { cancelAtPeriodEnd: value } })
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as service from './subscription.service'
|
||||
import { checkoutSchema, changePlanSchema, capturePaypalSchema } from './subscription.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── Public ────────────────────────────────────────────────────
|
||||
|
||||
router.get('/plans', (_req, res) => {
|
||||
ok(res, { data: service.getPlans() })
|
||||
})
|
||||
|
||||
router.get('/providers', (_req, res) => {
|
||||
ok(res, { data: service.getProviders() })
|
||||
})
|
||||
|
||||
// ─── Webhooks (no auth) ────────────────────────────────────────
|
||||
|
||||
router.post('/webhooks/amanpay', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const signature = (req.headers['x-amanpay-signature'] as string) ?? ''
|
||||
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
await service.handleAmanpayWebhook(req.body)
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/webhooks/paypal', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const isValid = await paypal.verifyWebhookEvent(req.headers as Record<string, string>, rawBody)
|
||||
if (paypal.isConfigured() && !isValid) return res.status(401).json({ error: 'invalid_signature' })
|
||||
await service.handlePaypalWebhook(req.body)
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── PayPal capture (auth but no subscription check) ──────────
|
||||
|
||||
router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, res, next) => {
|
||||
try {
|
||||
const { paypalOrderId } = parseBody(capturePaypalSchema, req)
|
||||
ok(res, { data: await service.capturePaypal(req.companyId, paypalOrderId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Authenticated ────────────────────────────────────────────
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant)
|
||||
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getSubscription(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/invoices', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getInvoices(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/checkout', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(checkoutSchema, req)
|
||||
ok(res, { data: await service.checkout(req.companyId, body) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(changePlanSchema, req)
|
||||
ok(res, { data: await service.changePlan(req.companyId, body) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/cancel', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.cancel(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/resume', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.resume(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const checkoutSchema = z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
export const changePlanSchema = z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
})
|
||||
|
||||
export const capturePaypalSchema = z.object({
|
||||
paypalOrderId: z.string(),
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { ValidationError } from '../../http/errors'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as repo from './subscription.repo'
|
||||
|
||||
export function addPeriod(date: Date, period: string): Date {
|
||||
const d = new Date(date)
|
||||
period === 'ANNUAL' ? d.setFullYear(d.getFullYear() + 1) : d.setMonth(d.getMonth() + 1)
|
||||
return d
|
||||
}
|
||||
|
||||
export function getPlans() {
|
||||
return PLAN_PRICES
|
||||
}
|
||||
|
||||
export function getProviders() {
|
||||
return { amanpay: amanpay.isConfigured(), paypal: paypal.isConfigured() }
|
||||
}
|
||||
|
||||
export function getSubscription(companyId: string) {
|
||||
return repo.findByCompany(companyId)
|
||||
}
|
||||
|
||||
export function getInvoices(companyId: string) {
|
||||
return repo.findInvoices(companyId)
|
||||
}
|
||||
|
||||
export async function handleAmanpayWebhook(event: any) {
|
||||
const transactionId = event.transaction_id ?? event.id
|
||||
const status = event.status?.toUpperCase()
|
||||
if (status === 'PAID' || status === 'SUCCEEDED') {
|
||||
const invoice = await repo.findInvoiceByAmanpay(transactionId)
|
||||
if (invoice) {
|
||||
await repo.markInvoicePaid(invoice.id)
|
||||
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePaypalWebhook(event: any) {
|
||||
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
|
||||
const captureId = event.resource?.id as string
|
||||
const invoice = await repo.findInvoiceByPaypal(captureId)
|
||||
if (invoice) {
|
||||
await repo.markInvoicePaid(invoice.id)
|
||||
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkout(companyId: string, body: {
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD' | 'USD' | 'EUR'; provider: 'AMANPAY' | 'PAYPAL'
|
||||
successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod]
|
||||
if (!prices) throw new ValidationError('Invalid plan or billing period')
|
||||
const amount = (prices as any)[body.currency]
|
||||
if (!amount) throw new ValidationError('Currency not supported for this plan')
|
||||
|
||||
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
|
||||
const subscription = await repo.findOrCreateSubscription(companyId, body.plan, body.billingPeriod, body.currency)
|
||||
|
||||
const orderId = `sub-${companyId}-${Date.now()}`
|
||||
const description = `${body.plan} plan — ${body.billingPeriod}`
|
||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||
|
||||
let checkoutUrl: string
|
||||
let amanpayTransactionId: string | null = null
|
||||
let paypalCaptureId: string | null = null
|
||||
|
||||
if (body.provider === 'AMANPAY') {
|
||||
if (!amanpay.isConfigured()) throw new ValidationError('AmanPay is not configured on this platform')
|
||||
const result = await amanpay.createCheckout({
|
||||
amount, currency: body.currency, orderId, description,
|
||||
customerEmail: company.email, customerName: company.name,
|
||||
successUrl: body.successUrl, failureUrl: body.failureUrl,
|
||||
webhookUrl: `${webhookBase}/api/v1/subscriptions/webhooks/amanpay`,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
amanpayTransactionId = result.transactionId
|
||||
} else {
|
||||
if (!paypal.isConfigured()) throw new ValidationError('PayPal is not configured on this platform')
|
||||
const result = await paypal.createOrder({ amount, currency: body.currency, orderId, description, returnUrl: body.successUrl, cancelUrl: body.failureUrl })
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
|
||||
const invoice = await repo.createInvoice({ companyId, subscriptionId: subscription.id, amount, currency: body.currency, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId })
|
||||
return { invoice, checkoutUrl }
|
||||
}
|
||||
|
||||
export async function capturePaypal(companyId: string, paypalOrderId: string) {
|
||||
const invoice = await repo.findInvoiceByPaypalForCompany(paypalOrderId, companyId)
|
||||
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||
await repo.updateInvoicePaypal(invoice.id, captureId)
|
||||
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export function changePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) {
|
||||
return repo.updatePlan(companyId, data)
|
||||
}
|
||||
|
||||
export function cancel(companyId: string) {
|
||||
return repo.setCancelAtPeriodEnd(companyId, true)
|
||||
}
|
||||
|
||||
export function resume(companyId: string) {
|
||||
return repo.setCancelAtPeriodEnd(companyId, false)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import * as service from './team.service'
|
||||
import { inviteSchema, roleSchema, idParamSchema } from './team.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getMembers(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/stats', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getMemberStats(req.companyId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/invite', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(inviteSchema, req)
|
||||
created(res, { data: await service.inviteEmployee(req.companyId, req.employee.id, body) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const { role } = parseBody(roleSchema, req)
|
||||
ok(res, { data: await service.updateEmployeeRole(req.companyId, req.employee.id, req.employee.role, id, { role }) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/deactivate', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, { data: await service.deactivateEmployee(req.companyId, req.employee.role, id) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/reactivate', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, { data: await service.reactivateEmployee(req.companyId, req.employee.role, id) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:id', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, { data: await service.removeEmployee(req.companyId, req.employee.role, id) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const inviteSchema = z.object({
|
||||
firstName: z.string().min(1).max(64),
|
||||
lastName: z.string().min(1).max(64),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['MANAGER', 'AGENT']),
|
||||
})
|
||||
|
||||
export const roleSchema = z.object({
|
||||
role: z.enum(['MANAGER', 'AGENT']),
|
||||
})
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
listEmployees,
|
||||
inviteEmployee,
|
||||
updateEmployeeRole,
|
||||
deactivateEmployee,
|
||||
reactivateEmployee,
|
||||
removeEmployee,
|
||||
} from '../../services/teamService'
|
||||
|
||||
export async function getMembers(companyId: string) {
|
||||
return listEmployees(companyId)
|
||||
}
|
||||
|
||||
export async function getMemberStats(companyId: string) {
|
||||
const members = await listEmployees(companyId)
|
||||
return {
|
||||
total: members.length,
|
||||
active: members.filter((m) => m.isActive && m.invitationStatus === 'accepted').length,
|
||||
pending: members.filter((m) => m.invitationStatus === 'pending').length,
|
||||
inactive: members.filter((m) => !m.isActive && m.invitationStatus === 'accepted').length,
|
||||
}
|
||||
}
|
||||
|
||||
export { inviteEmployee, updateEmployeeRole, deactivateEmployee, reactivateEmployee, removeEmployee }
|
||||
@@ -0,0 +1,7 @@
|
||||
export function presentVehicle(vehicle: any) {
|
||||
return vehicle
|
||||
}
|
||||
|
||||
export function presentVehicleList(vehicles: any[], meta: { total: number; page: number; pageSize: number; totalPages: number }) {
|
||||
return { data: vehicles, ...meta }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user