Compare commits
121 Commits
3b142ca4c7
...
fix_branch
| Author | SHA1 | Date | |
|---|---|---|---|
| ec03e783e5 | |||
| 9c50a59133 | |||
| aa6afea30b | |||
| 98f0b3ffad | |||
| 1d6ae6a54e | |||
| c77d0a8e89 | |||
| fefaf8108d | |||
| 9e096f0216 | |||
| 7f51825b1f | |||
| da35656839 | |||
| 026e33a228 | |||
| abec82a08f | |||
| f4ea01e9de | |||
| a5b6c559ea | |||
| e1b848042b | |||
| d238e6e806 | |||
| be30d19cdb | |||
| 24c58cf006 | |||
| 2befed486f | |||
| 2436907dc2 | |||
| fbbd5e4c1c | |||
| a45b318d5e | |||
| 1c8b3d5401 | |||
| e7e80103c7 | |||
| b5dfe66373 | |||
| c74789f949 | |||
| 0adebc08e5 | |||
| 342e8ee2fe | |||
| 030ecd8fd8 | |||
| 382738b52a | |||
| 2d5f34fc65 | |||
| 765c1ede8f | |||
| f6f7352c0d | |||
| 48809e8bf0 | |||
| 088fc2db65 | |||
| f4097b0b2f | |||
| 7a7fad65d0 | |||
| a7d0c29144 | |||
| b220807d70 | |||
| 2de00868ad | |||
| 2959285425 | |||
| c27f0909df | |||
| 511ab4d03b | |||
| 00d01bdaf4 | |||
| 68bf9ca610 | |||
| f330efb1ad | |||
| e3f40410e9 | |||
| c21916559f | |||
| 7ff2dbb139 | |||
| ad5f5ebab7 | |||
| a947e48c49 | |||
| 6913c298ad | |||
| b45f84d62b | |||
| 781512399b | |||
| 56c3bcf666 | |||
| 9c11f3660d | |||
| 7c1bd2d0c0 | |||
| 1fd1ddf3f2 | |||
| 0394f0ea2a | |||
| 2993dd5b57 | |||
| e93b988146 | |||
| ea2bd215f6 | |||
| 13b54f07de | |||
| 75a1d39050 | |||
| 0dcbe18c54 | |||
| 8ef61d7005 | |||
| 56984c4ea7 | |||
| 330fc11791 | |||
| 0ddf67c754 | |||
| bcfe518af2 | |||
| 30631504f1 | |||
| 757ad41c9b | |||
| 97d5ecfcf0 | |||
| 63f8df1e38 | |||
| e2b564aea5 | |||
| 184a4bac8b | |||
| 9703de974a | |||
| 5dfd5b1814 | |||
| 13d0512048 | |||
| b0c78ad8a2 | |||
| d8b7b92cb1 | |||
| fb77e91730 | |||
| 9ad92765c1 | |||
| f22e0d45e1 | |||
| a752a399c2 | |||
| e1e2f55c93 | |||
| 85d1aad956 | |||
| 26ab64e473 | |||
| 1e100cf367 | |||
| 3d6607e6c8 | |||
| ae10f5272f | |||
| a8472ddeed | |||
| 6de0690b76 | |||
| 0c9f3f8635 | |||
| 8aab968e09 | |||
| bffda95a7c | |||
| ad88fe0148 | |||
| df83c9876d | |||
| abb3a18345 | |||
| a486f891cf | |||
| 9dd4a3d087 | |||
| 7f86435769 | |||
| e6460de4d0 | |||
| 59b58d02ea | |||
| a48285ecc5 | |||
| 7392b094e0 | |||
| 4d82cf9f1a | |||
| f51d6e4634 | |||
| dce4fdc77e | |||
| dc72970ff1 | |||
| 38ed8045ab | |||
| ab03ee3a4d | |||
| 0f083d8e5d | |||
| e36a59b0bc | |||
| 7962bf9a92 | |||
| 35172ab46b | |||
| d7fb7b7a7b | |||
| 256ff0814e | |||
| c5f614b3e4 | |||
| cd67db99ed | |||
| 81b24080ac |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cp *)",
|
||||
"Bash(npm run type-check *)",
|
||||
"Bash(npm run test *)",
|
||||
"Bash(npm run build *)",
|
||||
"Bash(npm run lint *)",
|
||||
"Bash(docker *)",
|
||||
"Bash(Bash(curl *))",
|
||||
"Bash(Bash(nc *))",
|
||||
"Bash(Bash(curl *))",
|
||||
"Bash(Bash(jq *))",
|
||||
"Bash(curl *)",
|
||||
"Bash(find *)",
|
||||
"Bash(docker-compose *)",
|
||||
"Bash(npm run docker:dev:start:tools *)",
|
||||
"Bash(npm *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -11,17 +11,16 @@ API_PORT=4000
|
||||
API_URL=http://localhost:4000
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
||||
DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
ADMIN_URL=http://localhost:3000/admin
|
||||
DASHBOARD_INTERNAL_URL=http://host.docker.internal:3001
|
||||
ADMIN_INTERNAL_URL=http://host.docker.internal:3002
|
||||
# Asset prefix for the dashboard/admin dev servers so browsers load JS chunks from the
|
||||
# correct port rather than through the marketplace proxy (which doesn't have those chunks).
|
||||
# Asset prefixes for apps served through the homepage dev proxy.
|
||||
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
|
||||
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
|
||||
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
|
||||
JWT_SECRET=placeholder
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
@@ -34,15 +33,17 @@ CLERK_SECRET_KEY=placeholder
|
||||
NODE_ENV=development
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:4000,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:4000
|
||||
|
||||
# Email - disable Resend (placeholder), use SMTP instead
|
||||
RESEND_API_KEY=placeholder
|
||||
EMAIL_FROM=rentaldrivego@gmail.com
|
||||
# Email — Resend (primary) with SMTP fallback
|
||||
# Get your API key at https://resend.com/api-keys
|
||||
RESEND_API_KEY=re_PLACEHOLDER
|
||||
EMAIL_FROM=noreply@rentaldrivego.ma
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
# SMTP fallback (only used if Resend fails or is unconfigured)
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=placeholder
|
||||
MAIL_PASSWORD=kfahihfzbcvkczew
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
# ── Traefik domains — used in docker-compose labels ──────────────────────────
|
||||
ACME_EMAIL=ops@example.com
|
||||
ACME_EMAIL=rentaldrivego@gmail.com
|
||||
API_DOMAIN=api.rentaldrivego.ma
|
||||
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
|
||||
PORTAINER_DOMAIN=portainer.rentaldrivego.ma
|
||||
REGISTRY_DOMAIN=registry.rentaldrivego.ma
|
||||
REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000
|
||||
|
||||
|
||||
# ── Optional prebuilt image source ────────────────────────────────────────────
|
||||
# Used for pull-based deployments. CI injects APP_IMAGE and APP_VERSION
|
||||
# automatically during registry-backed deploys, so these can stay commented out.
|
||||
# Used for production deployments. CI injects IMAGE_TAG automatically during
|
||||
# registry-backed deploys; set it manually when running docker compose directly.
|
||||
IMAGE_TAG=latest
|
||||
# APP_IMAGE and APP_VERSION are still accepted by helper scripts for compatibility.
|
||||
# APP_IMAGE=registry.example.com/rentaldrivego/car_management_system
|
||||
# APP_VERSION=latest
|
||||
REGISTRY_HOST=registry.rentaldrivego.ma
|
||||
REGISTRY_USER=rentaldrivego_registry
|
||||
REGISTRY_PASSWORD=placeholder
|
||||
REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system
|
||||
REGISTRY_HTTP_SECRET=placeholder
|
||||
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false)
|
||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||
# Build the URL from individual vars (set to true to use POSTGRES_* vars below)
|
||||
DATABASE_URL=postgresql://dbadmin:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@localhost:5432/rentaldrivego
|
||||
DATABASE_URL_FROM_POSTGRES=true
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=rentaldrivego
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=placeholder
|
||||
POSTGRES_USER=dbadmin
|
||||
POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||
|
||||
|
||||
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
REDIS_URL=redis://redis:6379
|
||||
REDIS_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||
REDIS_URL=redis://:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@redis:6379
|
||||
|
||||
# ── API ───────────────────────────────────────────────────────────────────────
|
||||
API_PORT=4000
|
||||
# SSR server-side calls go via the internal Docker network
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
# Next.js rewrites between the marketplace and internal apps also use Docker-network URLs
|
||||
DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
||||
ADMIN_INTERNAL_URL=http://admin:3002
|
||||
# Public-facing API URL visible to browsers — MUST be your real domain, not localhost
|
||||
@@ -47,11 +44,15 @@ NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
|
||||
|
||||
|
||||
# ── Frontend public URLs ──────────────────────────────────────────────────────
|
||||
# Marketplace sits at the domain root (Traefik routes /dashboard and /admin to their apps)
|
||||
NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma
|
||||
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||
# Required for production builds. Must be an absolute https:// URL with no trailing slash.
|
||||
SITE_ORIGIN=https://rentaldrivego.ma
|
||||
|
||||
# Carplace public routes sit under /carplace on the public site domain.
|
||||
NEXT_PUBLIC_CARPLACE_URL=https://rentaldrivego.ma/carplace
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin
|
||||
NEXT_PUBLIC_HOMEPAGE_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||
DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
||||
ADMIN_URL=https://rentaldrivego.ma/admin
|
||||
@@ -62,7 +63,7 @@ ADMIN_URL=https://rentaldrivego.ma/admin
|
||||
CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
JWT_SECRET=placeholder
|
||||
JWT_SECRET=eb9ab3eb5d6648bdb82cbef29afde498c58f089829a037dfea6cb5e98ef2aae0
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
NODE_ENV=production
|
||||
@@ -74,11 +75,12 @@ EMAIL_FROM_NAME=RentalDriveGo
|
||||
# Option A — Resend
|
||||
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
||||
# Option B — SMTP (Gmail)
|
||||
# SMTP fallback (only used if Resend fails or is unconfigured)
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=placeholder
|
||||
MAIL_PASSWORD=kfahihfzbcvkczew
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Traefik domains
|
||||
ACME_EMAIL=ops@example.com
|
||||
ACME_EMAIL=rentaldrivego@gmail.com
|
||||
API_DOMAIN=api.example.com
|
||||
PUBLIC_SITE_DOMAIN=example.com
|
||||
PGMANAGE_DOMAIN=pgmanage.example.com
|
||||
@@ -7,6 +7,9 @@ PORTAINER_DOMAIN=portainer.example.com
|
||||
REGISTRY_DOMAIN=registry.example.com
|
||||
REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000
|
||||
|
||||
# Image tag
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# Database
|
||||
DATABASE_URL_FROM_POSTGRES=true
|
||||
POSTGRES_HOST=postgres
|
||||
@@ -17,6 +20,7 @@ POSTGRES_PASSWORD=placeholder
|
||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||
|
||||
# Cache
|
||||
REDIS_PASSWORD=placeholder
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# API
|
||||
@@ -26,8 +30,8 @@ API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
|
||||
|
||||
# Frontend public URLs
|
||||
NEXT_PUBLIC_MARKETING_URL=https://example.com
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=https://example.com
|
||||
NEXT_PUBLIC_HOMEPAGE_URL=https://example.com
|
||||
NEXT_PUBLIC_CARPLACE_URL=https://example.com
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
|
||||
@@ -41,6 +45,7 @@ CORS_ORIGINS=https://example.com,https://www.example.com
|
||||
JWT_SECRET=placeholder
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
SESSION_COOKIE_DOMAIN=.example.com
|
||||
NODE_ENV=production
|
||||
|
||||
# File storage
|
||||
|
||||
+37
-6
@@ -4,7 +4,13 @@
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Database ──────────────────────────────────────────────────
|
||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||
DATABASE_URL=postgresql://dbadmin:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@localhost:5432/rentaldrivego
|
||||
DATABASE_URL_FROM_POSTGRES=true
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=rentaldrivego
|
||||
POSTGRES_USER=dbadmin
|
||||
POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||
|
||||
# ─── API ───────────────────────────────────────────────────────
|
||||
API_PORT=4000
|
||||
@@ -12,9 +18,12 @@ API_URL=http://localhost:4000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
|
||||
# ─── JWT (Renter auth + Admin auth) ───────────────────────────
|
||||
JWT_SECRET=placeholder
|
||||
JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
# Optional in local development. Required in production when auth spans sibling subdomains.
|
||||
# Example: SESSION_COOKIE_DOMAIN=.rentaldrivego.ma
|
||||
SESSION_COOKIE_DOMAIN=
|
||||
|
||||
# ─── Clerk (Company employee auth) ────────────────────────────
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
|
||||
@@ -71,22 +80,44 @@ NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123
|
||||
# MAIL_* SMTP variables are intentionally omitted here.
|
||||
|
||||
# ─── Redis (Real-time / Socket.io) ────────────────────────────
|
||||
REDIS_PASSWORD=replace-with-production-redis-password
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# ─── App URLs ──────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
|
||||
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||
# Required for production builds. Must be an absolute https:// URL with no trailing slash.
|
||||
# SITE_ORIGIN=https://your-production-domain.example
|
||||
|
||||
|
||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_CARPLACE_URL=http://localhost:3000/carplace
|
||||
NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000
|
||||
# Public site is subdomain-based; use this for local dev:
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
||||
API_DOMAIN=api.rentaldrivego.ma
|
||||
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||
DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
|
||||
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
|
||||
ADMIN_SEED_EMAIL=admin@rentaldrivego.ma
|
||||
ADMIN_SEED_PASSWORD=placeholder
|
||||
ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||
ADMIN_SEED_FIRST_NAME=Super
|
||||
ADMIN_SEED_LAST_NAME=Admin
|
||||
|
||||
# SMTP fallback (only used if Resend fails or is unconfigured)
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=kfahihfzbcvkczew
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_REPLY_TO_NAME=RentalDriveGo
|
||||
|
||||
|
||||
IMAGE_TAG=13d0512048d86e9fe93e9395459d04663c2b7eb0
|
||||
|
||||
# ─── Misc ──────────────────────────────────────────────────────
|
||||
NODE_ENV=development
|
||||
NODE_ENV=production
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Build & Deploy
|
||||
name: Build & Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
branches: [fix_branch]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -11,10 +11,13 @@ concurrency:
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
# Gitea built-in container registry (HTTPS on port 443)
|
||||
# TODO: Remove after installing internal CA certificate on the runner
|
||||
GIT_SSL_NO_VERIFY: "true"
|
||||
REGISTRY_HOST: 192.168.3.80
|
||||
DEPLOY_REGISTRY_HOST: 10.0.0.4
|
||||
DEPLOY_SSH_HOST: 10.0.0.1
|
||||
DOCKERFILE_PATH: Dockerfile.production
|
||||
# VPS deployment target
|
||||
DOCKER_PLATFORM: linux/amd64
|
||||
DEPLOY_ROOT: /opt/rentaldrivego
|
||||
|
||||
jobs:
|
||||
@@ -25,132 +28,334 @@ jobs:
|
||||
image_repository: ${{ steps.image-meta.outputs.repository }}
|
||||
docker_image: ${{ steps.image-meta.outputs.full }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout repository
|
||||
shell: bash
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: "true"
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
CHECKOUT_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "::error::git must be available in the runner image; this workflow cannot install git while runner DNS is unavailable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
WORKSPACE="${GITHUB_WORKSPACE:-$PWD}"
|
||||
mkdir -p "$WORKSPACE"
|
||||
cd "$WORKSPACE"
|
||||
|
||||
SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}"
|
||||
REPOSITORY="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-}}"
|
||||
SHA="${GITEA_SHA:-${GITHUB_SHA:-}}"
|
||||
if [ -z "$SERVER_URL" ] || [ -z "$REPOSITORY" ] || [ -z "$SHA" ]; then
|
||||
echo "::error::Missing repository checkout context"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPOSITORY_URL="${SERVER_URL}/${REPOSITORY}.git"
|
||||
if [ ! -d .git ]; then
|
||||
git init
|
||||
git remote add origin "$REPOSITORY_URL"
|
||||
fi
|
||||
|
||||
git config --global --add safe.directory "$WORKSPACE"
|
||||
if [ -n "${CHECKOUT_TOKEN:-}" ]; then
|
||||
git -c "http.extraHeader=Authorization: token ${CHECKOUT_TOKEN}" fetch --no-tags --depth=1 origin "$SHA"
|
||||
else
|
||||
git fetch --no-tags --depth=1 origin "$SHA"
|
||||
fi
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Docker image metadata
|
||||
id: image-meta
|
||||
run: |
|
||||
# Gitea container registry path: host/owner/repo
|
||||
REPO="${{ gitea.repository }}"
|
||||
TAG="${{ gitea.sha }}"
|
||||
echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
|
||||
echo "full=${REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "latest=${REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "full=${DEPLOY_REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "latest=${DEPLOY_REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check Docker registry credentials
|
||||
id: registry-check
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
if [ -n "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
|
||||
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
||||
if [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ]; then
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::Registry credentials secrets not set — configure REGISTRY_USERNAME/REGISTRY_PASSWORD or REGISTRY_USER/REGISTRY_TOKEN; push will be skipped"
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install Docker CLI
|
||||
if: steps.registry-check.outputs.available == 'true'
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq docker.io
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
if: steps.registry-check.outputs.available == 'true'
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | \
|
||||
docker login "$REGISTRY_HOST" \
|
||||
-u "${{ gitea.actor }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE_PATH }}
|
||||
push: ${{ steps.registry-check.outputs.available == 'true' }}
|
||||
tags: |
|
||||
${{ steps.image-meta.outputs.full }}
|
||||
${{ steps.image-meta.outputs.latest }}
|
||||
build-args: |
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
||||
ADMIN_INTERNAL_URL=http://admin:3002
|
||||
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=${{ secrets.NEXT_PUBLIC_MARKETPLACE_URL }}
|
||||
NEXT_PUBLIC_DASHBOARD_URL=${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
|
||||
NEXT_PUBLIC_ADMIN_URL=${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
||||
|
||||
deploy:
|
||||
name: Deploy to VPS
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-image]
|
||||
- name: Validate public URLs
|
||||
shell: bash
|
||||
env:
|
||||
DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
|
||||
IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: "true"
|
||||
|
||||
- name: Check deploy credentials
|
||||
id: check-creds
|
||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
|
||||
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
|
||||
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
|
||||
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
||||
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
|
||||
run: |
|
||||
if [ -z "${{ secrets.VPS_SSH_KEY }}" ] || \
|
||||
[ -z "${{ secrets.VPS_IP }}" ] || \
|
||||
[ -z "${{ secrets.VPS_USER }}" ]; then
|
||||
echo "VPS secrets not fully configured — skipping deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
validate_url() {
|
||||
name="$1"
|
||||
value="$2"
|
||||
if [ -z "$value" ]; then
|
||||
echo "::error::$name is missing — add it to Gitea Actions secrets"
|
||||
exit 1
|
||||
fi
|
||||
case "$value" in
|
||||
http://*|https://*) ;;
|
||||
*)
|
||||
echo "::error::$name must be an absolute URL (got: $value)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
|
||||
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
|
||||
fi
|
||||
|
||||
- name: Install SSH client
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
validate_url NEXT_PUBLIC_API_URL "$NEXT_PUBLIC_API_URL"
|
||||
validate_url NEXT_PUBLIC_HOMEPAGE_URL "$NEXT_PUBLIC_HOMEPAGE_URL"
|
||||
validate_url NEXT_PUBLIC_CARPLACE_URL "$NEXT_PUBLIC_CARPLACE_URL"
|
||||
validate_url NEXT_PUBLIC_DASHBOARD_URL "$NEXT_PUBLIC_DASHBOARD_URL"
|
||||
validate_url NEXT_PUBLIC_ADMIN_URL "$NEXT_PUBLIC_ADMIN_URL"
|
||||
validate_url SITE_ORIGIN "$SITE_ORIGIN"
|
||||
|
||||
- name: Check remote build credentials
|
||||
id: check-build-host
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
||||
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_SSH_KEY" ] && [ -z "$VPS_SSH_KEY_B64" ]; then
|
||||
echo "::error::VPS_SSH_KEY_B64 or VPS_SSH_KEY is required to build on the remote Docker host"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$VPS_HOST" ] || \
|
||||
[ -z "${{ secrets.VPS_USER }}" ]; then
|
||||
echo "::error::VPS_USER and either VPS_IP or DEPLOY_SSH_HOST are required to build on the remote Docker host"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check SSH tools
|
||||
run: |
|
||||
if ! command -v ssh >/dev/null 2>&1 || ! command -v ssh-keygen >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1; then
|
||||
echo "::error::ssh, ssh-keygen, and tar must be available in the runner image; this workflow cannot install packages while runner DNS is unavailable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up SSH key
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
||||
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
|
||||
run: |
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_HOST" ]; then
|
||||
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using SSH host $VPS_HOST"
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
if [ -n "$VPS_SSH_KEY_B64" ]; then
|
||||
if ! printf '%s' "$VPS_SSH_KEY_B64" | tr -d '[:space:]' | base64 -d > ~/.ssh/id_rsa 2>/tmp/vps_ssh_key_decode.err; then
|
||||
if [ -n "$VPS_SSH_KEY" ]; then
|
||||
echo "::warning::VPS_SSH_KEY_B64 is not valid base64; using VPS_SSH_KEY instead"
|
||||
printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
|
||||
else
|
||||
echo "::error::VPS_SSH_KEY_B64 is not valid base64. Store a base64-encoded private key there, or set VPS_SSH_KEY to the raw private key."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
|
||||
fi
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H "${{ secrets.VPS_IP }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
key_header="$(head -n 1 ~/.ssh/id_rsa || true)"
|
||||
key_size="$(wc -c < ~/.ssh/id_rsa | tr -d ' ')"
|
||||
case "$key_header" in
|
||||
"-----BEGIN "*PRIVATE*" KEY-----") ;;
|
||||
ssh-*|"ecdsa-"*|"sk-"*)
|
||||
echo "::error::Decoded SSH key looks like a public key, not a private key. Encode the private key file, not the .pub file."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "::error::Decoded SSH key does not start with a private key header. Decoded byte count: $key_size."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if ! keygen_error="$(ssh-keygen -y -f ~/.ssh/id_rsa 2>&1 >/dev/null)"; then
|
||||
echo "::error::SSH private key is not readable by ssh-keygen: $keygen_error. Use an unencrypted private key or configure a CI-specific deploy key."
|
||||
exit 1
|
||||
fi
|
||||
ssh-keygen -y -f ~/.ssh/id_rsa > ~/.ssh/id_rsa.pub
|
||||
key_fingerprint="$(ssh-keygen -lf ~/.ssh/id_rsa.pub | awk '{print $2}')"
|
||||
echo "Loaded deploy key fingerprint: $key_fingerprint"
|
||||
echo "Deploy public key: $(cat ~/.ssh/id_rsa.pub)"
|
||||
touch ~/.ssh/known_hosts
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Sync deployment assets
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
- name: Test SSH authentication
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
run: |
|
||||
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" \
|
||||
"mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' '$DEPLOY_ROOT/dynamic'"
|
||||
scp docker-compose.production.yml \
|
||||
docker-compose.portainer.production.yml \
|
||||
docker-compose.registry.production.yml \
|
||||
docker-compose.registry.local.yml \
|
||||
traefik.yaml \
|
||||
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/"
|
||||
scp scripts/docker-prod-common.sh \
|
||||
scripts/docker-prod-deploy.sh \
|
||||
scripts/docker-prod-up-registry.sh \
|
||||
scripts/docker-registry-local-up.sh \
|
||||
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/scripts/"
|
||||
scp docker/pgmanage/override.py \
|
||||
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/docker/pgmanage/"
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_HOST" ]; then
|
||||
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
|
||||
exit 1
|
||||
fi
|
||||
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
key_fingerprint="$(ssh-keygen -lf "$HOME/.ssh/id_rsa.pub" | awk '{print $2}')"
|
||||
echo "Testing SSH authentication to $VPS_HOST with deploy key fingerprint $key_fingerprint"
|
||||
if ! ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "printf 'ssh authenticated as '; whoami"; then
|
||||
echo "::error::SSH authentication failed. Verify VPS_USER matches the account that has deploy key fingerprint $key_fingerprint in ~/.ssh/authorized_keys on $VPS_HOST."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run deploy script on VPS
|
||||
if: steps.check-creds.outputs.skip == 'false'
|
||||
- name: Sync build context to VPS
|
||||
env:
|
||||
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
run: |
|
||||
REGISTRY_PASSWORD_B64="$(printf '%s' "${{ secrets.REGISTRY_TOKEN }}" | base64 | tr -d '\n')"
|
||||
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_HOST" ]; then
|
||||
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
|
||||
exit 1
|
||||
fi
|
||||
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \
|
||||
"rm -rf '$REMOTE_BUILD_DIR' && mkdir -p '$REMOTE_BUILD_DIR'"
|
||||
tar \
|
||||
--exclude='.git' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.next' \
|
||||
--exclude='dist' \
|
||||
--exclude='coverage' \
|
||||
-czf - . | ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \
|
||||
"tar -xzf - -C '$REMOTE_BUILD_DIR'"
|
||||
|
||||
- name: Build and push on VPS
|
||||
env:
|
||||
PUSH_IMAGE: ${{ steps.registry-check.outputs.available == 'true' }}
|
||||
IMAGE_FULL: ${{ steps.image-meta.outputs.full }}
|
||||
IMAGE_LATEST: ${{ steps.image-meta.outputs.latest }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
|
||||
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
|
||||
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
|
||||
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
||||
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
|
||||
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
run: |
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_HOST" ]; then
|
||||
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
|
||||
exit 1
|
||||
fi
|
||||
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
|
||||
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
||||
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
|
||||
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
|
||||
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
|
||||
fi
|
||||
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
|
||||
set -e
|
||||
cd '$DEPLOY_ROOT'
|
||||
APP_IMAGE='$IMAGE_REPOSITORY' \
|
||||
APP_VERSION='${{ gitea.sha }}' \
|
||||
REGISTRY_HOST='$REGISTRY_HOST' \
|
||||
REGISTRY_USER='${{ gitea.actor }}' \
|
||||
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
|
||||
cd '$REMOTE_BUILD_DIR'
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo 'Docker must be installed on the VPS build host' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ '$PUSH_IMAGE' = 'true' ]; then
|
||||
printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d | docker login '$DEPLOY_REGISTRY_HOST' \
|
||||
--username '$REGISTRY_USERNAME' \
|
||||
--password-stdin
|
||||
fi
|
||||
docker build \
|
||||
--file '$DOCKERFILE_PATH' \
|
||||
--platform '$DOCKER_PLATFORM' \
|
||||
--tag '$IMAGE_FULL' \
|
||||
--tag '$IMAGE_LATEST' \
|
||||
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
|
||||
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
|
||||
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
|
||||
--build-arg NEXT_PUBLIC_API_URL='$NEXT_PUBLIC_API_URL' \
|
||||
--build-arg NEXT_PUBLIC_HOMEPAGE_URL='$NEXT_PUBLIC_HOMEPAGE_URL' \
|
||||
--build-arg NEXT_PUBLIC_CARPLACE_URL='$NEXT_PUBLIC_CARPLACE_URL' \
|
||||
--build-arg NEXT_PUBLIC_DASHBOARD_URL='$NEXT_PUBLIC_DASHBOARD_URL' \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_URL='$NEXT_PUBLIC_ADMIN_URL' \
|
||||
--build-arg SITE_ORIGIN='$SITE_ORIGIN' \
|
||||
.
|
||||
if [ '$PUSH_IMAGE' = 'true' ]; then
|
||||
docker push '$IMAGE_FULL'
|
||||
docker push '$IMAGE_LATEST'
|
||||
fi
|
||||
"
|
||||
|
||||
- name: Deploy production stack on VPS
|
||||
env:
|
||||
IMAGE_REPOSITORY: ${{ steps.image-meta.outputs.repository }}
|
||||
IMAGE_TAG: ${{ gitea.sha }}
|
||||
ENV_DOCKER_PRODUCTION: ${{ secrets.ENV_DOCKER_PRODUCTION }}
|
||||
ENV_DOCKER_PRODUCTION_B64: ${{ secrets.ENV_DOCKER_PRODUCTION_B64 }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
|
||||
VPS_HOST: ${{ secrets.VPS_IP }}
|
||||
run: |
|
||||
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
|
||||
if [ -z "$VPS_HOST" ]; then
|
||||
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
|
||||
exit 1
|
||||
fi
|
||||
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
ENV_DOCKER_PRODUCTION_B64_CLEAN="$(printf '%s' "$ENV_DOCKER_PRODUCTION_B64" | tr -d '[:space:]')"
|
||||
ENV_DOCKER_PRODUCTION_RAW_B64="$(printf '%s' "$ENV_DOCKER_PRODUCTION" | base64 | tr -d '\n')"
|
||||
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
|
||||
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
||||
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
|
||||
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
|
||||
set -e
|
||||
mkdir -p '$DEPLOY_ROOT' '$REMOTE_BUILD_DIR'
|
||||
if [ -n '$ENV_DOCKER_PRODUCTION_B64_CLEAN' ]; then
|
||||
printf '%s' '$ENV_DOCKER_PRODUCTION_B64_CLEAN' | base64 -d > '$DEPLOY_ROOT/.env.docker.production'
|
||||
chmod 600 '$DEPLOY_ROOT/.env.docker.production'
|
||||
elif [ -n '$ENV_DOCKER_PRODUCTION_RAW_B64' ]; then
|
||||
printf '%s' '$ENV_DOCKER_PRODUCTION_RAW_B64' | base64 -d > '$DEPLOY_ROOT/.env.docker.production'
|
||||
chmod 600 '$DEPLOY_ROOT/.env.docker.production'
|
||||
elif [ ! -f '$DEPLOY_ROOT/.env.docker.production' ]; then
|
||||
echo 'ENV_DOCKER_PRODUCTION or ENV_DOCKER_PRODUCTION_B64 is not set, and $DEPLOY_ROOT/.env.docker.production does not exist on the VPS' >&2
|
||||
exit 1
|
||||
else
|
||||
echo 'Using existing $DEPLOY_ROOT/.env.docker.production on the VPS'
|
||||
fi
|
||||
cp '$DEPLOY_ROOT/.env.docker.production' '$REMOTE_BUILD_DIR/.env.docker.production'
|
||||
chmod 600 '$REMOTE_BUILD_DIR/.env.docker.production'
|
||||
cd '$REMOTE_BUILD_DIR'
|
||||
export APP_IMAGE='$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY'
|
||||
export IMAGE_TAG='$IMAGE_TAG'
|
||||
export REGISTRY_HOST='$DEPLOY_REGISTRY_HOST'
|
||||
export REGISTRY_USER='$REGISTRY_USERNAME'
|
||||
export REGISTRY_PASSWORD=\"\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d)\"
|
||||
bash scripts/docker-prod-deploy.sh
|
||||
"
|
||||
|
||||
+218
-14
@@ -12,64 +12,244 @@ concurrency:
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
# TODO: Remove after installing internal CA certificate on the runner
|
||||
GIT_SSL_NO_VERIFY: "true"
|
||||
NPM_CONFIG_FETCH_RETRIES: "5"
|
||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "20000"
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000"
|
||||
NPM_CONFIG_INCLUDE: "optional"
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
name: Type Check (all packages)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
fi
|
||||
- run: npm run db:generate
|
||||
- run: npm run type-check
|
||||
|
||||
api-tests:
|
||||
name: API Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
fi
|
||||
- run: npm run db:generate
|
||||
- run: npm run type-check
|
||||
- run: npm run test:api
|
||||
|
||||
marketplace-tests:
|
||||
name: Marketplace Unit Tests
|
||||
homepage-tests:
|
||||
name: Homepage Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
fi
|
||||
- run: npm run build --workspace @rentaldrivego/types
|
||||
- run: npm run test:marketplace
|
||||
- run: npm run test:homepage
|
||||
|
||||
carplace-tests:
|
||||
name: Carplace Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
fi
|
||||
- run: npm run build --workspace @rentaldrivego/types
|
||||
- run: npm run test:carplace
|
||||
|
||||
admin-tests:
|
||||
name: Admin Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
fi
|
||||
- run: npm run test:admin
|
||||
|
||||
dashboard-tests:
|
||||
name: Dashboard Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
fi
|
||||
- run: npm run build --workspace @rentaldrivego/types
|
||||
- run: npm run test:dashboard
|
||||
|
||||
integration-tests:
|
||||
name: API Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: type-check
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -101,8 +281,32 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
npm ci --include=optional && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
- name: Repair Rollup optional dependency on Linux ARM64
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||
for attempt in 1 2 3; do
|
||||
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
npm cache verify || true
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||
fi
|
||||
- run: npm run db:generate
|
||||
- run: npm run db:deploy
|
||||
- run: npx prisma db push --schema packages/database/prisma/schema.prisma
|
||||
|
||||
@@ -13,6 +13,8 @@ apps/api/storage/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.docker.production
|
||||
production/.env.docker.production
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
+2
-2
@@ -28,14 +28,14 @@ api_tests:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
|
||||
marketplace_tests:
|
||||
carplace_tests:
|
||||
stage: test
|
||||
image: node:20-bookworm
|
||||
before_script:
|
||||
- npm ci
|
||||
- npm run build --workspace @rentaldrivego/types
|
||||
script:
|
||||
- npm run test:marketplace
|
||||
- npm run test:carplace
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
fetch-timeout=120000
|
||||
fetch-retries=5
|
||||
fetch-retry-mintimeout=10000
|
||||
fetch-retry-maxtimeout=60000
|
||||
@@ -0,0 +1,47 @@
|
||||
# RentalDriveGo Phase 16 Design Migration
|
||||
|
||||
## Outcome
|
||||
|
||||
The Phase 16 visual system has been applied at source level to the legacy application front ends in this archive:
|
||||
|
||||
- `homepage`: retained as the authoritative Phase 16 marketing implementation already present in the legacy archive.
|
||||
- `admin`: migrated to the Phase 16 token palette and component treatment; navigation was rebuilt as a responsive, accessible application shell.
|
||||
- `dashboard`: migrated to the same semantic surfaces, typography, borders, shadows, focus treatment, dark theme, and blue/orange action hierarchy.
|
||||
- `carplace`: migrated to the same public-site surfaces, conversion treatment, cards, forms, dark theme, and brand naming.
|
||||
- `api`: intentionally unchanged. A visual migration should not casually rewrite business logic because that is how weekends disappear.
|
||||
|
||||
## Design rules applied
|
||||
|
||||
- Blue is the operational primary color.
|
||||
- Orange is reserved for conversion emphasis and focus visibility.
|
||||
- Green is restored to success/status meaning instead of being used as a disguised primary action.
|
||||
- Light and dark themes share semantic tokens rather than unrelated hard-coded palettes.
|
||||
- English and French remain LTR; Arabic continues to use document RTL and an Arabic font fallback stack.
|
||||
- Interactive controls use a minimum 44px target, visible focus treatment, reduced-motion handling, and high-contrast fallbacks.
|
||||
- Cards and panels use the Phase 16 radius, border, surface, and shadow hierarchy.
|
||||
- The retired `RentalDriveGo` name was replaced with `RentalDriveGo` throughout all frontend source files.
|
||||
|
||||
## Important source finding
|
||||
|
||||
The supplied `RentalDriveGo_Phase16_Evidence_Package_v1.0.zip` is an evidence and launch-governance package, not the Phase 16 source repository. It contains 98 files, while its manifest describes a separate 648-file repository that was not included. The legacy archive already contained most of that repository's marketing homepage implementation: 182 files match the Phase 16 manifest checksums exactly. That embedded homepage implementation was therefore used as the reliable visual source of truth rather than fabricating a new design from launch paperwork.
|
||||
|
||||
## Files added
|
||||
|
||||
- `admin/src/styles/phase16-tokens.css`
|
||||
- `dashboard/src/styles/phase16-tokens.css`
|
||||
- `carplace/src/styles/phase16-tokens.css`
|
||||
- `scripts/validate-phase16-design.mjs`
|
||||
- `DESIGN_MIGRATION_REPORT.md`
|
||||
- `PHASE16_DESIGN_MIGRATION_MANIFEST.json`
|
||||
|
||||
## Validation performed
|
||||
|
||||
- Parsed 633 TypeScript and TSX source files with TypeScript 5.8 syntax validation: passed.
|
||||
- Checked 53 CSS files for balanced rule blocks: passed.
|
||||
- Verified all three migrated apps import the Phase 16 tokens: passed.
|
||||
- Verified no retired `RentalDriveGo` branding remains in frontend source: passed.
|
||||
- Ran `node scripts/validate-phase16-design.mjs`: passed.
|
||||
|
||||
## Validation not claimed
|
||||
|
||||
A full dependency-backed build and browser regression suite was not run. The supplied legacy archive omits its root workspace configuration and the local packages referenced as `@rentaldrivego/types` and `@rentaldrivego/database`. The Phase 16 evidence upload also omits the source repository and dependency lockfile it references. Those are packaging defects in the inputs, not facts to conceal with optimistic prose.
|
||||
+2
-1
@@ -5,6 +5,7 @@ WORKDIR /app
|
||||
RUN npm install -g npm@10.5.0 --no-fund --no-audit
|
||||
RUN corepack enable
|
||||
|
||||
COPY .npmrc ./
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
@@ -12,6 +13,6 @@ COPY config ./config
|
||||
|
||||
RUN npm ci --no-fund --no-audit
|
||||
|
||||
EXPOSE 3000 3001 3002 3003 4000
|
||||
EXPOSE 3000 3001 3002 3004 4000
|
||||
|
||||
CMD ["sh", "-c", "npm run db:generate && npm run dev"]
|
||||
|
||||
+16
-6
@@ -17,18 +17,26 @@ ARG ADMIN_INTERNAL_URL=http://admin:3002
|
||||
|
||||
# NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_MARKETPLACE_URL
|
||||
ARG NEXT_PUBLIC_HOMEPAGE_URL
|
||||
ARG NEXT_PUBLIC_CARPLACE_URL
|
||||
ARG NEXT_PUBLIC_DASHBOARD_URL
|
||||
ARG NEXT_PUBLIC_ADMIN_URL
|
||||
|
||||
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||
# Required in production — must be an absolute https:// URL with no path, query, or fragment.
|
||||
ARG SITE_ORIGIN
|
||||
|
||||
ENV API_INTERNAL_URL=$API_INTERNAL_URL \
|
||||
DASHBOARD_INTERNAL_URL=$DASHBOARD_INTERNAL_URL \
|
||||
ADMIN_INTERNAL_URL=$ADMIN_INTERNAL_URL \
|
||||
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=$NEXT_PUBLIC_MARKETPLACE_URL \
|
||||
NEXT_PUBLIC_HOMEPAGE_URL=$NEXT_PUBLIC_HOMEPAGE_URL \
|
||||
NEXT_PUBLIC_CARPLACE_URL=$NEXT_PUBLIC_CARPLACE_URL \
|
||||
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
|
||||
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL
|
||||
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
|
||||
SITE_ORIGIN=$SITE_ORIGIN
|
||||
|
||||
RUN npm install
|
||||
RUN npm ci --include=optional
|
||||
RUN npm run db:generate
|
||||
RUN npm run build
|
||||
|
||||
@@ -36,7 +44,8 @@ FROM node:20-bookworm AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_ENV=production \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||
|
||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate \
|
||||
&& groupadd --system --gid 10001 app \
|
||||
@@ -48,12 +57,13 @@ COPY --from=builder --chown=app:app /app/package.json /app/package-lock.json /ap
|
||||
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=app:app /app/apps ./apps
|
||||
COPY --from=builder --chown=app:app /app/packages ./packages
|
||||
COPY --from=builder --chown=app:app /app/config ./config
|
||||
COPY --chown=root:root docker/entrypoint.production.sh /usr/local/bin/rdg-entrypoint.sh
|
||||
|
||||
RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/rdg-entrypoint.sh"]
|
||||
|
||||
EXPOSE 3000 3001 3002 4000
|
||||
EXPOSE 3000 3001 3002 3004 4000
|
||||
|
||||
CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# RentalDriveGo Public Chrome Refactor Report
|
||||
|
||||
## Completed
|
||||
|
||||
1. Extracted the dashboard public navbar into `components/public/PublicNavbar.tsx`.
|
||||
2. Extracted the dashboard public footer into `components/public/PublicFooter.tsx`.
|
||||
3. Added `PublicPageLayout` as the reusable composition layer for authentication and onboarding pages.
|
||||
4. Updated sign-in, create-account, forgot-password, reset-password, verify-email, onboarding, and invitation pages to use the public component API.
|
||||
5. Preserved legacy component paths through compatibility exports.
|
||||
6. Moved carplace navbar/footer assembly out of the Next.js route layout and into reusable public components.
|
||||
7. Added active-page semantics to dashboard sign-in and create-account navbar actions.
|
||||
8. Added focused tests and implementation documentation.
|
||||
|
||||
## Deliberately unchanged
|
||||
|
||||
- Authentication requests and API endpoints
|
||||
- Redirect behavior between carplace, dashboard, and admin applications
|
||||
- Language and theme persistence
|
||||
- Embedded workspace behavior
|
||||
- Authenticated dashboard and admin navigation
|
||||
|
||||
## Validation performed
|
||||
|
||||
- Parsed all TypeScript and TSX source files successfully.
|
||||
- Verified all relative and `@/` local imports resolve.
|
||||
- Verified every `use client` directive remains the first statement.
|
||||
- Verified sign-in and create-account import and render `PublicPageLayout`.
|
||||
- Verified the carplace route layout no longer assembles navbar/footer inline.
|
||||
- Verified embedded password-recovery navigation preserves `embedded=1`.
|
||||
|
||||
## Validation limitation
|
||||
|
||||
A complete Next.js build was not executed because this archive does not include the monorepo root, workspace packages, lockfile, or installed dependencies referenced by the application package files. Static source validation was completed instead; pretending that equals a production build would be charmingly irresponsible.
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"project": "RentalDriveGo",
|
||||
"migration": "Apply Phase 16 design system to legacy Archive.zip frontends",
|
||||
"generated_at": "2026-06-26T02:14:35+00:00",
|
||||
"source_archives": {
|
||||
"legacy_archive": "Archive.zip",
|
||||
"phase16_evidence": "RentalDriveGo_Phase16_Evidence_Package_v1.0.zip"
|
||||
},
|
||||
"scope": {
|
||||
"homepage": "retained as embedded Phase 16 source of truth",
|
||||
"admin": "migrated",
|
||||
"dashboard": "migrated",
|
||||
"carplace": "migrated",
|
||||
"api": "unchanged"
|
||||
},
|
||||
"validation": {
|
||||
"typescript_syntax_files": 633,
|
||||
"typescript_syntax": "passed",
|
||||
"css_files_checked": 53,
|
||||
"css_balance": "passed",
|
||||
"design_migration_validator": "passed",
|
||||
"full_build": "not run because root workspace and local packages are absent from supplied archive"
|
||||
},
|
||||
"changed_file_count": 34,
|
||||
"files": [
|
||||
{
|
||||
"path": "DESIGN_MIGRATION_REPORT.md",
|
||||
"status": "added",
|
||||
"bytes": 3260,
|
||||
"sha256": "35c41d70484b917b38991791dfb2feb3e78234e4f30e2c99ba922d3d90ab83a6"
|
||||
},
|
||||
{
|
||||
"path": "scripts/validate-phase16-design.mjs",
|
||||
"status": "added",
|
||||
"bytes": 1891,
|
||||
"sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/app/layout.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 1833,
|
||||
"sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/app/globals.css",
|
||||
"status": "modified",
|
||||
"bytes": 7525,
|
||||
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/components/CarplaceFooter.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 4632,
|
||||
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/components/CarplaceHeader.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 10438,
|
||||
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/styles/phase16-tokens.css",
|
||||
"status": "added",
|
||||
"bytes": 4214,
|
||||
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
|
||||
},
|
||||
{
|
||||
"path": "carplace/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 13979,
|
||||
"sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/layout.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 2133,
|
||||
"sha256": "09399551351e3c7015e6700c3892cf81f2b45cf4d1b835e01645580e73608c05"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/globals.css",
|
||||
"status": "modified",
|
||||
"bytes": 16161,
|
||||
"sha256": "0d01004900a4a6c078e98167c910d02b5fa29b4ebeafe0520a45cb7f9274c8b8"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/styles/phase16-tokens.css",
|
||||
"status": "added",
|
||||
"bytes": 4214,
|
||||
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/components/layout/PublicFooter.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 8348,
|
||||
"sha256": "657f96cc725459f7bf7a668fe616aee4d295625c15366204b5987176fd60c8b8"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/components/layout/PublicHeader.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 10250,
|
||||
"sha256": "3e5ace09e73afbb95f09a1acf164011764cc789474430962426793564b8db459"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/components/layout/Sidebar.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 18500,
|
||||
"sha256": "d0f7571149a073c39b6a1b4331e6055d8347a8092bd858d7128a37a19847b4d7"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 6888,
|
||||
"sha256": "9b6bbc1479529c51d5dd65ce0dffa4ed6245a1de687c2cc9b2f62d3a396a8f83"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/onboarding/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 10593,
|
||||
"sha256": "a2255be38766258da3495191ce793da21992b6f4c13520d760959b7eff5ccc8e"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/reset-password/ResetPasswordPageClient.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 11998,
|
||||
"sha256": "3a8f0c268c825097da5a8814c26c2526103536b7b3e6f3f5d04e0311cd3fbe27"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/verify-email/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 5176,
|
||||
"sha256": "e1fc11c7e2af90c4b3614a1a8c6b9babe2ea5e9884eaa4e94fc0994462d554d6"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 14464,
|
||||
"sha256": "976e8f9244a8ca64e092373831970e3c4d7197ead33e3c478f6e8d9d375f9c3e"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 19760,
|
||||
"sha256": "861d033b4b86792644538f69a38634d46d6c58d43e87bbeb67dcef806cf6ab24"
|
||||
},
|
||||
{
|
||||
"path": "dashboard/src/app/(dashboard)/settings/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 42414,
|
||||
"sha256": "c46bdfd9c1c14d471b1aa03cca5e6d91f49dd8405aa27502f040bd97c0d5a64d"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/layout.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 1210,
|
||||
"sha256": "aaa6c6aedb14972a159a9bcf9f8dc0bdedd2681d8a1c37be989644975c94c243"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/globals.css",
|
||||
"status": "modified",
|
||||
"bytes": 8317,
|
||||
"sha256": "4134ea739c645e1550e75d16fdf1b45c13392fd153250840c1272603adbd2832"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/styles/phase16-tokens.css",
|
||||
"status": "added",
|
||||
"bytes": 4214,
|
||||
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/layout.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 10294,
|
||||
"sha256": "8390dba373ae0f8d02e8d872f5b0f1736f60553bf33e90f8c07c02207dedadda"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 13442,
|
||||
"sha256": "87995a735f7665723c791a6d528a14ebd78e529105e1bfa14654221248b62ca5"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/admin-users/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 12067,
|
||||
"sha256": "64dfa83abc16aef56347c1d832edb2b30f5f11497a6cb2908c5158d891031983"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/audit-logs/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 4369,
|
||||
"sha256": "0db9f45a4486dd4c3a8c89e82618fe49bf3f555aff9a7e8e16b028d2b53d72ef"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/billing/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 48678,
|
||||
"sha256": "598125e6be30d184c2277269e163bd3f54605a839a63041fa57ba27cb03cdadd"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/companies/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 8622,
|
||||
"sha256": "3513aa5a4ac525c0a6887cb3d77299c595b921e3e7f93001095f81f73f19fe31"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/containers/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 18580,
|
||||
"sha256": "d01c7cddedf9fb222554f8ea48fa44a515baae353b6675496eea7578b1c0a105"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/renters/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 6988,
|
||||
"sha256": "5ae947e70d674b930a5fc39f1c0577c25bb7c2ca484c761129fe091019aa0034"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/site-config/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 41636,
|
||||
"sha256": "800108cec0ea22c84ea8f60452ddb633e472237505a16a8bcc73ad89467b08ea"
|
||||
},
|
||||
{
|
||||
"path": "admin/src/app/dashboard/companies/[id]/page.tsx",
|
||||
"status": "modified",
|
||||
"bytes": 50874,
|
||||
"sha256": "5b763c4280f9d561004dbb08faa945b7b868f48086ca3b0ac62cacb228db02d1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"package": "RentalDriveGo_Phase16_Operational_UI_v1.0",
|
||||
"generatedOn": "2026-06-25",
|
||||
"scope": [
|
||||
"dashboard",
|
||||
"admin"
|
||||
],
|
||||
"includedApplications": [
|
||||
"homepage",
|
||||
"carplace",
|
||||
"dashboard",
|
||||
"admin",
|
||||
"api"
|
||||
],
|
||||
"designAuthority": "Phase 16 homepage implementation",
|
||||
"routeCoverage": {
|
||||
"admin": 12,
|
||||
"dashboard": 19
|
||||
},
|
||||
"validation": {
|
||||
"typescriptSourceFilesParsed": 596,
|
||||
"typescriptSyntaxFailures": 0,
|
||||
"localImportsChecked": 190,
|
||||
"unresolvedLocalImports": 0,
|
||||
"designGate": "passed",
|
||||
"productionBuild": "not run: missing monorepo root, workspace package, lockfile, and installed dependencies"
|
||||
},
|
||||
"keyFiles": [
|
||||
"admin/src/styles/phase16-tokens.css",
|
||||
"admin/src/app/globals.css",
|
||||
"admin/src/app/dashboard/layout.tsx",
|
||||
"dashboard/src/styles/phase16-tokens.css",
|
||||
"dashboard/src/app/globals.css",
|
||||
"dashboard/src/app/(dashboard)/layout.tsx",
|
||||
"dashboard/src/components/layout/Sidebar.tsx",
|
||||
"dashboard/src/components/layout/TopBar.tsx",
|
||||
"scripts/validate-phase16-design.mjs"
|
||||
],
|
||||
"documentation": [
|
||||
"PHASE16_OPERATIONAL_UI_REPORT.md",
|
||||
"docs/PHASE16_OPERATIONAL_UI_GUIDE.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# RentalDriveGo Phase 16 Operational UI Migration Report
|
||||
|
||||
**Date:** 2026-06-25
|
||||
**Scope:** Operator dashboard and platform admin
|
||||
**Design authority:** Existing Phase 16 homepage implementation and its blue/orange semantic design system
|
||||
|
||||
## Result
|
||||
|
||||
The dashboard and admin applications now use the same visual language as the Phase 16 homepage rather than behaving like separate products. The migration changes shared shells, global tokens, navigation, page hierarchy, cards, forms, tables, status treatments, responsive behavior, dark mode, RTL behavior, and focus states. API business logic and route contracts were deliberately left unchanged.
|
||||
|
||||
## Repository recovery
|
||||
|
||||
The previous navbar/footer refactor archive omitted the `homepage` and `api` applications. This package was rebuilt from the complete Phase 16 archive, then overlaid with the public navbar/footer refactor before the operational UI migration. The final repository contains:
|
||||
|
||||
- `homepage`
|
||||
- `carplace`
|
||||
- `dashboard`
|
||||
- `admin`
|
||||
- `api`
|
||||
- validation scripts and documentation
|
||||
|
||||
## Shared visual system
|
||||
|
||||
Both operational applications now consume matching `phase16-tokens.css` files with:
|
||||
|
||||
- Homepage blue as the operational action color
|
||||
- Orange reserved for conversion, focus, warning, and selected emphasis
|
||||
- Shared light and dark surface mappings
|
||||
- Shared 10 px, 16 px, 24 px, and 32 px radius scale
|
||||
- Shared 44 px minimum interactive target
|
||||
- Shared 76 px topbar and 272 px desktop sidebar dimensions
|
||||
- Shared card, overlay, and focus shadows
|
||||
- Inter for Latin content and Noto Sans Arabic fallbacks for Arabic
|
||||
|
||||
## Dashboard changes
|
||||
|
||||
- Rebuilt the authenticated shell with the Phase 16 page background, sidebar, sticky topbar, content width, and responsive spacing.
|
||||
- Reworked sidebar branding, active navigation, user card, status marker, RTL logical borders, and mobile behavior.
|
||||
- Reworked the topbar search, page title, status pill, notifications, account controls, and semantic icon buttons.
|
||||
- Migrated all 19 dashboard routes to the shared page-heading or hero pattern.
|
||||
- Applied shared styling to overview metrics, quick actions, reservations, contracts, online reservations, fleet, customers, team, reviews, complaints, offers, billing, subscription, reports, notifications, and settings.
|
||||
- Preserved printable contract styling as a document surface while aligning its surrounding application controls.
|
||||
|
||||
## Admin changes
|
||||
|
||||
- Rebuilt the admin shell with the same Phase 16 sidebar, topbar, page background, status treatment, mobile drawer, language controls, theme controls, and user card.
|
||||
- Migrated all 12 admin routes to the shared page-heading or hero pattern.
|
||||
- Standardized companies, company details, renters, administrators, billing, pricing, containers, audit logs, notifications, menu management, site configuration, and overview pages.
|
||||
- Converted operational create/save/preview actions from orange to homepage blue. Orange remains available for promotions, conversion previews, warnings, and focus.
|
||||
- Standardized dynamic form controls through the shared `.field` utility.
|
||||
|
||||
## Shared component and CSS contract
|
||||
|
||||
The main reusable classes are:
|
||||
|
||||
- `.rdg-app-shell`
|
||||
- `.rdg-workspace`
|
||||
- `.rdg-sidebar`
|
||||
- `.rdg-nav-item`
|
||||
- `.rdg-topbar`
|
||||
- `.rdg-app-main`
|
||||
- `.rdg-app-content`
|
||||
- `.rdg-page-hero`
|
||||
- `.rdg-page-heading`
|
||||
- `.rdg-page-kicker`
|
||||
- `.panel`
|
||||
- `.card`
|
||||
- `.field`
|
||||
- `.btn-primary`
|
||||
- `.btn-secondary`
|
||||
- `.btn-conversion`
|
||||
|
||||
## Behavior preserved
|
||||
|
||||
- Existing routes and URL structure
|
||||
- Authentication and access guards
|
||||
- API calls and data mutations
|
||||
- English, French, and Arabic support
|
||||
- True RTL layout behavior through logical properties
|
||||
- Light, dark, and system theme behavior
|
||||
- Embedded authentication behavior through `embedded=1`
|
||||
- Print behavior for contracts
|
||||
|
||||
## Validation completed
|
||||
|
||||
- 596 TypeScript/TSX source files parsed with zero syntax failures.
|
||||
- 190 local and alias imports checked in admin/dashboard with zero unresolved paths.
|
||||
- 31 operational route pages checked for a Phase 16 page heading or hero.
|
||||
- CSS brace balance checked for the migrated stylesheets.
|
||||
- Shared shell, token, responsive, forced-color, auth-layout, and retired-brand assertions passed.
|
||||
- Final archive integrity is checked after packaging.
|
||||
|
||||
## Build limitation
|
||||
|
||||
A production Next.js build was not run because the supplied archive does not include the monorepo root, root lockfile, root workspace configuration, installed dependencies, or the referenced `@rentaldrivego/types` workspace. Each application invokes that absent workspace in its `prebuild` script. Claiming a clean production build under those conditions would be fiction with a progress bar.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Phase 16 Operational UI Validation Report
|
||||
|
||||
**Validated:** 2026-06-25
|
||||
**Result:** Static migration gates passed
|
||||
|
||||
## Passed checks
|
||||
|
||||
| Check | Result |
|
||||
|---|---:|
|
||||
| Frontend/API source TypeScript and TSX parsed | 596 files, 0 syntax failures |
|
||||
| Admin/dashboard local and `@/` imports resolved | 190 imports, 0 unresolved |
|
||||
| Dashboard operational routes using Phase 16 heading/hero | 19 of 19 |
|
||||
| Admin operational routes using Phase 16 heading/hero | 12 of 12 |
|
||||
| Dashboard/admin token files present | Passed |
|
||||
| Shared sidebar, topbar, shell, panel, field, and action contracts present | Passed |
|
||||
| CSS brace balance | Passed |
|
||||
| Light/dark and Arabic RTL token mappings present | Passed |
|
||||
| Reduced-motion and forced-colors rules present | Passed |
|
||||
| Shared sign-in/create-account public layout retained | Passed |
|
||||
| Shared carplace navbar/footer components retained | Passed |
|
||||
| Retired `RentalDriveGo` brand in dashboard/admin source | 0 matches |
|
||||
| Complete app directories retained | `homepage`, `carplace`, `dashboard`, `admin`, `api` |
|
||||
|
||||
## Reproducible design gate
|
||||
|
||||
Run this from the extracted repository root:
|
||||
|
||||
```bash
|
||||
node scripts/validate-phase16-design.mjs
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
Phase 16 dashboard/admin design checks passed.
|
||||
```
|
||||
|
||||
## Not claimed
|
||||
|
||||
A production build, type-check against installed dependencies, integration test run, and browser visual regression run are not claimed. The supplied archive lacks the root workspace, root lockfile, `@rentaldrivego/types` package, and installed dependencies required by every application `prebuild` script.
|
||||
@@ -60,15 +60,15 @@ Changed files:
|
||||
|
||||
- `apps/api/src/app.ts`
|
||||
- `apps/dashboard/src/middleware.ts`
|
||||
- `apps/marketplace/src/middleware.ts`
|
||||
- `apps/carplace/src/middleware.ts`
|
||||
- `apps/admin/src/middleware.ts`
|
||||
- `apps/dashboard/src/middleware.test.ts`
|
||||
- `apps/marketplace/src/middleware.test.ts`
|
||||
- `apps/carplace/src/middleware.test.ts`
|
||||
|
||||
What changed:
|
||||
|
||||
- API now rejects requests containing `x-middleware-subrequest` before route handling.
|
||||
- Dashboard, marketplace, and admin Next middleware now reject the same header at the app layer.
|
||||
- Dashboard, carplace, and admin Next middleware now reject the same header at the app layer.
|
||||
- Added/updated middleware tests for the rejection path.
|
||||
|
||||
Security effect:
|
||||
|
||||
@@ -5,7 +5,7 @@ const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http:
|
||||
const apiUrl = new URL(apiOrigin)
|
||||
const ADMIN_BASE_PATH = '/admin'
|
||||
|
||||
// In Docker dev the admin app runs on port 3002 while the marketplace proxy
|
||||
// In Docker dev the admin app runs on port 3002 while the Carplace proxy
|
||||
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
|
||||
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
|
||||
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
|
||||
|
||||
+10
-6
@@ -18,20 +18,24 @@
|
||||
"@rentaldrivego/types": "*",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dayjs": "^1.11.11",
|
||||
"firebase-admin": "^10.3.0",
|
||||
"lucide-react": "^0.376.0",
|
||||
"next": "^16.2.7",
|
||||
"next": "^16.2.9",
|
||||
"node-cron": "4.5.0",
|
||||
"nodemailer": "9.0.1",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^2.12.7",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"turbo": "2.10.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.6.0"
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function AdminUsersPage() {
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingAdminId, setEditingAdminId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function fetchAdmins() {
|
||||
@@ -54,6 +55,7 @@ export default function AdminUsersPage() {
|
||||
function openCreateModal() {
|
||||
setEditingAdminId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
setShowPassword(false)
|
||||
setError(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
@@ -75,6 +77,7 @@ export default function AdminUsersPage() {
|
||||
function closeModal() {
|
||||
setShowModal(false)
|
||||
setEditingAdminId(null)
|
||||
setShowPassword(false)
|
||||
setForm(EMPTY_FORM)
|
||||
}
|
||||
|
||||
@@ -232,14 +235,33 @@ export default function AdminUsersPage() {
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">
|
||||
Password {editingAdminId ? <span className="text-zinc-500">(leave blank to keep current)</span> : null}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required={!editingAdminId}
|
||||
minLength={editingAdminId ? undefined : 8}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
className="w-full px-3 py-2 pr-10 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-zinc-500 hover:text-zinc-200"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
|
||||
|
||||
@@ -71,7 +71,7 @@ interface CompanyDetail {
|
||||
whatsappNumber: string | null
|
||||
defaultLocale: string
|
||||
defaultCurrency: string
|
||||
isListedOnMarketplace: boolean
|
||||
isListedOnCarplace: boolean
|
||||
} | null
|
||||
contractSettings: {
|
||||
legalName: string | null
|
||||
@@ -171,7 +171,7 @@ interface FormState {
|
||||
whatsappNumber: string
|
||||
defaultLocale: string
|
||||
defaultCurrency: string
|
||||
isListedOnMarketplace: boolean
|
||||
isListedOnCarplace: boolean
|
||||
}
|
||||
contractSettings: {
|
||||
legalName: string
|
||||
@@ -277,7 +277,7 @@ function createFormState(company: CompanyDetail): FormState {
|
||||
whatsappNumber: company.brand?.whatsappNumber ?? '',
|
||||
defaultLocale: company.brand?.defaultLocale ?? 'en',
|
||||
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
|
||||
isListedOnMarketplace: company.brand?.isListedOnMarketplace ?? true,
|
||||
isListedOnCarplace: company.brand?.isListedOnCarplace ?? true,
|
||||
},
|
||||
contractSettings: {
|
||||
legalName: company.contractSettings?.legalName ?? '',
|
||||
@@ -420,7 +420,7 @@ export default function AdminCompanyDetailPage() {
|
||||
whatsappNumber: toNullable(form.brand.whatsappNumber),
|
||||
defaultLocale: form.brand.defaultLocale,
|
||||
defaultCurrency: form.brand.defaultCurrency,
|
||||
isListedOnMarketplace: form.brand.isListedOnMarketplace,
|
||||
isListedOnCarplace: form.brand.isListedOnCarplace,
|
||||
},
|
||||
contractSettings: {
|
||||
legalName: toNullable(form.contractSettings.legalName),
|
||||
@@ -723,8 +723,8 @@ export default function AdminCompanyDetailPage() {
|
||||
<input className={INPUT_CLASS} value={form.brand.publicAddress} onChange={(e) => updateSection('brand', { publicAddress: e.target.value })} />
|
||||
</label>
|
||||
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
|
||||
<input type="checkbox" checked={form.brand.isListedOnMarketplace} onChange={(e) => updateSection('brand', { isListedOnMarketplace: e.target.checked })} />
|
||||
Listed on marketplace
|
||||
<input type="checkbox" checked={form.brand.isListedOnCarplace} onChange={(e) => updateSection('brand', { isListedOnCarplace: e.target.checked })} />
|
||||
Listed on Carplace
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
<aside className="flex w-60 flex-shrink-0 flex-col border-r border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/78">
|
||||
<Link href="/" className="block px-5 py-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">FleetOS</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">RentalDriveGo</p>
|
||||
</Link>
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{navLinks.map((link) => {
|
||||
|
||||
@@ -21,10 +21,10 @@ export default function AdminDashboardPage() {
|
||||
const copy = {
|
||||
en: {
|
||||
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
|
||||
brand: 'FleetOS',
|
||||
brand: 'RentalDriveGo',
|
||||
eyebrow: 'Operations command',
|
||||
platformOverview: 'FleetOS admin dashboard',
|
||||
subtitle: 'Monitor marketplace health, subscription coverage, renter activity, and operator actions from one control surface.',
|
||||
platformOverview: 'RentalDriveGo admin dashboard',
|
||||
subtitle: 'Monitor Carplace health, subscription coverage, renter activity, and operator actions from one control surface.',
|
||||
liveSignal: 'Live platform signal',
|
||||
readiness: 'Operational readiness',
|
||||
readinessBody: 'Core admin workflows are connected and ready for platform support.',
|
||||
@@ -34,16 +34,16 @@ export default function AdminDashboardPage() {
|
||||
cards: [
|
||||
['Companies', 'Search operators, review subscription state, and manage account status.', 'View all'],
|
||||
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
|
||||
['Audit logs', 'Trace every sensitive admin action across FleetOS.', 'View logs'],
|
||||
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'],
|
||||
],
|
||||
health: ['Tenant accounts', 'Marketplace identity', 'Admin audit trail'],
|
||||
health: ['Tenant accounts', 'Carplace identity', 'Admin audit trail'],
|
||||
},
|
||||
fr: {
|
||||
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
||||
brand: 'FleetOS',
|
||||
brand: 'RentalDriveGo',
|
||||
eyebrow: 'Centre des opérations',
|
||||
platformOverview: 'Tableau de bord admin FleetOS',
|
||||
subtitle: 'Suivez la santé de la marketplace, les abonnements, l’activité des locataires et les actions opérateur depuis une même interface.',
|
||||
platformOverview: 'Tableau de bord admin RentalDriveGo',
|
||||
subtitle: 'Suivez la santé de la Carplace, les abonnements, l’activité des locataires et les actions opérateur depuis une même interface.',
|
||||
liveSignal: 'Signal plateforme en direct',
|
||||
readiness: 'Disponibilité opérationnelle',
|
||||
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.',
|
||||
@@ -53,15 +53,15 @@ export default function AdminDashboardPage() {
|
||||
cards: [
|
||||
['Entreprises', 'Rechercher les opérateurs, vérifier les abonnements et gérer les statuts.', 'Voir tout'],
|
||||
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
|
||||
['Journaux d’audit', 'Tracer chaque action admin sensible dans FleetOS.', 'Voir les journaux'],
|
||||
['Journaux d’audit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
|
||||
],
|
||||
health: ['Comptes locataires', 'Identité marketplace', 'Piste d’audit admin'],
|
||||
health: ['Comptes locataires', 'Identité Carplace', 'Piste d’audit admin'],
|
||||
},
|
||||
ar: {
|
||||
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
||||
brand: 'FleetOS',
|
||||
brand: 'RentalDriveGo',
|
||||
eyebrow: 'مركز العمليات',
|
||||
platformOverview: 'لوحة إدارة FleetOS',
|
||||
platformOverview: 'لوحة إدارة RentalDriveGo',
|
||||
subtitle: 'راقب صحة السوق وحالة الاشتراكات ونشاط المستأجرين وإجراءات الإدارة من مساحة واحدة.',
|
||||
liveSignal: 'مؤشر المنصة المباشر',
|
||||
readiness: 'جاهزية التشغيل',
|
||||
@@ -72,7 +72,7 @@ export default function AdminDashboardPage() {
|
||||
cards: [
|
||||
['الشركات', 'ابحث عن المشغلين وراجع الاشتراكات وأدر حالة الحساب.', 'عرض الكل'],
|
||||
['المستأجرون', 'إدارة الثقة والحظر واستعادة الحسابات للمستأجرين.', 'عرض الكل'],
|
||||
['سجلات التدقيق', 'تتبع كل إجراء إداري حساس داخل FleetOS.', 'عرض السجلات'],
|
||||
['سجلات التدقيق', 'تتبع كل إجراء إداري حساس داخل RentalDriveGo.', 'عرض السجلات'],
|
||||
],
|
||||
health: ['حسابات الشركات', 'هوية السوق', 'سجل تدقيق الإدارة'],
|
||||
},
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
cloneMarketplaceHomepageContent,
|
||||
resolveMarketplaceHomepageSections,
|
||||
type MarketplaceHomepageConfig,
|
||||
type MarketplaceHomepageContent,
|
||||
type MarketplaceHomepageSectionType,
|
||||
type MarketplaceLanguage,
|
||||
cloneCarplaceHomepageContent,
|
||||
resolveCarplaceHomepageSections,
|
||||
type CarplaceHomepageConfig,
|
||||
type CarplaceHomepageContent,
|
||||
type CarplaceHomepageSectionType,
|
||||
type CarplaceLanguage,
|
||||
} from '@rentaldrivego/types'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
@@ -35,15 +35,15 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500'
|
||||
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
|
||||
|
||||
function cloneHomepageContent(content: MarketplaceHomepageConfig) {
|
||||
const cloned = JSON.parse(JSON.stringify(content)) as MarketplaceHomepageConfig
|
||||
;(['en', 'fr', 'ar'] as MarketplaceLanguage[]).forEach((language) => {
|
||||
cloned[language].sections = resolveMarketplaceHomepageSections(cloned[language].sections)
|
||||
function cloneHomepageContent(content: CarplaceHomepageConfig) {
|
||||
const cloned = JSON.parse(JSON.stringify(content)) as CarplaceHomepageConfig
|
||||
;(['en', 'fr', 'ar'] as CarplaceLanguage[]).forEach((language) => {
|
||||
cloned[language].sections = resolveCarplaceHomepageSections(cloned[language].sections)
|
||||
})
|
||||
return cloned
|
||||
}
|
||||
|
||||
const HOMEPAGE_SECTIONS: MarketplaceHomepageSectionType[] = [
|
||||
const HOMEPAGE_SECTIONS: CarplaceHomepageSectionType[] = [
|
||||
'hero',
|
||||
'surface',
|
||||
'pillars',
|
||||
@@ -58,12 +58,12 @@ export default function AdminSiteConfigPage() {
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Site configuration',
|
||||
description: 'Edit the main marketplace homepage here, or jump into a company to manage its branded public homepage and menu.',
|
||||
description: 'Edit the main Carplace homepage here, or jump into a company to manage its branded public homepage and menu.',
|
||||
homepageTitle: 'Main website homepage',
|
||||
homepageDescription: 'This controls the marketplace homepage shown on the main RentalDriveGo website.',
|
||||
homepageDescription: 'This controls the Carplace homepage shown on the main RentalDriveGo website.',
|
||||
saveHomepage: 'Save homepage',
|
||||
savingHomepage: 'Saving…',
|
||||
homepageSaved: 'Marketplace homepage saved.',
|
||||
homepageSaved: 'Carplace homepage saved.',
|
||||
search: 'Search by company or slug…',
|
||||
loading: 'Loading…',
|
||||
empty: 'No companies found',
|
||||
@@ -86,7 +86,7 @@ export default function AdminSiteConfigPage() {
|
||||
previewTitle: 'Live preview',
|
||||
previewDescription: 'Draft changes appear here before you save them.',
|
||||
homepageSections: 'Homepage sections',
|
||||
homepageSectionsDescription: 'Add or remove blocks from the main marketplace homepage.',
|
||||
homepageSectionsDescription: 'Add or remove blocks from the main Carplace homepage.',
|
||||
addSection: 'Add',
|
||||
removeSection: 'Remove',
|
||||
expand: 'Expand',
|
||||
@@ -94,12 +94,12 @@ export default function AdminSiteConfigPage() {
|
||||
},
|
||||
fr: {
|
||||
title: 'Configuration du site',
|
||||
description: 'Modifiez ici la page d’accueil principale de la marketplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||||
description: 'Modifiez ici la page d’accueil principale de la Carplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||||
homepageTitle: 'Page d’accueil du site principal',
|
||||
homepageDescription: 'Cette section contrôle la page d’accueil marketplace affichée sur le site principal de RentalDriveGo.',
|
||||
homepageDescription: 'Cette section contrôle la page d’accueil Carplace affichée sur le site principal de RentalDriveGo.',
|
||||
saveHomepage: 'Enregistrer la page d’accueil',
|
||||
savingHomepage: 'Enregistrement…',
|
||||
homepageSaved: 'Page d’accueil marketplace enregistrée.',
|
||||
homepageSaved: 'Page d’accueil Carplace enregistrée.',
|
||||
search: 'Rechercher par entreprise ou slug…',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucune entreprise trouvée',
|
||||
@@ -112,7 +112,7 @@ export default function AdminSiteConfigPage() {
|
||||
companiesTitle: 'Sites de marque des entreprises',
|
||||
languageLabel: 'Langue',
|
||||
hero: 'Bloc principal',
|
||||
surface: 'Bloc marketplace',
|
||||
surface: 'Bloc Carplace',
|
||||
companySection: 'Bloc opérateur',
|
||||
renterSection: 'Bloc client',
|
||||
valueSection: 'Blocs de valeur',
|
||||
@@ -122,7 +122,7 @@ export default function AdminSiteConfigPage() {
|
||||
previewTitle: 'Aperçu en direct',
|
||||
previewDescription: 'Les brouillons apparaissent ici avant l’enregistrement.',
|
||||
homepageSections: 'Sections de la page d’accueil',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil marketplace principale.',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil Carplace principale.',
|
||||
addSection: 'Ajouter',
|
||||
removeSection: 'Retirer',
|
||||
expand: 'Ouvrir',
|
||||
@@ -171,8 +171,8 @@ export default function AdminSiteConfigPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [homepage, setHomepage] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||
const [homepageLanguage, setHomepageLanguage] = useState<MarketplaceLanguage>(language)
|
||||
const [homepage, setHomepage] = useState<CarplaceHomepageConfig>(cloneCarplaceHomepageContent())
|
||||
const [homepageLanguage, setHomepageLanguage] = useState<CarplaceLanguage>(language)
|
||||
const [homepageSaving, setHomepageSaving] = useState(false)
|
||||
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
|
||||
const [homepageExpanded, setHomepageExpanded] = useState(false)
|
||||
@@ -189,7 +189,7 @@ export default function AdminSiteConfigPage() {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
@@ -202,7 +202,7 @@ export default function AdminSiteConfigPage() {
|
||||
|
||||
const homepageJson = await homepageRes.json()
|
||||
if (homepageRes.ok && homepageJson?.data) {
|
||||
setHomepage(cloneHomepageContent(homepageJson.data as MarketplaceHomepageConfig))
|
||||
setHomepage(cloneHomepageContent(homepageJson.data as CarplaceHomepageConfig))
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
@@ -225,7 +225,7 @@ export default function AdminSiteConfigPage() {
|
||||
)
|
||||
}, [search, companies])
|
||||
|
||||
function updateHomepageContent(patch: Partial<MarketplaceHomepageContent>) {
|
||||
function updateHomepageContent(patch: Partial<CarplaceHomepageContent>) {
|
||||
setHomepage((current) => ({
|
||||
...current,
|
||||
[homepageLanguage]: {
|
||||
@@ -257,16 +257,16 @@ export default function AdminSiteConfigPage() {
|
||||
}
|
||||
|
||||
function getActiveSections() {
|
||||
return resolveMarketplaceHomepageSections(activeContent.sections)
|
||||
return resolveCarplaceHomepageSections(activeContent.sections)
|
||||
}
|
||||
|
||||
function addHomepageSection(section: MarketplaceHomepageSectionType) {
|
||||
function addHomepageSection(section: CarplaceHomepageSectionType) {
|
||||
const sections = getActiveSections()
|
||||
if (sections.includes(section)) return
|
||||
updateHomepageContent({ sections: [...sections, section] })
|
||||
}
|
||||
|
||||
function removeHomepageSection(section: MarketplaceHomepageSectionType) {
|
||||
function removeHomepageSection(section: CarplaceHomepageSectionType) {
|
||||
const sections = getActiveSections().filter((item) => item !== section)
|
||||
if (sections.length === 0) return
|
||||
updateHomepageContent({ sections })
|
||||
@@ -276,7 +276,7 @@ export default function AdminSiteConfigPage() {
|
||||
setHomepageSaving(true)
|
||||
setHomepageMessage(null)
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -286,7 +286,7 @@ export default function AdminSiteConfigPage() {
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
|
||||
setHomepage(cloneHomepageContent(json.data as MarketplaceHomepageConfig))
|
||||
setHomepage(cloneHomepageContent(json.data as CarplaceHomepageConfig))
|
||||
setHomepageMessage(copy.homepageSaved)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
@@ -298,7 +298,7 @@ export default function AdminSiteConfigPage() {
|
||||
const activeContent = homepage[homepageLanguage]
|
||||
const activeSections = getActiveSections()
|
||||
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
|
||||
const sectionLabels: Record<MarketplaceHomepageSectionType, string> = {
|
||||
const sectionLabels: Record<CarplaceHomepageSectionType, string> = {
|
||||
hero: copy.hero,
|
||||
surface: copy.surface,
|
||||
pillars: copy.valueSection,
|
||||
@@ -337,7 +337,7 @@ export default function AdminSiteConfigPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
|
||||
{(['en', 'fr', 'ar'] as MarketplaceLanguage[]).map((value) => (
|
||||
{(['en', 'fr', 'ar'] as CarplaceLanguage[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
|
||||
@@ -13,7 +13,7 @@ html.dark {
|
||||
body {
|
||||
@apply text-blue-950 antialiased transition-colors dark:text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%);
|
||||
linear-gradient(180deg, #ffffff 0%, #fafafa 28%, #f5f5f5 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
@@ -28,12 +28,12 @@ body {
|
||||
|
||||
html.dark body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1128 0%, #0d1b38 35%, #07101e 100%);
|
||||
linear-gradient(180deg, #172554 0%, #1b3068 35%, #0f1a40 100%);
|
||||
}
|
||||
|
||||
html.dark .panel {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
border-color: rgb(30 64 175 / 0.40);
|
||||
background-color: rgb(23 37 84 / 0.72);
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -43,25 +43,25 @@ html.dark .panel {
|
||||
|
||||
/* ── Dark mode: zinc classes → deep navy blue ─────────────── */
|
||||
.bg-zinc-950 {
|
||||
background-color: rgb(6 13 30);
|
||||
background-color: #172554;
|
||||
}
|
||||
|
||||
.bg-zinc-950\/90 {
|
||||
background-color: rgb(6 13 30 / 0.9);
|
||||
background-color: rgb(23 37 84 / 0.9);
|
||||
}
|
||||
|
||||
.bg-zinc-900 {
|
||||
background-color: rgb(11 25 55);
|
||||
background-color: rgb(23 37 84 / 0.78);
|
||||
}
|
||||
|
||||
.bg-zinc-800,
|
||||
.bg-zinc-800\/50 {
|
||||
background-color: rgb(20 42 90);
|
||||
background-color: rgb(30 58 138 / 0.5);
|
||||
}
|
||||
|
||||
.border-zinc-800,
|
||||
.border-zinc-700 {
|
||||
border-color: rgb(30 60 130);
|
||||
border-color: rgb(30 64 175);
|
||||
}
|
||||
|
||||
.text-zinc-100,
|
||||
@@ -70,17 +70,17 @@ html.dark .panel {
|
||||
}
|
||||
|
||||
.text-zinc-500 {
|
||||
color: rgb(148 163 184);
|
||||
color: rgb(168 162 158);
|
||||
}
|
||||
|
||||
.text-zinc-400,
|
||||
.text-zinc-300 {
|
||||
color: rgb(100 116 139);
|
||||
color: rgb(120 113 108);
|
||||
}
|
||||
|
||||
.hover\:bg-zinc-800:hover,
|
||||
.hover\:bg-zinc-800\/50:hover {
|
||||
background-color: rgb(30 55 115);
|
||||
background-color: rgb(30 64 175 / 0.4);
|
||||
}
|
||||
|
||||
.hover\:text-zinc-200:hover {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { AdminI18nProvider } from '@/components/I18nProvider'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FleetOS Admin',
|
||||
description: 'FleetOS platform administration.',
|
||||
title: 'RentalDriveGo Admin',
|
||||
description: 'RentalDriveGo platform administration.',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -20,7 +20,9 @@ function AdminResetPasswordContent() {
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -95,27 +97,65 @@ function AdminResetPasswordContent() {
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">New password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Confirm password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirm ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -6,7 +6,9 @@ vi.mock('@/components/PublicFooter', () => ({ default: function MockAdminFooter(
|
||||
|
||||
import PublicShell from './PublicShell'
|
||||
|
||||
function childTypes(node: React.ReactElement): string[] {
|
||||
type WithChildren = { children?: React.ReactNode }
|
||||
|
||||
function childTypes(node: React.ReactElement<WithChildren>): string[] {
|
||||
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
|
||||
const type = child.type as any
|
||||
if (typeof type === 'string') return type
|
||||
|
||||
@@ -31,21 +31,22 @@
|
||||
"dayjs": "^1.11.11",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.5.1",
|
||||
"firebase-admin": "^12.1.0",
|
||||
"firebase-admin": "^10.3.0",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^8.0.10",
|
||||
"next": "16.2.9",
|
||||
"node-cron": "^4.5.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"resend": "^3.2.0",
|
||||
"socket.io": "^4.7.5",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"turbo": "2.10.0",
|
||||
"twilio": "^5.1.0",
|
||||
"zod": "^3.23.0",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
@@ -61,13 +62,11 @@
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.6.0"
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -24,11 +24,12 @@ import subscriptionsRouter, {
|
||||
subscriptionWebhookRouter,
|
||||
} from './modules/subscriptions/subscription.routes'
|
||||
import paymentsRouter from './modules/payments/payment.routes'
|
||||
import billingRouter from './modules/billing/billing.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 carplaceRouter from './modules/carplace/carplace.routes'
|
||||
import siteRouter from './modules/site/site.routes'
|
||||
import reviewsRouter from './modules/reviews/review.routes'
|
||||
import complaintsRouter from './modules/complaints/complaint.routes'
|
||||
@@ -95,8 +96,10 @@ const routeDocs = [
|
||||
{ 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}/carplace/home`, description: 'Carplace home' },
|
||||
{ method: 'GET', path: `${v1}/carplace/search`, description: 'Carplace paginated vehicle search' },
|
||||
{ method: 'POST', path: `${v1}/carplace/quotes`, description: 'Carplace availability and price estimate' },
|
||||
{ method: 'POST', path: `${v1}/carplace/reservations`, description: 'Create Carplace reservation request' },
|
||||
{ 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' },
|
||||
@@ -187,7 +190,7 @@ export function createApp() {
|
||||
app.use(`${v1}/admin/auth`, authLimiter)
|
||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||
|
||||
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
|
||||
app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
|
||||
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
||||
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
|
||||
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
|
||||
@@ -202,6 +205,7 @@ export function createApp() {
|
||||
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
||||
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||
app.use(`${v1}/billing`, apiLimiter, billingRouter)
|
||||
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
||||
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
||||
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"marketplaceHomepage": {
|
||||
"carplaceHomepage": {
|
||||
"en": {
|
||||
"sections": [
|
||||
"hero",
|
||||
@@ -13,13 +13,13 @@
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Marketplace discovery with a sharper front door.",
|
||||
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the company’s own branded checkout.",
|
||||
"heroTitle": "Carplace discovery with a sharper front door.",
|
||||
"heroBody": "Rental companies run private operations, renters browse a shared Carplace, and every booking ends on the company’s own branded checkout.",
|
||||
"startTrial": "Start free trial",
|
||||
"exploreVehicles": "Explore vehicles",
|
||||
"surfaceLabel": "Marketplace surface",
|
||||
"surfaceLabel": "Carplace surface",
|
||||
"surfaceTitle": "Designed for two audiences at once.",
|
||||
"surfaceBody": "Operators need control, renters need confidence. The marketplace should show both without feeling like a template.",
|
||||
"surfaceBody": "Operators need control, renters need confidence. The Carplace should show both without feeling like a template.",
|
||||
"liveLabel": "Live network",
|
||||
"trustedFleets": "Trusted fleets",
|
||||
"brandedFlows": "Branded booking flows",
|
||||
@@ -29,11 +29,11 @@
|
||||
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
|
||||
"renterKicker": "Renter experience",
|
||||
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
|
||||
"renterBody": "The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
|
||||
"renterBody": "The Carplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Unified publishing",
|
||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages."
|
||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into Carplace discovery and branded booking pages."
|
||||
},
|
||||
{
|
||||
"title": "Qualified discovery",
|
||||
@@ -47,7 +47,7 @@
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Shared marketplace visibility"
|
||||
"label": "Shared Carplace visibility"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
@@ -61,7 +61,7 @@
|
||||
"featureLabel": "What the product covers",
|
||||
"features": [
|
||||
"Fleet management with multi-photo uploads",
|
||||
"Marketplace offers and redirect booking flow",
|
||||
"Carplace offers and redirect booking flow",
|
||||
"Branded public booking site per company",
|
||||
"Customer CRM, analytics, and billing controls"
|
||||
],
|
||||
@@ -71,12 +71,12 @@
|
||||
{
|
||||
"number": "1",
|
||||
"title": "Create Account",
|
||||
"description": "Sign up for your company workspace and choose your pricing plan for the 90-day free trial."
|
||||
"description": "Sign up for your company workspace and choose your pricing plan for the 30-day free trial."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "Add Your Fleet",
|
||||
"description": "Upload vehicle photos, set prices, and create offers visible on the marketplace."
|
||||
"description": "Upload vehicle photos, set prices, and create offers visible on the Carplace."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
@@ -89,12 +89,12 @@
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Create your company workspace",
|
||||
"body": "Pick a plan, launch your 90-day trial, and verify the owner account."
|
||||
"body": "Pick a plan, launch your 30-day trial, and verify the owner account."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publish vehicles and offers",
|
||||
"body": "Upload photos once and control what appears on the marketplace and branded site."
|
||||
"body": "Upload photos once and control what appears on the Carplace and branded site."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
@@ -104,8 +104,8 @@
|
||||
],
|
||||
"stepLabel": "Step",
|
||||
"readyKicker": "Ready to launch",
|
||||
"readyTitle": "The marketplace homepage should explain the product in seconds.",
|
||||
"readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
|
||||
"readyTitle": "The Carplace homepage should explain the product in seconds.",
|
||||
"readyBody": "Use the Carplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
|
||||
"viewPricing": "View pricing",
|
||||
"createWorkspace": "Create workspace"
|
||||
},
|
||||
@@ -122,13 +122,13 @@
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Une vitrine marketplace plus claire et plus percutante.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
|
||||
"heroTitle": "Une vitrine Carplace plus claire et plus percutante.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une Carplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
|
||||
"startTrial": "Commencer l’essai gratuit",
|
||||
"exploreVehicles": "Explorer les véhicules",
|
||||
"surfaceLabel": "Vitrine marketplace",
|
||||
"surfaceLabel": "Vitrine Carplace",
|
||||
"surfaceTitle": "Pensée pour deux audiences à la fois.",
|
||||
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.",
|
||||
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La Carplace doit montrer les deux sans ressembler à un modèle générique.",
|
||||
"liveLabel": "Réseau actif",
|
||||
"trustedFleets": "Flottes fiables",
|
||||
"brandedFlows": "Parcours de marque",
|
||||
@@ -138,11 +138,11 @@
|
||||
"companyBody": "Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.",
|
||||
"renterKicker": "Expérience client",
|
||||
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site d’entreprise.",
|
||||
"renterBody": "La marketplace sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"renterBody": "La Carplace sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Publication unifiée",
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux d’administration vers la découverte marketplace et les pages de réservation de marque."
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux d’administration vers la découverte Carplace et les pages de réservation de marque."
|
||||
},
|
||||
{
|
||||
"title": "Découverte qualifiée",
|
||||
@@ -156,7 +156,7 @@
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Visibilité marketplace partagée"
|
||||
"label": "Visibilité Carplace partagée"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
@@ -170,7 +170,7 @@
|
||||
"featureLabel": "Ce que couvre le produit",
|
||||
"features": [
|
||||
"Gestion de flotte avec téléversement de plusieurs photos",
|
||||
"Offres marketplace et redirection vers la réservation",
|
||||
"Offres Carplace et redirection vers la réservation",
|
||||
"Site public de réservation par entreprise",
|
||||
"CRM client, analytics et contrôle de facturation"
|
||||
],
|
||||
@@ -180,12 +180,12 @@
|
||||
{
|
||||
"number": "1",
|
||||
"title": "Créer un compte",
|
||||
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 90 jours."
|
||||
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 30 jours."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"title": "Ajouter votre flotte",
|
||||
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la marketplace."
|
||||
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la Carplace."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
@@ -198,12 +198,12 @@
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Créez votre espace entreprise",
|
||||
"body": "Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire."
|
||||
"body": "Choisissez un plan, lancez votre essai de 30 jours et vérifiez le compte propriétaire."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publiez véhicules et offres",
|
||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque."
|
||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la Carplace et le site de marque."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
@@ -213,8 +213,8 @@
|
||||
],
|
||||
"stepLabel": "Étape",
|
||||
"readyKicker": "Prêt à démarrer",
|
||||
"readyTitle": "La page d’accueil marketplace doit présenter le produit en quelques secondes.",
|
||||
"readyBody": "Utilisez la marketplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
|
||||
"readyTitle": "La page d’accueil Carplace doit présenter le produit en quelques secondes.",
|
||||
"readyBody": "Utilisez la Carplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
|
||||
"viewPricing": "Voir les tarifs",
|
||||
"createWorkspace": "Créer l’espace"
|
||||
},
|
||||
@@ -289,7 +289,7 @@
|
||||
{
|
||||
"number": "1",
|
||||
"title": "إنشاء حساب",
|
||||
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً."
|
||||
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
|
||||
@@ -74,6 +74,22 @@ describe('errorMiddleware', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['P2021', 'P2022'])('normalizes Prisma schema mismatch errors for %s', (code) => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
const res = handle({ code })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(503)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'database_schema_mismatch',
|
||||
message: 'Database schema is out of date. Run database migrations and retry.',
|
||||
statusCode: 503,
|
||||
requestId: undefined,
|
||||
})
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('preserves AppError metadata in the response body', () => {
|
||||
const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' }))
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ export function errorMiddleware(err: any, req: Request, res: Response, _next: Ne
|
||||
return res.status(409).json(withRequestId(req, { error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 }))
|
||||
}
|
||||
|
||||
if (err.code === 'P2021' || err.code === 'P2022') {
|
||||
console.error('[API Error] Database schema mismatch', { requestId: req.requestId, err })
|
||||
return res.status(503).json(withRequestId(req, {
|
||||
error: 'database_schema_mismatch',
|
||||
message: 'Database schema is out of date. Run database migrations and retry.',
|
||||
statusCode: 503,
|
||||
}))
|
||||
}
|
||||
|
||||
if (err instanceof AppError) {
|
||||
if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err })
|
||||
return res.status(err.statusCode).json(withRequestId(req, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatDate,
|
||||
marketplaceReservationEmail,
|
||||
carplaceReservationEmail,
|
||||
resetPasswordEmail,
|
||||
signupEmail,
|
||||
} from './emailTranslations'
|
||||
@@ -40,8 +40,8 @@ describe('emailTranslations', () => {
|
||||
expect(html).toContain('45')
|
||||
})
|
||||
|
||||
it('includes optional contact phone in marketplace reservation HTML when present', () => {
|
||||
const html = marketplaceReservationEmail.html({
|
||||
it('includes optional contact phone in Carplace reservation HTML when present', () => {
|
||||
const html = carplaceReservationEmail.html({
|
||||
firstName: 'Yassine',
|
||||
vehicleYear: 2024,
|
||||
vehicleMake: 'Dacia',
|
||||
|
||||
@@ -117,9 +117,9 @@ export const resetPasswordEmail = {
|
||||
}, lang),
|
||||
}
|
||||
|
||||
// ─── Marketplace reservation request ─────────────────────────────────────────
|
||||
// ─── Carplace reservation request ─────────────────────────────────────────
|
||||
|
||||
export const marketplaceReservationEmail = {
|
||||
export const carplaceReservationEmail = {
|
||||
subject: (vehicleName: string, lang: Lang) => t({
|
||||
en: `Reservation Request Received — ${vehicleName}`,
|
||||
fr: `Demande de réservation reçue — ${vehicleName}`,
|
||||
|
||||
@@ -82,7 +82,7 @@ export const apiLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Limiter for public marketplace and site endpoints (no auth)
|
||||
// Limiter for public carplace and site endpoints (no auth)
|
||||
export const publicLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
subscription: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireSubscription } from './requireSubscription'
|
||||
|
||||
function responseStub() {
|
||||
@@ -12,44 +21,46 @@ function responseStub() {
|
||||
|
||||
describe('requireSubscription middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
|
||||
})
|
||||
|
||||
it('requires tenant/company context first', () => {
|
||||
it('requires tenant/company context first', async () => {
|
||||
const req = {} as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireSubscription(req, res, next)
|
||||
await requireSubscription(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks suspended companies with a billing URL', () => {
|
||||
it('blocks suspended companies with a subscription recovery URL', async () => {
|
||||
const req = { company: { status: 'SUSPENDED' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireSubscription(req, res, next)
|
||||
await requireSubscription(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(402)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'subscription_suspended',
|
||||
message: 'Your account has been suspended. Please contact support or renew your subscription.',
|
||||
statusCode: 402,
|
||||
billingUrl: 'https://dashboard.example.test/billing',
|
||||
billingUrl: 'https://dashboard.example.test/subscription',
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
expect(prisma.subscription.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks pending companies with setup guidance', () => {
|
||||
it('blocks pending companies with setup guidance', async () => {
|
||||
const req = { company: { status: 'PENDING' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireSubscription(req, res, next)
|
||||
await requireSubscription(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(402)
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -59,14 +70,55 @@ describe('requireSubscription middleware', () => {
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows active companies through', () => {
|
||||
const req = { company: { status: 'ACTIVE' } } as Request
|
||||
it('blocks expired trial subscriptions with a subscription recovery URL', async () => {
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'EXPIRED' } as never)
|
||||
|
||||
const req = { company: { id: 'company_1', status: 'TRIALING' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireSubscription(req, res, next)
|
||||
await requireSubscription(req, res, next)
|
||||
|
||||
expect(prisma.subscription.findUnique).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1' },
|
||||
select: { status: true },
|
||||
})
|
||||
expect(res.status).toHaveBeenCalledWith(402)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'subscription_required',
|
||||
message: 'Your subscription has ended. Please reactivate to continue.',
|
||||
statusCode: 402,
|
||||
billingUrl: 'https://dashboard.example.test/subscription',
|
||||
subscriptionStatus: 'EXPIRED',
|
||||
accessLevel: 'none',
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows active companies with active subscriptions through', async () => {
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'ACTIVE' } as never)
|
||||
|
||||
const req = { company: { id: 'company_1', status: 'ACTIVE' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireSubscription(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes subscription lookup failures to Express error handling', async () => {
|
||||
const error = new Error('database unavailable')
|
||||
vi.mocked(prisma.subscription.findUnique).mockRejectedValue(error as never)
|
||||
|
||||
const req = { company: { id: 'company_1', status: 'ACTIVE' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireSubscription(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledWith(error)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { getAccessLevel, hasAnyAccess } from '../modules/subscriptions/subscription.policy'
|
||||
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
|
||||
|
||||
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
|
||||
@@ -8,9 +10,10 @@ const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
|
||||
* Must be applied after `requireTenant`.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.company.status is not SUSPENDED or PENDING
|
||||
* req.company.status is not SUSPENDED or PENDING, and subscription access is not none
|
||||
*/
|
||||
export function requireSubscription(req: Request, res: Response, next: NextFunction) {
|
||||
export async function requireSubscription(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const company = req.company
|
||||
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
|
||||
|
||||
@@ -21,9 +24,31 @@ export function requireSubscription(req: Request, res: Response, next: NextFunct
|
||||
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` },
|
||||
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription` },
|
||||
)
|
||||
}
|
||||
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { companyId: company.id },
|
||||
select: { status: true },
|
||||
})
|
||||
const subscriptionStatus = subscription?.status ?? 'EXPIRED'
|
||||
|
||||
if (!hasAnyAccess(subscriptionStatus)) {
|
||||
return sendPaymentRequired(
|
||||
res,
|
||||
'subscription_required',
|
||||
'Your subscription has ended. Please reactivate to continue.',
|
||||
{
|
||||
billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription`,
|
||||
subscriptionStatus,
|
||||
accessLevel: getAccessLevel(subscriptionStatus),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,16 +559,16 @@ router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAd
|
||||
|
||||
// ─── Site config ───────────────────────────────────────────────
|
||||
|
||||
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
router.get('/site-config/carplace-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getMarketplaceHomepage())
|
||||
ok(res, await service.getCarplaceHomepage())
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
router.patch('/site-config/carplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { homepage } = parseBody(homepageUpdateSchema, req)
|
||||
ok(res, await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip))
|
||||
ok(res, await service.updateCarplaceHomepage(homepage, req.admin.id, req.ip))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import type { MarketplaceHomepageContent, MarketplaceHomepageHowItWorksStep, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep, MarketplaceHomepageTestimonial } from '@rentaldrivego/types'
|
||||
import type {
|
||||
CarplaceHomepageContent,
|
||||
CarplaceHomepageHowItWorksStep,
|
||||
CarplaceHomepageMetric,
|
||||
CarplaceHomepagePillar,
|
||||
CarplaceHomepageSectionType,
|
||||
CarplaceHomepageStep,
|
||||
CarplaceHomepageTestimonial,
|
||||
} from '@rentaldrivego/types'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
@@ -154,7 +162,7 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
|
||||
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
|
||||
defaultCurrency: z.literal('MAD').optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
isListedOnCarplace: z.boolean().optional(),
|
||||
homePageConfig: z.any().optional(),
|
||||
menuConfig: z.any().optional(),
|
||||
}).optional(),
|
||||
@@ -175,35 +183,35 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
}).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 marketplaceHowItWorksStepSchema: z.ZodType<MarketplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
|
||||
const marketplaceTestimonialSchema: z.ZodType<MarketplaceHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
|
||||
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
|
||||
const carplaceMetricSchema: z.ZodType<CarplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
|
||||
const carplacePillarSchema: z.ZodType<CarplaceHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
|
||||
const carplaceStepSchema: z.ZodType<CarplaceHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
|
||||
const carplaceHowItWorksStepSchema: z.ZodType<CarplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
|
||||
const carplaceTestimonialSchema: z.ZodType<CarplaceHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
|
||||
const carplaceSectionSchema: z.ZodType<CarplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
|
||||
|
||||
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
|
||||
sections: z.array(marketplaceSectionSchema).min(1),
|
||||
const carplaceHomepageContentSchema: z.ZodType<CarplaceHomepageContent> = z.object({
|
||||
sections: z.array(carplaceSectionSchema).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),
|
||||
pillars: z.array(carplacePillarSchema).length(3),
|
||||
metrics: z.array(carplaceMetricSchema).length(3),
|
||||
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1),
|
||||
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(marketplaceHowItWorksStepSchema).min(1),
|
||||
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().min(1),
|
||||
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(marketplaceTestimonialSchema).min(1),
|
||||
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(carplaceHowItWorksStepSchema).min(1),
|
||||
stepsTitle: z.string().min(1), steps: z.array(carplaceStepSchema).length(3), stepLabel: z.string().min(1),
|
||||
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(carplaceTestimonialSchema).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 carplaceHomepageConfigSchema = z.object({
|
||||
en: carplaceHomepageContentSchema,
|
||||
fr: carplaceHomepageContentSchema,
|
||||
ar: carplaceHomepageContentSchema,
|
||||
})
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
@@ -300,7 +308,7 @@ export const billingRefundSchema = z.object({
|
||||
})
|
||||
|
||||
export const homepageUpdateSchema = z.object({
|
||||
homepage: marketplaceHomepageConfigSchema,
|
||||
homepage: carplaceHomepageConfigSchema,
|
||||
})
|
||||
|
||||
export const pricingUpdateSchema = z.object({
|
||||
|
||||
@@ -3,7 +3,7 @@ import crypto from 'crypto'
|
||||
import { authenticator } from 'otplib'
|
||||
import { signActorToken } from '../../security/tokens'
|
||||
import qrcode from 'qrcode'
|
||||
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
|
||||
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import * as presenter from './admin.presenter'
|
||||
import * as repo from './admin.repo'
|
||||
@@ -402,16 +402,16 @@ export function issueBillingRefund(
|
||||
return billingService.issueRefund(invoiceId, data, adminId, ip)
|
||||
}
|
||||
|
||||
export function getMarketplaceHomepage() {
|
||||
return getMarketplaceHomepageContent()
|
||||
export function getCarplaceHomepage() {
|
||||
return getCarplaceHomepageContent()
|
||||
}
|
||||
|
||||
export async function updateMarketplaceHomepage(homepage: any, adminId: string, ip?: string) {
|
||||
const saved = await saveMarketplaceHomepageContent(homepage)
|
||||
export async function updateCarplaceHomepage(homepage: any, adminId: string, ip?: string) {
|
||||
const saved = await saveCarplaceHomepageContent(homepage)
|
||||
await repo.createAuditLog({
|
||||
adminUserId: adminId,
|
||||
action: 'UPDATE',
|
||||
resource: 'MarketplaceHomepage',
|
||||
resource: 'CarplaceHomepage',
|
||||
after: toAuditJson(saved),
|
||||
ipAddress: ip,
|
||||
userAgent: undefined,
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('analytics.service', () => {
|
||||
},
|
||||
] as never)
|
||||
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([
|
||||
{ source: 'MARKETPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
|
||||
{ source: 'CARPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
|
||||
{ source: 'DIRECT', _count: { id: 2 }, _sum: { totalAmount: null } },
|
||||
] as never)
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||
@@ -115,7 +115,7 @@ describe('analytics.service', () => {
|
||||
totalAmount: 1200,
|
||||
})])
|
||||
expect(result.sourceBreakdown).toEqual([
|
||||
{ source: 'MARKETPLACE', count: 3, revenue: 3600 },
|
||||
{ source: 'CARPLACE', count: 3, revenue: 3600 },
|
||||
{ source: 'DIRECT', count: 2, revenue: 0 },
|
||||
])
|
||||
expect(result.subscription).toEqual({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { parseBody } from '../../http/validate'
|
||||
import { created } from '../../http/respond'
|
||||
import { setSessionCookie } from '../../security/sessionCookies'
|
||||
import { created, ok } from '../../http/respond'
|
||||
import { accountStartSchema } from './auth.account.schemas'
|
||||
import * as service from './auth.account.service'
|
||||
|
||||
@@ -11,14 +10,13 @@ const router = Router()
|
||||
* POST /api/v1/auth/account/start
|
||||
*
|
||||
* Minimal signup — Step 0 of progressive profiling.
|
||||
* Creates a DRAFT company + OWNER employee and returns a JWT session.
|
||||
* See progressive-signup-plan.md Section 3.
|
||||
* Creates a DRAFT company + OWNER employee and sends a verification email.
|
||||
* The user must verify their email before signing in.
|
||||
*/
|
||||
router.post('/start', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(accountStartSchema, req)
|
||||
const result = await service.startAccount(body)
|
||||
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
|
||||
created(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import crypto from 'crypto'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { signActorToken } from '../../security/tokens'
|
||||
import { presentEmployeeSession } from './auth.presenter'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import * as repo from './auth.company.repo'
|
||||
import type { output } from 'zod'
|
||||
import type { accountStartSchema } from './auth.account.schemas'
|
||||
|
||||
type AccountStartInput = output<typeof accountStartSchema>
|
||||
|
||||
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`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal account creation — Step 0 of progressive signup.
|
||||
* Creates a DRAFT company + OWNER employee, logs the user in immediately.
|
||||
* Creates a DRAFT company + OWNER employee, sends a verification email.
|
||||
* The user must verify their email before they can log in.
|
||||
*/
|
||||
export async function startAccount(body: AccountStartInput) {
|
||||
if (await repo.findEmployeeByEmail(body.email)) {
|
||||
@@ -20,10 +33,11 @@ export async function startAccount(body: AccountStartInput) {
|
||||
|
||||
const slug = `company-${Date.now().toString(36)}`
|
||||
const passwordHash = await bcrypt.hash(body.password, 12)
|
||||
const verificationToken = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
const result = await prisma.$transaction(async (tx: any) => {
|
||||
const now = new Date()
|
||||
const trialEndAt = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000)
|
||||
const trialEndAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const company = await tx.company.create({
|
||||
data: {
|
||||
@@ -67,6 +81,7 @@ export async function startAccount(body: AccountStartInput) {
|
||||
lastName: '',
|
||||
email: body.email,
|
||||
passwordHash,
|
||||
emailVerificationToken: verificationToken,
|
||||
role: 'OWNER',
|
||||
preferredLanguage: body.preferredLanguage,
|
||||
isActive: true,
|
||||
@@ -74,12 +89,48 @@ export async function startAccount(body: AccountStartInput) {
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
return employee
|
||||
return { employee, company }
|
||||
})
|
||||
|
||||
const token = signActorToken(result.id, 'employee', {
|
||||
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as any,
|
||||
})
|
||||
const dashboardUrl = ensureDashboardBasePath(
|
||||
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||
)
|
||||
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
|
||||
|
||||
return { token, ...presentEmployeeSession(result as any, token) }
|
||||
const emailSubjects: Record<'en' | 'fr' | 'ar', string> = {
|
||||
en: 'Verify your email — RentalDriveGo',
|
||||
fr: 'Vérifiez votre email — RentalDriveGo',
|
||||
ar: 'تحقق من بريدك الإلكتروني — RentalDriveGo',
|
||||
}
|
||||
const emailBodies: Record<'en' | 'fr' | 'ar', string> = {
|
||||
en: `<p>Welcome to RentalDriveGo!</p><p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p><p>If you did not create this account, you can safely ignore this email.</p>`,
|
||||
fr: `<p>Bienvenue sur RentalDriveGo !</p><p>Cliquez sur le lien ci-dessous pour vérifier votre email et activer votre compte :</p><p><a href="${verifyUrl}">Vérifier l'email</a></p><p>Si vous n'avez pas créé ce compte, ignorez cet email.</p>`,
|
||||
ar: `<p>مرحباً بك في RentalDriveGo!</p><p>انقر على الرابط أدناه للتحقق من بريدك الإلكتروني وتفعيل حسابك:</p><p><a href="${verifyUrl}">تحقق من البريد الإلكتروني</a></p><p>إذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.</p>`,
|
||||
}
|
||||
const emailTexts: Record<'en' | 'fr' | 'ar', string> = {
|
||||
en: `Welcome to RentalDriveGo!\n\nVerify your email by visiting:\n${verifyUrl}\n\nIf you did not create this account, you can safely ignore this email.`,
|
||||
fr: `Bienvenue sur RentalDriveGo !\n\nVérifiez votre email en visitant :\n${verifyUrl}\n\nSi vous n'avez pas créé ce compte, ignorez cet email.`,
|
||||
ar: `مرحباً بك في RentalDriveGo!\n\nتحقق من بريدك الإلكتروني بزيارة:\n${verifyUrl}\n\nإذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.`,
|
||||
}
|
||||
|
||||
const lang: 'en' | 'fr' | 'ar' =
|
||||
body.preferredLanguage === 'ar' || body.preferredLanguage === 'fr'
|
||||
? body.preferredLanguage
|
||||
: 'en'
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: body.email,
|
||||
subject: emailSubjects[lang],
|
||||
html: emailBodies[lang],
|
||||
text: emailTexts[lang],
|
||||
}).catch((err) => {
|
||||
console.error('[AccountStart] Verification email delivery failed:', err?.message ?? String(err))
|
||||
console.error('[AccountStart] SMTP config — host:', process.env.MAIL_HOST ?? 'not set', '| port:', process.env.MAIL_PORT ?? 'not set', '| user:', process.env.MAIL_USERNAME ? '***' : 'not set', '| pass:', process.env.MAIL_PASSWORD ? '***' : 'not set')
|
||||
console.error('[AccountStart] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
|
||||
})
|
||||
|
||||
return {
|
||||
message: 'Account created. Please check your email to verify your address before signing in.',
|
||||
email: body.email,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ describe('auth.company.repo.createCompanySignup', () => {
|
||||
companyId: 'company_1',
|
||||
clerkUserId: 'local_owner_company_1',
|
||||
email: 'owner@example.com',
|
||||
emailVerified: baseInput.now,
|
||||
role: 'OWNER',
|
||||
preferredLanguage: 'fr',
|
||||
isActive: true,
|
||||
|
||||
@@ -136,6 +136,7 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
|
||||
email: input.ownerEmail,
|
||||
phone: input.companyPhone,
|
||||
passwordHash: input.passwordHash,
|
||||
emailVerified: input.now,
|
||||
role: 'OWNER',
|
||||
preferredLanguage: input.preferredLanguage,
|
||||
isActive: true,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { companySignupSchema } from './auth.company.schemas'
|
||||
import type { output } from 'zod'
|
||||
|
||||
type CompanySignupInput = output<typeof companySignupSchema>
|
||||
const TRIAL_PERIOD_DAYS = 90
|
||||
const TRIAL_PERIOD_DAYS = 30
|
||||
|
||||
function slugify(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
|
||||
|
||||
@@ -70,3 +70,27 @@ export function resetPassword(id: string, passwordHash: string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setEmailVerificationToken(id: string, token: string) {
|
||||
return prisma.employee.update({
|
||||
where: { id },
|
||||
data: { emailVerificationToken: token },
|
||||
})
|
||||
}
|
||||
|
||||
export function findEmployeeByVerificationToken(token: string) {
|
||||
return prisma.employee.findFirst({
|
||||
where: { emailVerificationToken: token },
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function verifyEmployeeEmail(id: string) {
|
||||
return prisma.employee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
emailVerificationToken: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,4 +61,18 @@ router.post('/reset-password', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/verify-email', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseBody(employeeResetPasswordSchema.pick({ token: true }), req)
|
||||
ok(res, await service.verifyEmail(token))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/resend-verification', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = parseBody(employeeForgotPasswordSchema, req)
|
||||
ok(res, await service.resendVerification(email))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -32,6 +32,8 @@ const employee = {
|
||||
preferredLanguage: 'fr',
|
||||
companyId: 'company_1',
|
||||
isActive: true,
|
||||
emailVerified: true,
|
||||
emailVerificationToken: null,
|
||||
passwordHash: 'hash_old',
|
||||
company: { name: 'Atlas Cars', slug: 'atlas' },
|
||||
}
|
||||
@@ -73,6 +75,38 @@ describe('auth.employee.service edge behavior', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects login for employees with a pending email verification token', async () => {
|
||||
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({
|
||||
...employee,
|
||||
emailVerified: null,
|
||||
emailVerificationToken: 'verify-token',
|
||||
} as never)
|
||||
|
||||
await expect(service.login({ email: 'agent@example.test', password: 'correct-password' })).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
error: 'email_not_verified',
|
||||
})
|
||||
})
|
||||
|
||||
it('logs in legacy full-signup owners that were created without a verification token', async () => {
|
||||
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({
|
||||
...employee,
|
||||
role: 'OWNER',
|
||||
emailVerified: null,
|
||||
emailVerificationToken: null,
|
||||
} as never)
|
||||
|
||||
const result = await service.login({ email: 'agent@example.test', password: 'correct-password' })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
token: expect.any(String),
|
||||
employee: {
|
||||
id: 'employee_1',
|
||||
role: 'OWNER',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('distinguishes employees without passwords from wrong credentials', async () => {
|
||||
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, passwordHash: null } as never)
|
||||
|
||||
|
||||
@@ -105,6 +105,10 @@ export async function login(body: EmployeeLoginInput) {
|
||||
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')
|
||||
}
|
||||
|
||||
if (!employee.emailVerified && employee.emailVerificationToken) {
|
||||
throw new AppError('Please verify your email address before signing in. Check your inbox for the verification link.', 401, 'email_not_verified')
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(body.password, employee.passwordHash)
|
||||
if (!validPassword) {
|
||||
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
|
||||
@@ -175,3 +179,59 @@ export async function resetPassword(token: string, password: string) {
|
||||
await repo.resetPassword(employee.id, await bcrypt.hash(password, 12))
|
||||
return { message: 'Password updated successfully. You can now sign in.' }
|
||||
}
|
||||
|
||||
export async function verifyEmail(token: string) {
|
||||
const employee = await repo.findEmployeeByVerificationToken(token)
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError('Verification link is invalid or has expired.', 400, 'invalid_token')
|
||||
}
|
||||
|
||||
await repo.verifyEmployeeEmail(employee.id)
|
||||
|
||||
return { message: 'Email verified successfully. You can now sign in.', email: employee.email }
|
||||
}
|
||||
|
||||
export async function resendVerification(email: string) {
|
||||
const employee = await repo.findActiveEmployeeByEmail(email)
|
||||
|
||||
if (!employee) {
|
||||
return { message: 'If that email is registered and not yet verified, a new verification link has been sent.' }
|
||||
}
|
||||
|
||||
if (employee.emailVerified) {
|
||||
return { message: 'This email is already verified. You can sign in.' }
|
||||
}
|
||||
|
||||
const verificationToken = crypto.randomBytes(32).toString('hex')
|
||||
await repo.setEmailVerificationToken(employee.id, verificationToken)
|
||||
|
||||
const dashboardUrl = ensureDashboardBasePath(
|
||||
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||
)
|
||||
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
|
||||
|
||||
const lang: 'en' | 'fr' | 'ar' =
|
||||
employee.preferredLanguage === 'ar' || employee.preferredLanguage === 'fr'
|
||||
? employee.preferredLanguage
|
||||
: 'en'
|
||||
|
||||
const emailSubjects: Record<'en' | 'fr' | 'ar', string> = {
|
||||
en: 'Verify your email — RentalDriveGo',
|
||||
fr: 'Vérifiez votre email — RentalDriveGo',
|
||||
ar: 'تحقق من بريدك الإلكتروني — RentalDriveGo',
|
||||
}
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: employee.email,
|
||||
subject: emailSubjects[lang],
|
||||
html: `<p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p>`,
|
||||
text: `Verify your email by visiting:\n${verifyUrl}`,
|
||||
}).catch((err) => {
|
||||
console.error('[ResendVerification] Email delivery failed:', err?.message ?? String(err))
|
||||
console.error('[ResendVerification] SMTP config — host:', process.env.MAIL_HOST ?? 'not set', '| port:', process.env.MAIL_PORT ?? 'not set', '| user:', process.env.MAIL_USERNAME ? '***' : 'not set')
|
||||
console.error('[ResendVerification] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
|
||||
})
|
||||
|
||||
return { message: 'A new verification link has been sent to your email.' }
|
||||
}
|
||||
|
||||
@@ -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 { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import * as service from './billing.service'
|
||||
import {
|
||||
billingListQuerySchema,
|
||||
billingSummaryQuerySchema,
|
||||
invoiceParamSchema,
|
||||
manualBillingPaymentSchema,
|
||||
} from './billing.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription, requireRole('OWNER'))
|
||||
|
||||
router.get('/summary', async (req, res, next) => {
|
||||
try {
|
||||
const query = parseQuery(billingSummaryQuerySchema, req)
|
||||
ok(res, await service.getSummary(req.companyId, query))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/invoices', async (req, res, next) => {
|
||||
try {
|
||||
const query = parseQuery(billingListQuerySchema, req)
|
||||
ok(res, await service.listInvoices(req.companyId, query))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/invoices/:invoiceId', async (req, res, next) => {
|
||||
try {
|
||||
const { invoiceId } = parseParams(invoiceParamSchema, req)
|
||||
ok(res, await service.getInvoice(req.companyId, invoiceId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/invoices/:invoiceId/payments/manual', async (req, res, next) => {
|
||||
try {
|
||||
const { invoiceId } = parseParams(invoiceParamSchema, req)
|
||||
const body = parseBody(manualBillingPaymentSchema, req)
|
||||
ok(res, await service.recordManualPayment(req.companyId, req.employee.id, invoiceId, body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { billingListQuerySchema } from './billing.schemas'
|
||||
|
||||
describe('billing.schemas billingListQuerySchema', () => {
|
||||
it('parses explicit outstandingOnly query strings as booleans', () => {
|
||||
expect(billingListQuerySchema.parse({ outstandingOnly: 'false' }).outstandingOnly).toBe(false)
|
||||
expect(billingListQuerySchema.parse({ outstandingOnly: 'true' }).outstandingOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults outstandingOnly to false', () => {
|
||||
expect(billingListQuerySchema.parse({}).outstandingOnly).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const queryBooleanSchema = z.preprocess((value) => {
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
return value
|
||||
}, z.boolean())
|
||||
|
||||
export const billingListQuerySchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().positive().max(100).default(25),
|
||||
search: z.string().trim().max(120).optional().default(''),
|
||||
paymentStatus: z.enum(['ALL', 'UNPAID', 'PARTIAL', 'PAID']).optional().default('ALL'),
|
||||
outstandingOnly: queryBooleanSchema.optional().default(false),
|
||||
})
|
||||
|
||||
export const billingSummaryQuerySchema = z.object({
|
||||
search: z.string().trim().max(120).optional().default(''),
|
||||
})
|
||||
|
||||
export const invoiceParamSchema = z.object({
|
||||
invoiceId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const manualBillingPaymentSchema = z.object({
|
||||
amountMinor: z.number().int().positive(),
|
||||
currency: z.literal('MAD').default('MAD'),
|
||||
type: z.enum(['CHARGE', 'DEPOSIT']),
|
||||
method: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL', 'OTHER']),
|
||||
receivedAt: z.string().datetime().optional(),
|
||||
reference: z.string().trim().max(120).optional(),
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
idempotencyKey: z.string().uuid(),
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildBillingInvoice } from './billing.service'
|
||||
|
||||
const baseReservation = {
|
||||
id: 'reservation_1',
|
||||
invoiceNumber: 'INV-001',
|
||||
contractNumber: 'CON-001',
|
||||
status: 'CONFIRMED',
|
||||
paymentStatus: 'UNPAID',
|
||||
startDate: new Date('2026-06-01T10:00:00.000Z'),
|
||||
endDate: new Date('2026-06-05T10:00:00.000Z'),
|
||||
totalAmount: 100000,
|
||||
depositAmount: 20000,
|
||||
customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' },
|
||||
vehicle: { make: 'Dacia', model: 'Duster', licensePlate: '123-A-6' },
|
||||
}
|
||||
|
||||
function payment(overrides: Partial<any>) {
|
||||
return {
|
||||
id: `payment_${Math.random()}`,
|
||||
reservationId: 'reservation_1',
|
||||
amount: 10000,
|
||||
currency: 'MAD',
|
||||
status: 'SUCCEEDED',
|
||||
type: 'CHARGE',
|
||||
paymentProvider: 'MANUAL',
|
||||
paymentMethod: 'CASH',
|
||||
paidAt: new Date('2026-06-01T12:00:00.000Z'),
|
||||
createdAt: new Date('2026-06-01T12:00:00.000Z'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('billing.service buildBillingInvoice', () => {
|
||||
it('keeps invoice charges and security deposits in separate balances', () => {
|
||||
const invoice = buildBillingInvoice({
|
||||
...baseReservation,
|
||||
rentalPayments: [
|
||||
payment({ amount: 100000, type: 'CHARGE' }),
|
||||
payment({ amount: 5000, type: 'DEPOSIT' }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(invoice.invoicePaid).toBe(100000)
|
||||
expect(invoice.invoiceBalanceDue).toBe(0)
|
||||
expect(invoice.depositCollected).toBe(5000)
|
||||
expect(invoice.depositOutstanding).toBe(15000)
|
||||
expect(invoice.paymentStatus).toBe('PAID')
|
||||
expect(invoice.depositStatus).toBe('PARTIALLY_COLLECTED')
|
||||
})
|
||||
|
||||
it('does not count failed or pending payments as collected', () => {
|
||||
const invoice = buildBillingInvoice({
|
||||
...baseReservation,
|
||||
rentalPayments: [
|
||||
payment({ amount: 30000, type: 'CHARGE', status: 'PENDING' }),
|
||||
payment({ amount: 20000, type: 'DEPOSIT', status: 'FAILED' }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(invoice.invoicePaid).toBe(0)
|
||||
expect(invoice.invoiceBalanceDue).toBe(100000)
|
||||
expect(invoice.depositCollected).toBe(0)
|
||||
expect(invoice.depositOutstanding).toBe(20000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
import { ConflictError, NotFoundError, ValidationError } from '../../http/errors'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
type BillingPayment = {
|
||||
id: string
|
||||
reservationId: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
type: string
|
||||
paymentProvider: string
|
||||
paymentMethod: string | null
|
||||
reference?: string | null
|
||||
note?: string | null
|
||||
receivedAt?: Date | null
|
||||
paidAt: Date | null
|
||||
createdAt: Date
|
||||
recordedByEmployee?: { firstName: string; lastName: string; email: string } | null
|
||||
}
|
||||
|
||||
type BillingReservation = {
|
||||
id: string
|
||||
invoiceNumber: string | null
|
||||
contractNumber: string | null
|
||||
status: string
|
||||
paymentStatus: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
totalAmount: number
|
||||
depositAmount: number
|
||||
customer: { firstName: string; lastName: string; email: string }
|
||||
vehicle: { make: string; model: string; licensePlate: string }
|
||||
rentalPayments: BillingPayment[]
|
||||
}
|
||||
|
||||
type ListQuery = {
|
||||
page: number
|
||||
pageSize: number
|
||||
search: string
|
||||
paymentStatus: 'ALL' | 'UNPAID' | 'PARTIAL' | 'PAID'
|
||||
outstandingOnly: boolean
|
||||
}
|
||||
|
||||
type SummaryQuery = {
|
||||
search: string
|
||||
}
|
||||
|
||||
type ManualPaymentInput = {
|
||||
amountMinor: number
|
||||
currency: 'MAD'
|
||||
type: 'CHARGE' | 'DEPOSIT'
|
||||
method: 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' | 'OTHER'
|
||||
receivedAt?: string
|
||||
reference?: string
|
||||
note?: string
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
const BILLING_CURRENCY = 'MAD'
|
||||
const COLLECTED_STATUS = new Set(['SUCCEEDED'])
|
||||
|
||||
function sumCollected(payments: BillingPayment[], type: 'CHARGE' | 'DEPOSIT') {
|
||||
return payments.reduce((total, payment) => {
|
||||
if (payment.type !== type || !COLLECTED_STATUS.has(payment.status)) return total
|
||||
return total + payment.amount
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function derivePaymentStatus(invoiceBalanceDue: number, invoicePaid: number) {
|
||||
if (invoiceBalanceDue <= 0) return 'PAID'
|
||||
if (invoicePaid > 0) return 'PARTIAL'
|
||||
return 'UNPAID'
|
||||
}
|
||||
|
||||
function deriveDepositStatus(depositRequired: number, depositCollected: number) {
|
||||
if (depositRequired <= 0) return 'NOT_REQUIRED'
|
||||
if (depositCollected <= 0) return 'OUTSTANDING'
|
||||
if (depositCollected < depositRequired) return 'PARTIALLY_COLLECTED'
|
||||
return 'HELD'
|
||||
}
|
||||
|
||||
export function buildBillingInvoice(reservation: BillingReservation) {
|
||||
const invoiceTotal = reservation.totalAmount
|
||||
const invoicePaid = sumCollected(reservation.rentalPayments, 'CHARGE')
|
||||
const invoiceRefunded = 0
|
||||
const invoiceBalanceDue = Math.max(invoiceTotal - invoicePaid, 0)
|
||||
const depositRequired = reservation.depositAmount
|
||||
const depositCollected = sumCollected(reservation.rentalPayments, 'DEPOSIT')
|
||||
const depositRefunded = 0
|
||||
const depositHeld = Math.max(depositCollected - depositRefunded, 0)
|
||||
const depositOutstanding = Math.max(depositRequired - depositCollected, 0)
|
||||
|
||||
const payments = [...reservation.rentalPayments]
|
||||
.sort((a, b) => new Date(b.paidAt ?? b.receivedAt ?? b.createdAt).getTime() - new Date(a.paidAt ?? a.receivedAt ?? a.createdAt).getTime())
|
||||
.map((payment) => ({
|
||||
id: payment.id,
|
||||
reservationId: payment.reservationId,
|
||||
amountMinor: payment.amount,
|
||||
currency: payment.currency,
|
||||
type: payment.type,
|
||||
channel: payment.paymentProvider === 'MANUAL' ? 'OFFLINE' : 'ONLINE',
|
||||
provider: payment.paymentProvider,
|
||||
method: payment.paymentMethod,
|
||||
status: payment.status,
|
||||
reference: payment.reference ?? null,
|
||||
note: payment.note ?? null,
|
||||
receivedAt: payment.receivedAt?.toISOString() ?? payment.paidAt?.toISOString() ?? payment.createdAt.toISOString(),
|
||||
paidAt: payment.paidAt?.toISOString() ?? null,
|
||||
createdAt: payment.createdAt.toISOString(),
|
||||
recordedBy: payment.recordedByEmployee
|
||||
? {
|
||||
name: `${payment.recordedByEmployee.firstName} ${payment.recordedByEmployee.lastName}`,
|
||||
email: payment.recordedByEmployee.email,
|
||||
}
|
||||
: null,
|
||||
refundedAmountMinor: 0,
|
||||
}))
|
||||
|
||||
return {
|
||||
id: reservation.id,
|
||||
reservationId: reservation.id,
|
||||
invoiceNumber: reservation.invoiceNumber,
|
||||
contractNumber: reservation.contractNumber,
|
||||
customer: reservation.customer,
|
||||
vehicle: reservation.vehicle,
|
||||
rentalPeriod: {
|
||||
startDate: reservation.startDate.toISOString(),
|
||||
endDate: reservation.endDate.toISOString(),
|
||||
},
|
||||
currency: BILLING_CURRENCY,
|
||||
status: reservation.status,
|
||||
paymentStatus: derivePaymentStatus(invoiceBalanceDue, invoicePaid),
|
||||
issuedAt: null,
|
||||
dueAt: reservation.startDate.toISOString(),
|
||||
subtotal: invoiceTotal,
|
||||
taxTotal: 0,
|
||||
discountTotal: 0,
|
||||
adjustmentTotal: 0,
|
||||
invoiceTotal,
|
||||
invoicePaid,
|
||||
invoiceRefunded,
|
||||
invoiceBalanceDue,
|
||||
depositRequired,
|
||||
depositCollected,
|
||||
depositRefunded,
|
||||
depositHeld,
|
||||
depositOutstanding,
|
||||
depositStatus: deriveDepositStatus(depositRequired, depositCollected),
|
||||
paymentCount: payments.length,
|
||||
latestPayment: payments[0] ?? null,
|
||||
payments,
|
||||
}
|
||||
}
|
||||
|
||||
function buildReservationWhere(companyId: string, search = '') {
|
||||
const trimmedSearch = search.trim()
|
||||
const where: any = { companyId }
|
||||
|
||||
if (trimmedSearch) {
|
||||
where.OR = [
|
||||
{ invoiceNumber: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||
{ contractNumber: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||
{ customer: { firstName: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||
{ customer: { lastName: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||
{ customer: { email: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||
{ vehicle: { make: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||
{ vehicle: { model: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||
{ vehicle: { licensePlate: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||
]
|
||||
}
|
||||
|
||||
return where
|
||||
}
|
||||
|
||||
const reservationInclude = {
|
||||
customer: true,
|
||||
vehicle: true,
|
||||
rentalPayments: {
|
||||
include: {
|
||||
recordedByEmployee: {
|
||||
select: { firstName: true, lastName: true, email: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
} as const
|
||||
|
||||
export async function listInvoices(companyId: string, query: ListQuery) {
|
||||
const where = buildReservationWhere(companyId, query.search)
|
||||
const reservations = await prisma.reservation.findMany({
|
||||
where,
|
||||
include: reservationInclude as any,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
let items = reservations.map((reservation) => buildBillingInvoice(reservation as any))
|
||||
if (query.paymentStatus !== 'ALL') {
|
||||
items = items.filter((invoice) => invoice.paymentStatus === query.paymentStatus)
|
||||
}
|
||||
if (query.outstandingOnly) {
|
||||
items = items.filter((invoice) => invoice.invoiceBalanceDue > 0 || invoice.depositOutstanding > 0)
|
||||
}
|
||||
|
||||
const totalRecords = items.length
|
||||
const pagedItems = items.slice((query.page - 1) * query.pageSize, query.page * query.pageSize)
|
||||
|
||||
return {
|
||||
items: pagedItems,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
totalItems: totalRecords,
|
||||
totalPages: Math.max(Math.ceil(totalRecords / query.pageSize), 1),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSummary(companyId: string, query: SummaryQuery) {
|
||||
const reservations = await prisma.reservation.findMany({
|
||||
where: buildReservationWhere(companyId, query.search),
|
||||
include: reservationInclude as any,
|
||||
})
|
||||
|
||||
const totals = reservations.reduce(
|
||||
(acc, reservation) => {
|
||||
const invoice = buildBillingInvoice(reservation as any)
|
||||
acc.totalInvoiced += invoice.invoiceTotal
|
||||
acc.totalCollected += invoice.invoicePaid
|
||||
acc.totalRefunded += invoice.invoiceRefunded
|
||||
acc.totalOutstanding += invoice.invoiceBalanceDue
|
||||
acc.depositsHeld += invoice.depositHeld
|
||||
if (invoice.invoiceBalanceDue > 0) acc.openInvoiceCount += 1
|
||||
return acc
|
||||
},
|
||||
{
|
||||
currency: BILLING_CURRENCY,
|
||||
totalInvoiced: 0,
|
||||
totalCollected: 0,
|
||||
totalRefunded: 0,
|
||||
totalOutstanding: 0,
|
||||
depositsHeld: 0,
|
||||
openInvoiceCount: 0,
|
||||
overdueInvoiceCount: 0,
|
||||
},
|
||||
)
|
||||
|
||||
return totals
|
||||
}
|
||||
|
||||
export async function getInvoice(companyId: string, invoiceId: string) {
|
||||
const reservation = await prisma.reservation.findFirst({
|
||||
where: { id: invoiceId, companyId },
|
||||
include: reservationInclude as any,
|
||||
})
|
||||
|
||||
if (!reservation) throw new NotFoundError('Billing invoice not found')
|
||||
return buildBillingInvoice(reservation as any)
|
||||
}
|
||||
|
||||
export async function recordManualPayment(companyId: string, employeeId: string, invoiceId: string, body: ManualPaymentInput) {
|
||||
const result = await prisma.$transaction(async (tx: any) => {
|
||||
const existingPayment = await tx.rentalPayment.findFirst({
|
||||
where: { companyId, idempotencyKey: body.idempotencyKey },
|
||||
})
|
||||
|
||||
if (existingPayment) {
|
||||
return existingPayment
|
||||
}
|
||||
|
||||
const reservation = await tx.reservation.findFirst({
|
||||
where: { id: invoiceId, companyId },
|
||||
include: reservationInclude as any,
|
||||
})
|
||||
|
||||
if (!reservation) throw new NotFoundError('Billing invoice not found')
|
||||
|
||||
const invoice = buildBillingInvoice(reservation)
|
||||
if (body.currency !== invoice.currency) {
|
||||
throw new ValidationError('Payment currency must match the invoice currency')
|
||||
}
|
||||
|
||||
const permittedAmount = body.type === 'DEPOSIT' ? invoice.depositOutstanding : invoice.invoiceBalanceDue
|
||||
if (permittedAmount <= 0) {
|
||||
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Invoice is already fully paid')
|
||||
}
|
||||
if (body.amountMinor > permittedAmount) {
|
||||
throw new ValidationError(body.type === 'DEPOSIT' ? 'Payment amount exceeds deposit outstanding' : 'Payment amount exceeds invoice balance due')
|
||||
}
|
||||
|
||||
const receivedAt = body.receivedAt ? new Date(body.receivedAt) : new Date()
|
||||
const payment = await tx.rentalPayment.create({
|
||||
data: {
|
||||
companyId,
|
||||
reservationId: reservation.id,
|
||||
amount: body.amountMinor,
|
||||
currency: body.currency,
|
||||
status: 'SUCCEEDED',
|
||||
type: body.type,
|
||||
paymentProvider: 'MANUAL',
|
||||
paymentMethod: body.method,
|
||||
reference: body.reference,
|
||||
note: body.note,
|
||||
receivedAt,
|
||||
paidAt: receivedAt,
|
||||
recordedByEmployeeId: employeeId,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
},
|
||||
})
|
||||
|
||||
if (body.type === 'CHARGE') {
|
||||
const paidAmount = invoice.invoicePaid + body.amountMinor
|
||||
await tx.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: {
|
||||
paidAmount,
|
||||
paymentStatus: paidAmount >= invoice.invoiceTotal ? 'PAID' : 'PARTIAL',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return payment
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presentVehicleWithAvailability } from './marketplace.presenter'
|
||||
import { presentVehicleWithAvailability } from './carplace.presenter'
|
||||
|
||||
describe('marketplace.presenter', () => {
|
||||
describe('carplace.presenter', () => {
|
||||
it('adds public availability fields without mutating the original vehicle shape', () => {
|
||||
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
|
||||
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
|
||||
+15
-12
@@ -11,9 +11,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
import * as repo from './marketplace.repo'
|
||||
import * as repo from './carplace.repo'
|
||||
|
||||
describe('marketplace.repo public query and write boundaries', () => {
|
||||
describe('carplace.repo public query and write boundaries', () => {
|
||||
it('finds public offers using active/public/current-window filters and featured ordering', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
|
||||
@@ -33,38 +33,41 @@ describe('marketplace.repo public query and write boundaries', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('lists marketplace cities only from active listed companies with a public city', async () => {
|
||||
it('lists carplace cities only from active listed companies with a public city', async () => {
|
||||
await repo.findCitiesFromCompanies()
|
||||
|
||||
expect(prismaMock.company.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
|
||||
brand: { isListedOnCarplace: true, publicCity: { not: null } },
|
||||
},
|
||||
select: { brand: { select: { publicCity: true } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('requires published vehicles and an active company when loading marketplace vehicle details', async () => {
|
||||
await repo.findVehicleForMarketplace('vehicle_1', 'atlas')
|
||||
it('requires published vehicles and an active company when loading carplace vehicle details', async () => {
|
||||
await repo.findVehicleForCarplace('vehicle_1', 'atlas')
|
||||
|
||||
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'vehicle_1',
|
||||
isPublished: true,
|
||||
company: { slug: 'atlas', status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
company: {
|
||||
OR: [{ slug: 'atlas' }, { brand: { subdomain: 'atlas' } }],
|
||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||
},
|
||||
},
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('patches marketplace customer address fields without deleting existing address metadata', async () => {
|
||||
it('patches carplace customer address fields without deleting existing address metadata', async () => {
|
||||
prismaMock.customer.findUnique.mockResolvedValueOnce({
|
||||
id: 'customer_1',
|
||||
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' },
|
||||
})
|
||||
|
||||
await repo.upsertMarketplaceCustomer('company_1', {
|
||||
await repo.upsertCarplaceCustomer('company_1', {
|
||||
email: 'renter@example.test',
|
||||
firstName: 'Nora',
|
||||
lastName: 'Renter',
|
||||
@@ -86,11 +89,11 @@ describe('marketplace.repo public query and write boundaries', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('creates marketplace reservations as draft marketplace-sourced records', async () => {
|
||||
it('creates carplace reservations as draft carplace-sourced records', async () => {
|
||||
const startDate = new Date('2026-07-01T10:00:00.000Z')
|
||||
const endDate = new Date('2026-07-05T10:00:00.000Z')
|
||||
|
||||
await repo.createMarketplaceReservation({
|
||||
await repo.createCarplaceReservation({
|
||||
companyId: 'company_1',
|
||||
vehicleId: 'vehicle_1',
|
||||
customerId: 'customer_1',
|
||||
@@ -108,7 +111,7 @@ describe('marketplace.repo public query and write boundaries', () => {
|
||||
companyId: 'company_1',
|
||||
vehicleId: 'vehicle_1',
|
||||
customerId: 'customer_1',
|
||||
source: 'MARKETPLACE',
|
||||
source: 'CARPLACE',
|
||||
status: 'DRAFT',
|
||||
}),
|
||||
})
|
||||
+16
-16
@@ -13,7 +13,7 @@ export async function findCitiesFromCompanies() {
|
||||
return prisma.company.findMany({
|
||||
where: {
|
||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
|
||||
brand: { isListedOnCarplace: true, publicCity: { not: null } },
|
||||
},
|
||||
select: { brand: { select: { publicCity: true } } },
|
||||
})
|
||||
@@ -23,7 +23,7 @@ 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 } },
|
||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, carplaceRating: true, primaryColor: true } },
|
||||
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
|
||||
},
|
||||
skip,
|
||||
@@ -35,31 +35,31 @@ export async function findPublishedVehicles(where: any) {
|
||||
return prisma.vehicle.findMany({
|
||||
where,
|
||||
include: {
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, carplaceRating: true, primaryColor: true } } } },
|
||||
},
|
||||
orderBy: { dailyRate: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleForMarketplace(vehicleId: string, companySlug: string) {
|
||||
export async function findVehicleForCarplace(vehicleId: string, companySlug: string) {
|
||||
return prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
|
||||
where: { id: vehicleId, isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: companySlug }, { brand: { subdomain: companySlug } }] } },
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleForMarketplaceById(vehicleId: string) {
|
||||
export async function findVehicleForCarplaceById(vehicleId: string) {
|
||||
return prisma.vehicle.findFirst({
|
||||
where: {
|
||||
id: vehicleId,
|
||||
isPublished: true,
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } },
|
||||
},
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
export async function upsertCarplaceCustomer(companyId: string, data: {
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
@@ -115,17 +115,17 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
return prisma.customer.update({ where: { id: existing.id }, data: payload })
|
||||
}
|
||||
|
||||
export async function createMarketplaceReservation(data: {
|
||||
export async function createCarplaceReservation(data: {
|
||||
companyId: string; vehicleId: string; customerId: string
|
||||
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
|
||||
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
|
||||
}) {
|
||||
return prisma.reservation.create({
|
||||
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
|
||||
data: { ...data, source: 'CARPLACE', status: 'DRAFT' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createMarketplaceFunnelEvent(data: {
|
||||
export async function createCarplaceFunnelEvent(data: {
|
||||
eventName: string
|
||||
companySlug: string
|
||||
vehicleId: string
|
||||
@@ -138,12 +138,12 @@ export async function createMarketplaceFunnelEvent(data: {
|
||||
where: {
|
||||
id: data.vehicleId,
|
||||
isPublished: true,
|
||||
company: { slug: data.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: data.companySlug }, { brand: { subdomain: data.companySlug } }] },
|
||||
},
|
||||
select: { companyId: true },
|
||||
})
|
||||
|
||||
return prisma.marketplaceFunnelEvent.create({
|
||||
return prisma.carplaceFunnelEvent.create({
|
||||
data: {
|
||||
eventName: data.eventName,
|
||||
companyId: vehicle?.companyId ?? null,
|
||||
@@ -159,7 +159,7 @@ export async function createMarketplaceFunnelEvent(data: {
|
||||
|
||||
export async function findCompanyPage(slug: string) {
|
||||
return prisma.company.findFirst({
|
||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] },
|
||||
include: {
|
||||
brand: true,
|
||||
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
|
||||
@@ -169,7 +169,7 @@ export async function findCompanyPage(slug: string) {
|
||||
}
|
||||
|
||||
export async function findCompanyBySlug(slug: string) {
|
||||
return prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
return prisma.company.findFirstOrThrow({ where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] } })
|
||||
}
|
||||
|
||||
export async function findCompanyReviews(companyId: string) {
|
||||
@@ -189,7 +189,7 @@ export async function findCompanyVehicles(companyId: string) {
|
||||
}
|
||||
|
||||
export async function findVehicleById(slug: string, vehicleId: string) {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] } })
|
||||
return prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
|
||||
include: { company: { include: { brand: true } } },
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { Router } from 'express'
|
||||
import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||
import { created, ok } from '../../http/respond'
|
||||
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
|
||||
import * as service from './carplace.service'
|
||||
import {
|
||||
carplaceQuoteSchema,
|
||||
companiesQuerySchema,
|
||||
carplaceFunnelEventSchema,
|
||||
offerCodeParamSchema,
|
||||
reviewBodySchema,
|
||||
reviewTokenSchema,
|
||||
paginationSchema,
|
||||
searchSchema,
|
||||
slugParamSchema,
|
||||
carplaceReservationSchema,
|
||||
vehicleAvailabilityQuerySchema,
|
||||
vehicleParamSchema,
|
||||
} from './carplace.schemas'
|
||||
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
|
||||
const idempotencyCache = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
|
||||
const IDEMPOTENCY_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
function cleanupIdempotencyCache(now = Date.now()) {
|
||||
for (const [key, value] of idempotencyCache) if (value.expiresAt <= now) idempotencyCache.delete(key)
|
||||
}
|
||||
|
||||
router.get('/home', async (_req, res, next) => {
|
||||
try {
|
||||
const [cities, offers, companies, search] = await Promise.all([
|
||||
service.getCities(),
|
||||
service.getPublicOffers(),
|
||||
service.getListedCompanies({ page: 1, pageSize: 8 }),
|
||||
service.searchVehiclesPage({ page: 1, pageSize: 9 }),
|
||||
])
|
||||
ok(res, { cities, offers: offers.slice(0, 6), companies, vehicles: search.items })
|
||||
} catch (error) {
|
||||
if (isDatabaseUnavailableError(error)) return ok(res, { cities: [], offers: [], companies: [], vehicles: [] })
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/cities', async (_req, res, next) => {
|
||||
try { ok(res, await service.getCities()) }
|
||||
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||
})
|
||||
|
||||
router.get('/offers', async (_req, res, next) => {
|
||||
try { ok(res, await service.getPublicOffers()) }
|
||||
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||
})
|
||||
|
||||
router.get('/companies', async (req, res, next) => {
|
||||
try {
|
||||
const filters = parseQuery(companiesQuerySchema, req)
|
||||
const pagination = parseQuery(paginationSchema, req)
|
||||
ok(res, await service.getListedCompanies({ ...filters, ...pagination }))
|
||||
} catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||
})
|
||||
|
||||
router.get('/search', async (req, res, next) => {
|
||||
try {
|
||||
const filters = parseQuery(searchSchema, req)
|
||||
const pagination = parseQuery(paginationSchema, req)
|
||||
ok(res, await service.searchVehiclesPage({ ...filters, ...pagination }))
|
||||
} catch (error) {
|
||||
if (isDatabaseUnavailableError(error)) return ok(res, { items: [], pagination: { page: 1, pageSize: 20, totalItems: 0, totalPages: 0 }, facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } } })
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/quotes', async (req, res, next) => {
|
||||
try { ok(res, await service.createCarplaceQuote(parseBody(carplaceQuoteSchema, req))) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.post('/reservations', async (req, res, next) => {
|
||||
try {
|
||||
cleanupIdempotencyCache()
|
||||
const body = parseBody(carplaceReservationSchema, req)
|
||||
const key = body.idempotencyKey ?? req.header('Idempotency-Key')?.trim()
|
||||
const fingerprint = createHash('sha256').update(JSON.stringify(body)).digest('hex')
|
||||
if (key) {
|
||||
const cached = idempotencyCache.get(key)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
if (cached.fingerprint !== fingerprint) return res.status(409).json({ error: 'idempotency_conflict', message: 'This idempotency key was already used with a different request', statusCode: 409 })
|
||||
return ok(res, cached.result)
|
||||
}
|
||||
}
|
||||
const result = await service.createCarplaceReservation(body)
|
||||
if (key) idempotencyCache.set(key, { expiresAt: Date.now() + IDEMPOTENCY_TTL_MS, fingerprint, result })
|
||||
created(res, result)
|
||||
} catch (error) {
|
||||
if (isDatabaseUnavailableError(error)) return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/events', async (req, res, next) => {
|
||||
try { ok(res, await service.trackCarplaceFunnelEvent(parseBody(carplaceFunnelEventSchema, req), req.renterId)) }
|
||||
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, { success: false }); next(error) }
|
||||
})
|
||||
|
||||
router.get('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
ok(res, await service.getReviewContext(token))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.post('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
created(res, await service.submitReview(token, parseBody(reviewBodySchema, req)))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.post('/offers/:code/validate', async (req, res, next) => {
|
||||
try {
|
||||
const { code } = parseParams(offerCodeParamSchema, req)
|
||||
ok(res, await service.validateOfferCode(code))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.get('/:slug', async (req, res, next) => {
|
||||
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyPage(slug)) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.get('/:slug/reviews', async (req, res, next) => {
|
||||
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyReviews(slug)) }
|
||||
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||
})
|
||||
|
||||
|
||||
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyVehicles(slug)) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyOffers(slug)) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
try { const { slug, id } = parseParams(vehicleParamSchema, req); ok(res, await service.getVehicleDetail(slug, id)) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(vehicleParamSchema, req)
|
||||
const range = parseQuery(vehicleAvailabilityQuerySchema, req)
|
||||
ok(res, await service.getCarplaceVehicleAvailability(slug, id, range))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
export default router
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { marketplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './marketplace.schemas'
|
||||
import { carplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './carplace.schemas'
|
||||
|
||||
describe('marketplace.schemas', () => {
|
||||
describe('carplace.schemas', () => {
|
||||
it('coerces pagination and caps untrusted public page sizes', () => {
|
||||
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
|
||||
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
|
||||
@@ -18,8 +18,8 @@ describe('marketplace.schemas', () => {
|
||||
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
|
||||
})
|
||||
|
||||
it('defaults marketplace reservation language while keeping date and email validation strict', () => {
|
||||
const parsed = marketplaceReservationSchema.parse({
|
||||
it('defaults carplace reservation language while keeping date and email validation strict', () => {
|
||||
const parsed = carplaceReservationSchema.parse({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
companySlug: 'atlas-cars',
|
||||
firstName: 'Aya',
|
||||
@@ -30,8 +30,8 @@ describe('marketplace.schemas', () => {
|
||||
})
|
||||
|
||||
expect(parsed.language).toBe('fr')
|
||||
expect(() => marketplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
|
||||
expect(() => marketplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
|
||||
expect(() => carplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
|
||||
expect(() => carplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
|
||||
})
|
||||
|
||||
it('validates review ratings as bounded integers', () => {
|
||||
+15
-2
@@ -29,7 +29,17 @@ export const vehicleAvailabilityQuerySchema = z.object({
|
||||
endDate: z.string().datetime(),
|
||||
})
|
||||
|
||||
export const marketplaceReservationSchema = z.object({
|
||||
|
||||
export const carplaceQuoteSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
pickupLocation: z.string().trim().max(100).optional(),
|
||||
returnLocation: z.string().trim().max(100).optional(),
|
||||
promoCode: z.string().trim().max(100).optional(),
|
||||
})
|
||||
|
||||
export const carplaceReservationSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
companySlug: z.string().trim().max(100).optional(),
|
||||
firstName: z.string().min(1).max(100),
|
||||
@@ -37,6 +47,7 @@ export const marketplaceReservationSchema = z.object({
|
||||
email: z.string().email(),
|
||||
// Contact info — collected at reservation time
|
||||
phone: z.string().min(1).max(30).optional(),
|
||||
driverAge: z.coerce.number().int().min(18).max(100).optional(),
|
||||
// Identity & license — optional at reservation, collected at pickup
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().min(1).max(100).optional(),
|
||||
@@ -52,11 +63,13 @@ export const marketplaceReservationSchema = z.object({
|
||||
endDate: z.string().datetime(),
|
||||
pickupLocation: z.string().trim().max(100).optional(),
|
||||
returnLocation: z.string().trim().max(100).optional(),
|
||||
promoCode: z.string().trim().max(100).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
idempotencyKey: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
export const marketplaceFunnelEventSchema = z.object({
|
||||
export const carplaceFunnelEventSchema = z.object({
|
||||
eventName: z.enum([
|
||||
'booking_form_viewed',
|
||||
'trip_dates_selected',
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./marketplace.repo', () => ({
|
||||
vi.mock('./carplace.repo', () => ({
|
||||
findCompanyPage: vi.fn(),
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyReviews: vi.fn(),
|
||||
@@ -12,16 +12,16 @@ vi.mock('./marketplace.repo', () => ({
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
||||
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
|
||||
vi.mock('../../lib/emailTranslations', () => ({
|
||||
marketplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
|
||||
carplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
|
||||
}))
|
||||
|
||||
import * as repo from './marketplace.repo'
|
||||
import * as repo from './carplace.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './marketplace.service'
|
||||
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './carplace.service'
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('marketplace.service public page edges', () => {
|
||||
describe('carplace.service public page edges', () => {
|
||||
it('enriches company page vehicles with availability without changing company metadata', async () => {
|
||||
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
|
||||
vi.mocked(repo.findCompanyPage).mockResolvedValue({
|
||||
+187
-38
@@ -1,8 +1,8 @@
|
||||
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'
|
||||
import { carplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import * as repo from './carplace.repo'
|
||||
|
||||
function normalizeLocation(value?: string | null) {
|
||||
const normalized = value?.trim()
|
||||
@@ -21,6 +21,28 @@ function includesLocation(values: string[], target: string) {
|
||||
return values.some((value) => value.toLocaleLowerCase() === key)
|
||||
}
|
||||
|
||||
function calculateOfferDiscount(
|
||||
offer: { type?: string; discountValue?: number; specialRate?: number | null; maxRedemptions?: number | null; redemptionCount?: number | null },
|
||||
baseSubtotal: number,
|
||||
dailyRate: number,
|
||||
rentalDays: number,
|
||||
) {
|
||||
if (offer.maxRedemptions != null && (offer.redemptionCount ?? 0) >= offer.maxRedemptions) {
|
||||
throw new AppError('Offer code has reached its redemption limit', 409, 'offer_exhausted')
|
||||
}
|
||||
|
||||
const value = Number(offer.discountValue ?? 0)
|
||||
let discount = 0
|
||||
if (offer.type === 'PERCENTAGE') discount = Math.round(baseSubtotal * (value / 100))
|
||||
else if (offer.type === 'FIXED_AMOUNT') discount = value
|
||||
else if (offer.type === 'FREE_DAY') discount = dailyRate * value
|
||||
else if (offer.type === 'SPECIAL_RATE') {
|
||||
const specialRate = Number(offer.specialRate ?? value)
|
||||
discount = Math.max(0, baseSubtotal - (specialRate * rentalDays))
|
||||
}
|
||||
return Math.min(baseSubtotal, Math.max(0, Math.round(discount)))
|
||||
}
|
||||
|
||||
function matchesVehicleLocationRules(
|
||||
vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean },
|
||||
params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' },
|
||||
@@ -65,7 +87,7 @@ export async function getListedCompanies(params: {
|
||||
}) {
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }
|
||||
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: 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() } } }
|
||||
@@ -78,13 +100,23 @@ export async function searchVehicles(params: {
|
||||
startDate?: string; endDate?: string; category?: string
|
||||
maxPrice?: number; transmission?: string; make?: string; model?: string
|
||||
page?: number; pageSize?: number
|
||||
}) {
|
||||
const result = await searchVehiclesPage(params)
|
||||
return result.items
|
||||
}
|
||||
|
||||
export async function searchVehiclesPage(params: {
|
||||
city?: string; pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different'
|
||||
startDate?: string; endDate?: string; category?: string
|
||||
maxPrice?: number; transmission?: string; make?: string; model?: string
|
||||
page?: number; pageSize?: number
|
||||
}) {
|
||||
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 } },
|
||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } },
|
||||
}
|
||||
if (params.category) where.category = params.category
|
||||
if (params.maxPrice !== undefined) where.dailyRate = { lte: params.maxPrice }
|
||||
@@ -92,40 +124,83 @@ export async function searchVehicles(params: {
|
||||
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' } } }
|
||||
where.company = { ...where.company, brand: { isListedOnCarplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
|
||||
}
|
||||
|
||||
let range: { startDate: Date; endDate: Date } | undefined
|
||||
if (params.startDate || params.endDate) {
|
||||
if (!params.startDate || !params.endDate) throw new AppError('Both start and end dates are required', 400, 'invalid_dates')
|
||||
const startDate = new Date(params.startDate)
|
||||
const endDate = new Date(params.endDate)
|
||||
if (!Number.isFinite(startDate.getTime()) || !Number.isFinite(endDate.getTime()) || endDate <= startDate) {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
range = { startDate, endDate }
|
||||
}
|
||||
|
||||
const vehicles = await repo.findPublishedVehicles(where) as any[]
|
||||
const locationFiltered = vehicles.filter((vehicle: any) => matchesVehicleLocationRules(vehicle, params))
|
||||
const pagedVehicles = locationFiltered.slice((page - 1) * pageSize, page * pageSize)
|
||||
const availabilityByVehicle = new Map<string, any>()
|
||||
|
||||
let availableVehicles = locationFiltered
|
||||
if (range) {
|
||||
const availability = await Promise.all(
|
||||
pagedVehicles.map(async (v: any) => {
|
||||
const a = await getVehicleAvailabilitySummary(v.id, {
|
||||
companyId: v.companyId,
|
||||
range: params.startDate && params.endDate
|
||||
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
|
||||
: undefined,
|
||||
})
|
||||
return [v.id, a] as const
|
||||
locationFiltered.map(async (vehicle: any) => {
|
||||
const result = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId, range })
|
||||
availabilityByVehicle.set(vehicle.id, result)
|
||||
return result.available ? vehicle : null
|
||||
}),
|
||||
)
|
||||
const availMap = new Map<string, any>(availability)
|
||||
|
||||
return pagedVehicles.map((v: any) => ({
|
||||
...v,
|
||||
availability: availMap.get(v.id)?.available ?? null,
|
||||
availabilityStatus: availMap.get(v.id)?.status ?? 'AVAILABLE',
|
||||
nextAvailableAt: availMap.get(v.id)?.nextAvailableAt ?? null,
|
||||
}))
|
||||
availableVehicles = availability.filter((vehicle): vehicle is any => Boolean(vehicle))
|
||||
}
|
||||
|
||||
export async function getMarketplaceVehicleAvailability(
|
||||
const pageItems = availableVehicles.slice((page - 1) * pageSize, page * pageSize)
|
||||
const items = pageItems.map((vehicle: any) => {
|
||||
const availability = availabilityByVehicle.get(vehicle.id)
|
||||
return {
|
||||
...vehicle,
|
||||
availability: availability?.available ?? true,
|
||||
availabilityStatus: availability?.status ?? 'AVAILABLE',
|
||||
nextAvailableAt: availability?.nextAvailableAt ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
const countValues = (key: 'category' | 'transmission' | 'make') => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const vehicle of availableVehicles) {
|
||||
const value = typeof vehicle[key] === 'string' ? vehicle[key].trim() : ''
|
||||
if (value) counts.set(value, (counts.get(value) ?? 0) + 1)
|
||||
}
|
||||
return Array.from(counts, ([value, count]) => ({ value, count })).sort((a, b) => a.value.localeCompare(b.value))
|
||||
}
|
||||
const prices = availableVehicles.map((vehicle) => Number(vehicle.dailyRate)).filter(Number.isFinite)
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
totalItems: availableVehicles.length,
|
||||
totalPages: Math.ceil(availableVehicles.length / pageSize),
|
||||
},
|
||||
facets: {
|
||||
categories: countValues('category'),
|
||||
transmissions: countValues('transmission'),
|
||||
makes: countValues('make'),
|
||||
price: {
|
||||
min: prices.length ? Math.min(...prices) : null,
|
||||
max: prices.length ? Math.max(...prices) : null,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCarplaceVehicleAvailability(
|
||||
slug: string,
|
||||
vehicleId: string,
|
||||
params: { startDate: string; endDate: string },
|
||||
) {
|
||||
const vehicle = await repo.findVehicleForMarketplace(vehicleId, slug)
|
||||
const vehicle = await repo.findVehicleForCarplace(vehicleId, slug)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const startDate = new Date(params.startDate)
|
||||
@@ -148,20 +223,85 @@ export async function getMarketplaceVehicleAvailability(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCarplaceQuote(body: {
|
||||
vehicleId: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
pickupLocation?: string
|
||||
returnLocation?: string
|
||||
promoCode?: string
|
||||
}) {
|
||||
const startDate = new Date(body.startDate)
|
||||
const endDate = new Date(body.endDate)
|
||||
if (!Number.isFinite(startDate.getTime()) || !Number.isFinite(endDate.getTime()) || endDate <= startDate) {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
|
||||
const vehicle = await repo.findVehicleForCarplaceById(body.vehicleId)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const pickupLocation = normalizeLocation(body.pickupLocation)
|
||||
const returnLocation = normalizeLocation(body.returnLocation) ?? pickupLocation
|
||||
const pickupLocations = normalizeLocationList(vehicle.pickupLocations)
|
||||
const dropoffLocations = normalizeLocationList(vehicle.dropoffLocations)
|
||||
|
||||
if (pickupLocation && pickupLocations.length > 0 && !includesLocation(pickupLocations, pickupLocation)) {
|
||||
throw new AppError('Selected pickup location is not available for this vehicle', 400, 'invalid_pickup_location')
|
||||
}
|
||||
if (pickupLocation && returnLocation && pickupLocation.toLocaleLowerCase() !== returnLocation.toLocaleLowerCase()) {
|
||||
if (!vehicle.allowDifferentDropoff) throw new AppError('This vehicle must be returned to the same pickup location', 400, 'same_dropoff_required')
|
||||
if (dropoffLocations.length > 0 && !includesLocation(dropoffLocations, returnLocation)) {
|
||||
throw new AppError('Selected return location is not available for this vehicle', 400, 'invalid_return_location')
|
||||
}
|
||||
}
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||
companyId: vehicle.companyId,
|
||||
range: { startDate, endDate },
|
||||
})
|
||||
const rentalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||
const baseSubtotal = vehicle.dailyRate * rentalDays
|
||||
let discountAmount = 0
|
||||
const discounts: Array<{ label: string; amount: number }> = []
|
||||
|
||||
if (body.promoCode) {
|
||||
const offer = await repo.findOfferByCode(body.promoCode.trim())
|
||||
if (!offer || offer.companyId !== vehicle.companyId) throw new AppError('Offer code is invalid or expired', 400, 'offer_invalid')
|
||||
discountAmount = calculateOfferDiscount(offer, baseSubtotal, vehicle.dailyRate, rentalDays)
|
||||
discounts.push({ label: offer.title, amount: discountAmount })
|
||||
}
|
||||
|
||||
return {
|
||||
available: availability.available,
|
||||
availabilityStatus: availability.status,
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
currency: 'MAD' as const,
|
||||
rentalDays,
|
||||
dailyRate: vehicle.dailyRate,
|
||||
baseSubtotal,
|
||||
discounts,
|
||||
fees: [],
|
||||
taxes: [],
|
||||
estimatedTotal: Math.max(0, baseSubtotal - discountAmount),
|
||||
securityDeposit: null,
|
||||
finalPriceRequiresCompanyConfirmation: true as const,
|
||||
}
|
||||
}
|
||||
|
||||
function generateBookingReference() {
|
||||
const year = new Date().getUTCFullYear()
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
|
||||
return `BK-${year}-${random}`
|
||||
}
|
||||
|
||||
export async function createMarketplaceReservation(body: {
|
||||
export async function createCarplaceReservation(body: {
|
||||
vehicleId: string; companySlug?: string; firstName: string; lastName: string
|
||||
email: string; phone?: string; dateOfBirth?: string; nationality?: string
|
||||
email: string; phone?: string; driverAge?: number; dateOfBirth?: string; nationality?: string
|
||||
identityDocumentNumber?: string; fullAddress?: string
|
||||
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
|
||||
licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: string
|
||||
startDate: string; endDate: string
|
||||
pickupLocation?: string; returnLocation?: string; notes?: string; language?: string
|
||||
pickupLocation?: string; returnLocation?: string; promoCode?: string; notes?: string; language?: string
|
||||
}) {
|
||||
const startDate = new Date(body.startDate)
|
||||
const endDate = new Date(body.endDate)
|
||||
@@ -170,7 +310,7 @@ export async function createMarketplaceReservation(body: {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
|
||||
const vehicle = await repo.findVehicleForMarketplaceById(body.vehicleId)
|
||||
const vehicle = await repo.findVehicleForCarplaceById(body.vehicleId)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const pickupLocation = normalizeLocation(body.pickupLocation)
|
||||
@@ -198,7 +338,7 @@ export async function createMarketplaceReservation(body: {
|
||||
})
|
||||
}
|
||||
|
||||
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, {
|
||||
const customer = await repo.upsertCarplaceCustomer(vehicle.companyId, {
|
||||
email: body.email,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
@@ -216,9 +356,18 @@ export async function createMarketplaceReservation(body: {
|
||||
})
|
||||
|
||||
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||
const totalAmount = vehicle.dailyRate * totalDays
|
||||
const baseSubtotal = vehicle.dailyRate * totalDays
|
||||
let discountAmount = 0
|
||||
if (body.promoCode) {
|
||||
const offer = await repo.findOfferByCode(body.promoCode.trim())
|
||||
if (!offer || offer.companyId !== vehicle.companyId) throw new AppError('Offer code is invalid or expired', 400, 'offer_invalid')
|
||||
discountAmount = calculateOfferDiscount(offer, baseSubtotal, vehicle.dailyRate, totalDays)
|
||||
}
|
||||
const totalAmount = Math.max(0, baseSubtotal - discountAmount)
|
||||
|
||||
const reservation = await repo.createMarketplaceReservation({
|
||||
const reservationNotes = [body.notes?.trim(), body.driverAge ? `Driver age: ${body.driverAge}` : null].filter(Boolean).join('\n').slice(0, 500) || undefined
|
||||
|
||||
const reservation = await repo.createCarplaceReservation({
|
||||
companyId: vehicle.companyId,
|
||||
vehicleId: body.vehicleId,
|
||||
customerId: customer.id,
|
||||
@@ -229,7 +378,7 @@ export async function createMarketplaceReservation(body: {
|
||||
dailyRate: vehicle.dailyRate,
|
||||
totalDays,
|
||||
totalAmount,
|
||||
notes: body.notes,
|
||||
notes: reservationNotes,
|
||||
bookingReference: generateBookingReference(),
|
||||
})
|
||||
|
||||
@@ -245,14 +394,14 @@ export async function createMarketplaceReservation(body: {
|
||||
|
||||
sendTransactionalEmail({
|
||||
to: body.email,
|
||||
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||
html: marketplaceReservationEmail.html(emailOpts, lang),
|
||||
text: marketplaceReservationEmail.text(emailOpts, lang),
|
||||
subject: carplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||
html: carplaceReservationEmail.html(emailOpts, lang),
|
||||
text: carplaceReservationEmail.text(emailOpts, lang),
|
||||
}).catch(() => null)
|
||||
|
||||
sendNotification({
|
||||
type: 'NEW_BOOKING',
|
||||
title: 'New Marketplace Reservation Request',
|
||||
title: 'New Carplace 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'],
|
||||
@@ -270,7 +419,7 @@ export async function createMarketplaceReservation(body: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function trackMarketplaceFunnelEvent(body: {
|
||||
export async function trackCarplaceFunnelEvent(body: {
|
||||
eventName: string
|
||||
companySlug: string
|
||||
vehicleId: string
|
||||
@@ -278,7 +427,7 @@ export async function trackMarketplaceFunnelEvent(body: {
|
||||
path?: string
|
||||
metadata?: Record<string, string | number | boolean | null>
|
||||
}, renterId?: string) {
|
||||
await repo.createMarketplaceFunnelEvent({
|
||||
await repo.createCarplaceFunnelEvent({
|
||||
...body,
|
||||
renterId: renterId ?? null,
|
||||
})
|
||||
+108
-26
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
|
||||
|
||||
vi.mock('./marketplace.repo', () => ({
|
||||
vi.mock('./carplace.repo', () => ({
|
||||
findPublicOffers: vi.fn(),
|
||||
findCitiesFromCompanies: vi.fn(),
|
||||
findListedCompanies: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
findVehicleForMarketplace: vi.fn(),
|
||||
findVehicleForMarketplaceById: vi.fn(),
|
||||
upsertMarketplaceCustomer: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
findVehicleForCarplace: vi.fn(),
|
||||
findVehicleForCarplaceById: vi.fn(),
|
||||
upsertCarplaceCustomer: vi.fn(),
|
||||
createCarplaceReservation: vi.fn(),
|
||||
findCompanyPage: vi.fn(),
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyReviews: vi.fn(),
|
||||
@@ -33,19 +33,19 @@ vi.mock('../../services/notificationService', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/emailTranslations', () => ({
|
||||
marketplaceReservationEmail: {
|
||||
carplaceReservationEmail: {
|
||||
subject: vi.fn(() => 'Subject'),
|
||||
html: vi.fn(() => '<html>'),
|
||||
text: vi.fn(() => 'text'),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as repo from './marketplace.repo'
|
||||
import * as repo from './carplace.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import {
|
||||
getCities, searchVehicles, createMarketplaceReservation,
|
||||
getCities, searchVehicles, createCarplaceQuote, createCarplaceReservation,
|
||||
getReviewContext, submitReview, validateOfferCode,
|
||||
} from './marketplace.service'
|
||||
} from './carplace.service'
|
||||
|
||||
const SLUG = 'test-company'
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('getCities', () => {
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('searchVehicles', () => {
|
||||
it('returns vehicles enriched with availability data', async () => {
|
||||
it('returns published vehicles with an available Carplace status when no dates are selected', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('searchVehicles', () => {
|
||||
expect(result[0].availabilityStatus).toBe('AVAILABLE')
|
||||
})
|
||||
|
||||
it('passes date range to availability service when provided', async () => {
|
||||
it('passes the date range to availability and removes unavailable vehicles before pagination', 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' })
|
||||
|
||||
@@ -101,7 +101,33 @@ describe('searchVehicles', () => {
|
||||
companyId: 'co-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)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('paginates after date-specific availability filtering', async () => {
|
||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([
|
||||
makeVehicle({ id: 'v-1' }),
|
||||
makeVehicle({ id: 'v-2' }),
|
||||
makeVehicle({ id: 'v-3' }),
|
||||
] as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockImplementation(async (vehicleId) => ({
|
||||
available: vehicleId !== 'v-1',
|
||||
status: vehicleId === 'v-1' ? 'RESERVED' : 'AVAILABLE',
|
||||
nextAvailableAt: null,
|
||||
blockingReason: vehicleId === 'v-1' ? 'RESERVATION' : null,
|
||||
}) as any)
|
||||
|
||||
const result = await searchVehicles({
|
||||
startDate: '2026-07-10T10:00:00.000Z',
|
||||
endDate: '2026-07-12T10:00:00.000Z',
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('v-2')
|
||||
})
|
||||
|
||||
it('filters out vehicles that do not support different drop-off when requested', async () => {
|
||||
@@ -119,7 +145,49 @@ describe('searchVehicles', () => {
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('createMarketplaceReservation', () => {
|
||||
describe('createCarplaceQuote', () => {
|
||||
const body = {
|
||||
vehicleId: 'v-1',
|
||||
startDate: '2026-07-10T10:00:00.000Z',
|
||||
endDate: '2026-07-13T10:00:00.000Z',
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
}
|
||||
|
||||
it('returns an availability-aware three-day estimate', async () => {
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await createCarplaceQuote(body)
|
||||
|
||||
expect(result.rentalDays).toBe(3)
|
||||
expect(result.baseSubtotal).toBe(30000)
|
||||
expect(result.estimatedTotal).toBe(30000)
|
||||
expect(result.finalPriceRequiresCompanyConfirmation).toBe(true)
|
||||
})
|
||||
|
||||
it('calculates fixed and free-day promotions instead of assuming every offer is a percentage', async () => {
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'co-1', type: 'FREE_DAY', discountValue: 1, title: 'One free day', maxRedemptions: null, redemptionCount: 0 } as any)
|
||||
|
||||
const result = await createCarplaceQuote({ ...body, promoCode: 'FREE1' })
|
||||
|
||||
expect(result.discounts).toEqual([{ label: 'One free day', amount: 10000 }])
|
||||
expect(result.estimatedTotal).toBe(20000)
|
||||
})
|
||||
|
||||
it('rejects a promotion owned by a different rental company', async () => {
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'other-company', type: 'PERCENTAGE', discountValue: 10, title: 'Wrong company' } as any)
|
||||
|
||||
await expect(createCarplaceQuote({ ...body, promoCode: 'WRONG' })).rejects.toMatchObject({ error: 'offer_invalid' })
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('createCarplaceReservation', () => {
|
||||
const baseBody = {
|
||||
vehicleId: 'v-1', companySlug: SLUG,
|
||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||
@@ -137,49 +205,63 @@ describe('createMarketplaceReservation', () => {
|
||||
}
|
||||
|
||||
it('creates reservation and returns reservationId', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(repo.findVehicleForCarplaceById).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', bookingReference: 'BK-2026-ABC123' } as any)
|
||||
vi.mocked(repo.upsertCarplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||
vi.mocked(repo.createCarplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
|
||||
|
||||
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||
const result = await createCarplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||
expect(result.reservationId).toBe('r-1')
|
||||
expect(result.bookingReference).toBe('BK-2026-ABC123')
|
||||
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
||||
expect(repo.upsertCarplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
||||
identityDocumentNumber: 'CIN123456',
|
||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||
driverLicense: 'DL-123456',
|
||||
}))
|
||||
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(repo.createCarplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
}))
|
||||
})
|
||||
|
||||
it('throws NotFoundError when vehicle not found', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(null)
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(null)
|
||||
|
||||
await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
||||
await expect(createCarplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws AppError when vehicle is unavailable', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||
|
||||
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||
await expect(createCarplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||
})
|
||||
|
||||
it('rejects different return locations when the vehicle requires same drop-off', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
|
||||
await expect(
|
||||
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||
createCarplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||
).rejects.toMatchObject({ error: 'same_dropoff_required' })
|
||||
})
|
||||
|
||||
it('stores the same discounted total shown by the Carplace quote', async () => {
|
||||
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'co-1', type: 'PERCENTAGE', discountValue: 10, title: 'Ten percent', maxRedemptions: null, redemptionCount: 0 } as any)
|
||||
vi.mocked(repo.upsertCarplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||
vi.mocked(repo.createCarplaceReservation).mockResolvedValue({ id: 'r-2', bookingReference: 'BK-2026-PROMO1' } as any)
|
||||
|
||||
await createCarplaceReservation({ ...baseBody, promoCode: 'SAVE10' })
|
||||
|
||||
expect(repo.createCarplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
totalAmount: 27000,
|
||||
}))
|
||||
})
|
||||
|
||||
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' }),
|
||||
createCarplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
|
||||
).rejects.toMatchObject({ error: 'invalid_dates' })
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ const fullBrand = {
|
||||
amanpaySecretKey: 'super-secret-key',
|
||||
paypalEmail: 'paypal@example.com',
|
||||
paypalMerchantId: 'paypal-merchant-xyz',
|
||||
isListedOnMarketplace: true,
|
||||
isListedOnCarplace: true,
|
||||
}
|
||||
|
||||
describe('presentBrand', () => {
|
||||
@@ -84,7 +84,7 @@ describe('presentBrand', () => {
|
||||
primaryColor: '#1A56DB',
|
||||
defaultLocale: 'en',
|
||||
defaultCurrency: 'MAD',
|
||||
isListedOnMarketplace: true,
|
||||
isListedOnCarplace: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ 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 {
|
||||
normalizeSettingsLocale,
|
||||
requireSettingsFeature,
|
||||
resolveSettingsEntitlements,
|
||||
resolveSettingsMenu,
|
||||
} from './settingsEntitlements'
|
||||
import {
|
||||
companySchema, brandSchema, contractSettingsSchema,
|
||||
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
|
||||
@@ -40,7 +46,7 @@ router.get('/me/brand', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
|
||||
router.patch('/me/brand', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(brandSchema, req)
|
||||
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
|
||||
@@ -48,7 +54,7 @@ router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
|
||||
router.post('/me/brand/logo', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), 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)
|
||||
@@ -56,7 +62,7 @@ router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'),
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
|
||||
router.post('/me/brand/hero', requireRole('OWNER'), requireSettingsFeature('settings.branding_hero'), 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)
|
||||
@@ -64,6 +70,18 @@ router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'),
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/settings-menu', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await resolveSettingsMenu(req.companyId, normalizeSettingsLocale(req.query.locale as string | undefined)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/settings-entitlements', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await resolveSettingsEntitlements(req.companyId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { subdomain } = parseBody(subdomainSchema, req)
|
||||
@@ -101,7 +119,7 @@ router.get('/me/contract-settings', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/contract-settings', requireRole('MANAGER'), async (req, res, next) => {
|
||||
router.patch('/me/contract-settings', requireRole('MANAGER'), requireSettingsFeature('settings.rental_policies_basic'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(contractSettingsSchema, req)
|
||||
const settings = await service.updateContractSettings(req.companyId, body)
|
||||
@@ -116,7 +134,7 @@ router.get('/me/insurance-policies', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, next) => {
|
||||
router.post('/me/insurance-policies', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(insurancePolicySchema, req)
|
||||
const policy = await service.createInsurancePolicy(req.companyId, body)
|
||||
@@ -124,7 +142,7 @@ router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, n
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(insurancePolicySchema.partial(), req)
|
||||
@@ -133,7 +151,7 @@ router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, r
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deleteInsurancePolicy(id, req.companyId)
|
||||
@@ -148,7 +166,7 @@ router.get('/me/pricing-rules', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next) => {
|
||||
router.post('/me/pricing-rules', requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(pricingRuleSchema, req)
|
||||
const rule = await service.createPricingRule(req.companyId, body)
|
||||
@@ -156,7 +174,7 @@ router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(pricingRuleSchema.partial(), req)
|
||||
@@ -165,7 +183,7 @@ router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, n
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), async (req, res, next) => {
|
||||
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deletePricingRule(id, req.companyId)
|
||||
@@ -180,7 +198,7 @@ router.get('/me/accounting-settings', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), async (req, res, next) => {
|
||||
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), requireSettingsFeature('settings.accounting_defaults'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(accountingSettingsSchema, req)
|
||||
const settings = await service.updateAccountingSettings(req.companyId, body)
|
||||
|
||||
@@ -26,7 +26,7 @@ export const brandSchema = z.object({
|
||||
amanpaySecretKey: z.string().optional(),
|
||||
paypalEmail: optionalEmailField(),
|
||||
paypalMerchantId: z.string().optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
isListedOnCarplace: z.boolean().optional(),
|
||||
homePageConfig: z.object({
|
||||
heroTitle: z.union([z.string(), z.null()]).optional(),
|
||||
heroDescription: z.union([z.string(), z.null()]).optional(),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('/storage/companies/company_1/brand/logo.jpg') }))
|
||||
vi.mock('./settingsEntitlements', () => ({
|
||||
resolveSettingsEntitlements: vi.fn(),
|
||||
}))
|
||||
vi.mock('./company.repo', () => ({
|
||||
findCompany: vi.fn(),
|
||||
updateCompany: vi.fn(),
|
||||
@@ -28,8 +31,43 @@ vi.mock('./company.repo', () => ({
|
||||
}))
|
||||
|
||||
const repo = await import('./company.repo')
|
||||
const entitlements = await import('./settingsEntitlements')
|
||||
const service = await import('./company.service')
|
||||
|
||||
const editableSettingsFeatures = [
|
||||
'settings.company_profile',
|
||||
'settings.public_contact',
|
||||
'settings.locale_currency',
|
||||
'settings.carplace_listing',
|
||||
'settings.branding_basic',
|
||||
'settings.branding_custom',
|
||||
'settings.branding_hero',
|
||||
'settings.renter_payments',
|
||||
'settings.rental_policies_basic',
|
||||
'settings.additional_driver_fees',
|
||||
'settings.insurance_policies',
|
||||
'settings.pricing_rules',
|
||||
'settings.accounting_defaults',
|
||||
'settings.accounting_scheduled_delivery',
|
||||
'settings.accounting_advanced_formats',
|
||||
] as const
|
||||
|
||||
function buildEditableEntitlements() {
|
||||
return {
|
||||
plan: 'PRO',
|
||||
subscriptionStatus: 'ACTIVE',
|
||||
currentPeriodEnd: null,
|
||||
trialEndAt: null,
|
||||
accessLevel: 'full',
|
||||
features: Object.fromEntries(editableSettingsFeatures.map((featureKey) => [featureKey, {
|
||||
available: true,
|
||||
editable: true,
|
||||
requiredPlan: null,
|
||||
reason: null,
|
||||
}])),
|
||||
}
|
||||
}
|
||||
|
||||
const currentBrand = {
|
||||
id: 'brand_1',
|
||||
companyId: 'company_1',
|
||||
@@ -44,6 +82,7 @@ const currentBrand = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(entitlements.resolveSettingsEntitlements).mockResolvedValue(buildEditableEntitlements() as any)
|
||||
delete process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { uploadImage } from '../../lib/storage'
|
||||
import { ConflictError, NotFoundError } from '../../http/errors'
|
||||
import { ConflictError, ForbiddenError, NotFoundError } from '../../http/errors'
|
||||
import { presentCompany, presentBrand } from './company.presenter'
|
||||
import * as repo from './company.repo'
|
||||
import { resolveSettingsEntitlements, SettingsFeatureKey } from './settingsEntitlements'
|
||||
|
||||
function buildPaymentMethodsEnabled(input: {
|
||||
amanpayMerchantId?: string | null
|
||||
@@ -27,6 +28,12 @@ export async function getBrand(companyId: string) {
|
||||
}
|
||||
|
||||
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
|
||||
await assertSettingsFeature(companyId, 'settings.branding_basic')
|
||||
if (body.primaryColor || body.accentColor) await assertSettingsFeature(companyId, 'settings.branding_custom')
|
||||
if (body.amanpayMerchantId || body.amanpaySecretKey || body.paypalEmail || body.paypalMerchantId) {
|
||||
await assertSettingsFeature(companyId, 'settings.renter_payments')
|
||||
}
|
||||
|
||||
const current = await repo.findBrand(companyId)
|
||||
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
|
||||
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
|
||||
@@ -41,6 +48,7 @@ export async function updateBrand(companyId: string, body: any, companyName: str
|
||||
}
|
||||
|
||||
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||
await assertSettingsFeature(companyId, 'settings.branding_basic')
|
||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
companyId,
|
||||
@@ -50,6 +58,7 @@ export async function uploadLogo(companyId: string, companyName: string, company
|
||||
}
|
||||
|
||||
export async function uploadHeroImage(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||
await assertSettingsFeature(companyId, 'settings.branding_hero')
|
||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
companyId,
|
||||
@@ -95,6 +104,14 @@ export async function getContractSettings(companyId: string) {
|
||||
}
|
||||
|
||||
export async function updateContractSettings(companyId: string, data: any) {
|
||||
await assertSettingsFeature(companyId, 'settings.rental_policies_basic')
|
||||
if (
|
||||
data.additionalDriverCharge ||
|
||||
data.additionalDriverDailyRate !== undefined ||
|
||||
data.additionalDriverFlatRate !== undefined
|
||||
) {
|
||||
await assertSettingsFeature(companyId, 'settings.additional_driver_fees')
|
||||
}
|
||||
return repo.upsertContractSettings(companyId, data)
|
||||
}
|
||||
|
||||
@@ -103,16 +120,19 @@ export async function getInsurancePolicies(companyId: string) {
|
||||
}
|
||||
|
||||
export async function createInsurancePolicy(companyId: string, data: any) {
|
||||
await assertSettingsFeature(companyId, 'settings.insurance_policies')
|
||||
return repo.createInsurancePolicy(companyId, data)
|
||||
}
|
||||
|
||||
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
|
||||
await assertSettingsFeature(companyId, 'settings.insurance_policies')
|
||||
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) {
|
||||
await assertSettingsFeature(companyId, 'settings.insurance_policies')
|
||||
const result = await repo.deleteInsurancePolicy(id, companyId)
|
||||
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
||||
}
|
||||
@@ -122,16 +142,19 @@ export async function getPricingRules(companyId: string) {
|
||||
}
|
||||
|
||||
export async function createPricingRule(companyId: string, data: any) {
|
||||
await assertSettingsFeature(companyId, 'settings.pricing_rules')
|
||||
return repo.createPricingRule(companyId, data)
|
||||
}
|
||||
|
||||
export async function updatePricingRule(id: string, companyId: string, data: any) {
|
||||
await assertSettingsFeature(companyId, 'settings.pricing_rules')
|
||||
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) {
|
||||
await assertSettingsFeature(companyId, 'settings.pricing_rules')
|
||||
const result = await repo.deletePricingRule(id, companyId)
|
||||
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
||||
}
|
||||
@@ -141,6 +164,9 @@ export async function getAccountingSettings(companyId: string) {
|
||||
}
|
||||
|
||||
export async function updateAccountingSettings(companyId: string, data: any) {
|
||||
await assertSettingsFeature(companyId, 'settings.accounting_defaults')
|
||||
if (data.autoSendReport) await assertSettingsFeature(companyId, 'settings.accounting_scheduled_delivery')
|
||||
if (data.reportFormat === 'BOTH') await assertSettingsFeature(companyId, 'settings.accounting_advanced_formats')
|
||||
return repo.upsertAccountingSettings(companyId, data)
|
||||
}
|
||||
|
||||
@@ -151,3 +177,10 @@ export async function getApiKey(companyId: string) {
|
||||
export async function regenerateApiKey(companyId: string) {
|
||||
return repo.regenerateApiKey(companyId)
|
||||
}
|
||||
|
||||
async function assertSettingsFeature(companyId: string, featureKey: SettingsFeatureKey) {
|
||||
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||
if (!entitlements.features[featureKey]?.editable) {
|
||||
throw new ForbiddenError('This settings feature is not available for your current subscription.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,51 @@
|
||||
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('./settingsEntitlements', () => ({
|
||||
resolveSettingsEntitlements: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/companies/comp_1/brand/logo.jpg'),
|
||||
}))
|
||||
|
||||
const repo = await import('./company.repo')
|
||||
const entitlements = await import('./settingsEntitlements')
|
||||
const service = await import('./company.service')
|
||||
|
||||
const editableSettingsFeatures = [
|
||||
'settings.company_profile',
|
||||
'settings.public_contact',
|
||||
'settings.locale_currency',
|
||||
'settings.carplace_listing',
|
||||
'settings.branding_basic',
|
||||
'settings.branding_custom',
|
||||
'settings.branding_hero',
|
||||
'settings.renter_payments',
|
||||
'settings.rental_policies_basic',
|
||||
'settings.additional_driver_fees',
|
||||
'settings.insurance_policies',
|
||||
'settings.pricing_rules',
|
||||
'settings.accounting_defaults',
|
||||
'settings.accounting_scheduled_delivery',
|
||||
'settings.accounting_advanced_formats',
|
||||
] as const
|
||||
|
||||
function buildEditableEntitlements() {
|
||||
return {
|
||||
plan: 'PRO',
|
||||
subscriptionStatus: 'ACTIVE',
|
||||
currentPeriodEnd: null,
|
||||
trialEndAt: null,
|
||||
accessLevel: 'full',
|
||||
features: Object.fromEntries(editableSettingsFeatures.map((featureKey) => [featureKey, {
|
||||
available: true,
|
||||
editable: true,
|
||||
requiredPlan: null,
|
||||
reason: null,
|
||||
}])),
|
||||
}
|
||||
}
|
||||
|
||||
const mockCompany = {
|
||||
id: 'comp_1',
|
||||
name: 'Test Rentals',
|
||||
@@ -33,6 +72,7 @@ const mockBrand = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(entitlements.resolveSettingsEntitlements).mockResolvedValue(buildEditableEntitlements() as any)
|
||||
})
|
||||
|
||||
describe('company.service', () => {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
subscription: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { resolveSettingsEntitlements, resolveSettingsMenu } from './settingsEntitlements'
|
||||
|
||||
describe('settingsEntitlements', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps company profile available while locking premium STARTER capabilities', async () => {
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||
plan: 'STARTER',
|
||||
status: 'ACTIVE',
|
||||
currentPeriodEnd: null,
|
||||
trialEndAt: null,
|
||||
} as never)
|
||||
|
||||
const result = await resolveSettingsEntitlements('company_1')
|
||||
|
||||
expect(result.features['settings.company_profile']).toMatchObject({ available: true, editable: true })
|
||||
expect(result.features['settings.renter_payments']).toMatchObject({
|
||||
available: false,
|
||||
editable: false,
|
||||
requiredPlan: 'GROWTH',
|
||||
})
|
||||
expect(result.features['settings.accounting_scheduled_delivery']).toMatchObject({
|
||||
available: false,
|
||||
requiredPlan: 'PRO',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the approved seven settings sections with localized locked states', async () => {
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||
plan: 'STARTER',
|
||||
status: 'ACTIVE',
|
||||
currentPeriodEnd: null,
|
||||
trialEndAt: null,
|
||||
} as never)
|
||||
|
||||
const result = await resolveSettingsMenu('company_1', 'fr')
|
||||
|
||||
expect(result.items).toHaveLength(7)
|
||||
expect(result.items.map((item) => item.sectionKey)).toEqual([
|
||||
'company',
|
||||
'carplace',
|
||||
'payments',
|
||||
'rental-policies',
|
||||
'insurance',
|
||||
'pricing',
|
||||
'accounting',
|
||||
])
|
||||
expect(result.items.find((item) => item.sectionKey === 'payments')).toMatchObject({
|
||||
label: 'Méthodes de paiement',
|
||||
state: 'LOCKED',
|
||||
requiredPlan: 'GROWTH',
|
||||
})
|
||||
})
|
||||
|
||||
it('makes premium features read-only during past-due access', async () => {
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||
plan: 'PRO',
|
||||
status: 'PAST_DUE',
|
||||
currentPeriodEnd: null,
|
||||
trialEndAt: null,
|
||||
} as never)
|
||||
|
||||
const result = await resolveSettingsEntitlements('company_1')
|
||||
|
||||
expect(result.features['settings.company_profile']).toMatchObject({ available: true, editable: true })
|
||||
expect(result.features['settings.pricing_rules']).toMatchObject({ available: true, editable: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { getAccessLevel } from '../subscriptions/subscription.policy'
|
||||
import { sendForbidden, sendUnauthorized } from '../../middleware/authHelpers'
|
||||
|
||||
export type SettingsFeatureKey =
|
||||
| 'settings.company_profile'
|
||||
| 'settings.public_contact'
|
||||
| 'settings.locale_currency'
|
||||
| 'settings.carplace_listing'
|
||||
| 'settings.branding_basic'
|
||||
| 'settings.branding_custom'
|
||||
| 'settings.branding_hero'
|
||||
| 'settings.renter_payments'
|
||||
| 'settings.rental_policies_basic'
|
||||
| 'settings.additional_driver_fees'
|
||||
| 'settings.insurance_policies'
|
||||
| 'settings.pricing_rules'
|
||||
| 'settings.accounting_defaults'
|
||||
| 'settings.accounting_scheduled_delivery'
|
||||
| 'settings.accounting_advanced_formats'
|
||||
|
||||
export type SettingsSectionKey =
|
||||
| 'company'
|
||||
| 'carplace'
|
||||
| 'payments'
|
||||
| 'rental-policies'
|
||||
| 'insurance'
|
||||
| 'pricing'
|
||||
| 'accounting'
|
||||
|
||||
type Locale = 'en' | 'fr' | 'ar'
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
type SubscriptionStatus =
|
||||
| 'TRIALING'
|
||||
| 'ACTIVE'
|
||||
| 'PAYMENT_PENDING'
|
||||
| 'PAST_DUE'
|
||||
| 'SUSPENDED'
|
||||
| 'CANCELLED'
|
||||
| 'EXPIRED'
|
||||
| 'PAUSED'
|
||||
| 'UNPAID'
|
||||
|
||||
type MenuState = 'ENABLED' | 'LOCKED'
|
||||
|
||||
const ALL_PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||
const GROWTH_PLUS: Plan[] = ['GROWTH', 'PRO']
|
||||
const PRO_ONLY: Plan[] = ['PRO']
|
||||
|
||||
const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = {
|
||||
en: {
|
||||
company: { label: 'Company Profile', description: 'Manage public company details and defaults.' },
|
||||
carplace: { label: 'Branding and Carplace', description: 'Control Carplace listing, logo, colors, and media.' },
|
||||
payments: { label: 'Payment Methods', description: 'Configure renter payment providers.' },
|
||||
'rental-policies': { label: 'Rental Policies', description: 'Set fuel, damage, and additional-driver policies.' },
|
||||
insurance: { label: 'Insurance Policies', description: 'Manage optional and required insurance products.' },
|
||||
pricing: { label: 'Pricing Rules', description: 'Automate surcharges, discounts, and driver-based pricing.' },
|
||||
accounting: { label: 'Accounting and Reports', description: 'Configure accounting defaults and report delivery.' },
|
||||
},
|
||||
fr: {
|
||||
company: { label: 'Profil entreprise', description: 'Gérez les informations publiques et les valeurs par défaut.' },
|
||||
carplace: { label: 'Marque et vitrine', description: 'Contrôlez la publication, le logo, les couleurs et les médias.' },
|
||||
payments: { label: 'Méthodes de paiement', description: 'Configurez les prestataires de paiement des locataires.' },
|
||||
'rental-policies': { label: 'Politiques de location', description: 'Définissez les règles carburant, dommages et conducteurs.' },
|
||||
insurance: { label: 'Polices d’assurance', description: 'Gérez les assurances optionnelles et obligatoires.' },
|
||||
pricing: { label: 'Règles tarifaires', description: 'Automatisez les suppléments, remises et règles conducteur.' },
|
||||
accounting: { label: 'Comptabilité et rapports', description: 'Configurez les paramètres comptables et l’envoi des rapports.' },
|
||||
},
|
||||
ar: {
|
||||
company: { label: 'ملف الشركة', description: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' },
|
||||
carplace: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' },
|
||||
payments: { label: 'طرق الدفع', description: 'إعداد مزودي دفع المستأجرين.' },
|
||||
'rental-policies': { label: 'سياسات الإيجار', description: 'ضبط سياسات الوقود والأضرار والسائق الإضافي.' },
|
||||
insurance: { label: 'سياسات التأمين', description: 'إدارة منتجات التأمين الاختيارية والإلزامية.' },
|
||||
pricing: { label: 'قواعد التسعير', description: 'أتمتة الرسوم والخصومات وقواعد السائق.' },
|
||||
accounting: { label: 'المحاسبة والتقارير', description: 'إعداد الافتراضات المحاسبية وتسليم التقارير.' },
|
||||
},
|
||||
}
|
||||
|
||||
export const SETTINGS_MENU_CATALOG: Array<{
|
||||
menuKey: SettingsSectionKey
|
||||
sectionKey: SettingsSectionKey
|
||||
iconKey: string
|
||||
sortOrder: number
|
||||
plans: Plan[]
|
||||
requiredPlan: Plan | null
|
||||
}> = [
|
||||
{ menuKey: 'company', sectionKey: 'company', iconKey: 'building', sortOrder: 10, plans: ALL_PLANS, requiredPlan: null },
|
||||
{ menuKey: 'carplace', sectionKey: 'carplace', iconKey: 'palette', sortOrder: 20, plans: ALL_PLANS, requiredPlan: null },
|
||||
{ menuKey: 'payments', sectionKey: 'payments', iconKey: 'credit-card', sortOrder: 30, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
{ menuKey: 'rental-policies', sectionKey: 'rental-policies', iconKey: 'file-text', sortOrder: 40, plans: ALL_PLANS, requiredPlan: null },
|
||||
{ menuKey: 'insurance', sectionKey: 'insurance', iconKey: 'shield', sortOrder: 50, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
{ menuKey: 'pricing', sectionKey: 'pricing', iconKey: 'tags', sortOrder: 60, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
{ menuKey: 'accounting', sectionKey: 'accounting', iconKey: 'bar-chart', sortOrder: 70, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
]
|
||||
|
||||
const FEATURE_PLANS: Record<SettingsFeatureKey, { plans: Plan[]; requiredPlan: Plan | null }> = {
|
||||
'settings.company_profile': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.public_contact': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.locale_currency': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.carplace_listing': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.branding_basic': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.branding_custom': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
'settings.branding_hero': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
'settings.renter_payments': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
'settings.rental_policies_basic': { plans: ALL_PLANS, requiredPlan: null },
|
||||
'settings.additional_driver_fees': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
'settings.insurance_policies': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
'settings.pricing_rules': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
'settings.accounting_defaults': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||
'settings.accounting_scheduled_delivery': { plans: PRO_ONLY, requiredPlan: 'PRO' },
|
||||
'settings.accounting_advanced_formats': { plans: PRO_ONLY, requiredPlan: 'PRO' },
|
||||
}
|
||||
|
||||
export function normalizeSettingsLocale(value?: string): Locale {
|
||||
return value === 'fr' || value === 'ar' ? value : 'en'
|
||||
}
|
||||
|
||||
async function getSubscription(companyId: string) {
|
||||
const subscriptionModel = (prisma as any).subscription
|
||||
if (!subscriptionModel?.findUnique) {
|
||||
return {
|
||||
plan: 'GROWTH' as Plan,
|
||||
subscriptionStatus: 'ACTIVE' as SubscriptionStatus,
|
||||
currentPeriodEnd: null,
|
||||
trialEndAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = await subscriptionModel.findUnique({ where: { companyId } })
|
||||
return {
|
||||
plan: subscription?.plan ?? 'STARTER',
|
||||
subscriptionStatus: subscription?.status ?? 'EXPIRED',
|
||||
currentPeriodEnd: subscription?.currentPeriodEnd?.toISOString() ?? null,
|
||||
trialEndAt: subscription?.trialEndAt?.toISOString() ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function statusAllowsAvailability(status: SubscriptionStatus) {
|
||||
return getAccessLevel(status) !== 'none'
|
||||
}
|
||||
|
||||
function statusAllowsEditing(status: SubscriptionStatus, featureKey: SettingsFeatureKey) {
|
||||
const accessLevel = getAccessLevel(status)
|
||||
if (accessLevel === 'full') return true
|
||||
if (status === 'PAST_DUE') {
|
||||
return [
|
||||
'settings.company_profile',
|
||||
'settings.public_contact',
|
||||
'settings.locale_currency',
|
||||
'settings.carplace_listing',
|
||||
'settings.branding_basic',
|
||||
'settings.rental_policies_basic',
|
||||
].includes(featureKey)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function resolveSettingsEntitlements(companyId: string) {
|
||||
const subscription = await getSubscription(companyId)
|
||||
const accessLevel = getAccessLevel(subscription.subscriptionStatus)
|
||||
const features = Object.fromEntries(
|
||||
Object.entries(FEATURE_PLANS).map(([featureKey, assignment]) => {
|
||||
const assigned = assignment.plans.includes(subscription.plan)
|
||||
const available = assigned && statusAllowsAvailability(subscription.subscriptionStatus)
|
||||
const editable = available && statusAllowsEditing(subscription.subscriptionStatus, featureKey as SettingsFeatureKey)
|
||||
return [featureKey, {
|
||||
available,
|
||||
editable,
|
||||
requiredPlan: assigned ? null : assignment.requiredPlan,
|
||||
reason: available ? null : assigned ? 'subscription_status_restricted' : 'plan_required',
|
||||
}]
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
plan: subscription.plan,
|
||||
subscriptionStatus: subscription.subscriptionStatus,
|
||||
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||
trialEndAt: subscription.trialEndAt,
|
||||
accessLevel,
|
||||
features,
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSettingsMenu(companyId: string, locale: Locale) {
|
||||
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||
return {
|
||||
version: 1,
|
||||
items: SETTINGS_MENU_CATALOG.map((item) => {
|
||||
const assigned = item.plans.includes(entitlements.plan)
|
||||
const coreAvailable = entitlements.accessLevel !== 'none' || item.sectionKey === 'company'
|
||||
const state: MenuState = assigned && coreAvailable ? 'ENABLED' : 'LOCKED'
|
||||
const copy = SECTION_COPY[locale][item.sectionKey]
|
||||
return {
|
||||
menuKey: item.menuKey,
|
||||
sectionKey: item.sectionKey,
|
||||
label: copy.label,
|
||||
description: copy.description,
|
||||
iconKey: item.iconKey,
|
||||
sortOrder: item.sortOrder,
|
||||
state,
|
||||
requiredPlan: state === 'LOCKED' ? item.requiredPlan : null,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function requireSettingsFeature(featureKey: SettingsFeatureKey) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.companyId) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
||||
|
||||
const entitlements = await resolveSettingsEntitlements(req.companyId)
|
||||
const feature = entitlements.features[featureKey]
|
||||
if (!feature?.editable) {
|
||||
return sendForbidden(res, 'feature_unavailable', 'This settings feature is not available for your current subscription.', {
|
||||
featureKey,
|
||||
requiredPlan: feature?.requiredPlan ?? null,
|
||||
subscriptionStatus: entitlements.subscriptionStatus,
|
||||
accessLevel: entitlements.accessLevel,
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
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,
|
||||
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
|
||||
} 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.post('/events', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(marketplaceFunnelEventSchema, req)
|
||||
ok(res, await service.trackMarketplaceFunnelEvent(body, req.renterId))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Analytics events are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = parseParams(reviewTokenSchema, req)
|
||||
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/vehicles/:id/availability', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(vehicleParamSchema, req)
|
||||
const { startDate, endDate } = parseQuery(vehicleAvailabilityQuerySchema, req)
|
||||
ok(res, await service.getMarketplaceVehicleAvailability(slug, id, { startDate, endDate }))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = parseParams(slugParamSchema, req)
|
||||
ok(res, await service.getCompanyOffers(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -198,4 +198,180 @@ describe('menu.service', () => {
|
||||
})
|
||||
expect(result.items.find((item) => item.label === 'Reports')?.reasons.join(' ')).toContain('STARTER does not include this menu item')
|
||||
})
|
||||
|
||||
it('returns the approved seven-item STARTER owner sidebar in order', async () => {
|
||||
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
|
||||
id: 'employee_1',
|
||||
role: 'OWNER',
|
||||
isActive: true,
|
||||
companyId: 'company_1',
|
||||
} as never)
|
||||
|
||||
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
|
||||
id: 'company_1',
|
||||
name: 'Atlas Cars',
|
||||
status: 'ACTIVE',
|
||||
subscription: { plan: 'STARTER', status: 'ACTIVE' },
|
||||
} as never)
|
||||
|
||||
const item = (
|
||||
systemKey: string,
|
||||
label: string,
|
||||
routeOrUrl: string,
|
||||
icon: string,
|
||||
displayOrder: number,
|
||||
plans: string[],
|
||||
) => ({
|
||||
id: `item_${systemKey}`,
|
||||
systemKey,
|
||||
label,
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl,
|
||||
icon,
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
isRequired: displayOrder <= 70,
|
||||
isActive: true,
|
||||
displayOrder,
|
||||
roleVisibilities: [{ role: 'OWNER' }],
|
||||
subscriptionAssignments: plans.map((plan) => ({ plan, displayOrder, isActive: true })),
|
||||
companyAssignments: [],
|
||||
})
|
||||
|
||||
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
|
||||
item('dashboard', 'Dashboard', '/', 'LayoutDashboard', 10, ['STARTER', 'GROWTH', 'PRO']),
|
||||
item('reservations', 'Reservations', '/reservations', 'Calendar', 20, ['STARTER', 'GROWTH', 'PRO']),
|
||||
item('fleet', 'Fleet', '/fleet', 'Car', 30, ['STARTER', 'GROWTH', 'PRO']),
|
||||
item('customers', 'Customers', '/customers', 'Users', 40, ['STARTER', 'GROWTH', 'PRO']),
|
||||
item('reports', 'Reports', '/reports', 'BarChart2', 50, ['STARTER', 'GROWTH', 'PRO']),
|
||||
item('billing', 'Billing', '/billing', 'CreditCard', 60, ['STARTER', 'GROWTH', 'PRO']),
|
||||
item('settings', 'Settings', '/settings', 'Settings', 70, ['STARTER', 'GROWTH', 'PRO']),
|
||||
item('online-reservations', 'Online Reservations', '/online-reservations', 'Globe', 80, ['GROWTH', 'PRO']),
|
||||
item('offers', 'Offers', '/offers', 'Tag', 90, ['GROWTH', 'PRO']),
|
||||
item('team', 'Team', '/team', 'UserPlus', 100, ['GROWTH', 'PRO']),
|
||||
item('contracts', 'Contracts', '/contracts', 'FileText', 110, ['GROWTH', 'PRO']),
|
||||
item('notifications', 'Notifications', '/notifications', 'Bell', 120, ['GROWTH', 'PRO']),
|
||||
item('reviews', 'Reviews', '/reviews', 'Star', 130, ['PRO']),
|
||||
item('complaints', 'Complaints', '/complaints', 'AlertTriangle', 140, ['PRO']),
|
||||
{ ...item('subscription', 'Subscription', '/subscription', 'CreditCard', 999, []), isActive: false },
|
||||
] as never)
|
||||
|
||||
const result = await getEmployeeMenu('employee_1')
|
||||
|
||||
expect(result.items.map((menuItem) => menuItem.systemKey)).toEqual([
|
||||
'dashboard',
|
||||
'reservations',
|
||||
'fleet',
|
||||
'customers',
|
||||
'reports',
|
||||
'billing',
|
||||
'settings',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no feature menu for expired trial subscriptions', async () => {
|
||||
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
|
||||
id: 'employee_1',
|
||||
role: 'OWNER',
|
||||
isActive: true,
|
||||
companyId: 'company_1',
|
||||
} as never)
|
||||
|
||||
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
|
||||
id: 'company_1',
|
||||
name: 'Atlas Cars',
|
||||
status: 'TRIALING',
|
||||
subscription: { plan: 'STARTER', status: 'EXPIRED' },
|
||||
} as never)
|
||||
|
||||
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
|
||||
{
|
||||
id: 'item_dashboard',
|
||||
systemKey: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/',
|
||||
icon: 'LayoutDashboard',
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
isRequired: true,
|
||||
isActive: true,
|
||||
displayOrder: 10,
|
||||
roleVisibilities: [{ role: 'OWNER' }],
|
||||
subscriptionAssignments: [{ plan: 'STARTER', displayOrder: 10, isActive: true }],
|
||||
companyAssignments: [],
|
||||
},
|
||||
] as never)
|
||||
|
||||
const result = await getEmployeeMenu('employee_1')
|
||||
|
||||
expect(result.subscriptionStatus).toBe('EXPIRED')
|
||||
expect(result.subscriptionAccessLevel).toBe('none')
|
||||
expect(result.items).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to the baseline feature menu when full-access entitlements only expose dashboard and subscription', async () => {
|
||||
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
|
||||
id: 'employee_1',
|
||||
role: 'OWNER',
|
||||
isActive: true,
|
||||
companyId: 'company_1',
|
||||
} as never)
|
||||
|
||||
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
|
||||
id: 'company_1',
|
||||
name: 'Atlas Cars',
|
||||
status: 'TRIALING',
|
||||
subscription: { plan: 'STARTER', status: 'TRIALING' },
|
||||
} as never)
|
||||
|
||||
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
|
||||
{
|
||||
id: 'item_dashboard',
|
||||
systemKey: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/',
|
||||
icon: 'LayoutDashboard',
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
isRequired: true,
|
||||
isActive: true,
|
||||
displayOrder: 10,
|
||||
roleVisibilities: [{ role: 'OWNER' }],
|
||||
subscriptionAssignments: [{ plan: 'STARTER', displayOrder: 10, isActive: true }],
|
||||
companyAssignments: [],
|
||||
},
|
||||
{
|
||||
id: 'item_subscription',
|
||||
systemKey: 'subscription',
|
||||
label: 'Subscription',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/subscription',
|
||||
icon: 'CreditCard',
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
isRequired: false,
|
||||
isActive: true,
|
||||
displayOrder: 999,
|
||||
roleVisibilities: [{ role: 'OWNER' }],
|
||||
subscriptionAssignments: [],
|
||||
companyAssignments: [],
|
||||
},
|
||||
] as never)
|
||||
|
||||
const result = await getEmployeeMenu('employee_1')
|
||||
|
||||
expect(result.subscriptionStatus).toBe('TRIALING')
|
||||
expect(result.subscriptionAccessLevel).toBe('full')
|
||||
expect(result.items.map((menuItem) => menuItem.systemKey)).toEqual([
|
||||
'dashboard',
|
||||
'reservations',
|
||||
'fleet',
|
||||
'customers',
|
||||
'reports',
|
||||
'billing',
|
||||
'settings',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppError } from '../../http/errors'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { getAccessLevel, hasFullAccess, type AccessLevel } from '../subscriptions/subscription.policy'
|
||||
|
||||
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
@@ -68,6 +69,17 @@ const MENU_ITEM_TYPES_WITHOUT_ROUTE = new Set([
|
||||
'SECTION_LABEL',
|
||||
'DIVIDER',
|
||||
])
|
||||
const ROLE_RANK: Record<EmployeeRole, number> = { OWNER: 3, MANAGER: 2, AGENT: 1 }
|
||||
const BASELINE_EMPLOYEE_MENU_ITEMS = [
|
||||
{ systemKey: 'dashboard', label: 'Dashboard', routeOrUrl: '/', icon: 'LayoutDashboard', displayOrder: 10, minRole: 'AGENT' },
|
||||
{ systemKey: 'reservations', label: 'Reservations', routeOrUrl: '/reservations', icon: 'Calendar', displayOrder: 20, minRole: 'AGENT' },
|
||||
{ systemKey: 'fleet', label: 'Fleet', routeOrUrl: '/fleet', icon: 'Car', displayOrder: 30, minRole: 'AGENT' },
|
||||
{ systemKey: 'customers', label: 'Customers', routeOrUrl: '/customers', icon: 'Users', displayOrder: 40, minRole: 'AGENT' },
|
||||
{ systemKey: 'reports', label: 'Reports', routeOrUrl: '/reports', icon: 'BarChart2', displayOrder: 50, minRole: 'MANAGER' },
|
||||
{ systemKey: 'billing', label: 'Billing', routeOrUrl: '/billing', icon: 'CreditCard', displayOrder: 60, minRole: 'MANAGER' },
|
||||
{ systemKey: 'settings', label: 'Settings', routeOrUrl: '/settings', icon: 'Settings', displayOrder: 70, minRole: 'OWNER' },
|
||||
] as const
|
||||
const MENU_RECOVERY_ROUTES = new Set(['/subscription', '/subscription/success', '/subscription/cancel'])
|
||||
|
||||
function normalizeNullableString(value?: string | null) {
|
||||
if (typeof value !== 'string') return null
|
||||
@@ -82,6 +94,10 @@ function sortByDisplayOrder<T extends { displayOrder: number; label?: string | n
|
||||
})
|
||||
}
|
||||
|
||||
function hasMinRole(role: EmployeeRole, minRole: EmployeeRole): boolean {
|
||||
return ROLE_RANK[role] >= ROLE_RANK[minRole]
|
||||
}
|
||||
|
||||
function toPlanValue(plan: Plan) {
|
||||
return plan
|
||||
}
|
||||
@@ -466,23 +482,27 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
|
||||
})
|
||||
|
||||
const currentPlan = company.subscription?.plan ?? null
|
||||
const currentPlanName = currentPlan as Plan | null
|
||||
const subscriptionStatus = company.subscription?.status ?? 'EXPIRED'
|
||||
const subscriptionAccessLevel = getAccessLevel(subscriptionStatus)
|
||||
const subscriptionHasFullAccess = hasFullAccess(subscriptionStatus)
|
||||
const currentPlanName = subscriptionHasFullAccess ? currentPlan as Plan | null : null
|
||||
|
||||
const evaluated: EvaluatedMenuItem[] = (menuItems as any[]).map((item: any) => {
|
||||
const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0
|
||||
const subscriptionAssignment = item.subscriptionAssignments.find(
|
||||
(assignment: any) => assignment.isActive && (!currentPlan || assignment.plan === currentPlan),
|
||||
(assignment: any) => assignment.isActive && (!currentPlanName || assignment.plan === currentPlanName),
|
||||
)
|
||||
const companyAssignment = item.companyAssignments.find((assignment: any) => assignment.isActive)
|
||||
const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility: any) => visibility.role === role)
|
||||
const subscriptionEntitled = Boolean(subscriptionAssignment || (defaultsToAllSubscriptions && currentPlan))
|
||||
const entitled = Boolean(subscriptionEntitled || companyAssignment)
|
||||
const subscriptionEntitled = Boolean(subscriptionHasFullAccess && (subscriptionAssignment || (defaultsToAllSubscriptions && currentPlanName)))
|
||||
const entitled = Boolean(subscriptionHasFullAccess && (subscriptionEntitled || companyAssignment))
|
||||
const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive)
|
||||
const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder
|
||||
|
||||
const reasons: string[] = []
|
||||
if (!item.isActive) reasons.push('Hidden because the menu item is inactive.')
|
||||
if (!employeeIsActive) reasons.push('Hidden because the user account is inactive.')
|
||||
if (!subscriptionHasFullAccess) reasons.push(`Hidden because subscription status ${subscriptionStatus} does not allow dashboard feature access.`)
|
||||
if (!entitled) reasons.push(describeEntitlement(currentPlanName, false, false, false))
|
||||
if (entitled) {
|
||||
reasons.push(
|
||||
@@ -518,6 +538,8 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
|
||||
company,
|
||||
role,
|
||||
currentPlan: currentPlanName,
|
||||
subscriptionStatus,
|
||||
subscriptionAccessLevel: subscriptionAccessLevel as AccessLevel,
|
||||
items: sortByDisplayOrder(evaluated),
|
||||
}
|
||||
}
|
||||
@@ -572,12 +594,40 @@ function buildMenuTree(items: Array<{
|
||||
return roots
|
||||
}
|
||||
|
||||
function hasFeatureMenuRoute(items: Array<{ itemType: MenuItemType; routeOrUrl: string | null }>) {
|
||||
return items.some((item) => (
|
||||
item.itemType === 'INTERNAL_PAGE' &&
|
||||
item.routeOrUrl &&
|
||||
item.routeOrUrl !== '/' &&
|
||||
!MENU_RECOVERY_ROUTES.has(item.routeOrUrl)
|
||||
))
|
||||
}
|
||||
|
||||
function buildBaselineEmployeeMenu(role: EmployeeRole) {
|
||||
return BASELINE_EMPLOYEE_MENU_ITEMS
|
||||
.filter((item) => hasMinRole(role, item.minRole))
|
||||
.map((item) => ({
|
||||
id: `baseline_${item.systemKey}`,
|
||||
systemKey: item.systemKey,
|
||||
label: item.label,
|
||||
itemType: 'INTERNAL_PAGE' as MenuItemType,
|
||||
routeOrUrl: item.routeOrUrl,
|
||||
icon: item.icon,
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
displayOrder: item.displayOrder,
|
||||
children: [],
|
||||
}))
|
||||
}
|
||||
|
||||
export async function previewCompanyMenu(input: MenuPreviewInput) {
|
||||
const context = await getMenuEvaluationContext(input.companyId, input.role)
|
||||
return {
|
||||
company: context.company,
|
||||
role: context.role,
|
||||
subscriptionPlan: context.currentPlan,
|
||||
subscriptionStatus: context.subscriptionStatus,
|
||||
subscriptionAccessLevel: context.subscriptionAccessLevel,
|
||||
items: context.items,
|
||||
finalMenu: buildMenuTree(
|
||||
context.items
|
||||
@@ -610,12 +660,7 @@ export async function getEmployeeMenu(employeeId: string) {
|
||||
|
||||
const context = await getMenuEvaluationContext(employee.companyId, employee.role, employee.isActive)
|
||||
const visibleItems = context.items.filter((item) => item.visible)
|
||||
|
||||
return {
|
||||
companyId: employee.companyId,
|
||||
role: employee.role,
|
||||
subscriptionPlan: context.currentPlan,
|
||||
items: buildMenuTree(
|
||||
const menuItems = buildMenuTree(
|
||||
visibleItems.map((item) => ({
|
||||
id: item.id,
|
||||
systemKey: item.systemKey,
|
||||
@@ -627,7 +672,19 @@ export async function getEmployeeMenu(employeeId: string) {
|
||||
openInNewTab: item.openInNewTab,
|
||||
displayOrder: item.displayOrder,
|
||||
})),
|
||||
),
|
||||
)
|
||||
const resolvedItems =
|
||||
context.subscriptionAccessLevel === 'full' && employee.isActive && !hasFeatureMenuRoute(menuItems)
|
||||
? buildBaselineEmployeeMenu(employee.role)
|
||||
: menuItems
|
||||
|
||||
return {
|
||||
companyId: employee.companyId,
|
||||
role: employee.role,
|
||||
subscriptionPlan: context.currentPlan,
|
||||
subscriptionStatus: context.subscriptionStatus,
|
||||
subscriptionAccessLevel: context.subscriptionAccessLevel,
|
||||
items: resolvedItems,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
rentalPayment: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn(), updateMany: vi.fn(), create: vi.fn() },
|
||||
reservation: { findFirstOrThrow: vi.fn(), update: vi.fn() },
|
||||
reservation: { findFirstOrThrow: vi.fn(), update: vi.fn(), findUniqueOrThrow: vi.fn() },
|
||||
$transaction: vi.fn(async (callback) => callback({
|
||||
reservation: {
|
||||
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
||||
id: 'reservation_1',
|
||||
totalAmount: 5000,
|
||||
rentalPayments: [{ amount: 2500 }, { amount: 1500 }],
|
||||
}),
|
||||
update: vi.fn(),
|
||||
},
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -46,13 +56,10 @@ describe('payment.repo edge queries', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('increments paid amount and promotes reservation payment status to paid', async () => {
|
||||
it('recomputes paid amount from successful charge payments', async () => {
|
||||
await repo.incrementReservationPaid('reservation_1', 2500)
|
||||
|
||||
expect(prisma.reservation.update).toHaveBeenCalledWith({
|
||||
where: { id: 'reservation_1' },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: 2500 } },
|
||||
})
|
||||
expect(prisma.$transaction).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps partial refunds to the correct payment status', async () => {
|
||||
|
||||
@@ -32,12 +32,15 @@ export function findPaymentOrThrow(paymentId: string, companyId: string, reserva
|
||||
export function findReservationOrThrow(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id, companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
include: { vehicle: true, customer: true, rentalPayments: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findReservation(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id, companyId },
|
||||
include: { rentalPayments: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function markPaymentSucceeded(id: string) {
|
||||
@@ -48,8 +51,26 @@ export function markPaymentFailed(query: { amanpayTransactionId?: string; paypal
|
||||
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 incrementReservationPaid(reservationId: string, _amount: number) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const reservation = await tx.reservation.findUniqueOrThrow({
|
||||
where: { id: reservationId },
|
||||
include: {
|
||||
rentalPayments: {
|
||||
where: { type: 'CHARGE', status: 'SUCCEEDED' },
|
||||
select: { amount: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
const paidAmount = reservation.rentalPayments.reduce((total, payment) => total + payment.amount, 0)
|
||||
return tx.reservation.update({
|
||||
where: { id: reservationId },
|
||||
data: {
|
||||
paidAmount,
|
||||
paymentStatus: paidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL',
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function createPayment(data: {
|
||||
|
||||
@@ -72,7 +72,7 @@ router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (r
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/manual', requireRole('MANAGER'), async (req, res, next) => {
|
||||
router.post('/reservations/:id/manual', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
const body = parseBody(manualPaymentSchema, req)
|
||||
|
||||
@@ -52,6 +52,7 @@ const reservation = {
|
||||
depositAmount: 300,
|
||||
totalAmount: 1200,
|
||||
paidAmount: 200,
|
||||
rentalPayments: [],
|
||||
vehicle: { make: 'Dacia', model: 'Duster' },
|
||||
customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' },
|
||||
}
|
||||
@@ -106,7 +107,11 @@ describe('payment.service', () => {
|
||||
})
|
||||
|
||||
it('refuses to initialize a charge for a fully paid reservation before touching gateways', async () => {
|
||||
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({ ...reservation, paymentStatus: 'PAID' } as never)
|
||||
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({
|
||||
...reservation,
|
||||
paymentStatus: 'PAID',
|
||||
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1200 }],
|
||||
} as never)
|
||||
|
||||
await expect(initCharge('reservation_1', 'company_1', {
|
||||
provider: 'PAYPAL',
|
||||
@@ -120,8 +125,36 @@ describe('payment.service', () => {
|
||||
expect(repo.createPayment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows an outstanding deposit when the rental invoice is fully paid', async () => {
|
||||
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({
|
||||
...reservation,
|
||||
paymentStatus: 'PAID',
|
||||
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1200 }],
|
||||
} as never)
|
||||
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(amanpay.createCheckout).mockResolvedValue({ checkoutUrl: 'https://pay.example/checkout', transactionId: 'aman_txn_2' } as never)
|
||||
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_2', status: 'PENDING' } as never)
|
||||
|
||||
await initCharge('reservation_1', 'company_1', {
|
||||
provider: 'AMANPAY',
|
||||
type: 'DEPOSIT',
|
||||
currency: 'MAD',
|
||||
successUrl: 'https://app.example/success',
|
||||
failureUrl: 'https://app.example/failure',
|
||||
})
|
||||
|
||||
expect(amanpay.createCheckout).toHaveBeenCalledWith(expect.objectContaining({ amount: 300 }))
|
||||
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({ type: 'DEPOSIT' }))
|
||||
})
|
||||
|
||||
it('records manual payments and marks the reservation as partially or fully paid from the balance math', async () => {
|
||||
vi.mocked(repo.findReservation).mockResolvedValue({ id: 'reservation_1', totalAmount: 1000, paidAmount: 700 } as never)
|
||||
vi.mocked(repo.findReservation).mockResolvedValue({
|
||||
id: 'reservation_1',
|
||||
totalAmount: 1000,
|
||||
depositAmount: 300,
|
||||
paidAmount: 700,
|
||||
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 700 }],
|
||||
} as never)
|
||||
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_1', amount: 300, status: 'SUCCEEDED' } as never)
|
||||
|
||||
const payment = await recordManualPayment('reservation_1', 'company_1', {
|
||||
@@ -133,7 +166,7 @@ describe('payment.service', () => {
|
||||
|
||||
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: 'SUCCEEDED',
|
||||
paymentProvider: 'AMANPAY',
|
||||
paymentProvider: 'MANUAL',
|
||||
paymentMethod: 'CASH',
|
||||
paidAt: expect.any(Date),
|
||||
}))
|
||||
@@ -142,7 +175,13 @@ describe('payment.service', () => {
|
||||
})
|
||||
|
||||
it('rejects manual overpayments instead of silently corrupting the reservation balance', async () => {
|
||||
vi.mocked(repo.findReservation).mockResolvedValue({ id: 'reservation_1', totalAmount: 1000, paidAmount: 950 } as never)
|
||||
vi.mocked(repo.findReservation).mockResolvedValue({
|
||||
id: 'reservation_1',
|
||||
totalAmount: 1000,
|
||||
depositAmount: 300,
|
||||
paidAmount: 950,
|
||||
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 950 }],
|
||||
} as never)
|
||||
|
||||
await expect(recordManualPayment('reservation_1', 'company_1', {
|
||||
amount: 100,
|
||||
@@ -156,8 +195,8 @@ describe('payment.service', () => {
|
||||
})
|
||||
|
||||
it('applies paid AmanPay and denied PayPal webhook events to the matching records', async () => {
|
||||
vi.mocked(repo.findByAmanpay).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 450 } as never)
|
||||
vi.mocked(repo.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500 } as never)
|
||||
vi.mocked(repo.findByAmanpay).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 450, type: 'CHARGE' } as never)
|
||||
vi.mocked(repo.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500, type: 'CHARGE' } as never)
|
||||
|
||||
await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' })
|
||||
await handlePaypalWebhook({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'paypal_capture_1' } })
|
||||
@@ -171,7 +210,7 @@ describe('payment.service', () => {
|
||||
})
|
||||
|
||||
it('captures PayPal orders, stores the capture id, and increments the original reservation payment', async () => {
|
||||
vi.mocked(repo.findByPaypalForCompany).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 800 } as never)
|
||||
vi.mocked(repo.findByPaypalForCompany).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 800, type: 'CHARGE' } as never)
|
||||
vi.mocked(paypal.captureOrder).mockResolvedValue({ purchase_units: [{ payments: { captures: [{ id: 'capture_123' }] } }] } as never)
|
||||
vi.mocked(repo.updatePaypalCapture).mockResolvedValue({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' } as never)
|
||||
|
||||
|
||||
@@ -12,6 +12,17 @@ export function listByReservation(reservationId: string, companyId: string) {
|
||||
return repo.findByReservation(reservationId, companyId)
|
||||
}
|
||||
|
||||
function sumSucceededPayments(payments: any[], type: 'CHARGE' | 'DEPOSIT') {
|
||||
return payments.reduce((total: number, payment: any) => {
|
||||
if (payment.type !== type || payment.status !== 'SUCCEEDED') return total
|
||||
return total + payment.amount
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function getInvoicePaid(reservation: any, rentalPayments: any[]) {
|
||||
return Math.max(sumSucceededPayments(rentalPayments, 'CHARGE'), reservation.paidAmount ?? 0)
|
||||
}
|
||||
|
||||
async function applyAmanpayWebhook(event: any) {
|
||||
const transactionId = event.transaction_id ?? event.id
|
||||
const status = event.status?.toUpperCase()
|
||||
@@ -19,8 +30,10 @@ async function applyAmanpayWebhook(event: any) {
|
||||
const payment = await repo.findByAmanpay(transactionId)
|
||||
if (payment && payment.status !== 'SUCCEEDED') {
|
||||
await repo.markPaymentSucceeded(payment.id)
|
||||
if (payment.type === 'CHARGE') {
|
||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||
}
|
||||
}
|
||||
} else if (status === 'FAILED') {
|
||||
await repo.markPaymentFailed({ amanpayTransactionId: transactionId })
|
||||
}
|
||||
@@ -33,8 +46,10 @@ async function applyPaypalWebhook(event: any) {
|
||||
const payment = await repo.findByPaypal(captureId)
|
||||
if (payment && payment.status !== 'SUCCEEDED') {
|
||||
await repo.markPaymentSucceeded(payment.id)
|
||||
if (payment.type === 'CHARGE') {
|
||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||
}
|
||||
}
|
||||
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
|
||||
await repo.markPaymentFailed({ paypalCaptureId: event.resource?.id })
|
||||
}
|
||||
@@ -65,9 +80,18 @@ export async function initCharge(reservationId: string, companyId: string, body:
|
||||
currency: 'MAD'; successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
|
||||
if (reservation.paymentStatus === 'PAID') throw new ConflictError('Reservation is already fully paid')
|
||||
const rentalPayments = (reservation as any).rentalPayments ?? []
|
||||
const invoicePaid = getInvoicePaid(reservation, rentalPayments)
|
||||
const depositCollected = sumSucceededPayments(rentalPayments, 'DEPOSIT')
|
||||
const balanceDue = body.type === 'DEPOSIT'
|
||||
? reservation.depositAmount - depositCollected
|
||||
: reservation.totalAmount - invoicePaid
|
||||
|
||||
const amount = body.type === 'DEPOSIT' ? reservation.depositAmount : reservation.totalAmount
|
||||
if (balanceDue <= 0) {
|
||||
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid')
|
||||
}
|
||||
|
||||
const amount = balanceDue
|
||||
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'
|
||||
@@ -103,7 +127,9 @@ export async function capturePaypal(reservationId: string, companyId: string, pa
|
||||
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)
|
||||
if (payment.type === 'CHARGE') {
|
||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
@@ -111,13 +137,28 @@ export async function recordManualPayment(reservationId: string, companyId: stri
|
||||
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 rentalPayments = (reservation as any).rentalPayments ?? []
|
||||
const invoicePaid = getInvoicePaid(reservation, rentalPayments)
|
||||
const depositCollected = sumSucceededPayments(rentalPayments, 'DEPOSIT')
|
||||
const remaining = body.type === 'DEPOSIT'
|
||||
? reservation.depositAmount - depositCollected
|
||||
: reservation.totalAmount - invoicePaid
|
||||
|
||||
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
|
||||
if (remaining <= 0) {
|
||||
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid')
|
||||
}
|
||||
if (body.amount <= 0) {
|
||||
throw new ValidationError('Payment amount must be greater than zero')
|
||||
}
|
||||
if (body.amount > remaining) {
|
||||
throw new ValidationError(body.type === 'DEPOSIT' ? 'Payment amount exceeds deposit outstanding' : 'Payment amount exceeds remaining balance')
|
||||
}
|
||||
|
||||
const payment = await repo.createPayment({ companyId, reservationId, amount: body.amount, currency: body.currency, status: 'SUCCEEDED', type: body.type, paymentProvider: 'MANUAL', paymentMethod: body.paymentMethod, paidAt: new Date() })
|
||||
if (body.type === 'CHARGE') {
|
||||
const newPaidAmount = invoicePaid + body.amount
|
||||
await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL')
|
||||
}
|
||||
return payment
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function confirmReservation(id: string, companyId: string) {
|
||||
},
|
||||
}).catch(() => null)
|
||||
|
||||
if (reservation.source === 'MARKETPLACE') {
|
||||
if (reservation.source === 'CARPLACE') {
|
||||
const progress = buildBookingRequestProgress({
|
||||
source: reservation.source,
|
||||
status: 'CONFIRMED',
|
||||
@@ -193,7 +193,7 @@ export async function closeReservation(id: string, companyId: string, closedBy:
|
||||
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
|
||||
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
||||
const vehicleLabel = `${updated.vehicle.year} ${updated.vehicle.make} ${updated.vehicle.model}`
|
||||
const reviewUrl = `${process.env.MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
||||
const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
||||
|
||||
sendTransactionalEmail({
|
||||
to: customer.email,
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('reservation.presenter boundary behavior', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation_1',
|
||||
status: 'DRAFT',
|
||||
source: 'MARKETPLACE',
|
||||
source: 'CARPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
@@ -84,7 +84,7 @@ describe('reservation.presenter boundary behavior', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation_1',
|
||||
status: 'DRAFT',
|
||||
source: 'MARKETPLACE',
|
||||
source: 'CARPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('serializeReservationForDashboard', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation-1',
|
||||
status: 'CONFIRMED',
|
||||
source: 'MARKETPLACE',
|
||||
source: 'CARPLACE',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'UNPAID',
|
||||
|
||||
@@ -68,8 +68,8 @@ export function buildBookingRequestProgress(reservation: {
|
||||
}
|
||||
|
||||
const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status)
|
||||
const documentsRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && documentsMissing.length > 0
|
||||
const paymentRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
|
||||
const documentsRequired = reservation.source === 'CARPLACE' && companyConfirmed && documentsMissing.length > 0
|
||||
const paymentRequired = reservation.source === 'CARPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
|
||||
|
||||
let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED'
|
||||
if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED'
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('review.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
|
||||
process.env.MARKETPLACE_URL = 'https://market.example'
|
||||
process.env.CARPLACE_URL = 'https://market.example'
|
||||
})
|
||||
|
||||
it('returns reviews with rating filters and pagination metadata', async () => {
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function sendReviewReminder(id: string, companyId: string) {
|
||||
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
||||
const vehicle = reservation.vehicle
|
||||
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : 'your rental vehicle'
|
||||
const reviewUrl = `${process.env.MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
||||
const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: customer.email,
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('site.presenter', () => {
|
||||
paymentMethodsEnabled: ['PAYPAL'],
|
||||
defaultLocale: 'fr',
|
||||
defaultCurrency: 'MAD',
|
||||
isListedOnMarketplace: true,
|
||||
isListedOnCarplace: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('site.presenter', () => {
|
||||
paymentMethodsEnabled: ['PAYPAL'],
|
||||
defaultLocale: 'fr',
|
||||
defaultCurrency: 'MAD',
|
||||
isListedOnMarketplace: true,
|
||||
isListedOnCarplace: true,
|
||||
},
|
||||
})
|
||||
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
|
||||
|
||||
@@ -30,8 +30,8 @@ export function presentBrand(company: {
|
||||
paypalEmail: brand.paypalEmail,
|
||||
paypalMerchantId: brand.paypalMerchantId,
|
||||
paymentMethodsEnabled: brand.paymentMethodsEnabled,
|
||||
isListedOnMarketplace: brand.isListedOnMarketplace,
|
||||
marketplaceRating: brand.marketplaceRating,
|
||||
isListedOnCarplace: brand.isListedOnCarplace,
|
||||
carplaceRating: brand.carplaceRating,
|
||||
homePageConfig: brand.homePageConfig,
|
||||
menuConfig: brand.menuConfig,
|
||||
} : null,
|
||||
|
||||
@@ -35,7 +35,7 @@ export const bookSchema = z.object({
|
||||
offerId: z.string().cuid().optional(),
|
||||
promoCodeUsed: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
source: z.enum(['PUBLIC_SITE', 'MARKETPLACE']).default('PUBLIC_SITE'),
|
||||
source: z.enum(['PUBLIC_SITE', 'CARPLACE']).default('PUBLIC_SITE'),
|
||||
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
||||
additionalDrivers: z.array(z.object({
|
||||
firstName: z.string().min(1),
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('../../lib/prisma', () => ({
|
||||
planFeature: { findMany: vi.fn() },
|
||||
},
|
||||
}))
|
||||
vi.mock('../../services/platformContentService', () => ({ getMarketplaceHomepageContent: vi.fn() }))
|
||||
vi.mock('../../services/platformContentService', () => ({ getCarplaceHomepageContent: vi.fn() }))
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
||||
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
|
||||
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
|
||||
@@ -59,11 +59,19 @@ import * as service from './site.service'
|
||||
|
||||
const company = { id: 'company_1', slug: 'atlas', email: 'office@example.test', contractSettings: { depositRequired: true }, brand: { publicEmail: 'hello@example.test' } }
|
||||
|
||||
function futureDateRange(startOffsetDays: number, durationDays: number) {
|
||||
const start = new Date(Date.now() + startOffsetDays * 24 * 60 * 60 * 1000)
|
||||
const end = new Date(start.getTime() + durationDays * 24 * 60 * 60 * 1000)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function bookingBody(overrides: Record<string, unknown> = {}) {
|
||||
const { start, end } = futureDateRange(30, 3)
|
||||
|
||||
return {
|
||||
vehicleId: 'vehicle_1',
|
||||
startDate: '2026-07-01T00:00:00.000Z',
|
||||
endDate: '2026-07-04T00:00:00.000Z',
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString(),
|
||||
firstName: 'Nora',
|
||||
lastName: 'Renter',
|
||||
email: 'nora@example.test',
|
||||
@@ -103,6 +111,11 @@ describe('site.service public booking/payment boundaries', () => {
|
||||
})
|
||||
|
||||
it('calculates booking totals from base rate, free-day promo, pricing rules, insurance and additional drivers', async () => {
|
||||
const body = bookingBody({
|
||||
promoCodeUsed: 'FREEDAY',
|
||||
selectedInsurancePolicyIds: ['insurance_1'],
|
||||
additionalDrivers: [{ firstName: 'Second' }],
|
||||
})
|
||||
vi.mocked(repo.findVehicleById).mockResolvedValue({ id: 'vehicle_1', companyId: 'company_1', dailyRate: 500, category: 'SUV' } as never)
|
||||
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'customer_1' } as never)
|
||||
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'offer_1', type: 'FREE_DAY', discountValue: 1 } as never)
|
||||
@@ -111,8 +124,8 @@ describe('site.service public booking/payment boundaries', () => {
|
||||
id: 'reservation_1',
|
||||
bookingReference: 'BK-2026-AB12CD',
|
||||
status: 'DRAFT',
|
||||
startDate: new Date('2026-07-01T10:00:00.000Z'),
|
||||
endDate: new Date('2026-07-04T10:00:00.000Z'),
|
||||
startDate: new Date(body.startDate),
|
||||
endDate: new Date(body.endDate),
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
vehicle: { make: 'Dacia', model: 'Duster', year: 2024 },
|
||||
@@ -125,11 +138,7 @@ describe('site.service public booking/payment boundaries', () => {
|
||||
paymentStatus: 'UNPAID',
|
||||
} as never)
|
||||
|
||||
const result = await service.createBooking('atlas', bookingBody({
|
||||
promoCodeUsed: 'FREEDAY',
|
||||
selectedInsurancePolicyIds: ['insurance_1'],
|
||||
additionalDrivers: [{ firstName: 'Second' }],
|
||||
}))
|
||||
const result = await service.createBooking('atlas', body)
|
||||
|
||||
expect(repo.createReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
totalDays: 3,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AppError, NotFoundError } from '../../http/errors'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { applyPricingRules } from '../../services/pricingRuleService'
|
||||
import { validateLicense } from '../../services/licenseValidationService'
|
||||
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
|
||||
import { getCarplaceHomepageContent } from '../../services/platformContentService'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
@@ -30,7 +30,7 @@ function assertAllowedPaymentRedirect(urlValue: string, company: any) {
|
||||
allowedHosts.add('127.0.0.1:3000')
|
||||
allowedHosts.add('127.0.0.1:4000')
|
||||
}
|
||||
for (const value of [process.env.MARKETPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_MARKETPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
|
||||
for (const value of [process.env.CARPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_CARPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
|
||||
if (!value) continue
|
||||
try { allowedHosts.add(new URL(value).host) } catch {}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ function generateBookingReference() {
|
||||
}
|
||||
|
||||
export async function getPlatformHomepage() {
|
||||
return getMarketplaceHomepageContent()
|
||||
return getCarplaceHomepageContent()
|
||||
}
|
||||
|
||||
export async function getPlatformPricing() {
|
||||
|
||||
@@ -66,7 +66,7 @@ vi.mock('../../services/paypalService', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../services/platformContentService', () => ({
|
||||
getMarketplaceHomepageContent: vi.fn(),
|
||||
getCarplaceHomepageContent: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as repo from './site.repo'
|
||||
@@ -82,6 +82,12 @@ import {
|
||||
|
||||
const SLUG = 'test-company'
|
||||
|
||||
function futureDateRange(startOffsetDays: number, durationDays: number) {
|
||||
const start = new Date(Date.now() + startOffsetDays * 24 * 60 * 60 * 1000)
|
||||
const end = new Date(start.getTime() + durationDays * 24 * 60 * 60 * 1000)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function makeCompany(overrides: object = {}) {
|
||||
return {
|
||||
id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com',
|
||||
@@ -173,10 +179,11 @@ describe('validatePromoCode', () => {
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('createBooking', () => {
|
||||
const { start: bookingStart, end: bookingEnd } = futureDateRange(30, 3)
|
||||
const baseBody = {
|
||||
vehicleId: 'v-1',
|
||||
startDate: '2026-07-01T00:00:00.000Z',
|
||||
endDate: '2026-07-04T00:00:00.000Z',
|
||||
startDate: bookingStart.toISOString(),
|
||||
endDate: bookingEnd.toISOString(),
|
||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||
phone: '+212600000000',
|
||||
pickupLocation: 'Casablanca',
|
||||
@@ -194,8 +201,8 @@ describe('createBooking', () => {
|
||||
id: 'r-1',
|
||||
bookingReference: 'BK-2026-ABC123',
|
||||
status: 'DRAFT',
|
||||
startDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||
endDate: new Date('2026-07-04T00:00:00.000Z'),
|
||||
startDate: bookingStart,
|
||||
endDate: bookingEnd,
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
vehicle: { make: 'Toyota', model: 'Camry', year: 2022 },
|
||||
|
||||
@@ -2,7 +2,7 @@ export type AccessLevel = 'full' | 'limited' | 'read_only' | 'none'
|
||||
|
||||
export const SUBSCRIPTION_POLICY = {
|
||||
trial: {
|
||||
durationDays: 14,
|
||||
durationDays: 30,
|
||||
requiresPaymentMethod: false, // set true when payment capture is mandatory
|
||||
oneTrialPerCompany: true,
|
||||
},
|
||||
|
||||
@@ -70,18 +70,18 @@ describe('subscription.service operational edges', () => {
|
||||
|
||||
it('builds plans from pricing config rows when platform overrides exist', async () => {
|
||||
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
|
||||
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 9900 },
|
||||
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 99000 },
|
||||
{ plan: 'PRO', billingPeriod: 'MONTHLY', amount: 29900 },
|
||||
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 14900 },
|
||||
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 143040 },
|
||||
{ plan: 'PRO', billingPeriod: 'MONTHLY', amount: 39900 },
|
||||
] as never)
|
||||
|
||||
await expect(service.getPlans()).resolves.toEqual({
|
||||
STARTER: {
|
||||
MONTHLY: { MAD: 9900 },
|
||||
ANNUAL: { MAD: 99000 },
|
||||
MONTHLY: { MAD: 14900 },
|
||||
ANNUAL: { MAD: 143040 },
|
||||
},
|
||||
PRO: {
|
||||
MONTHLY: { MAD: 29900 },
|
||||
MONTHLY: { MAD: 39900 },
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -102,7 +102,7 @@ describe('subscription.service operational edges', () => {
|
||||
'PRO',
|
||||
'MONTHLY',
|
||||
'MAD',
|
||||
new Date('2026-06-15T00:00:00.000Z'),
|
||||
new Date('2026-07-01T00:00:00.000Z'),
|
||||
)
|
||||
expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
subscriptionId: 'sub_1',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearSessionCookie, setSessionCookie } from './sessionCookies'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
function mockResponse() {
|
||||
return {
|
||||
cookie: vi.fn(),
|
||||
clearCookie: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
describe('session cookies', () => {
|
||||
it('sets employee session cookies for the configured parent domain', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
process.env.SESSION_COOKIE_DOMAIN = '.rentaldrivego.ma'
|
||||
const res = mockResponse()
|
||||
|
||||
setSessionCookie(res as never, 'employee', 'token-123', 1000)
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('employee_session', 'token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
domain: '.rentaldrivego.ma',
|
||||
maxAge: 1000,
|
||||
})
|
||||
})
|
||||
|
||||
it('clears cookies with the same domain attributes used when setting them', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
process.env.SESSION_COOKIE_DOMAIN = '.rentaldrivego.ma'
|
||||
const res = mockResponse()
|
||||
|
||||
clearSessionCookie(res as never, 'employee')
|
||||
|
||||
expect(res.clearCookie).toHaveBeenCalledWith('employee_session', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
domain: '.rentaldrivego.ma',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the domain option when no shared cookie domain is configured', () => {
|
||||
delete process.env.SESSION_COOKIE_DOMAIN
|
||||
const res = mockResponse()
|
||||
|
||||
setSessionCookie(res as never, 'employee', 'token-123')
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('employee_session', 'token-123', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user