84 Commits

Author SHA1 Message Date
root ec03e783e5 remove the extra carplace folder
Build & Push / Build & Push Docker Image (push) Successful in 1m7s
2026-07-05 14:56:46 -04:00
root 9c50a59133 add missing packages
Build & Push / Build & Push Docker Image (push) Successful in 3m50s
2026-07-04 19:12:06 -04:00
root aa6afea30b fix search section at the carplace page
Build & Push / Build & Push Docker Image (push) Successful in 3m48s
2026-07-04 18:51:40 -04:00
root 98f0b3ffad fix session cookies url
Build & Push / Build & Push Docker Image (push) Successful in 52s
2026-07-04 18:36:50 -04:00
root 1d6ae6a54e Accept registry credential aliases in Gitea deploy workflow
Build & Push / Build & Push Docker Image (push) Successful in 4m53s
Allow the build-and-deploy workflow to use either REGISTRY_USERNAME/REGISTRY_PASSWORD or REGISTRY_USER/REGISTRY_TOKEN secrets, and remove duplicate Carplace URL env entries.
2026-07-04 18:25:10 -04:00
root c77d0a8e89 Rename legacy storefront app and references to carplace
Build & Push / Build & Push Docker Image (push) Failing after 10m39s
Replace storefront naming across source, tests, docs, config, and production scripts. Rename the legacy top-level app directory and Carplace component files, remove duplicate storefront startup scripts, and refresh the lockfile.
2026-07-04 18:10:08 -04:00
root fefaf8108d Fix production routing through Traefik
Build & Push / Build & Push Docker Image (push) Successful in 50s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- run the production deploy script after building the image
- support raw or base64 production env secrets during deploy
- copy the production env file into the remote build context
- include homepage in production deploy, pull, and health checks
- use a stable Traefik Compose project name
- reuse an existing Traefik container instead of starting a duplicate proxy
- update Traefik router rules to use HeaderRegexp with traefik:latest
2026-07-04 17:45:43 -04:00
root 9e096f0216 Fix production deploy Traefik reuse
Build & Push / Build & Push Docker Image (push) Successful in 53s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- run the production deploy script after building the image
- support raw or base64 production env secrets during deploy
- copy the production env file into the remote build context
- include homepage in production deploy, pull, and health checks
- use a stable Traefik Compose project name
- reuse an existing Traefik container instead of starting a duplicate proxy
2026-07-04 17:39:22 -04:00
root 7f51825b1f Fix production deploy routing and Traefik project reuse
Build & Push / Build & Push Docker Image (push) Failing after 28s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- run the production deploy script after building the image
- support raw or base64 production env secrets during deploy
- copy the production env file into the remote build context
- include homepage in production deploy, pull, and health checks
- use a stable Traefik Compose project name to avoid duplicate proxy containers
2026-07-04 17:37:15 -04:00
root da35656839 Fix production deploy env path and homepage routing
Build & Push / Build & Push Docker Image (push) Failing after 34s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- keep NEXT_PUBLIC_STOREFRONT_URL as a fallback for existing secrets
- run the production deploy script after building the image
- support raw or base64 production env secrets during deploy
- copy the production env file into the remote build context
- include homepage in production deploy, pull, and health checks
2026-07-04 17:34:47 -04:00
root 026e33a228 Fix production deploy env handling and homepage routing
Build & Push / Build & Push Docker Image (push) Failing after 24s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- keep NEXT_PUBLIC_STOREFRONT_URL as a fallback for existing secrets
- run the production deploy script after building the image
- support raw or base64 production env secrets during deploy
- reuse an existing VPS production env file when present
- include homepage in production deploy, pull, and health checks
2026-07-04 17:17:23 -04:00
root abec82a08f Fix production deployment for public site routing
Build & Push / Build & Push Docker Image (push) Failing after 27s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- keep NEXT_PUBLIC_STOREFRONT_URL as a fallback for existing secrets
- run the production deploy script after building the image
- allow deploys to reuse an existing VPS production env file
- include homepage in production deploy, pull, and health checks
- allow deploy to use a release image already built locally on the VPS
2026-07-04 17:14:34 -04:00
root f4ea01e9de Fix production deployment for public site routing
Build & Push / Build & Push Docker Image (push) Failing after 3m35s
- pass `NEXT_PUBLIC_CARPLACE_URL` into the production Docker build
- keep `NEXT_PUBLIC_STOREFRONT_URL` as a backward-compatible fallback
- run the production deploy script from the Gitea workflow after building
- include `homepage` in production deploy, pull, and health-check service lists
- allow deploy to use a release image already built locally on the VPS
2026-07-04 17:08:47 -04:00
root a5b6c559ea remove deploy from build push code
Build & Push / Build & Push Docker Image (push) Successful in 24s
2026-07-04 16:50:18 -04:00
root e1b848042b build fix 13
Build & Deploy / Build & Push Docker Image (push) Successful in 5m47s
Build & Deploy / Deploy to VPS (push) Failing after 21s
2026-07-04 16:38:28 -04:00
root d238e6e806 build fix 12
Build & Deploy / Build & Push Docker Image (push) Failing after 32s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 16:34:38 -04:00
root be30d19cdb build fix 11
Build & Deploy / Build & Push Docker Image (push) Failing after 18s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 15:45:38 -04:00
root 24c58cf006 build fix 10
Build & Deploy / Build & Push Docker Image (push) Failing after 34s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 15:40:56 -04:00
root 2befed486f build fix 9
Build & Deploy / Build & Push Docker Image (push) Failing after 5s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 15:33:14 -04:00
root 2436907dc2 build fix 8
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 15:30:50 -04:00
root fbbd5e4c1c deploy fix 7
Build & Deploy / Build & Push Docker Image (push) Failing after 3s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 15:29:09 -04:00
root a45b318d5e build fix7
Build & Deploy / Build & Push Docker Image (push) Failing after 5s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 14:21:00 -04:00
root 1c8b3d5401 fix build 6
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 14:17:50 -04:00
root e7e80103c7 fix build 5
Build & Deploy / Build & Push Docker Image (push) Failing after 5s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 14:07:41 -04:00
root b5dfe66373 fix build 4
Build & Deploy / Build & Push Docker Image (push) Failing after 4s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 14:05:16 -04:00
root c74789f949 fix build 3
Build & Deploy / Build & Push Docker Image (push) Failing after 5s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:57:44 -04:00
root 0adebc08e5 fix build 2
Build & Deploy / Build & Push Docker Image (push) Failing after 6s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:56:15 -04:00
root 342e8ee2fe fix build generate real private key
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:54:56 -04:00
root 030ecd8fd8 fix build afet private key
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:50:23 -04:00
root 382738b52a fix build after adding vps key
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:47:09 -04:00
root 2d5f34fc65 fix build
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:40:59 -04:00
root 765c1ede8f fix build
Build & Deploy / Build & Push Docker Image (push) Failing after 6s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:39:01 -04:00
root f6f7352c0d fix build
Build & Deploy / Build & Push Docker Image (push) Failing after 1m22s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:35:43 -04:00
root 48809e8bf0 fix build
Build & Deploy / Build & Push Docker Image (push) Failing after 7s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:33:12 -04:00
root 088fc2db65 fix build
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:29:16 -04:00
root f4097b0b2f fix deploy
Build & Deploy / Build & Push Docker Image (push) Failing after 40s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:26:04 -04:00
root 7a7fad65d0 fix build issue
Build & Deploy / Build & Push Docker Image (push) Failing after 3m14s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:20:05 -04:00
root a7d0c29144 update default branch
Build & Deploy / Build & Push Docker Image (push) Failing after 11s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:13:26 -04:00
root b220807d70 Fix dashboard auth image preload and API base resolution
Build & Deploy / Build & Push Docker Image (push) Successful in 3m10s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 46s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Carplace Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 59s
Test / Type Check (all packages) (pull_request) Failing after 10s
Test / API Unit Tests (pull_request) Has been skipped
Test / Homepage Unit Tests (pull_request) Has been skipped
Test / Carplace Unit Tests (pull_request) Has been skipped
Test / Admin Unit Tests (pull_request) Has been skipped
Test / Dashboard Unit Tests (pull_request) Has been skipped
Test / API Integration Tests (pull_request) Has been skipped
2026-07-02 23:04:54 -04:00
root 2de00868ad fix(dashboard): proxy API requests through dashboard route
Build & Deploy / Build & Push Docker Image (push) Successful in 2m58s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 39s
Test / API Integration Tests (push) Successful in 59s
2026-07-02 22:14:34 -04:00
root 2959285425 fix redirect 2nd time
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 46s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Carplace Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 22:01:16 -04:00
root c27f0909df fix sidebar menu redirect in production
Build & Deploy / Build & Push Docker Image (push) Successful in 3m15s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 59s
2026-07-02 21:43:56 -04:00
root 511ab4d03b fix the login user
Build & Deploy / Build & Push Docker Image (push) Successful in 3m5s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 46s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 21:21:15 -04:00
root 00d01bdaf4 fix company login issue
Build & Deploy / Build & Push Docker Image (push) Successful in 3m8s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 48s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 19:23:26 -04:00
root 68bf9ca610 fix carplace page
Build & Deploy / Build & Push Docker Image (push) Successful in 3m16s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 56s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Carplace Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 19:05:12 -04:00
root f330efb1ad fix test issues
Build & Deploy / Build & Push Docker Image (push) Successful in 3m1s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 5s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 18:50:03 -04:00
root e3f40410e9 fix(api): report database schema mismatches clearly
Build & Deploy / Build & Push Docker Image (push) Successful in 3m10s
Test / Type Check (all packages) (push) Successful in 1m2s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Failing after 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 41s
Test / API Integration Tests (push) Successful in 59s
2026-07-02 18:40:42 -04:00
root c21916559f chore: remove unused marketplace configuration
Build & Deploy / Build & Push Docker Image (push) Successful in 3m9s
Test / Type Check (all packages) (push) Successful in 56s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 49s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 18:24:19 -04:00
root 7ff2dbb139 chore: wire Carplace into dev and production stacks
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 18:15:42 -04:00
root ad5f5ebab7 fix(api): support shared session cookie domain
Build & Deploy / Build & Push Docker Image (push) Successful in 2m49s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 48s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Storefront Unit Tests (push) Successful in 47s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 59s
2026-07-02 15:10:40 -04:00
root a947e48c49 Fixed the dashboard sidebar redirect issue by making browser-side dashboard API calls always go through the same-origin proxy at /dashboard/api/v1
Build & Deploy / Build & Push Docker Image (push) Successful in 2m56s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 46s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Has been cancelled
2026-07-02 15:02:10 -04:00
root 6913c298ad Route storefront marketplace under /storefront
Build & Deploy / Build & Push Docker Image (push) Successful in 2m57s
Test / Type Check (all packages) (push) Successful in 57s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m4s
Replace the legacy /explore marketplace route with /storefront across the
storefront app and production routing.

- move the marketplace index page to the storefront root route
- move company and vehicle detail pages from /explore/* to /* internally
- update marketplace links, renter dashboard links, saved company links, and
  header navigation to target /storefront
- remove remaining explore route references and wording from storefront code
- configure production Traefik to route /storefront and strip the prefix before
  forwarding to the storefront service
- set the storefront production asset prefix to /storefront so Next chunks load
  from /storefront/_next instead of the shared root /_next path
- redirect locale entry paths such as /storefront/fr back to /storefront while
  persisting the selected language cookie
- update middleware tests for the locale redirect and adjusted redirect mock

Verification:
- npm run test --workspace @rentaldrivego/storefront
- npm run build --workspace @rentaldrivego/storefront
2026-07-02 14:47:50 -04:00
root b45f84d62b The dashboard middleware was treating /dashboard/api/v1/* as protected dashboard pages, so even the login request:
Build & Deploy / Build & Push Docker Image (push) Successful in 2m49s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
/dashboard/api/v1/auth/employee/login
was getting 307 redirected to /dashboard/sign-in. That meant sign-in could never set/read the employee_session cookie, and every dashboard page kept bouncing back to sign-in.
2026-07-02 14:19:45 -04:00
root 781512399b Update homepage routes to include language and mode
Build & Deploy / Build & Push Docker Image (push) Successful in 2m51s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 39s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 14:10:17 -04:00
root 56c3bcf666 fix homepage storefront proxy and CSP
Build & Deploy / Build & Push Docker Image (push) Successful in 2m50s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 54s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 13:55:10 -04:00
root 9c11f3660d Fix dashboard redirects leaking internal port
Build & Deploy / Build & Push Docker Image (push) Successful in 2m54s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-02 13:34:28 -04:00
root 7c1bd2d0c0 Support insecure VPN registry pulls
Build & Deploy / Build & Push Docker Image (push) Successful in 2m52s
Test / Type Check (all packages) (push) Successful in 50s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Storefront Unit Tests (push) Successful in 39s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-02 12:06:01 -04:00
root 1fd1ddf3f2 Support registry token for VPN image pulls
Build & Deploy / Build & Push Docker Image (push) Successful in 2m50s
Test / Type Check (all packages) (push) Successful in 50s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 45s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 59s
2026-07-02 11:49:58 -04:00
root 0394f0ea2a Update production registry pull over VPN
Build & Deploy / Build & Push Docker Image (push) Successful in 2m53s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 46s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 58s
Configure production defaults to pull images directly from the VPN registry at 10.0.0.4, with the VPS reachable at 10.0.0.1, and disable archive handoff by default.
2026-07-02 11:36:35 -04:00
root 2993dd5b57 fix: return baseline menu for full-access accounts
Build & Deploy / Build & Push Docker Image (push) Failing after 6m52s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 11s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-02 03:31:14 -04:00
root e93b988146 fix: align dashboard guard with fallback menu access
Build & Deploy / Build & Push Docker Image (push) Successful in 2m46s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-02 03:18:42 -04:00
root ea2bd215f6 fix: align dashboard guard with fallback menu access
Build & Deploy / Build & Push Docker Image (push) Successful in 2m56s
Test / Type Check (all packages) (push) Successful in 51s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 39s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-02 03:06:45 -04:00
root 13b54f07de fix: normalize homepage API URL and enforce expired trial access
Build & Deploy / Build & Push Docker Image (push) Successful in 2m54s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Failing after 1m9s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 02:45:08 -04:00
root 75a1d39050 fix: enforce expired trial subscription menu access
Build & Deploy / Build & Push Docker Image (push) Successful in 2m48s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 1m9s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 47s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 02:32:37 -04:00
root 0dcbe18c54 fix production API and storefront routing
Build & Deploy / Build & Push Docker Image (push) Successful in 2m52s
Test / Type Check (all packages) (push) Successful in 57s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 45s
Test / Admin Unit Tests (push) Successful in 47s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 59s
Normalize dashboard API base URLs so bare API origins still call /api/v1 endpoints. Reuse the shared dashboard API base on email verification.

Update production storefront URL examples to use the root domain and route footer, policy, and workspace storefront paths through Traefik.
2026-07-02 02:02:31 -04:00
root 8ef61d7005 I also hardened the dashboard API helper so a bare origin
Build & Deploy / Build & Push Docker Image (push) Successful in 2m47s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 01:43:27 -04:00
root 56984c4ea7 fix the acme email
Build & Deploy / Build & Push Docker Image (push) Successful in 2m50s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-02 01:05:32 -04:00
root 330fc11791 Updated the homepage CSP for the new browser error.
Build & Deploy / Build & Push Docker Image (push) Successful in 3m1s
Test / Type Check (all packages) (push) Successful in 57s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 00:30:30 -04:00
root 0ddf67c754 Fix the remaining CSP errors. The repeated hash was from Next’s generated <Image> markup: style="color:transparent".
Build & Deploy / Build & Push Docker Image (push) Successful in 3m3s
Test / Type Check (all packages) (push) Successful in 57s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 50s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m3s
2026-07-01 23:52:09 -04:00
root bcfe518af2 Fix the homepage CSP inline-style violations by removing React style attributes from the homepage app and moving them into CSS modules.
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 59s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 51s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-01 23:33:37 -04:00
root 30631504f1 fix production domain
Build & Deploy / Build & Push Docker Image (push) Successful in 2m57s
Test / Type Check (all packages) (push) Successful in 58s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 51s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m2s
2026-07-01 22:51:46 -04:00
root 757ad41c9b add image pull to production
Build & Deploy / Build & Push Docker Image (push) Successful in 3m15s
Test / Type Check (all packages) (push) Successful in 1m2s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 51s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-01 22:25:00 -04:00
root 97d5ecfcf0 pull docker image
Build & Deploy / Build & Push Docker Image (push) Successful in 3m4s
Test / Type Check (all packages) (push) Successful in 1m2s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 52s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-01 22:12:49 -04:00
root 63f8df1e38 fix production
Build & Deploy / Build & Push Docker Image (push) Successful in 3m26s
Test / Type Check (all packages) (push) Successful in 59s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 52s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
2026-07-01 22:06:02 -04:00
root e2b564aea5 fix prod
Build & Deploy / Build & Push Docker Image (push) Successful in 2m51s
Test / Type Check (all packages) (push) Successful in 51s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 59s
2026-07-01 09:18:14 -04:00
root 184a4bac8b fix production storfront and homepage
Build & Deploy / Build & Push Docker Image (push) Successful in 2m45s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-01 09:02:31 -04:00
root 9703de974a fix api tests
Build & Deploy / Build & Push Docker Image (push) Successful in 2m52s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 41s
Test / Storefront Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-01 01:43:36 -04:00
root 5dfd5b1814 fix prod deployment
Build & Deploy / Build & Push Docker Image (push) Failing after 34s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 57s
Test / API Unit Tests (push) Failing after 52s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Storefront Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Has been cancelled
2026-07-01 01:37:37 -04:00
root 13d0512048 registry fix
Build & Deploy / Build & Push Docker Image (push) Successful in 9m39s
Test / Type Check (all packages) (push) Successful in 4m12s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Successful in 3m37s
Test / Homepage Unit Tests (push) Successful in 3m24s
Test / Storefront Unit Tests (push) Successful in 2m45s
Test / Admin Unit Tests (push) Successful in 3m41s
Test / Dashboard Unit Tests (push) Successful in 3m7s
Test / API Integration Tests (push) Successful in 3m31s
2026-06-30 09:01:18 -04:00
root b0c78ad8a2 registry fix
Build & Deploy / Build & Push Docker Image (push) Failing after 10m37s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Has been cancelled
Test / API Unit Tests (push) Has been cancelled
Test / Homepage Unit Tests (push) Has been cancelled
Test / Storefront Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
2026-06-30 08:49:09 -04:00
root d8b7b92cb1 fixed registry secure
Build & Deploy / Build & Push Docker Image (push) Failing after 10m27s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 3m9s
Test / API Unit Tests (push) Successful in 3m40s
Test / Homepage Unit Tests (push) Successful in 3m5s
Test / Storefront Unit Tests (push) Successful in 4m28s
Test / Admin Unit Tests (push) Successful in 3m39s
Test / Dashboard Unit Tests (push) Successful in 3m8s
Test / API Integration Tests (push) Successful in 3m58s
2026-06-30 01:38:01 -04:00
root fb77e91730 fix registry creation image
Build & Deploy / Build & Push Docker Image (push) Failing after 11m2s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 3m40s
Test / API Unit Tests (push) Has been cancelled
Test / Homepage Unit Tests (push) Has been cancelled
Test / Storefront Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
2026-06-30 01:22:26 -04:00
root 9ad92765c1 fix test failure
Test / Type Check (all packages) (push) Successful in 3m30s
Test / API Unit Tests (push) Successful in 3m5s
Test / Homepage Unit Tests (push) Successful in 3m0s
Test / Storefront Unit Tests (push) Successful in 3m36s
Test / Admin Unit Tests (push) Successful in 4m0s
Test / Dashboard Unit Tests (push) Successful in 3m11s
Test / API Integration Tests (push) Successful in 3m6s
Build & Deploy / Build & Push Docker Image (push) Failing after 14s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-06-30 00:23:31 -04:00
root f22e0d45e1 fix subscription page
Build & Deploy / Build & Push Docker Image (push) Successful in 7m57s
Test / Type Check (all packages) (push) Successful in 4m27s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 3m2s
Test / Homepage Unit Tests (push) Successful in 4m3s
Test / Storefront Unit Tests (push) Successful in 3m32s
Test / Admin Unit Tests (push) Successful in 3m27s
Test / Dashboard Unit Tests (push) Successful in 3m3s
Test / API Integration Tests (push) Failing after 3m53s
2026-06-29 23:15:55 -04:00
429 changed files with 11627 additions and 13856 deletions
+2 -3
View File
@@ -11,17 +11,16 @@ API_PORT=4000
API_URL=http://localhost:4000 API_URL=http://localhost:4000
API_INTERNAL_URL=http://api:4000/api/v1 API_INTERNAL_URL=http://api:4000/api/v1
NEXT_PUBLIC_API_URL=http://localhost: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_DASHBOARD_URL=http://localhost:3000/dashboard
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
DASHBOARD_URL=http://localhost:3000/dashboard DASHBOARD_URL=http://localhost:3000/dashboard
ADMIN_URL=http://localhost:3000/admin ADMIN_URL=http://localhost:3000/admin
DASHBOARD_INTERNAL_URL=http://host.docker.internal:3001 DASHBOARD_INTERNAL_URL=http://host.docker.internal:3001
ADMIN_INTERNAL_URL=http://host.docker.internal:3002 ADMIN_INTERNAL_URL=http://host.docker.internal:3002
# Asset prefix for the dashboard/admin dev servers so browsers load JS chunks from the # Asset prefixes for apps served through the homepage dev proxy.
# correct port rather than through the marketplace proxy (which doesn't have those chunks).
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
ADMIN_ASSET_PREFIX=http://localhost:3002/admin ADMIN_ASSET_PREFIX=http://localhost:3002/admin
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
JWT_SECRET=placeholder JWT_SECRET=placeholder
JWT_EXPIRY=8h JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d RENTER_JWT_EXPIRY=7d
+17 -20
View File
@@ -1,43 +1,40 @@
# ── Traefik domains — used in docker-compose labels ────────────────────────── # ── Traefik domains — used in docker-compose labels ──────────────────────────
ACME_EMAIL=ops@example.com ACME_EMAIL=rentaldrivego@gmail.com
API_DOMAIN=api.rentaldrivego.ma API_DOMAIN=api.rentaldrivego.ma
PUBLIC_SITE_DOMAIN=rentaldrivego.ma PUBLIC_SITE_DOMAIN=rentaldrivego.ma
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
PORTAINER_DOMAIN=portainer.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 ──────────────────────────────────────────── # ── Optional prebuilt image source ────────────────────────────────────────────
# Used for pull-based deployments. CI injects APP_IMAGE and APP_VERSION # Used for production deployments. CI injects IMAGE_TAG automatically during
# automatically during registry-backed deploys, so these can stay commented out. # 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_IMAGE=registry.example.com/rentaldrivego/car_management_system
# APP_VERSION=latest # 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 ────────────────────────────────────────────────────────────────── # ── Database ──────────────────────────────────────────────────────────────────
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false) # 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) # 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 DATABASE_URL_FROM_POSTGRES=true
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_DB=rentaldrivego POSTGRES_DB=rentaldrivego
POSTGRES_USER=postgres POSTGRES_USER=dbadmin
POSTGRES_PASSWORD=placeholder POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
# ── Cache ───────────────────────────────────────────────────────────────────── # ── Cache ─────────────────────────────────────────────────────────────────────
REDIS_URL=redis://redis:6379 REDIS_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
REDIS_URL=redis://:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@redis:6379
# ── API ─────────────────────────────────────────────────────────────────────── # ── API ───────────────────────────────────────────────────────────────────────
API_PORT=4000 API_PORT=4000
# SSR server-side calls go via the internal Docker network # SSR server-side calls go via the internal Docker network
API_INTERNAL_URL=http://api:4000/api/v1 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 DASHBOARD_INTERNAL_URL=http://dashboard:3001
ADMIN_INTERNAL_URL=http://admin:3002 ADMIN_INTERNAL_URL=http://admin:3002
# Public-facing API URL visible to browsers — MUST be your real domain, not localhost # Public-facing API URL visible to browsers — MUST be your real domain, not localhost
@@ -51,9 +48,8 @@ NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
# Required for production builds. Must be an absolute https:// URL with no trailing slash. # Required for production builds. Must be an absolute https:// URL with no trailing slash.
SITE_ORIGIN=https://rentaldrivego.ma SITE_ORIGIN=https://rentaldrivego.ma
# Marketplace sits at the domain root (Traefik routes /dashboard and /admin to their apps) # Carplace public routes sit under /carplace on the public site domain.
NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma NEXT_PUBLIC_CARPLACE_URL=https://rentaldrivego.ma/carplace
NEXT_PUBLIC_STOREFRONT_URL=https://rentaldrivego.ma/storefront
NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard
NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin
NEXT_PUBLIC_HOMEPAGE_URL=https://rentaldrivego.ma NEXT_PUBLIC_HOMEPAGE_URL=https://rentaldrivego.ma
@@ -67,7 +63,7 @@ ADMIN_URL=https://rentaldrivego.ma/admin
CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma
# ── Auth ────────────────────────────────────────────────────────────────────── # ── Auth ──────────────────────────────────────────────────────────────────────
JWT_SECRET=placeholder JWT_SECRET=eb9ab3eb5d6648bdb82cbef29afde498c58f089829a037dfea6cb5e98ef2aae0
JWT_EXPIRY=8h JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d RENTER_JWT_EXPIRY=7d
NODE_ENV=production NODE_ENV=production
@@ -79,11 +75,12 @@ EMAIL_FROM_NAME=RentalDriveGo
# Option A — Resend # Option A — Resend
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V #RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
# Option B — SMTP (Gmail) # Option B — SMTP (Gmail)
# SMTP fallback (only used if Resend fails or is unconfigured)
MAIL_HOST=smtp.gmail.com MAIL_HOST=smtp.gmail.com
MAIL_PORT=587 MAIL_PORT=587
MAIL_SCHEME=smtp MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=placeholder MAIL_PASSWORD=kfahihfzbcvkczew
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
+7 -3
View File
@@ -1,5 +1,5 @@
# Traefik domains # Traefik domains
ACME_EMAIL=ops@example.com ACME_EMAIL=rentaldrivego@gmail.com
API_DOMAIN=api.example.com API_DOMAIN=api.example.com
PUBLIC_SITE_DOMAIN=example.com PUBLIC_SITE_DOMAIN=example.com
PGMANAGE_DOMAIN=pgmanage.example.com PGMANAGE_DOMAIN=pgmanage.example.com
@@ -7,6 +7,9 @@ PORTAINER_DOMAIN=portainer.example.com
REGISTRY_DOMAIN=registry.example.com REGISTRY_DOMAIN=registry.example.com
REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000 REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000
# Image tag
IMAGE_TAG=latest
# Database # Database
DATABASE_URL_FROM_POSTGRES=true DATABASE_URL_FROM_POSTGRES=true
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
@@ -17,6 +20,7 @@ POSTGRES_PASSWORD=placeholder
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
# Cache # Cache
REDIS_PASSWORD=placeholder
REDIS_URL=redis://redis:6379 REDIS_URL=redis://redis:6379
# API # API
@@ -26,9 +30,8 @@ API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1 NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
# Frontend public URLs # Frontend public URLs
NEXT_PUBLIC_MARKETING_URL=https://example.com
NEXT_PUBLIC_HOMEPAGE_URL=https://example.com NEXT_PUBLIC_HOMEPAGE_URL=https://example.com
NEXT_PUBLIC_STOREFRONT_URL=https://example.com/storefront NEXT_PUBLIC_CARPLACE_URL=https://example.com
NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
@@ -42,6 +45,7 @@ CORS_ORIGINS=https://example.com,https://www.example.com
JWT_SECRET=placeholder JWT_SECRET=placeholder
JWT_EXPIRY=8h JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d RENTER_JWT_EXPIRY=7d
SESSION_COOKIE_DOMAIN=.example.com
NODE_ENV=production NODE_ENV=production
# File storage # File storage
+31 -6
View File
@@ -4,7 +4,13 @@
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# ─── Database ────────────────────────────────────────────────── # ─── 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 ───────────────────────────────────────────────────────
API_PORT=4000 API_PORT=4000
@@ -12,9 +18,12 @@ API_URL=http://localhost:4000
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
# ─── JWT (Renter auth + Admin auth) ─────────────────────────── # ─── JWT (Renter auth + Admin auth) ───────────────────────────
JWT_SECRET=placeholder JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
JWT_EXPIRY=8h JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d 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) ──────────────────────────── # ─── Clerk (Company employee auth) ────────────────────────────
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
@@ -71,6 +80,7 @@ NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123
# MAIL_* SMTP variables are intentionally omitted here. # MAIL_* SMTP variables are intentionally omitted here.
# ─── Redis (Real-time / Socket.io) ──────────────────────────── # ─── Redis (Real-time / Socket.io) ────────────────────────────
REDIS_PASSWORD=replace-with-production-redis-password
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
# ─── App URLs ────────────────────────────────────────────────── # ─── App URLs ──────────────────────────────────────────────────
@@ -79,20 +89,35 @@ REDIS_URL=redis://localhost:6379
# SITE_ORIGIN=https://your-production-domain.example # SITE_ORIGIN=https://your-production-domain.example
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
NEXT_PUBLIC_STOREFRONT_URL=http://localhost:3000/storefront NEXT_PUBLIC_CARPLACE_URL=http://localhost:3000/carplace
NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000 NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000
# Public site is subdomain-based; use this for local dev: # Public site is subdomain-based; use this for local dev:
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
API_DOMAIN=api.rentaldrivego.ma
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
DASHBOARD_URL=http://localhost:3000/dashboard DASHBOARD_URL=http://localhost:3000/dashboard
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ──────── # ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
ADMIN_SEED_EMAIL=admin@rentaldrivego.ma ADMIN_SEED_EMAIL=admin@rentaldrivego.ma
ADMIN_SEED_PASSWORD=placeholder ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
ADMIN_SEED_FIRST_NAME=Super ADMIN_SEED_FIRST_NAME=Super
ADMIN_SEED_LAST_NAME=Admin 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 ────────────────────────────────────────────────────── # ─── Misc ──────────────────────────────────────────────────────
NODE_ENV=development NODE_ENV=production
+283 -104
View File
@@ -1,8 +1,8 @@
name: Build & Deploy name: Build & Push
on: on:
push: push:
branches: [develop] branches: [fix_branch]
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@@ -14,6 +14,8 @@ env:
# TODO: Remove after installing internal CA certificate on the runner # TODO: Remove after installing internal CA certificate on the runner
GIT_SSL_NO_VERIFY: "true" GIT_SSL_NO_VERIFY: "true"
REGISTRY_HOST: 192.168.3.80 REGISTRY_HOST: 192.168.3.80
DEPLOY_REGISTRY_HOST: 10.0.0.4
DEPLOY_SSH_HOST: 10.0.0.1
DOCKERFILE_PATH: Dockerfile.production DOCKERFILE_PATH: Dockerfile.production
DOCKER_PLATFORM: linux/amd64 DOCKER_PLATFORM: linux/amd64
DEPLOY_ROOT: /opt/rentaldrivego DEPLOY_ROOT: /opt/rentaldrivego
@@ -26,10 +28,45 @@ jobs:
image_repository: ${{ steps.image-meta.outputs.repository }} image_repository: ${{ steps.image-meta.outputs.repository }}
docker_image: ${{ steps.image-meta.outputs.full }} docker_image: ${{ steps.image-meta.outputs.full }}
steps: steps:
- uses: actions/checkout@v4 - name: Checkout repository
shell: bash
env:
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 WORKSPACE="${GITHUB_WORKSPACE:-$PWD}"
uses: docker/setup-buildx-action@v3 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 - name: Docker image metadata
id: image-meta id: image-meta
@@ -37,16 +74,23 @@ jobs:
REPO="${{ gitea.repository }}" REPO="${{ gitea.repository }}"
TAG="${{ gitea.sha }}" TAG="${{ gitea.sha }}"
echo "repository=${REPO}" >> "$GITHUB_OUTPUT" echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
echo "full=${REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT" echo "full=${DEPLOY_REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
echo "latest=${REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT" echo "latest=${DEPLOY_REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
- name: Check Docker registry credentials - name: Check Docker registry credentials
id: registry-check 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: | run: |
if [ -n "${{ secrets.REGISTRY_USERNAME }}" ] && [ -n "${{ secrets.REGISTRY_PASSWORD }}" ]; 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" echo "available=true" >> "$GITHUB_OUTPUT"
else else
echo "::warning::REGISTRY_USERNAME or REGISTRY_PASSWORD secrets not set — push will be skipped" 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" echo "available=false" >> "$GITHUB_OUTPUT"
fi fi
@@ -55,128 +99,263 @@ jobs:
env: env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }} NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
NEXT_PUBLIC_STOREFRONT_URL: ${{ secrets.NEXT_PUBLIC_STOREFRONT_URL }} NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }} NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }} NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }} SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
run: | run: |
for variable in \ validate_url() {
NEXT_PUBLIC_API_URL \ name="$1"
NEXT_PUBLIC_HOMEPAGE_URL \ value="$2"
NEXT_PUBLIC_STOREFRONT_URL \
NEXT_PUBLIC_DASHBOARD_URL \
NEXT_PUBLIC_ADMIN_URL \
SITE_ORIGIN
do
value="${!variable}"
if [ -z "$value" ]; then if [ -z "$value" ]; then
echo "::error::$variable is missing — add it to Gitea Actions secrets" echo "::error::$name is missing — add it to Gitea Actions secrets"
exit 1 exit 1
fi fi
case "$value" in case "$value" in
http://*|https://*) ;; http://*|https://*) ;;
*) *)
echo "::error::$variable must be an absolute URL (got: $value)" echo "::error::$name must be an absolute URL (got: $value)"
exit 1 exit 1
;; ;;
esac esac
done }
- name: Log in to Gitea Container Registry if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
if: steps.registry-check.outputs.available == 'true' NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ env.DOCKERFILE_PATH }}
platforms: ${{ env.DOCKER_PLATFORM }}
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_HOMEPAGE_URL=${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
NEXT_PUBLIC_STOREFRONT_URL=${{ secrets.NEXT_PUBLIC_STOREFRONT_URL }}
NEXT_PUBLIC_DASHBOARD_URL=${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
NEXT_PUBLIC_ADMIN_URL=${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN=${{ secrets.SITE_ORIGIN }}
deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
needs: [build-image]
env:
DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
steps:
- uses: actions/checkout@v4
- name: Check deploy credentials
id: check-creds
run: |
if [ -z "${{ secrets.VPS_SSH_KEY }}" ] || \
[ -z "${{ secrets.VPS_IP }}" ] || \
[ -z "${{ secrets.VPS_USER }}" ]; then
echo "VPS secrets not fully configured — skipping deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi fi
- name: Install SSH client validate_url NEXT_PUBLIC_API_URL "$NEXT_PUBLIC_API_URL"
if: steps.check-creds.outputs.skip == 'false' 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: | 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 - 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: | 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 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 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 chmod 644 ~/.ssh/known_hosts
- name: Sync deployment assets - name: Test SSH authentication
if: steps.check-creds.outputs.skip == 'false' env:
VPS_HOST: ${{ secrets.VPS_IP }}
run: | run: |
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" \ VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
"mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' '$DEPLOY_ROOT/dynamic'" if [ -z "$VPS_HOST" ]; then
scp docker-compose.production.yml \ echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
docker-compose.portainer.production.yml \ exit 1
docker-compose.registry.production.yml \ fi
docker-compose.registry.local.yml \ SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
traefik.yaml \ key_fingerprint="$(ssh-keygen -lf "$HOME/.ssh/id_rsa.pub" | awk '{print $2}')"
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/" echo "Testing SSH authentication to $VPS_HOST with deploy key fingerprint $key_fingerprint"
scp scripts/docker-prod-common.sh \ if ! ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "printf 'ssh authenticated as '; whoami"; then
scripts/docker-prod-deploy.sh \ 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."
scripts/docker-prod-up-registry.sh \ exit 1
scripts/docker-registry-local-up.sh \ fi
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/scripts/"
scp docker/pgmanage/override.py \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/docker/pgmanage/"
- name: Run deploy script on VPS - name: Sync build context to VPS
if: steps.check-creds.outputs.skip == 'false' env:
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
VPS_HOST: ${{ secrets.VPS_IP }}
run: | run: |
REGISTRY_PASSWORD_B64="$(printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | base64 | tr -d '\n')" VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" " 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 set -e
cd '$DEPLOY_ROOT' cd '$REMOTE_BUILD_DIR'
APP_IMAGE='$IMAGE_REPOSITORY' \ if ! command -v docker >/dev/null 2>&1; then
APP_VERSION='${{ gitea.sha }}' \ echo 'Docker must be installed on the VPS build host' >&2
REGISTRY_HOST='$REGISTRY_HOST' \ exit 1
REGISTRY_USER='${{ secrets.REGISTRY_USERNAME }}' \ fi
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \ 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 bash scripts/docker-prod-deploy.sh
" "
+3 -3
View File
@@ -133,8 +133,8 @@ jobs:
- run: npm run build --workspace @rentaldrivego/types - run: npm run build --workspace @rentaldrivego/types
- run: npm run test:homepage - run: npm run test:homepage
storefront-tests: carplace-tests:
name: Storefront Unit Tests name: Carplace Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: type-check needs: type-check
steps: steps:
@@ -169,7 +169,7 @@ jobs:
node -e "require('@rollup/rollup-linux-arm64-gnu')" node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi fi
- run: npm run build --workspace @rentaldrivego/types - run: npm run build --workspace @rentaldrivego/types
- run: npm run test:storefront - run: npm run test:carplace
admin-tests: admin-tests:
name: Admin Unit Tests name: Admin Unit Tests
+2
View File
@@ -13,6 +13,8 @@ apps/api/storage/
.env .env
.env.local .env.local
.env.*.local .env.*.local
.env.docker.production
production/.env.docker.production
# Turbo # Turbo
.turbo .turbo
+2 -2
View File
@@ -28,14 +28,14 @@ api_tests:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
storefront_tests: carplace_tests:
stage: test stage: test
image: node:20-bookworm image: node:20-bookworm
before_script: before_script:
- npm ci - npm ci
- npm run build --workspace @rentaldrivego/types - npm run build --workspace @rentaldrivego/types
script: script:
- npm run test:storefront - npm run test:carplace
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
+2 -2
View File
@@ -7,7 +7,7 @@ The Phase 16 visual system has been applied at source level to the legacy applic
- `homepage`: retained as the authoritative Phase 16 marketing implementation already present in the legacy 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. - `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. - `dashboard`: migrated to the same semantic surfaces, typography, borders, shadows, focus treatment, dark theme, and blue/orange action hierarchy.
- `storefront`: migrated to the same public-site surfaces, conversion treatment, cards, forms, dark theme, and brand naming. - `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. - `api`: intentionally unchanged. A visual migration should not casually rewrite business logic because that is how weekends disappear.
## Design rules applied ## Design rules applied
@@ -29,7 +29,7 @@ The supplied `RentalDriveGo_Phase16_Evidence_Package_v1.0.zip` is an evidence an
- `admin/src/styles/phase16-tokens.css` - `admin/src/styles/phase16-tokens.css`
- `dashboard/src/styles/phase16-tokens.css` - `dashboard/src/styles/phase16-tokens.css`
- `storefront/src/styles/phase16-tokens.css` - `carplace/src/styles/phase16-tokens.css`
- `scripts/validate-phase16-design.mjs` - `scripts/validate-phase16-design.mjs`
- `DESIGN_MIGRATION_REPORT.md` - `DESIGN_MIGRATION_REPORT.md`
- `PHASE16_DESIGN_MIGRATION_MANIFEST.json` - `PHASE16_DESIGN_MIGRATION_MANIFEST.json`
+5 -3
View File
@@ -18,7 +18,7 @@ ARG ADMIN_INTERNAL_URL=http://admin:3002
# NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles # NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles
ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_HOMEPAGE_URL ARG NEXT_PUBLIC_HOMEPAGE_URL
ARG NEXT_PUBLIC_STOREFRONT_URL ARG NEXT_PUBLIC_CARPLACE_URL
ARG NEXT_PUBLIC_DASHBOARD_URL ARG NEXT_PUBLIC_DASHBOARD_URL
ARG NEXT_PUBLIC_ADMIN_URL ARG NEXT_PUBLIC_ADMIN_URL
@@ -31,7 +31,7 @@ ENV API_INTERNAL_URL=$API_INTERNAL_URL \
ADMIN_INTERNAL_URL=$ADMIN_INTERNAL_URL \ ADMIN_INTERNAL_URL=$ADMIN_INTERNAL_URL \
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_HOMEPAGE_URL=$NEXT_PUBLIC_HOMEPAGE_URL \ NEXT_PUBLIC_HOMEPAGE_URL=$NEXT_PUBLIC_HOMEPAGE_URL \
NEXT_PUBLIC_STOREFRONT_URL=$NEXT_PUBLIC_STOREFRONT_URL \ NEXT_PUBLIC_CARPLACE_URL=$NEXT_PUBLIC_CARPLACE_URL \
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_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 SITE_ORIGIN=$SITE_ORIGIN
@@ -44,7 +44,8 @@ FROM node:20-bookworm AS runner
WORKDIR /app 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 \ RUN corepack enable && corepack prepare npm@10.5.0 --activate \
&& groupadd --system --gid 10001 app \ && groupadd --system --gid 10001 app \
@@ -56,6 +57,7 @@ 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/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/apps ./apps 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/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 COPY --chown=root:root docker/entrypoint.production.sh /usr/local/bin/rdg-entrypoint.sh
RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh
+3 -3
View File
@@ -7,14 +7,14 @@
3. Added `PublicPageLayout` as the reusable composition layer for authentication and onboarding pages. 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. 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. 5. Preserved legacy component paths through compatibility exports.
6. Moved storefront navbar/footer assembly out of the Next.js route layout and into reusable public components. 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. 7. Added active-page semantics to dashboard sign-in and create-account navbar actions.
8. Added focused tests and implementation documentation. 8. Added focused tests and implementation documentation.
## Deliberately unchanged ## Deliberately unchanged
- Authentication requests and API endpoints - Authentication requests and API endpoints
- Redirect behavior between storefront, dashboard, and admin applications - Redirect behavior between carplace, dashboard, and admin applications
- Language and theme persistence - Language and theme persistence
- Embedded workspace behavior - Embedded workspace behavior
- Authenticated dashboard and admin navigation - Authenticated dashboard and admin navigation
@@ -25,7 +25,7 @@
- Verified all relative and `@/` local imports resolve. - Verified all relative and `@/` local imports resolve.
- Verified every `use client` directive remains the first statement. - Verified every `use client` directive remains the first statement.
- Verified sign-in and create-account import and render `PublicPageLayout`. - Verified sign-in and create-account import and render `PublicPageLayout`.
- Verified the storefront route layout no longer assembles navbar/footer inline. - Verified the carplace route layout no longer assembles navbar/footer inline.
- Verified embedded password-recovery navigation preserves `embedded=1`. - Verified embedded password-recovery navigation preserves `embedded=1`.
## Validation limitation ## Validation limitation
+7 -7
View File
@@ -11,7 +11,7 @@
"homepage": "retained as embedded Phase 16 source of truth", "homepage": "retained as embedded Phase 16 source of truth",
"admin": "migrated", "admin": "migrated",
"dashboard": "migrated", "dashboard": "migrated",
"storefront": "migrated", "carplace": "migrated",
"api": "unchanged" "api": "unchanged"
}, },
"validation": { "validation": {
@@ -37,37 +37,37 @@
"sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64" "sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64"
}, },
{ {
"path": "storefront/src/app/layout.tsx", "path": "carplace/src/app/layout.tsx",
"status": "modified", "status": "modified",
"bytes": 1833, "bytes": 1833,
"sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27" "sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27"
}, },
{ {
"path": "storefront/src/app/globals.css", "path": "carplace/src/app/globals.css",
"status": "modified", "status": "modified",
"bytes": 7525, "bytes": 7525,
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6" "sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
}, },
{ {
"path": "storefront/src/components/StorefrontFooter.tsx", "path": "carplace/src/components/CarplaceFooter.tsx",
"status": "modified", "status": "modified",
"bytes": 4632, "bytes": 4632,
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3" "sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
}, },
{ {
"path": "storefront/src/components/StorefrontHeader.tsx", "path": "carplace/src/components/CarplaceHeader.tsx",
"status": "modified", "status": "modified",
"bytes": 10438, "bytes": 10438,
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213" "sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
}, },
{ {
"path": "storefront/src/styles/phase16-tokens.css", "path": "carplace/src/styles/phase16-tokens.css",
"status": "added", "status": "added",
"bytes": 4214, "bytes": 4214,
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0" "sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
}, },
{ {
"path": "storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx", "path": "carplace/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
"status": "modified", "status": "modified",
"bytes": 13979, "bytes": 13979,
"sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b" "sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b"
+1 -1
View File
@@ -7,7 +7,7 @@
], ],
"includedApplications": [ "includedApplications": [
"homepage", "homepage",
"storefront", "carplace",
"dashboard", "dashboard",
"admin", "admin",
"api" "api"
+1 -1
View File
@@ -13,7 +13,7 @@ The dashboard and admin applications now use the same visual language as the Pha
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: 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` - `homepage`
- `storefront` - `carplace`
- `dashboard` - `dashboard`
- `admin` - `admin`
- `api` - `api`
+2 -2
View File
@@ -17,9 +17,9 @@
| Light/dark and Arabic RTL token mappings present | Passed | | Light/dark and Arabic RTL token mappings present | Passed |
| Reduced-motion and forced-colors rules present | Passed | | Reduced-motion and forced-colors rules present | Passed |
| Shared sign-in/create-account public layout retained | Passed | | Shared sign-in/create-account public layout retained | Passed |
| Shared storefront navbar/footer components retained | Passed | | Shared carplace navbar/footer components retained | Passed |
| Retired `RentalDriveGo` brand in dashboard/admin source | 0 matches | | Retired `RentalDriveGo` brand in dashboard/admin source | 0 matches |
| Complete app directories retained | `homepage`, `storefront`, `dashboard`, `admin`, `api` | | Complete app directories retained | `homepage`, `carplace`, `dashboard`, `admin`, `api` |
## Reproducible design gate ## Reproducible design gate
@@ -60,15 +60,15 @@ Changed files:
- `apps/api/src/app.ts` - `apps/api/src/app.ts`
- `apps/dashboard/src/middleware.ts` - `apps/dashboard/src/middleware.ts`
- `apps/storefront/src/middleware.ts` - `apps/carplace/src/middleware.ts`
- `apps/admin/src/middleware.ts` - `apps/admin/src/middleware.ts`
- `apps/dashboard/src/middleware.test.ts` - `apps/dashboard/src/middleware.test.ts`
- `apps/storefront/src/middleware.test.ts` - `apps/carplace/src/middleware.test.ts`
What changed: What changed:
- API now rejects requests containing `x-middleware-subrequest` before route handling. - API now rejects requests containing `x-middleware-subrequest` before route handling.
- Dashboard, storefront, 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. - Added/updated middleware tests for the rejection path.
Security effect: Security effect:
+1 -1
View File
@@ -5,7 +5,7 @@ const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http:
const apiUrl = new URL(apiOrigin) const apiUrl = new URL(apiOrigin)
const ADMIN_BASE_PATH = '/admin' const ADMIN_BASE_PATH = '/admin'
// In Docker dev the admin app runs on port 3002 while the storefront 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 // 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 // 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). // port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
@@ -71,7 +71,7 @@ interface CompanyDetail {
whatsappNumber: string | null whatsappNumber: string | null
defaultLocale: string defaultLocale: string
defaultCurrency: string defaultCurrency: string
isListedOnMarketplace: boolean isListedOnCarplace: boolean
} | null } | null
contractSettings: { contractSettings: {
legalName: string | null legalName: string | null
@@ -171,7 +171,7 @@ interface FormState {
whatsappNumber: string whatsappNumber: string
defaultLocale: string defaultLocale: string
defaultCurrency: string defaultCurrency: string
isListedOnMarketplace: boolean isListedOnCarplace: boolean
} }
contractSettings: { contractSettings: {
legalName: string legalName: string
@@ -277,7 +277,7 @@ function createFormState(company: CompanyDetail): FormState {
whatsappNumber: company.brand?.whatsappNumber ?? '', whatsappNumber: company.brand?.whatsappNumber ?? '',
defaultLocale: company.brand?.defaultLocale ?? 'en', defaultLocale: company.brand?.defaultLocale ?? 'en',
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD', defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
isListedOnMarketplace: company.brand?.isListedOnMarketplace ?? true, isListedOnCarplace: company.brand?.isListedOnCarplace ?? true,
}, },
contractSettings: { contractSettings: {
legalName: company.contractSettings?.legalName ?? '', legalName: company.contractSettings?.legalName ?? '',
@@ -420,7 +420,7 @@ export default function AdminCompanyDetailPage() {
whatsappNumber: toNullable(form.brand.whatsappNumber), whatsappNumber: toNullable(form.brand.whatsappNumber),
defaultLocale: form.brand.defaultLocale, defaultLocale: form.brand.defaultLocale,
defaultCurrency: form.brand.defaultCurrency, defaultCurrency: form.brand.defaultCurrency,
isListedOnMarketplace: form.brand.isListedOnMarketplace, isListedOnCarplace: form.brand.isListedOnCarplace,
}, },
contractSettings: { contractSettings: {
legalName: toNullable(form.contractSettings.legalName), 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 })} /> <input className={INPUT_CLASS} value={form.brand.publicAddress} onChange={(e) => updateSection('brand', { publicAddress: e.target.value })} />
</label> </label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300"> <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 })} /> <input type="checkbox" checked={form.brand.isListedOnCarplace} onChange={(e) => updateSection('brand', { isListedOnCarplace: e.target.checked })} />
Listed on storefront Listed on Carplace
</label> </label>
</div> </div>
</section> </section>
+4 -4
View File
@@ -24,7 +24,7 @@ export default function AdminDashboardPage() {
brand: 'RentalDriveGo', brand: 'RentalDriveGo',
eyebrow: 'Operations command', eyebrow: 'Operations command',
platformOverview: 'RentalDriveGo admin dashboard', platformOverview: 'RentalDriveGo admin dashboard',
subtitle: 'Monitor storefront health, subscription coverage, renter activity, and operator actions from one control surface.', subtitle: 'Monitor Carplace health, subscription coverage, renter activity, and operator actions from one control surface.',
liveSignal: 'Live platform signal', liveSignal: 'Live platform signal',
readiness: 'Operational readiness', readiness: 'Operational readiness',
readinessBody: 'Core admin workflows are connected and ready for platform support.', readinessBody: 'Core admin workflows are connected and ready for platform support.',
@@ -36,14 +36,14 @@ export default function AdminDashboardPage() {
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'], ['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'], ['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'],
], ],
health: ['Tenant accounts', 'Storefront identity', 'Admin audit trail'], health: ['Tenant accounts', 'Carplace identity', 'Admin audit trail'],
}, },
fr: { fr: {
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'], kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
brand: 'RentalDriveGo', brand: 'RentalDriveGo',
eyebrow: 'Centre des opérations', eyebrow: 'Centre des opérations',
platformOverview: 'Tableau de bord admin RentalDriveGo', platformOverview: 'Tableau de bord admin RentalDriveGo',
subtitle: 'Suivez la santé de la storefront, les abonnements, lactivité des locataires et les actions opérateur depuis une même interface.', subtitle: 'Suivez la santé de la Carplace, les abonnements, lactivité des locataires et les actions opérateur depuis une même interface.',
liveSignal: 'Signal plateforme en direct', liveSignal: 'Signal plateforme en direct',
readiness: 'Disponibilité opérationnelle', readiness: 'Disponibilité opérationnelle',
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.', readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.',
@@ -55,7 +55,7 @@ export default function AdminDashboardPage() {
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'], ['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
['Journaux daudit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'], ['Journaux daudit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
], ],
health: ['Comptes locataires', 'Identité storefront', 'Piste daudit admin'], health: ['Comptes locataires', 'Identité Carplace', 'Piste daudit admin'],
}, },
ar: { ar: {
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'], kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
@@ -3,12 +3,12 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { import {
cloneStorefrontHomepageContent, cloneCarplaceHomepageContent,
resolveStorefrontHomepageSections, resolveCarplaceHomepageSections,
type StorefrontHomepageConfig, type CarplaceHomepageConfig,
type StorefrontHomepageContent, type CarplaceHomepageContent,
type StorefrontHomepageSectionType, type CarplaceHomepageSectionType,
type StorefrontLanguage, type CarplaceLanguage,
} from '@rentaldrivego/types' } from '@rentaldrivego/types'
import { useAdminI18n } from '@/components/I18nProvider' import { useAdminI18n } from '@/components/I18nProvider'
import { ADMIN_API_BASE } from '@/lib/api' 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 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' const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
function cloneHomepageContent(content: StorefrontHomepageConfig) { function cloneHomepageContent(content: CarplaceHomepageConfig) {
const cloned = JSON.parse(JSON.stringify(content)) as StorefrontHomepageConfig const cloned = JSON.parse(JSON.stringify(content)) as CarplaceHomepageConfig
;(['en', 'fr', 'ar'] as StorefrontLanguage[]).forEach((language) => { ;(['en', 'fr', 'ar'] as CarplaceLanguage[]).forEach((language) => {
cloned[language].sections = resolveStorefrontHomepageSections(cloned[language].sections) cloned[language].sections = resolveCarplaceHomepageSections(cloned[language].sections)
}) })
return cloned return cloned
} }
const HOMEPAGE_SECTIONS: StorefrontHomepageSectionType[] = [ const HOMEPAGE_SECTIONS: CarplaceHomepageSectionType[] = [
'hero', 'hero',
'surface', 'surface',
'pillars', 'pillars',
@@ -58,12 +58,12 @@ export default function AdminSiteConfigPage() {
const copy = { const copy = {
en: { en: {
title: 'Site configuration', title: 'Site configuration',
description: 'Edit the main storefront 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', homepageTitle: 'Main website homepage',
homepageDescription: 'This controls the storefront homepage shown on the main RentalDriveGo website.', homepageDescription: 'This controls the Carplace homepage shown on the main RentalDriveGo website.',
saveHomepage: 'Save homepage', saveHomepage: 'Save homepage',
savingHomepage: 'Saving…', savingHomepage: 'Saving…',
homepageSaved: 'Storefront homepage saved.', homepageSaved: 'Carplace homepage saved.',
search: 'Search by company or slug…', search: 'Search by company or slug…',
loading: 'Loading…', loading: 'Loading…',
empty: 'No companies found', empty: 'No companies found',
@@ -86,7 +86,7 @@ export default function AdminSiteConfigPage() {
previewTitle: 'Live preview', previewTitle: 'Live preview',
previewDescription: 'Draft changes appear here before you save them.', previewDescription: 'Draft changes appear here before you save them.',
homepageSections: 'Homepage sections', homepageSections: 'Homepage sections',
homepageSectionsDescription: 'Add or remove blocks from the main storefront homepage.', homepageSectionsDescription: 'Add or remove blocks from the main Carplace homepage.',
addSection: 'Add', addSection: 'Add',
removeSection: 'Remove', removeSection: 'Remove',
expand: 'Expand', expand: 'Expand',
@@ -94,12 +94,12 @@ export default function AdminSiteConfigPage() {
}, },
fr: { fr: {
title: 'Configuration du site', title: 'Configuration du site',
description: 'Modifiez ici la page daccueil principale de la storefront, ou ouvrez une entreprise pour gérer sa page publique et son menu.', description: 'Modifiez ici la page daccueil principale de la Carplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
homepageTitle: 'Page daccueil du site principal', homepageTitle: 'Page daccueil du site principal',
homepageDescription: 'Cette section contrôle la page daccueil storefront affichée sur le site principal de RentalDriveGo.', homepageDescription: 'Cette section contrôle la page daccueil Carplace affichée sur le site principal de RentalDriveGo.',
saveHomepage: 'Enregistrer la page daccueil', saveHomepage: 'Enregistrer la page daccueil',
savingHomepage: 'Enregistrement…', savingHomepage: 'Enregistrement…',
homepageSaved: 'Page daccueil storefront enregistrée.', homepageSaved: 'Page daccueil Carplace enregistrée.',
search: 'Rechercher par entreprise ou slug…', search: 'Rechercher par entreprise ou slug…',
loading: 'Chargement…', loading: 'Chargement…',
empty: 'Aucune entreprise trouvée', empty: 'Aucune entreprise trouvée',
@@ -112,7 +112,7 @@ export default function AdminSiteConfigPage() {
companiesTitle: 'Sites de marque des entreprises', companiesTitle: 'Sites de marque des entreprises',
languageLabel: 'Langue', languageLabel: 'Langue',
hero: 'Bloc principal', hero: 'Bloc principal',
surface: 'Bloc storefront', surface: 'Bloc Carplace',
companySection: 'Bloc opérateur', companySection: 'Bloc opérateur',
renterSection: 'Bloc client', renterSection: 'Bloc client',
valueSection: 'Blocs de valeur', valueSection: 'Blocs de valeur',
@@ -122,7 +122,7 @@ export default function AdminSiteConfigPage() {
previewTitle: 'Aperçu en direct', previewTitle: 'Aperçu en direct',
previewDescription: 'Les brouillons apparaissent ici avant lenregistrement.', previewDescription: 'Les brouillons apparaissent ici avant lenregistrement.',
homepageSections: 'Sections de la page daccueil', homepageSections: 'Sections de la page daccueil',
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page daccueil storefront principale.', homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page daccueil Carplace principale.',
addSection: 'Ajouter', addSection: 'Ajouter',
removeSection: 'Retirer', removeSection: 'Retirer',
expand: 'Ouvrir', expand: 'Ouvrir',
@@ -171,8 +171,8 @@ export default function AdminSiteConfigPage() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [homepage, setHomepage] = useState<StorefrontHomepageConfig>(cloneStorefrontHomepageContent()) const [homepage, setHomepage] = useState<CarplaceHomepageConfig>(cloneCarplaceHomepageContent())
const [homepageLanguage, setHomepageLanguage] = useState<StorefrontLanguage>(language) const [homepageLanguage, setHomepageLanguage] = useState<CarplaceLanguage>(language)
const [homepageSaving, setHomepageSaving] = useState(false) const [homepageSaving, setHomepageSaving] = useState(false)
const [homepageMessage, setHomepageMessage] = useState<string | null>(null) const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
const [homepageExpanded, setHomepageExpanded] = useState(false) const [homepageExpanded, setHomepageExpanded] = useState(false)
@@ -189,7 +189,7 @@ export default function AdminSiteConfigPage() {
credentials: 'include', credentials: 'include',
cache: 'no-store', cache: 'no-store',
}), }),
fetch(`${ADMIN_API_BASE}/admin/site-config/storefront-homepage`, { fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
credentials: 'include', credentials: 'include',
cache: 'no-store', cache: 'no-store',
}), }),
@@ -202,7 +202,7 @@ export default function AdminSiteConfigPage() {
const homepageJson = await homepageRes.json() const homepageJson = await homepageRes.json()
if (homepageRes.ok && homepageJson?.data) { if (homepageRes.ok && homepageJson?.data) {
setHomepage(cloneHomepageContent(homepageJson.data as StorefrontHomepageConfig)) setHomepage(cloneHomepageContent(homepageJson.data as CarplaceHomepageConfig))
} }
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
@@ -225,7 +225,7 @@ export default function AdminSiteConfigPage() {
) )
}, [search, companies]) }, [search, companies])
function updateHomepageContent(patch: Partial<StorefrontHomepageContent>) { function updateHomepageContent(patch: Partial<CarplaceHomepageContent>) {
setHomepage((current) => ({ setHomepage((current) => ({
...current, ...current,
[homepageLanguage]: { [homepageLanguage]: {
@@ -257,16 +257,16 @@ export default function AdminSiteConfigPage() {
} }
function getActiveSections() { function getActiveSections() {
return resolveStorefrontHomepageSections(activeContent.sections) return resolveCarplaceHomepageSections(activeContent.sections)
} }
function addHomepageSection(section: StorefrontHomepageSectionType) { function addHomepageSection(section: CarplaceHomepageSectionType) {
const sections = getActiveSections() const sections = getActiveSections()
if (sections.includes(section)) return if (sections.includes(section)) return
updateHomepageContent({ sections: [...sections, section] }) updateHomepageContent({ sections: [...sections, section] })
} }
function removeHomepageSection(section: StorefrontHomepageSectionType) { function removeHomepageSection(section: CarplaceHomepageSectionType) {
const sections = getActiveSections().filter((item) => item !== section) const sections = getActiveSections().filter((item) => item !== section)
if (sections.length === 0) return if (sections.length === 0) return
updateHomepageContent({ sections }) updateHomepageContent({ sections })
@@ -276,7 +276,7 @@ export default function AdminSiteConfigPage() {
setHomepageSaving(true) setHomepageSaving(true)
setHomepageMessage(null) setHomepageMessage(null)
try { try {
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/storefront-homepage`, { const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -286,7 +286,7 @@ export default function AdminSiteConfigPage() {
}) })
const json = await res.json() const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage') if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
setHomepage(cloneHomepageContent(json.data as StorefrontHomepageConfig)) setHomepage(cloneHomepageContent(json.data as CarplaceHomepageConfig))
setHomepageMessage(copy.homepageSaved) setHomepageMessage(copy.homepageSaved)
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
@@ -298,7 +298,7 @@ export default function AdminSiteConfigPage() {
const activeContent = homepage[homepageLanguage] const activeContent = homepage[homepageLanguage]
const activeSections = getActiveSections() const activeSections = getActiveSections()
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section)) const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
const sectionLabels: Record<StorefrontHomepageSectionType, string> = { const sectionLabels: Record<CarplaceHomepageSectionType, string> = {
hero: copy.hero, hero: copy.hero,
surface: copy.surface, surface: copy.surface,
pillars: copy.valueSection, 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">
<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> <span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
{(['en', 'fr', 'ar'] as StorefrontLanguage[]).map((value) => ( {(['en', 'fr', 'ar'] as CarplaceLanguage[]).map((value) => (
<button <button
key={value} key={value}
type="button" type="button"
+6 -4
View File
@@ -29,7 +29,7 @@ import customersRouter from './modules/customers/customer.routes'
import vehiclesRouter from './modules/vehicles/vehicle.routes' import vehiclesRouter from './modules/vehicles/vehicle.routes'
import companiesRouter from './modules/companies/company.routes' import companiesRouter from './modules/companies/company.routes'
import reservationsRouter from './modules/reservations/reservation.routes' import reservationsRouter from './modules/reservations/reservation.routes'
import storefrontRouter from './modules/storefront/storefront.routes' import carplaceRouter from './modules/carplace/carplace.routes'
import siteRouter from './modules/site/site.routes' import siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.routes' import reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.routes' import complaintsRouter from './modules/complaints/complaint.routes'
@@ -96,8 +96,10 @@ const routeDocs = [
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' }, { method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' }, { method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' }, { method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
{ method: 'GET', path: `${v1}/storefront/cities`, description: 'Storefront cities' }, { method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' },
{ method: 'GET', path: `${v1}/storefront/search`, description: 'Storefront search' }, { 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}/site/:slug/brand`, description: 'Public site brand config' },
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' }, { method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' }, { method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
@@ -188,7 +190,7 @@ export function createApp() {
app.use(`${v1}/admin/auth`, authLimiter) app.use(`${v1}/admin/auth`, authLimiter)
app.use(`${v1}/admin`, adminLimiter, adminRouter) app.use(`${v1}/admin`, adminLimiter, adminRouter)
app.use(`${v1}/storefront`, publicLimiter, storefrontRouter) app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter) app.use(`${v1}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter) app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter) app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
+30 -30
View File
@@ -1,5 +1,5 @@
{ {
"storefrontHomepage": { "carplaceHomepage": {
"en": { "en": {
"sections": [ "sections": [
"hero", "hero",
@@ -13,13 +13,13 @@
"closing" "closing"
], ],
"heroKicker": "RentalDriveGo", "heroKicker": "RentalDriveGo",
"heroTitle": "Storefront discovery with a sharper front door.", "heroTitle": "Carplace discovery with a sharper front door.",
"heroBody": "Rental companies run private operations, renters browse a shared storefront, and every booking ends on the companys own branded checkout.", "heroBody": "Rental companies run private operations, renters browse a shared Carplace, and every booking ends on the companys own branded checkout.",
"startTrial": "Start free trial", "startTrial": "Start free trial",
"exploreVehicles": "Explore vehicles", "exploreVehicles": "Explore vehicles",
"surfaceLabel": "Storefront surface", "surfaceLabel": "Carplace surface",
"surfaceTitle": "Designed for two audiences at once.", "surfaceTitle": "Designed for two audiences at once.",
"surfaceBody": "Operators need control, renters need confidence. The storefront 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", "liveLabel": "Live network",
"trustedFleets": "Trusted fleets", "trustedFleets": "Trusted fleets",
"brandedFlows": "Branded booking flows", "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.", "companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
"renterKicker": "Renter experience", "renterKicker": "Renter experience",
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.", "renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
"renterBody": "The storefront 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": [ "pillars": [
{ {
"title": "Unified publishing", "title": "Unified publishing",
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into storefront 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", "title": "Qualified discovery",
@@ -47,7 +47,7 @@
"metrics": [ "metrics": [
{ {
"value": "01", "value": "01",
"label": "Shared storefront visibility" "label": "Shared Carplace visibility"
}, },
{ {
"value": "02", "value": "02",
@@ -61,7 +61,7 @@
"featureLabel": "What the product covers", "featureLabel": "What the product covers",
"features": [ "features": [
"Fleet management with multi-photo uploads", "Fleet management with multi-photo uploads",
"Storefront offers and redirect booking flow", "Carplace offers and redirect booking flow",
"Branded public booking site per company", "Branded public booking site per company",
"Customer CRM, analytics, and billing controls" "Customer CRM, analytics, and billing controls"
], ],
@@ -71,12 +71,12 @@
{ {
"number": "1", "number": "1",
"title": "Create Account", "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", "number": "2",
"title": "Add Your Fleet", "title": "Add Your Fleet",
"description": "Upload vehicle photos, set prices, and create offers visible on the storefront." "description": "Upload vehicle photos, set prices, and create offers visible on the Carplace."
}, },
{ {
"number": "3", "number": "3",
@@ -89,12 +89,12 @@
{ {
"step": "1", "step": "1",
"title": "Create your company workspace", "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", "step": "2",
"title": "Publish vehicles and offers", "title": "Publish vehicles and offers",
"body": "Upload photos once and control what appears on the storefront and branded site." "body": "Upload photos once and control what appears on the Carplace and branded site."
}, },
{ {
"step": "3", "step": "3",
@@ -104,8 +104,8 @@
], ],
"stepLabel": "Step", "stepLabel": "Step",
"readyKicker": "Ready to launch", "readyKicker": "Ready to launch",
"readyTitle": "The storefront homepage should explain the product in seconds.", "readyTitle": "The Carplace homepage should explain the product in seconds.",
"readyBody": "Use the storefront to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.", "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", "viewPricing": "View pricing",
"createWorkspace": "Create workspace" "createWorkspace": "Create workspace"
}, },
@@ -122,13 +122,13 @@
"closing" "closing"
], ],
"heroKicker": "RentalDriveGo", "heroKicker": "RentalDriveGo",
"heroTitle": "Une vitrine storefront plus claire et plus percutante.", "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 storefront commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.", "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 lessai gratuit", "startTrial": "Commencer lessai gratuit",
"exploreVehicles": "Explorer les véhicules", "exploreVehicles": "Explorer les véhicules",
"surfaceLabel": "Vitrine storefront", "surfaceLabel": "Vitrine Carplace",
"surfaceTitle": "Pensée pour deux audiences à la fois.", "surfaceTitle": "Pensée pour deux audiences à la fois.",
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La storefront 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", "liveLabel": "Réseau actif",
"trustedFleets": "Flottes fiables", "trustedFleets": "Flottes fiables",
"brandedFlows": "Parcours de marque", "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.", "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", "renterKicker": "Expérience client",
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site dentreprise.", "renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site dentreprise.",
"renterBody": "La storefront sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.", "renterBody": "La Carplace sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
"pillars": [ "pillars": [
{ {
"title": "Publication unifiée", "title": "Publication unifiée",
"body": "Les photos, tarifs et offres circulent depuis un seul flux dadministration vers la découverte storefront et les pages de réservation de marque." "body": "Les photos, tarifs et offres circulent depuis un seul flux dadministration vers la découverte Carplace et les pages de réservation de marque."
}, },
{ {
"title": "Découverte qualifiée", "title": "Découverte qualifiée",
@@ -156,7 +156,7 @@
"metrics": [ "metrics": [
{ {
"value": "01", "value": "01",
"label": "Visibilité storefront partagée" "label": "Visibilité Carplace partagée"
}, },
{ {
"value": "02", "value": "02",
@@ -170,7 +170,7 @@
"featureLabel": "Ce que couvre le produit", "featureLabel": "Ce que couvre le produit",
"features": [ "features": [
"Gestion de flotte avec téléversement de plusieurs photos", "Gestion de flotte avec téléversement de plusieurs photos",
"Offres storefront et redirection vers la réservation", "Offres Carplace et redirection vers la réservation",
"Site public de réservation par entreprise", "Site public de réservation par entreprise",
"CRM client, analytics et contrôle de facturation" "CRM client, analytics et contrôle de facturation"
], ],
@@ -180,12 +180,12 @@
{ {
"number": "1", "number": "1",
"title": "Créer un compte", "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", "number": "2",
"title": "Ajouter votre flotte", "title": "Ajouter votre flotte",
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la storefront." "description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la Carplace."
}, },
{ {
"number": "3", "number": "3",
@@ -198,12 +198,12 @@
{ {
"step": "1", "step": "1",
"title": "Créez votre espace entreprise", "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", "step": "2",
"title": "Publiez véhicules et offres", "title": "Publiez véhicules et offres",
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la storefront 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", "step": "3",
@@ -213,8 +213,8 @@
], ],
"stepLabel": "Étape", "stepLabel": "Étape",
"readyKicker": "Prêt à démarrer", "readyKicker": "Prêt à démarrer",
"readyTitle": "La page daccueil storefront doit présenter le produit en quelques secondes.", "readyTitle": "La page daccueil Carplace doit présenter le produit en quelques secondes.",
"readyBody": "Utilisez la storefront 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.", "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", "viewPricing": "Voir les tarifs",
"createWorkspace": "Créer lespace" "createWorkspace": "Créer lespace"
}, },
@@ -289,7 +289,7 @@
{ {
"number": "1", "number": "1",
"title": "إنشاء حساب", "title": "إنشاء حساب",
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً." "description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
}, },
{ {
"number": "2", "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', () => { it('preserves AppError metadata in the response body', () => {
const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' })) 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 })) 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 instanceof AppError) {
if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err }) if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err })
return res.status(err.statusCode).json(withRequestId(req, { return res.status(err.statusCode).json(withRequestId(req, {
+3 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
formatDate, formatDate,
storefrontReservationEmail, carplaceReservationEmail,
resetPasswordEmail, resetPasswordEmail,
signupEmail, signupEmail,
} from './emailTranslations' } from './emailTranslations'
@@ -40,8 +40,8 @@ describe('emailTranslations', () => {
expect(html).toContain('45') expect(html).toContain('45')
}) })
it('includes optional contact phone in storefront reservation HTML when present', () => { it('includes optional contact phone in Carplace reservation HTML when present', () => {
const html = storefrontReservationEmail.html({ const html = carplaceReservationEmail.html({
firstName: 'Yassine', firstName: 'Yassine',
vehicleYear: 2024, vehicleYear: 2024,
vehicleMake: 'Dacia', vehicleMake: 'Dacia',
+2 -2
View File
@@ -117,9 +117,9 @@ export const resetPasswordEmail = {
}, lang), }, lang),
} }
// ─── Storefront reservation request ───────────────────────────────────────── // ─── Carplace reservation request ─────────────────────────────────────────
export const storefrontReservationEmail = { export const carplaceReservationEmail = {
subject: (vehicleName: string, lang: Lang) => t({ subject: (vehicleName: string, lang: Lang) => t({
en: `Reservation Request Received — ${vehicleName}`, en: `Reservation Request Received — ${vehicleName}`,
fr: `Demande de réservation reçue — ${vehicleName}`, fr: `Demande de réservation reçue — ${vehicleName}`,
+1 -1
View File
@@ -82,7 +82,7 @@ export const apiLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 }, message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
}) })
// Limiter for public storefront and site endpoints (no auth) // Limiter for public carplace and site endpoints (no auth)
export const publicLimiter = rateLimit({ export const publicLimiter = rateLimit({
windowMs: 60 * 1000, windowMs: 60 * 1000,
max: 60, max: 60,
@@ -1,6 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express' 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' import { requireSubscription } from './requireSubscription'
function responseStub() { function responseStub() {
@@ -12,44 +21,46 @@ function responseStub() {
describe('requireSubscription middleware', () => { describe('requireSubscription middleware', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test' 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 req = {} as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction const next = vi.fn() as NextFunction
requireSubscription(req, res, next) await requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(401) expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 }) expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
expect(next).not.toHaveBeenCalled() 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 req = { company: { status: 'SUSPENDED' } } as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction const next = vi.fn() as NextFunction
requireSubscription(req, res, next) await requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402) expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
error: 'subscription_suspended', error: 'subscription_suspended',
message: 'Your account has been suspended. Please contact support or renew your subscription.', message: 'Your account has been suspended. Please contact support or renew your subscription.',
statusCode: 402, statusCode: 402,
billingUrl: 'https://dashboard.example.test/billing', billingUrl: 'https://dashboard.example.test/subscription',
}) })
expect(next).not.toHaveBeenCalled() 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 req = { company: { status: 'PENDING' } } as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction const next = vi.fn() as NextFunction
requireSubscription(req, res, next) await requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402) expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
@@ -59,14 +70,55 @@ describe('requireSubscription middleware', () => {
expect(next).not.toHaveBeenCalled() expect(next).not.toHaveBeenCalled()
}) })
it('allows active companies through', () => { it('blocks expired trial subscriptions with a subscription recovery URL', async () => {
const req = { company: { status: 'ACTIVE' } } as Request vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'EXPIRED' } as never)
const req = { company: { id: 'company_1', status: 'TRIALING' } } as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction 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(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled() 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()
})
}) })
+28 -3
View File
@@ -1,4 +1,6 @@
import { Request, Response, NextFunction } from 'express' import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { getAccessLevel, hasAnyAccess } from '../modules/subscriptions/subscription.policy'
import { sendUnauthorized, sendPaymentRequired } from './authHelpers' import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING'] const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
@@ -8,9 +10,10 @@ const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
* Must be applied after `requireTenant`. * Must be applied after `requireTenant`.
* *
* Guarantees on success: * 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 const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context') 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' company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.' ? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.', : '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() next()
} catch (error) {
next(error)
}
} }
+4 -4
View File
@@ -559,16 +559,16 @@ router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAd
// ─── Site config ─────────────────────────────────────────────── // ─── Site config ───────────────────────────────────────────────
router.get('/site-config/storefront-homepage', requireAdminAuth, async (req, res, next) => { router.get('/site-config/carplace-homepage', requireAdminAuth, async (req, res, next) => {
try { try {
ok(res, await service.getStorefrontHomepage()) ok(res, await service.getCarplaceHomepage())
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
router.patch('/site-config/storefront-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { router.patch('/site-config/carplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try { try {
const { homepage } = parseBody(homepageUpdateSchema, req) const { homepage } = parseBody(homepageUpdateSchema, req)
ok(res, await service.updateStorefrontHomepage(homepage, req.admin.id, req.ip)) ok(res, await service.updateCarplaceHomepage(homepage, req.admin.id, req.ip))
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
+28 -20
View File
@@ -1,5 +1,13 @@
import { z } from 'zod' import { z } from 'zod'
import type { StorefrontHomepageContent, StorefrontHomepageHowItWorksStep, StorefrontHomepageMetric, StorefrontHomepagePillar, StorefrontHomepageSectionType, StorefrontHomepageStep, StorefrontHomepageTestimonial } from '@rentaldrivego/types' import type {
CarplaceHomepageContent,
CarplaceHomepageHowItWorksStep,
CarplaceHomepageMetric,
CarplaceHomepagePillar,
CarplaceHomepageSectionType,
CarplaceHomepageStep,
CarplaceHomepageTestimonial,
} from '@rentaldrivego/types'
export const loginSchema = z.object({ export const loginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(), email: z.string().email().max(255).trim().toLowerCase(),
@@ -154,7 +162,7 @@ export const adminCompanyUpdateSchema = z.object({
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl, publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(), whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
defaultCurrency: z.literal('MAD').optional(), defaultCurrency: z.literal('MAD').optional(),
isListedOnMarketplace: z.boolean().optional(), isListedOnCarplace: z.boolean().optional(),
homePageConfig: z.any().optional(), homePageConfig: z.any().optional(),
menuConfig: z.any().optional(), menuConfig: z.any().optional(),
}).optional(), }).optional(),
@@ -175,35 +183,35 @@ export const adminCompanyUpdateSchema = z.object({
}).optional(), }).optional(),
}) })
const storefrontMetricSchema: z.ZodType<StorefrontHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) }) const carplaceMetricSchema: z.ZodType<CarplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
const storefrontPillarSchema: z.ZodType<StorefrontHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) }) const carplacePillarSchema: z.ZodType<CarplaceHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
const storefrontStepSchema: z.ZodType<StorefrontHomepageStep> = z.object({ step: z.string().min(1), 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 storefrontHowItWorksStepSchema: z.ZodType<StorefrontHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: 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 storefrontTestimonialSchema: z.ZodType<StorefrontHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() }) 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 storefrontSectionSchema: z.ZodType<StorefrontHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing']) const carplaceSectionSchema: z.ZodType<CarplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
const storefrontHomepageContentSchema: z.ZodType<StorefrontHomepageContent> = z.object({ const carplaceHomepageContentSchema: z.ZodType<CarplaceHomepageContent> = z.object({
sections: z.array(storefrontSectionSchema).min(1), sections: z.array(carplaceSectionSchema).min(1),
heroKicker: z.string().min(1), heroTitle: z.string().min(1), heroBody: z.string().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), 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), 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), 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), 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), renterKicker: z.string().min(1), renterTitle: z.string().min(1), renterBody: z.string().min(1),
pillars: z.array(storefrontPillarSchema).length(3), pillars: z.array(carplacePillarSchema).length(3),
metrics: z.array(storefrontMetricSchema).length(3), metrics: z.array(carplaceMetricSchema).length(3),
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1), 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(storefrontHowItWorksStepSchema).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(storefrontStepSchema).length(3), stepLabel: z.string().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(storefrontTestimonialSchema).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), 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), viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
}) })
export const storefrontHomepageConfigSchema = z.object({ export const carplaceHomepageConfigSchema = z.object({
en: storefrontHomepageContentSchema, en: carplaceHomepageContentSchema,
fr: storefrontHomepageContentSchema, fr: carplaceHomepageContentSchema,
ar: storefrontHomepageContentSchema, ar: carplaceHomepageContentSchema,
}) })
export const idParamSchema = z.object({ export const idParamSchema = z.object({
@@ -300,7 +308,7 @@ export const billingRefundSchema = z.object({
}) })
export const homepageUpdateSchema = z.object({ export const homepageUpdateSchema = z.object({
homepage: storefrontHomepageConfigSchema, homepage: carplaceHomepageConfigSchema,
}) })
export const pricingUpdateSchema = z.object({ export const pricingUpdateSchema = z.object({
+6 -6
View File
@@ -3,7 +3,7 @@ import crypto from 'crypto'
import { authenticator } from 'otplib' import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens' import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode' import qrcode from 'qrcode'
import { getStorefrontHomepageContent, saveStorefrontHomepageContent } from '../../services/platformContentService' import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService' import { sendTransactionalEmail } from '../../services/notificationService'
import * as presenter from './admin.presenter' import * as presenter from './admin.presenter'
import * as repo from './admin.repo' import * as repo from './admin.repo'
@@ -402,16 +402,16 @@ export function issueBillingRefund(
return billingService.issueRefund(invoiceId, data, adminId, ip) return billingService.issueRefund(invoiceId, data, adminId, ip)
} }
export function getStorefrontHomepage() { export function getCarplaceHomepage() {
return getStorefrontHomepageContent() return getCarplaceHomepageContent()
} }
export async function updateStorefrontHomepage(homepage: any, adminId: string, ip?: string) { export async function updateCarplaceHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveStorefrontHomepageContent(homepage) const saved = await saveCarplaceHomepageContent(homepage)
await repo.createAuditLog({ await repo.createAuditLog({
adminUserId: adminId, adminUserId: adminId,
action: 'UPDATE', action: 'UPDATE',
resource: 'StorefrontHomepage', resource: 'CarplaceHomepage',
after: toAuditJson(saved), after: toAuditJson(saved),
ipAddress: ip, ipAddress: ip,
userAgent: undefined, userAgent: undefined,
@@ -85,7 +85,7 @@ describe('analytics.service', () => {
}, },
] as never) ] as never)
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([ 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 } }, { source: 'DIRECT', _count: { id: 2 }, _sum: { totalAmount: null } },
] as never) ] as never)
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
@@ -115,7 +115,7 @@ describe('analytics.service', () => {
totalAmount: 1200, totalAmount: 1200,
})]) })])
expect(result.sourceBreakdown).toEqual([ expect(result.sourceBreakdown).toEqual([
{ source: 'MARKETPLACE', count: 3, revenue: 3600 }, { source: 'CARPLACE', count: 3, revenue: 3600 },
{ source: 'DIRECT', count: 2, revenue: 0 }, { source: 'DIRECT', count: 2, revenue: 0 },
]) ])
expect(result.subscription).toEqual({ expect(result.subscription).toEqual({
@@ -37,7 +37,7 @@ export async function startAccount(body: AccountStartInput) {
const result = await prisma.$transaction(async (tx: any) => { const result = await prisma.$transaction(async (tx: any) => {
const now = new Date() 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({ const company = await tx.company.create({
data: { data: {
@@ -104,6 +104,7 @@ describe('auth.company.repo.createCompanySignup', () => {
companyId: 'company_1', companyId: 'company_1',
clerkUserId: 'local_owner_company_1', clerkUserId: 'local_owner_company_1',
email: 'owner@example.com', email: 'owner@example.com',
emailVerified: baseInput.now,
role: 'OWNER', role: 'OWNER',
preferredLanguage: 'fr', preferredLanguage: 'fr',
isActive: true, isActive: true,
@@ -136,6 +136,7 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
email: input.ownerEmail, email: input.ownerEmail,
phone: input.companyPhone, phone: input.companyPhone,
passwordHash: input.passwordHash, passwordHash: input.passwordHash,
emailVerified: input.now,
role: 'OWNER', role: 'OWNER',
preferredLanguage: input.preferredLanguage, preferredLanguage: input.preferredLanguage,
isActive: true, isActive: true,
@@ -13,7 +13,7 @@ import { companySignupSchema } from './auth.company.schemas'
import type { output } from 'zod' import type { output } from 'zod'
type CompanySignupInput = output<typeof companySignupSchema> type CompanySignupInput = output<typeof companySignupSchema>
const TRIAL_PERIOD_DAYS = 90 const TRIAL_PERIOD_DAYS = 30
function slugify(value: string) { function slugify(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company' return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
@@ -33,6 +33,7 @@ const employee = {
companyId: 'company_1', companyId: 'company_1',
isActive: true, isActive: true,
emailVerified: true, emailVerified: true,
emailVerificationToken: null,
passwordHash: 'hash_old', passwordHash: 'hash_old',
company: { name: 'Atlas Cars', slug: 'atlas' }, company: { name: 'Atlas Cars', slug: 'atlas' },
} }
@@ -74,8 +75,12 @@ describe('auth.employee.service edge behavior', () => {
}) })
}) })
it('rejects login for employees whose email has not been verified', async () => { it('rejects login for employees with a pending email verification token', async () => {
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, emailVerified: false } as never) 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({ await expect(service.login({ email: 'agent@example.test', password: 'correct-password' })).rejects.toMatchObject({
statusCode: 401, statusCode: 401,
@@ -83,6 +88,25 @@ describe('auth.employee.service edge behavior', () => {
}) })
}) })
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 () => { it('distinguishes employees without passwords from wrong credentials', async () => {
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, passwordHash: null } as never) vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, passwordHash: null } as never)
@@ -105,7 +105,7 @@ 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') 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) { 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') throw new AppError('Please verify your email address before signing in. Check your inbox for the verification link.', 401, 'email_not_verified')
} }
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { presentVehicleWithAvailability } from './storefront.presenter' import { presentVehicleWithAvailability } from './carplace.presenter'
describe('storefront.presenter', () => { describe('carplace.presenter', () => {
it('adds public availability fields without mutating the original vehicle shape', () => { it('adds public availability fields without mutating the original vehicle shape', () => {
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z') const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 } const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
@@ -11,9 +11,9 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock })) vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import * as repo from './storefront.repo' import * as repo from './carplace.repo'
describe('storefront.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 () => { it('finds public offers using active/public/current-window filters and featured ordering', async () => {
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z')) vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
@@ -33,38 +33,41 @@ describe('storefront.repo public query and write boundaries', () => {
vi.useRealTimers() vi.useRealTimers()
}) })
it('lists storefront 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() await repo.findCitiesFromCompanies()
expect(prismaMock.company.findMany).toHaveBeenCalledWith({ expect(prismaMock.company.findMany).toHaveBeenCalledWith({
where: { where: {
status: { in: ['ACTIVE', 'TRIALING'] }, status: { in: ['ACTIVE', 'TRIALING'] },
brand: { isListedOnMarketplace: true, publicCity: { not: null } }, brand: { isListedOnCarplace: true, publicCity: { not: null } },
}, },
select: { brand: { select: { publicCity: true } } }, select: { brand: { select: { publicCity: true } } },
}) })
}) })
it('requires published vehicles and an active company when loading storefront vehicle details', async () => { it('requires published vehicles and an active company when loading carplace vehicle details', async () => {
await repo.findVehicleForStorefront('vehicle_1', 'atlas') await repo.findVehicleForCarplace('vehicle_1', 'atlas')
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({ expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
where: { where: {
id: 'vehicle_1', id: 'vehicle_1',
isPublished: true, 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 } } }, include: { company: { include: { brand: true } } },
}) })
}) })
it('patches storefront 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({ prismaMock.customer.findUnique.mockResolvedValueOnce({
id: 'customer_1', id: 'customer_1',
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' }, address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' },
}) })
await repo.upsertMarketplaceCustomer('company_1', { await repo.upsertCarplaceCustomer('company_1', {
email: 'renter@example.test', email: 'renter@example.test',
firstName: 'Nora', firstName: 'Nora',
lastName: 'Renter', lastName: 'Renter',
@@ -86,11 +89,11 @@ describe('storefront.repo public query and write boundaries', () => {
}) })
}) })
it('creates storefront reservations as draft storefront-sourced records', async () => { it('creates carplace reservations as draft carplace-sourced records', async () => {
const startDate = new Date('2026-07-01T10:00:00.000Z') const startDate = new Date('2026-07-01T10:00:00.000Z')
const endDate = new Date('2026-07-05T10:00:00.000Z') const endDate = new Date('2026-07-05T10:00:00.000Z')
await repo.createStorefrontReservation({ await repo.createCarplaceReservation({
companyId: 'company_1', companyId: 'company_1',
vehicleId: 'vehicle_1', vehicleId: 'vehicle_1',
customerId: 'customer_1', customerId: 'customer_1',
@@ -108,7 +111,7 @@ describe('storefront.repo public query and write boundaries', () => {
companyId: 'company_1', companyId: 'company_1',
vehicleId: 'vehicle_1', vehicleId: 'vehicle_1',
customerId: 'customer_1', customerId: 'customer_1',
source: 'MARKETPLACE', source: 'CARPLACE',
status: 'DRAFT', status: 'DRAFT',
}), }),
}) })
@@ -13,7 +13,7 @@ export async function findCitiesFromCompanies() {
return prisma.company.findMany({ return prisma.company.findMany({
where: { where: {
status: { in: ['ACTIVE', 'TRIALING'] }, status: { in: ['ACTIVE', 'TRIALING'] },
brand: { isListedOnMarketplace: true, publicCity: { not: null } }, brand: { isListedOnCarplace: true, publicCity: { not: null } },
}, },
select: { brand: { select: { publicCity: true } } }, select: { brand: { select: { publicCity: true } } },
}) })
@@ -23,7 +23,7 @@ export async function findListedCompanies(where: any, skip: number, take: number
return prisma.company.findMany({ return prisma.company.findMany({
where, where,
include: { 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' } } } }, _count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
}, },
skip, skip,
@@ -35,31 +35,31 @@ export async function findPublishedVehicles(where: any) {
return prisma.vehicle.findMany({ return prisma.vehicle.findMany({
where, where,
include: { 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' }, orderBy: { dailyRate: 'asc' },
}) })
} }
export async function findVehicleForStorefront(vehicleId: string, companySlug: string) { export async function findVehicleForCarplace(vehicleId: string, companySlug: string) {
return prisma.vehicle.findFirst({ 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 } } }, include: { company: { include: { brand: true } } },
}) })
} }
export async function findVehicleForStorefrontById(vehicleId: string) { export async function findVehicleForCarplaceById(vehicleId: string) {
return prisma.vehicle.findFirst({ return prisma.vehicle.findFirst({
where: { where: {
id: vehicleId, id: vehicleId,
isPublished: true, isPublished: true,
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }, company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } },
}, },
include: { company: { include: { brand: true } } }, include: { company: { include: { brand: true } } },
}) })
} }
export async function upsertMarketplaceCustomer(companyId: string, data: { export async function upsertCarplaceCustomer(companyId: string, data: {
email: string email: string
firstName: string firstName: string
lastName: string lastName: string
@@ -115,17 +115,17 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
return prisma.customer.update({ where: { id: existing.id }, data: payload }) return prisma.customer.update({ where: { id: existing.id }, data: payload })
} }
export async function createStorefrontReservation(data: { export async function createCarplaceReservation(data: {
companyId: string; vehicleId: string; customerId: string companyId: string; vehicleId: string; customerId: string
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
}) { }) {
return prisma.reservation.create({ return prisma.reservation.create({
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' }, data: { ...data, source: 'CARPLACE', status: 'DRAFT' },
}) })
} }
export async function createStorefrontFunnelEvent(data: { export async function createCarplaceFunnelEvent(data: {
eventName: string eventName: string
companySlug: string companySlug: string
vehicleId: string vehicleId: string
@@ -138,12 +138,12 @@ export async function createStorefrontFunnelEvent(data: {
where: { where: {
id: data.vehicleId, id: data.vehicleId,
isPublished: true, 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 }, select: { companyId: true },
}) })
return prisma.marketplaceFunnelEvent.create({ return prisma.carplaceFunnelEvent.create({
data: { data: {
eventName: data.eventName, eventName: data.eventName,
companyId: vehicle?.companyId ?? null, companyId: vehicle?.companyId ?? null,
@@ -159,7 +159,7 @@ export async function createStorefrontFunnelEvent(data: {
export async function findCompanyPage(slug: string) { export async function findCompanyPage(slug: string) {
return prisma.company.findFirst({ return prisma.company.findFirst({
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } }, where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] },
include: { include: {
brand: true, brand: true,
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } }, 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) { 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) { export async function findCompanyReviews(companyId: string) {
@@ -189,7 +189,7 @@ export async function findCompanyVehicles(companyId: string) {
} }
export async function findVehicleById(slug: string, vehicleId: 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({ return prisma.vehicle.findFirstOrThrow({
where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' }, where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
include: { company: { include: { brand: true } } }, 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
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { storefrontReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './storefront.schemas' import { carplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './carplace.schemas'
describe('storefront.schemas', () => { describe('carplace.schemas', () => {
it('coerces pagination and caps untrusted public page sizes', () => { it('coerces pagination and caps untrusted public page sizes', () => {
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 }) expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 }) expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
@@ -18,8 +18,8 @@ describe('storefront.schemas', () => {
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow() expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
}) })
it('defaults storefront reservation language while keeping date and email validation strict', () => { it('defaults carplace reservation language while keeping date and email validation strict', () => {
const parsed = storefrontReservationSchema.parse({ const parsed = carplaceReservationSchema.parse({
vehicleId: 'ckvvehicle000000000000001', vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars', companySlug: 'atlas-cars',
firstName: 'Aya', firstName: 'Aya',
@@ -30,8 +30,8 @@ describe('storefront.schemas', () => {
}) })
expect(parsed.language).toBe('fr') expect(parsed.language).toBe('fr')
expect(() => storefrontReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow() expect(() => carplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
expect(() => storefrontReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow() expect(() => carplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
}) })
it('validates review ratings as bounded integers', () => { it('validates review ratings as bounded integers', () => {
@@ -29,7 +29,17 @@ export const vehicleAvailabilityQuerySchema = z.object({
endDate: z.string().datetime(), endDate: z.string().datetime(),
}) })
export const storefrontReservationSchema = 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(), vehicleId: z.string().cuid(),
companySlug: z.string().trim().max(100).optional(), companySlug: z.string().trim().max(100).optional(),
firstName: z.string().min(1).max(100), firstName: z.string().min(1).max(100),
@@ -37,6 +47,7 @@ export const storefrontReservationSchema = z.object({
email: z.string().email(), email: z.string().email(),
// Contact info — collected at reservation time // Contact info — collected at reservation time
phone: z.string().min(1).max(30).optional(), 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 // Identity & license — optional at reservation, collected at pickup
dateOfBirth: z.string().datetime().optional(), dateOfBirth: z.string().datetime().optional(),
nationality: z.string().min(1).max(100).optional(), nationality: z.string().min(1).max(100).optional(),
@@ -52,11 +63,13 @@ export const storefrontReservationSchema = z.object({
endDate: z.string().datetime(), endDate: z.string().datetime(),
pickupLocation: z.string().trim().max(100).optional(), pickupLocation: z.string().trim().max(100).optional(),
returnLocation: 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(), notes: z.string().max(500).optional(),
language: z.enum(['en', 'fr', 'ar']).default('fr'), 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([ eventName: z.enum([
'booking_form_viewed', 'booking_form_viewed',
'trip_dates_selected', 'trip_dates_selected',
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./storefront.repo', () => ({ vi.mock('./carplace.repo', () => ({
findCompanyPage: vi.fn(), findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(), findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(), findCompanyReviews: vi.fn(),
@@ -12,16 +12,16 @@ vi.mock('./storefront.repo', () => ({
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() })) vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() })) vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
vi.mock('../../lib/emailTranslations', () => ({ vi.mock('../../lib/emailTranslations', () => ({
storefrontReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() }, carplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
})) }))
import * as repo from './storefront.repo' import * as repo from './carplace.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './storefront.service' import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './carplace.service'
beforeEach(() => vi.clearAllMocks()) beforeEach(() => vi.clearAllMocks())
describe('storefront.service public page edges', () => { describe('carplace.service public page edges', () => {
it('enriches company page vehicles with availability without changing company metadata', async () => { it('enriches company page vehicles with availability without changing company metadata', async () => {
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z') const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
vi.mocked(repo.findCompanyPage).mockResolvedValue({ vi.mocked(repo.findCompanyPage).mockResolvedValue({
@@ -1,8 +1,8 @@
import { AppError, NotFoundError, ConflictError } from '../../http/errors' import { AppError, NotFoundError, ConflictError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService' import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
import { storefrontReservationEmail, type Lang } from '../../lib/emailTranslations' import { carplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './storefront.repo' import * as repo from './carplace.repo'
function normalizeLocation(value?: string | null) { function normalizeLocation(value?: string | null) {
const normalized = value?.trim() const normalized = value?.trim()
@@ -21,6 +21,28 @@ function includesLocation(values: string[], target: string) {
return values.some((value) => value.toLocaleLowerCase() === key) 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( function matchesVehicleLocationRules(
vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean }, vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean },
params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' }, params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' },
@@ -65,7 +87,7 @@ export async function getListedCompanies(params: {
}) { }) {
const page = params.page ?? 1 const page = params.page ?? 1
const pageSize = params.pageSize ?? 20 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.city) where.brand = { ...where.brand, publicCity: { contains: params.city, mode: 'insensitive' } }
if (params.hasOffer === 'true') { if (params.hasOffer === 'true') {
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } } 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 startDate?: string; endDate?: string; category?: string
maxPrice?: number; transmission?: string; make?: string; model?: string maxPrice?: number; transmission?: string; make?: string; model?: string
page?: number; pageSize?: number 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 page = params.page ?? 1
const pageSize = params.pageSize ?? 20 const pageSize = params.pageSize ?? 20
const where: any = { const where: any = {
isPublished: true, isPublished: true,
status: 'AVAILABLE', 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.category) where.category = params.category
if (params.maxPrice !== undefined) where.dailyRate = { lte: params.maxPrice } 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.make) where.make = { contains: params.make, mode: 'insensitive' }
if (params.model) where.model = { contains: params.model, mode: 'insensitive' } if (params.model) where.model = { contains: params.model, mode: 'insensitive' }
if (params.city) { 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 vehicles = await repo.findPublishedVehicles(where) as any[]
const locationFiltered = vehicles.filter((vehicle: any) => matchesVehicleLocationRules(vehicle, params)) 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( const availability = await Promise.all(
pagedVehicles.map(async (v: any) => { locationFiltered.map(async (vehicle: any) => {
const a = await getVehicleAvailabilitySummary(v.id, { const result = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId, range })
companyId: v.companyId, availabilityByVehicle.set(vehicle.id, result)
range: params.startDate && params.endDate return result.available ? vehicle : null
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
: undefined,
})
return [v.id, a] as const
}), }),
) )
const availMap = new Map<string, any>(availability) availableVehicles = availability.filter((vehicle): vehicle is any => Boolean(vehicle))
}
return pagedVehicles.map((v: any) => ({ const pageItems = availableVehicles.slice((page - 1) * pageSize, page * pageSize)
...v, const items = pageItems.map((vehicle: any) => {
availability: availMap.get(v.id)?.available ?? null, const availability = availabilityByVehicle.get(vehicle.id)
availabilityStatus: availMap.get(v.id)?.status ?? 'AVAILABLE', return {
nextAvailableAt: availMap.get(v.id)?.nextAvailableAt ?? null, ...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 getStorefrontVehicleAvailability( export async function getCarplaceVehicleAvailability(
slug: string, slug: string,
vehicleId: string, vehicleId: string,
params: { startDate: string; endDate: string }, params: { startDate: string; endDate: string },
) { ) {
const vehicle = await repo.findVehicleForStorefront(vehicleId, slug) const vehicle = await repo.findVehicleForCarplace(vehicleId, slug)
if (!vehicle) throw new NotFoundError('Vehicle not found') if (!vehicle) throw new NotFoundError('Vehicle not found')
const startDate = new Date(params.startDate) const startDate = new Date(params.startDate)
@@ -148,20 +223,85 @@ export async function getStorefrontVehicleAvailability(
} }
} }
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() { function generateBookingReference() {
const year = new Date().getUTCFullYear() const year = new Date().getUTCFullYear()
const random = Math.random().toString(36).slice(2, 8).toUpperCase() const random = Math.random().toString(36).slice(2, 8).toUpperCase()
return `BK-${year}-${random}` return `BK-${year}-${random}`
} }
export async function createStorefrontReservation(body: { export async function createCarplaceReservation(body: {
vehicleId: string; companySlug?: string; firstName: string; lastName: string vehicleId: string; companySlug?: string; firstName: string; lastName: string
email: string; phone?: string; dateOfBirth?: string; nationality?: string email: string; phone?: string; driverAge?: number; dateOfBirth?: string; nationality?: string
identityDocumentNumber?: string; fullAddress?: string identityDocumentNumber?: string; fullAddress?: string
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: string licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: string
startDate: string; endDate: 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 startDate = new Date(body.startDate)
const endDate = new Date(body.endDate) const endDate = new Date(body.endDate)
@@ -170,7 +310,7 @@ export async function createStorefrontReservation(body: {
throw new AppError('End date must be after start date', 400, 'invalid_dates') throw new AppError('End date must be after start date', 400, 'invalid_dates')
} }
const vehicle = await repo.findVehicleForStorefrontById(body.vehicleId) const vehicle = await repo.findVehicleForCarplaceById(body.vehicleId)
if (!vehicle) throw new NotFoundError('Vehicle not found') if (!vehicle) throw new NotFoundError('Vehicle not found')
const pickupLocation = normalizeLocation(body.pickupLocation) const pickupLocation = normalizeLocation(body.pickupLocation)
@@ -198,7 +338,7 @@ export async function createStorefrontReservation(body: {
}) })
} }
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, { const customer = await repo.upsertCarplaceCustomer(vehicle.companyId, {
email: body.email, email: body.email,
firstName: body.firstName, firstName: body.firstName,
lastName: body.lastName, lastName: body.lastName,
@@ -216,9 +356,18 @@ export async function createStorefrontReservation(body: {
}) })
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000)) 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.createStorefrontReservation({ 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, companyId: vehicle.companyId,
vehicleId: body.vehicleId, vehicleId: body.vehicleId,
customerId: customer.id, customerId: customer.id,
@@ -229,7 +378,7 @@ export async function createStorefrontReservation(body: {
dailyRate: vehicle.dailyRate, dailyRate: vehicle.dailyRate,
totalDays, totalDays,
totalAmount, totalAmount,
notes: body.notes, notes: reservationNotes,
bookingReference: generateBookingReference(), bookingReference: generateBookingReference(),
}) })
@@ -245,14 +394,14 @@ export async function createStorefrontReservation(body: {
sendTransactionalEmail({ sendTransactionalEmail({
to: body.email, to: body.email,
subject: storefrontReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang), subject: carplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
html: storefrontReservationEmail.html(emailOpts, lang), html: carplaceReservationEmail.html(emailOpts, lang),
text: storefrontReservationEmail.text(emailOpts, lang), text: carplaceReservationEmail.text(emailOpts, lang),
}).catch(() => null) }).catch(() => null)
sendNotification({ sendNotification({
type: 'NEW_BOOKING', type: 'NEW_BOOKING',
title: 'New Storefront 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)}.`, 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, companyId: vehicle.companyId,
channels: ['IN_APP'], channels: ['IN_APP'],
@@ -270,7 +419,7 @@ export async function createStorefrontReservation(body: {
} }
} }
export async function trackStorefrontFunnelEvent(body: { export async function trackCarplaceFunnelEvent(body: {
eventName: string eventName: string
companySlug: string companySlug: string
vehicleId: string vehicleId: string
@@ -278,7 +427,7 @@ export async function trackStorefrontFunnelEvent(body: {
path?: string path?: string
metadata?: Record<string, string | number | boolean | null> metadata?: Record<string, string | number | boolean | null>
}, renterId?: string) { }, renterId?: string) {
await repo.createStorefrontFunnelEvent({ await repo.createCarplaceFunnelEvent({
...body, ...body,
renterId: renterId ?? null, renterId: renterId ?? null,
}) })
@@ -1,15 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NotFoundError, ConflictError, AppError } from '../../http/errors' import { NotFoundError, ConflictError, AppError } from '../../http/errors'
vi.mock('./storefront.repo', () => ({ vi.mock('./carplace.repo', () => ({
findPublicOffers: vi.fn(), findPublicOffers: vi.fn(),
findCitiesFromCompanies: vi.fn(), findCitiesFromCompanies: vi.fn(),
findListedCompanies: vi.fn(), findListedCompanies: vi.fn(),
findPublishedVehicles: vi.fn(), findPublishedVehicles: vi.fn(),
findVehicleForStorefront: vi.fn(), findVehicleForCarplace: vi.fn(),
findVehicleForStorefrontById: vi.fn(), findVehicleForCarplaceById: vi.fn(),
upsertMarketplaceCustomer: vi.fn(), upsertCarplaceCustomer: vi.fn(),
createStorefrontReservation: vi.fn(), createCarplaceReservation: vi.fn(),
findCompanyPage: vi.fn(), findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(), findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(), findCompanyReviews: vi.fn(),
@@ -33,19 +33,19 @@ vi.mock('../../services/notificationService', () => ({
})) }))
vi.mock('../../lib/emailTranslations', () => ({ vi.mock('../../lib/emailTranslations', () => ({
storefrontReservationEmail: { carplaceReservationEmail: {
subject: vi.fn(() => 'Subject'), subject: vi.fn(() => 'Subject'),
html: vi.fn(() => '<html>'), html: vi.fn(() => '<html>'),
text: vi.fn(() => 'text'), text: vi.fn(() => 'text'),
}, },
})) }))
import * as repo from './storefront.repo' import * as repo from './carplace.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { import {
getCities, searchVehicles, createStorefrontReservation, getCities, searchVehicles, createCarplaceQuote, createCarplaceReservation,
getReviewContext, submitReview, validateOfferCode, getReviewContext, submitReview, validateOfferCode,
} from './storefront.service' } from './carplace.service'
const SLUG = 'test-company' const SLUG = 'test-company'
@@ -80,7 +80,7 @@ describe('getCities', () => {
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
describe('searchVehicles', () => { 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(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null }) vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
@@ -91,7 +91,7 @@ describe('searchVehicles', () => {
expect(result[0].availabilityStatus).toBe('AVAILABLE') 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(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: new Date('2025-07-01'), blockingReason: 'RESERVATION' }) 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', companyId: 'co-1',
range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') }, 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 () => { it('filters out vehicles that do not support different drop-off when requested', async () => {
@@ -119,7 +145,49 @@ describe('searchVehicles', () => {
}) })
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
describe('createStorefrontReservation', () => { 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 = { const baseBody = {
vehicleId: 'v-1', companySlug: SLUG, vehicleId: 'v-1', companySlug: SLUG,
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
@@ -137,49 +205,63 @@ describe('createStorefrontReservation', () => {
} }
it('creates reservation and returns reservationId', async () => { it('creates reservation and returns reservationId', async () => {
vi.mocked(repo.findVehicleForStorefrontById).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(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any) vi.mocked(repo.upsertCarplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(repo.createStorefrontReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any) vi.mocked(repo.createCarplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
const result = await createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' }) const result = await createCarplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
expect(result.reservationId).toBe('r-1') expect(result.reservationId).toBe('r-1')
expect(result.bookingReference).toBe('BK-2026-ABC123') 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', identityDocumentNumber: 'CIN123456',
fullAddress: '123 Avenue Hassan II, Casablanca', fullAddress: '123 Avenue Hassan II, Casablanca',
driverLicense: 'DL-123456', driverLicense: 'DL-123456',
})) }))
expect(repo.createStorefrontReservation).toHaveBeenCalledWith(expect.objectContaining({ expect(repo.createCarplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
pickupLocation: 'Casablanca', pickupLocation: 'Casablanca',
returnLocation: 'Casablanca', returnLocation: 'Casablanca',
})) }))
}) })
it('throws NotFoundError when vehicle not found', async () => { it('throws NotFoundError when vehicle not found', async () => {
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(null) vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(null)
await expect(createStorefrontReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError) await expect(createCarplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
}) })
it('throws AppError when vehicle is unavailable', async () => { it('throws AppError when vehicle is unavailable', async () => {
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any) vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' }) vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
await expect(createStorefrontReservation(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 () => { it('rejects different return locations when the vehicle requires same drop-off', async () => {
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any) vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
await expect( await expect(
createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }), createCarplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
).rejects.toMatchObject({ error: 'same_dropoff_required' }) ).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 () => { it('throws AppError for invalid date range', async () => {
await expect( await expect(
createStorefrontReservation({ ...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' }) ).rejects.toMatchObject({ error: 'invalid_dates' })
}) })
}) })
@@ -14,7 +14,7 @@ const fullBrand = {
amanpaySecretKey: 'super-secret-key', amanpaySecretKey: 'super-secret-key',
paypalEmail: 'paypal@example.com', paypalEmail: 'paypal@example.com',
paypalMerchantId: 'paypal-merchant-xyz', paypalMerchantId: 'paypal-merchant-xyz',
isListedOnMarketplace: true, isListedOnCarplace: true,
} }
describe('presentBrand', () => { describe('presentBrand', () => {
@@ -84,7 +84,7 @@ describe('presentBrand', () => {
primaryColor: '#1A56DB', primaryColor: '#1A56DB',
defaultLocale: 'en', defaultLocale: 'en',
defaultCurrency: 'MAD', defaultCurrency: 'MAD',
isListedOnMarketplace: true, isListedOnCarplace: true,
}) })
}) })
@@ -26,7 +26,7 @@ export const brandSchema = z.object({
amanpaySecretKey: z.string().optional(), amanpaySecretKey: z.string().optional(),
paypalEmail: optionalEmailField(), paypalEmail: optionalEmailField(),
paypalMerchantId: z.string().optional(), paypalMerchantId: z.string().optional(),
isListedOnMarketplace: z.boolean().optional(), isListedOnCarplace: z.boolean().optional(),
homePageConfig: z.object({ homePageConfig: z.object({
heroTitle: z.union([z.string(), z.null()]).optional(), heroTitle: z.union([z.string(), z.null()]).optional(),
heroDescription: 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' 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('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('/storage/companies/company_1/brand/logo.jpg') }))
vi.mock('./settingsEntitlements', () => ({
resolveSettingsEntitlements: vi.fn(),
}))
vi.mock('./company.repo', () => ({ vi.mock('./company.repo', () => ({
findCompany: vi.fn(), findCompany: vi.fn(),
updateCompany: vi.fn(), updateCompany: vi.fn(),
@@ -28,8 +31,43 @@ vi.mock('./company.repo', () => ({
})) }))
const repo = await import('./company.repo') const repo = await import('./company.repo')
const entitlements = await import('./settingsEntitlements')
const service = await import('./company.service') 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 = { const currentBrand = {
id: 'brand_1', id: 'brand_1',
companyId: 'company_1', companyId: 'company_1',
@@ -44,6 +82,7 @@ const currentBrand = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(entitlements.resolveSettingsEntitlements).mockResolvedValue(buildEditableEntitlements() as any)
delete process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET delete process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET
}) })
+42 -2
View File
@@ -1,12 +1,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' 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('./company.repo')
vi.mock('./settingsEntitlements', () => ({
resolveSettingsEntitlements: vi.fn(),
}))
vi.mock('../../lib/storage', () => ({ vi.mock('../../lib/storage', () => ({
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/companies/comp_1/brand/logo.jpg'), 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 = { const mockCompany = {
id: 'comp_1', id: 'comp_1',
name: 'Test Rentals', name: 'Test Rentals',
@@ -33,6 +72,7 @@ const mockBrand = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(entitlements.resolveSettingsEntitlements).mockResolvedValue(buildEditableEntitlements() as any)
}) })
describe('company.service', () => { describe('company.service', () => {
@@ -51,7 +51,7 @@ describe('settingsEntitlements', () => {
expect(result.items).toHaveLength(7) expect(result.items).toHaveLength(7)
expect(result.items.map((item) => item.sectionKey)).toEqual([ expect(result.items.map((item) => item.sectionKey)).toEqual([
'company', 'company',
'storefront', 'carplace',
'payments', 'payments',
'rental-policies', 'rental-policies',
'insurance', 'insurance',
@@ -7,7 +7,7 @@ export type SettingsFeatureKey =
| 'settings.company_profile' | 'settings.company_profile'
| 'settings.public_contact' | 'settings.public_contact'
| 'settings.locale_currency' | 'settings.locale_currency'
| 'settings.storefront_listing' | 'settings.carplace_listing'
| 'settings.branding_basic' | 'settings.branding_basic'
| 'settings.branding_custom' | 'settings.branding_custom'
| 'settings.branding_hero' | 'settings.branding_hero'
@@ -22,7 +22,7 @@ export type SettingsFeatureKey =
export type SettingsSectionKey = export type SettingsSectionKey =
| 'company' | 'company'
| 'storefront' | 'carplace'
| 'payments' | 'payments'
| 'rental-policies' | 'rental-policies'
| 'insurance' | 'insurance'
@@ -51,7 +51,7 @@ const PRO_ONLY: Plan[] = ['PRO']
const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = { const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = {
en: { en: {
company: { label: 'Company Profile', description: 'Manage public company details and defaults.' }, company: { label: 'Company Profile', description: 'Manage public company details and defaults.' },
storefront: { label: 'Branding and Storefront', description: 'Control marketplace listing, logo, colors, and storefront media.' }, carplace: { label: 'Branding and Carplace', description: 'Control Carplace listing, logo, colors, and media.' },
payments: { label: 'Payment Methods', description: 'Configure renter payment providers.' }, payments: { label: 'Payment Methods', description: 'Configure renter payment providers.' },
'rental-policies': { label: 'Rental Policies', description: 'Set fuel, damage, and additional-driver policies.' }, 'rental-policies': { label: 'Rental Policies', description: 'Set fuel, damage, and additional-driver policies.' },
insurance: { label: 'Insurance Policies', description: 'Manage optional and required insurance products.' }, insurance: { label: 'Insurance Policies', description: 'Manage optional and required insurance products.' },
@@ -60,7 +60,7 @@ const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; d
}, },
fr: { fr: {
company: { label: 'Profil entreprise', description: 'Gérez les informations publiques et les valeurs par défaut.' }, company: { label: 'Profil entreprise', description: 'Gérez les informations publiques et les valeurs par défaut.' },
storefront: { label: 'Marque et vitrine', description: 'Contrôlez la publication, le logo, les couleurs et les médias.' }, 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.' }, 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.' }, 'rental-policies': { label: 'Politiques de location', description: 'Définissez les règles carburant, dommages et conducteurs.' },
insurance: { label: 'Polices dassurance', description: 'Gérez les assurances optionnelles et obligatoires.' }, insurance: { label: 'Polices dassurance', description: 'Gérez les assurances optionnelles et obligatoires.' },
@@ -69,7 +69,7 @@ const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; d
}, },
ar: { ar: {
company: { label: 'ملف الشركة', description: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' }, company: { label: 'ملف الشركة', description: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' },
storefront: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' }, carplace: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' },
payments: { label: 'طرق الدفع', description: 'إعداد مزودي دفع المستأجرين.' }, payments: { label: 'طرق الدفع', description: 'إعداد مزودي دفع المستأجرين.' },
'rental-policies': { label: 'سياسات الإيجار', description: 'ضبط سياسات الوقود والأضرار والسائق الإضافي.' }, 'rental-policies': { label: 'سياسات الإيجار', description: 'ضبط سياسات الوقود والأضرار والسائق الإضافي.' },
insurance: { label: 'سياسات التأمين', description: 'إدارة منتجات التأمين الاختيارية والإلزامية.' }, insurance: { label: 'سياسات التأمين', description: 'إدارة منتجات التأمين الاختيارية والإلزامية.' },
@@ -87,7 +87,7 @@ export const SETTINGS_MENU_CATALOG: Array<{
requiredPlan: Plan | null requiredPlan: Plan | null
}> = [ }> = [
{ menuKey: 'company', sectionKey: 'company', iconKey: 'building', sortOrder: 10, plans: ALL_PLANS, requiredPlan: null }, { menuKey: 'company', sectionKey: 'company', iconKey: 'building', sortOrder: 10, plans: ALL_PLANS, requiredPlan: null },
{ menuKey: 'storefront', sectionKey: 'storefront', iconKey: 'palette', sortOrder: 20, 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: '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: '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: 'insurance', sectionKey: 'insurance', iconKey: 'shield', sortOrder: 50, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
@@ -99,7 +99,7 @@ const FEATURE_PLANS: Record<SettingsFeatureKey, { plans: Plan[]; requiredPlan: P
'settings.company_profile': { plans: ALL_PLANS, requiredPlan: null }, 'settings.company_profile': { plans: ALL_PLANS, requiredPlan: null },
'settings.public_contact': { plans: ALL_PLANS, requiredPlan: null }, 'settings.public_contact': { plans: ALL_PLANS, requiredPlan: null },
'settings.locale_currency': { plans: ALL_PLANS, requiredPlan: null }, 'settings.locale_currency': { plans: ALL_PLANS, requiredPlan: null },
'settings.storefront_listing': { plans: ALL_PLANS, requiredPlan: null }, 'settings.carplace_listing': { plans: ALL_PLANS, requiredPlan: null },
'settings.branding_basic': { plans: ALL_PLANS, requiredPlan: null }, 'settings.branding_basic': { plans: ALL_PLANS, requiredPlan: null },
'settings.branding_custom': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' }, 'settings.branding_custom': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
'settings.branding_hero': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' }, 'settings.branding_hero': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
@@ -149,7 +149,7 @@ function statusAllowsEditing(status: SubscriptionStatus, featureKey: SettingsFea
'settings.company_profile', 'settings.company_profile',
'settings.public_contact', 'settings.public_contact',
'settings.locale_currency', 'settings.locale_currency',
'settings.storefront_listing', 'settings.carplace_listing',
'settings.branding_basic', 'settings.branding_basic',
'settings.rental_policies_basic', 'settings.rental_policies_basic',
].includes(featureKey) ].includes(featureKey)
@@ -268,4 +268,110 @@ describe('menu.service', () => {
'settings', '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',
])
})
}) })
+68 -11
View File
@@ -1,5 +1,6 @@
import { AppError } from '../../http/errors' import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { getAccessLevel, hasFullAccess, type AccessLevel } from '../subscriptions/subscription.policy'
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT' type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' type Plan = 'STARTER' | 'GROWTH' | 'PRO'
@@ -68,6 +69,17 @@ const MENU_ITEM_TYPES_WITHOUT_ROUTE = new Set([
'SECTION_LABEL', 'SECTION_LABEL',
'DIVIDER', '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) { function normalizeNullableString(value?: string | null) {
if (typeof value !== 'string') return 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) { function toPlanValue(plan: Plan) {
return plan return plan
} }
@@ -466,23 +482,27 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
}) })
const currentPlan = company.subscription?.plan ?? null 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 evaluated: EvaluatedMenuItem[] = (menuItems as any[]).map((item: any) => {
const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0 const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0
const subscriptionAssignment = item.subscriptionAssignments.find( 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 companyAssignment = item.companyAssignments.find((assignment: any) => assignment.isActive)
const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility: any) => visibility.role === role) const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility: any) => visibility.role === role)
const subscriptionEntitled = Boolean(subscriptionAssignment || (defaultsToAllSubscriptions && currentPlan)) const subscriptionEntitled = Boolean(subscriptionHasFullAccess && (subscriptionAssignment || (defaultsToAllSubscriptions && currentPlanName)))
const entitled = Boolean(subscriptionEntitled || companyAssignment) const entitled = Boolean(subscriptionHasFullAccess && (subscriptionEntitled || companyAssignment))
const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive) const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive)
const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder
const reasons: string[] = [] const reasons: string[] = []
if (!item.isActive) reasons.push('Hidden because the menu item is inactive.') if (!item.isActive) reasons.push('Hidden because the menu item is inactive.')
if (!employeeIsActive) reasons.push('Hidden because the user account 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(describeEntitlement(currentPlanName, false, false, false))
if (entitled) { if (entitled) {
reasons.push( reasons.push(
@@ -518,6 +538,8 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
company, company,
role, role,
currentPlan: currentPlanName, currentPlan: currentPlanName,
subscriptionStatus,
subscriptionAccessLevel: subscriptionAccessLevel as AccessLevel,
items: sortByDisplayOrder(evaluated), items: sortByDisplayOrder(evaluated),
} }
} }
@@ -572,12 +594,40 @@ function buildMenuTree(items: Array<{
return roots 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) { export async function previewCompanyMenu(input: MenuPreviewInput) {
const context = await getMenuEvaluationContext(input.companyId, input.role) const context = await getMenuEvaluationContext(input.companyId, input.role)
return { return {
company: context.company, company: context.company,
role: context.role, role: context.role,
subscriptionPlan: context.currentPlan, subscriptionPlan: context.currentPlan,
subscriptionStatus: context.subscriptionStatus,
subscriptionAccessLevel: context.subscriptionAccessLevel,
items: context.items, items: context.items,
finalMenu: buildMenuTree( finalMenu: buildMenuTree(
context.items context.items
@@ -610,12 +660,7 @@ export async function getEmployeeMenu(employeeId: string) {
const context = await getMenuEvaluationContext(employee.companyId, employee.role, employee.isActive) const context = await getMenuEvaluationContext(employee.companyId, employee.role, employee.isActive)
const visibleItems = context.items.filter((item) => item.visible) const visibleItems = context.items.filter((item) => item.visible)
const menuItems = buildMenuTree(
return {
companyId: employee.companyId,
role: employee.role,
subscriptionPlan: context.currentPlan,
items: buildMenuTree(
visibleItems.map((item) => ({ visibleItems.map((item) => ({
id: item.id, id: item.id,
systemKey: item.systemKey, systemKey: item.systemKey,
@@ -627,7 +672,19 @@ export async function getEmployeeMenu(employeeId: string) {
openInNewTab: item.openInNewTab, openInNewTab: item.openInNewTab,
displayOrder: item.displayOrder, 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,
} }
} }
@@ -12,6 +12,17 @@ export function listByReservation(reservationId: string, companyId: string) {
return repo.findByReservation(reservationId, companyId) 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) { async function applyAmanpayWebhook(event: any) {
const transactionId = event.transaction_id ?? event.id const transactionId = event.transaction_id ?? event.id
const status = event.status?.toUpperCase() const status = event.status?.toUpperCase()
@@ -70,22 +81,17 @@ export async function initCharge(reservationId: string, companyId: string, body:
}) { }) {
const reservation = await repo.findReservationOrThrow(reservationId, companyId) const reservation = await repo.findReservationOrThrow(reservationId, companyId)
const rentalPayments = (reservation as any).rentalPayments ?? [] const rentalPayments = (reservation as any).rentalPayments ?? []
const invoicePaid = rentalPayments.reduce((total: number, payment: any) => { const invoicePaid = getInvoicePaid(reservation, rentalPayments)
if (payment.type !== 'CHARGE' || payment.status !== 'SUCCEEDED') return total const depositCollected = sumSucceededPayments(rentalPayments, 'DEPOSIT')
return total + payment.amount const balanceDue = body.type === 'DEPOSIT'
}, 0) ? reservation.depositAmount - depositCollected
const depositCollected = rentalPayments.reduce((total: number, payment: any) => { : reservation.totalAmount - invoicePaid
if (payment.type !== 'DEPOSIT' || payment.status !== 'SUCCEEDED') return total
return total + payment.amount
}, 0)
const chargeRemaining = Math.max(reservation.totalAmount - invoicePaid, 0)
const depositRemaining = Math.max(reservation.depositAmount - depositCollected, 0)
const amount = body.type === 'DEPOSIT' ? depositRemaining : chargeRemaining
if (amount <= 0) { if (balanceDue <= 0) {
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid') 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 description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
const orderId = `${reservationId}-${body.type}-${Date.now()}` const orderId = `${reservationId}-${body.type}-${Date.now()}`
const webhookBase = process.env.API_URL ?? 'http://localhost:4000' const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
@@ -132,21 +138,18 @@ export async function recordManualPayment(reservationId: string, companyId: stri
}) { }) {
const reservation = await repo.findReservation(reservationId, companyId) const reservation = await repo.findReservation(reservationId, companyId)
const rentalPayments = (reservation as any).rentalPayments ?? [] const rentalPayments = (reservation as any).rentalPayments ?? []
const invoicePaid = rentalPayments.reduce((total: number, payment: any) => { const invoicePaid = getInvoicePaid(reservation, rentalPayments)
if (payment.type !== 'CHARGE' || payment.status !== 'SUCCEEDED') return total const depositCollected = sumSucceededPayments(rentalPayments, 'DEPOSIT')
return total + payment.amount const remaining = body.type === 'DEPOSIT'
}, 0) ? reservation.depositAmount - depositCollected
const depositCollected = rentalPayments.reduce((total: number, payment: any) => { : reservation.totalAmount - invoicePaid
if (payment.type !== 'DEPOSIT' || payment.status !== 'SUCCEEDED') return total
return total + payment.amount
}, 0)
const chargeRemaining = Math.max(reservation.totalAmount - invoicePaid, 0)
const depositRemaining = Math.max(reservation.depositAmount - depositCollected, 0)
const remaining = body.type === 'DEPOSIT' ? depositRemaining : chargeRemaining
if (remaining <= 0) { if (remaining <= 0) {
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid') 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) { if (body.amount > remaining) {
throw new ValidationError(body.type === 'DEPOSIT' ? 'Payment amount exceeds deposit outstanding' : 'Payment amount exceeds remaining balance') throw new ValidationError(body.type === 'DEPOSIT' ? 'Payment amount exceeds deposit outstanding' : 'Payment amount exceeds remaining balance')
} }
@@ -96,7 +96,7 @@ export async function confirmReservation(id: string, companyId: string) {
}, },
}).catch(() => null) }).catch(() => null)
if (reservation.source === 'MARKETPLACE') { if (reservation.source === 'CARPLACE') {
const progress = buildBookingRequestProgress({ const progress = buildBookingRequestProgress({
source: reservation.source, source: reservation.source,
status: 'CONFIRMED', status: 'CONFIRMED',
@@ -193,7 +193,7 @@ export async function closeReservation(id: string, companyId: string, closedBy:
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company' const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${updated.vehicle.year} ${updated.vehicle.make} ${updated.vehicle.model}` const vehicleLabel = `${updated.vehicle.year} ${updated.vehicle.make} ${updated.vehicle.model}`
const reviewUrl = `${process.env.STOREFRONT_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}` const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
sendTransactionalEmail({ sendTransactionalEmail({
to: customer.email, to: customer.email,
@@ -60,7 +60,7 @@ describe('reservation.presenter boundary behavior', () => {
const result = serializeReservationForDashboard({ const result = serializeReservationForDashboard({
id: 'reservation_1', id: 'reservation_1',
status: 'DRAFT', status: 'DRAFT',
source: 'MARKETPLACE', source: 'CARPLACE',
contractNumber: null, contractNumber: null,
invoiceNumber: null, invoiceNumber: null,
paymentStatus: 'UNPAID', paymentStatus: 'UNPAID',
@@ -84,7 +84,7 @@ describe('reservation.presenter boundary behavior', () => {
const result = serializeReservationForDashboard({ const result = serializeReservationForDashboard({
id: 'reservation_1', id: 'reservation_1',
status: 'DRAFT', status: 'DRAFT',
source: 'MARKETPLACE', source: 'CARPLACE',
contractNumber: null, contractNumber: null,
invoiceNumber: null, invoiceNumber: null,
paymentStatus: 'UNPAID', paymentStatus: 'UNPAID',
@@ -19,7 +19,7 @@ describe('serializeReservationForDashboard', () => {
const result = serializeReservationForDashboard({ const result = serializeReservationForDashboard({
id: 'reservation-1', id: 'reservation-1',
status: 'CONFIRMED', status: 'CONFIRMED',
source: 'MARKETPLACE', source: 'CARPLACE',
contractNumber: null, contractNumber: null,
invoiceNumber: null, invoiceNumber: null,
paymentStatus: 'UNPAID', paymentStatus: 'UNPAID',
@@ -68,8 +68,8 @@ export function buildBookingRequestProgress(reservation: {
} }
const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status) const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status)
const documentsRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && documentsMissing.length > 0 const documentsRequired = reservation.source === 'CARPLACE' && companyConfirmed && documentsMissing.length > 0
const paymentRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus) const paymentRequired = reservation.source === 'CARPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED' let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED'
if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED' if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED'
@@ -49,7 +49,7 @@ describe('review.service', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z')) vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
process.env.STOREFRONT_URL = 'https://market.example' process.env.CARPLACE_URL = 'https://market.example'
}) })
it('returns reviews with rating filters and pagination metadata', async () => { 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 companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicle = reservation.vehicle const vehicle = reservation.vehicle
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : 'your rental vehicle' const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : 'your rental vehicle'
const reviewUrl = `${process.env.STOREFRONT_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}` const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
await sendTransactionalEmail({ await sendTransactionalEmail({
to: customer.email, to: customer.email,
@@ -18,7 +18,7 @@ describe('site.presenter', () => {
paymentMethodsEnabled: ['PAYPAL'], paymentMethodsEnabled: ['PAYPAL'],
defaultLocale: 'fr', defaultLocale: 'fr',
defaultCurrency: 'MAD', defaultCurrency: 'MAD',
isListedOnMarketplace: true, isListedOnCarplace: true,
}, },
}) })
@@ -32,7 +32,7 @@ describe('site.presenter', () => {
paymentMethodsEnabled: ['PAYPAL'], paymentMethodsEnabled: ['PAYPAL'],
defaultLocale: 'fr', defaultLocale: 'fr',
defaultCurrency: 'MAD', defaultCurrency: 'MAD',
isListedOnMarketplace: true, isListedOnCarplace: true,
}, },
}) })
expect(result.brand).not.toHaveProperty('amanpaySecretKey') expect(result.brand).not.toHaveProperty('amanpaySecretKey')
+2 -2
View File
@@ -30,8 +30,8 @@ export function presentBrand(company: {
paypalEmail: brand.paypalEmail, paypalEmail: brand.paypalEmail,
paypalMerchantId: brand.paypalMerchantId, paypalMerchantId: brand.paypalMerchantId,
paymentMethodsEnabled: brand.paymentMethodsEnabled, paymentMethodsEnabled: brand.paymentMethodsEnabled,
isListedOnMarketplace: brand.isListedOnMarketplace, isListedOnCarplace: brand.isListedOnCarplace,
marketplaceRating: brand.marketplaceRating, carplaceRating: brand.carplaceRating,
homePageConfig: brand.homePageConfig, homePageConfig: brand.homePageConfig,
menuConfig: brand.menuConfig, menuConfig: brand.menuConfig,
} : null, } : null,
+1 -1
View File
@@ -35,7 +35,7 @@ export const bookSchema = z.object({
offerId: z.string().cuid().optional(), offerId: z.string().cuid().optional(),
promoCodeUsed: z.string().optional(), promoCodeUsed: z.string().optional(),
notes: 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([]), selectedInsurancePolicyIds: z.array(z.string()).default([]),
additionalDrivers: z.array(z.object({ additionalDrivers: z.array(z.object({
firstName: z.string().min(1), firstName: z.string().min(1),
@@ -10,7 +10,7 @@ vi.mock('../../lib/prisma', () => ({
planFeature: { findMany: vi.fn() }, planFeature: { findMany: vi.fn() },
}, },
})) }))
vi.mock('../../services/platformContentService', () => ({ getStorefrontHomepageContent: vi.fn() })) vi.mock('../../services/platformContentService', () => ({ getCarplaceHomepageContent: vi.fn() }))
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() })) vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() })) vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: 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' } } 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> = {}) { function bookingBody(overrides: Record<string, unknown> = {}) {
const { start, end } = futureDateRange(30, 3)
return { return {
vehicleId: 'vehicle_1', vehicleId: 'vehicle_1',
startDate: '2026-07-01T00:00:00.000Z', startDate: start.toISOString(),
endDate: '2026-07-04T00:00:00.000Z', endDate: end.toISOString(),
firstName: 'Nora', firstName: 'Nora',
lastName: 'Renter', lastName: 'Renter',
email: 'nora@example.test', 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 () => { 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.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.upsertCustomer).mockResolvedValue({ id: 'customer_1' } as never)
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'offer_1', type: 'FREE_DAY', discountValue: 1 } as never) vi.mocked(repo.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', id: 'reservation_1',
bookingReference: 'BK-2026-AB12CD', bookingReference: 'BK-2026-AB12CD',
status: 'DRAFT', status: 'DRAFT',
startDate: new Date('2026-07-01T10:00:00.000Z'), startDate: new Date(body.startDate),
endDate: new Date('2026-07-04T10:00:00.000Z'), endDate: new Date(body.endDate),
pickupLocation: 'Casablanca', pickupLocation: 'Casablanca',
returnLocation: 'Casablanca', returnLocation: 'Casablanca',
vehicle: { make: 'Dacia', model: 'Duster', year: 2024 }, vehicle: { make: 'Dacia', model: 'Duster', year: 2024 },
@@ -125,11 +138,7 @@ describe('site.service public booking/payment boundaries', () => {
paymentStatus: 'UNPAID', paymentStatus: 'UNPAID',
} as never) } as never)
const result = await service.createBooking('atlas', bookingBody({ const result = await service.createBooking('atlas', body)
promoCodeUsed: 'FREEDAY',
selectedInsurancePolicyIds: ['insurance_1'],
additionalDrivers: [{ firstName: 'Second' }],
}))
expect(repo.createReservation).toHaveBeenCalledWith(expect.objectContaining({ expect(repo.createReservation).toHaveBeenCalledWith(expect.objectContaining({
totalDays: 3, totalDays: 3,
+3 -3
View File
@@ -3,7 +3,7 @@ import { AppError, NotFoundError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyPricingRules } from '../../services/pricingRuleService' import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense } from '../../services/licenseValidationService' import { validateLicense } from '../../services/licenseValidationService'
import { getStorefrontHomepageContent } from '../../services/platformContentService' import { getCarplaceHomepageContent } from '../../services/platformContentService'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import * as amanpay from '../../services/amanpayService' import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService' 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:3000')
allowedHosts.add('127.0.0.1:4000') allowedHosts.add('127.0.0.1:4000')
} }
for (const value of [process.env.STOREFRONT_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_STOREFRONT_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 if (!value) continue
try { allowedHosts.add(new URL(value).host) } catch {} try { allowedHosts.add(new URL(value).host) } catch {}
} }
@@ -53,7 +53,7 @@ function generateBookingReference() {
} }
export async function getPlatformHomepage() { export async function getPlatformHomepage() {
return getStorefrontHomepageContent() return getCarplaceHomepageContent()
} }
export async function getPlatformPricing() { export async function getPlatformPricing() {
+12 -5
View File
@@ -66,7 +66,7 @@ vi.mock('../../services/paypalService', () => ({
})) }))
vi.mock('../../services/platformContentService', () => ({ vi.mock('../../services/platformContentService', () => ({
getStorefrontHomepageContent: vi.fn(), getCarplaceHomepageContent: vi.fn(),
})) }))
import * as repo from './site.repo' import * as repo from './site.repo'
@@ -82,6 +82,12 @@ import {
const SLUG = 'test-company' 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 = {}) { function makeCompany(overrides: object = {}) {
return { return {
id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com', id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com',
@@ -173,10 +179,11 @@ describe('validatePromoCode', () => {
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
describe('createBooking', () => { describe('createBooking', () => {
const { start: bookingStart, end: bookingEnd } = futureDateRange(30, 3)
const baseBody = { const baseBody = {
vehicleId: 'v-1', vehicleId: 'v-1',
startDate: '2026-07-01T00:00:00.000Z', startDate: bookingStart.toISOString(),
endDate: '2026-07-04T00:00:00.000Z', endDate: bookingEnd.toISOString(),
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
phone: '+212600000000', phone: '+212600000000',
pickupLocation: 'Casablanca', pickupLocation: 'Casablanca',
@@ -194,8 +201,8 @@ describe('createBooking', () => {
id: 'r-1', id: 'r-1',
bookingReference: 'BK-2026-ABC123', bookingReference: 'BK-2026-ABC123',
status: 'DRAFT', status: 'DRAFT',
startDate: new Date('2026-07-01T00:00:00.000Z'), startDate: bookingStart,
endDate: new Date('2026-07-04T00:00:00.000Z'), endDate: bookingEnd,
pickupLocation: 'Casablanca', pickupLocation: 'Casablanca',
returnLocation: 'Casablanca', returnLocation: 'Casablanca',
vehicle: { make: 'Toyota', model: 'Camry', year: 2022 }, vehicle: { make: 'Toyota', model: 'Camry', year: 2022 },
@@ -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 './storefront.service'
import {
paginationSchema, searchSchema, companiesQuerySchema, storefrontReservationSchema,
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
} from './storefront.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(storefrontReservationSchema, req)
const result = await service.createStorefrontReservation(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.trackStorefrontFunnelEvent(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: 'Storefront 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.getStorefrontVehicleAvailability(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
@@ -2,7 +2,7 @@ export type AccessLevel = 'full' | 'limited' | 'read_only' | 'none'
export const SUBSCRIPTION_POLICY = { export const SUBSCRIPTION_POLICY = {
trial: { trial: {
durationDays: 14, durationDays: 30,
requiresPaymentMethod: false, // set true when payment capture is mandatory requiresPaymentMethod: false, // set true when payment capture is mandatory
oneTrialPerCompany: true, oneTrialPerCompany: true,
}, },
@@ -70,18 +70,18 @@ describe('subscription.service operational edges', () => {
it('builds plans from pricing config rows when platform overrides exist', async () => { it('builds plans from pricing config rows when platform overrides exist', async () => {
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([ vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 9900 }, { plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 14900 },
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 99000 }, { plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 143040 },
{ plan: 'PRO', billingPeriod: 'MONTHLY', amount: 29900 }, { plan: 'PRO', billingPeriod: 'MONTHLY', amount: 39900 },
] as never) ] as never)
await expect(service.getPlans()).resolves.toEqual({ await expect(service.getPlans()).resolves.toEqual({
STARTER: { STARTER: {
MONTHLY: { MAD: 9900 }, MONTHLY: { MAD: 14900 },
ANNUAL: { MAD: 99000 }, ANNUAL: { MAD: 143040 },
}, },
PRO: { PRO: {
MONTHLY: { MAD: 29900 }, MONTHLY: { MAD: 39900 },
}, },
}) })
}) })
@@ -102,7 +102,7 @@ describe('subscription.service operational edges', () => {
'PRO', 'PRO',
'MONTHLY', 'MONTHLY',
'MAD', 'MAD',
new Date('2026-06-15T00:00:00.000Z'), new Date('2026-07-01T00:00:00.000Z'),
) )
expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({ expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({
subscriptionId: 'sub_1', 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: '/',
})
})
})
+13 -11
View File
@@ -1,4 +1,4 @@
import type { Response } from 'express' import type { CookieOptions, Response } from 'express'
import type { ActorType } from './tokens' import type { ActorType } from './tokens'
const COOKIE_NAMES: Record<ActorType, string> = { const COOKIE_NAMES: Record<ActorType, string> = {
@@ -11,21 +11,23 @@ export function getSessionCookieName(actorType: ActorType) {
return COOKIE_NAMES[actorType] return COOKIE_NAMES[actorType]
} }
export function setSessionCookie(res: Response, actorType: ActorType, token: string, maxAgeMs?: number) { function getSessionCookieOptions(actorType: ActorType, maxAgeMs?: number): CookieOptions {
res.cookie(getSessionCookieName(actorType), token, { const domain = process.env.SESSION_COOKIE_DOMAIN?.trim()
return {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: actorType === 'admin' ? 'strict' : 'lax', sameSite: actorType === 'admin' ? 'strict' : 'lax',
path: '/', path: '/',
maxAge: maxAgeMs, ...(domain ? { domain } : {}),
}) ...(maxAgeMs !== undefined ? { maxAge: maxAgeMs } : {}),
}
}
export function setSessionCookie(res: Response, actorType: ActorType, token: string, maxAgeMs?: number) {
res.cookie(getSessionCookieName(actorType), token, getSessionCookieOptions(actorType, maxAgeMs))
} }
export function clearSessionCookie(res: Response, actorType: ActorType) { export function clearSessionCookie(res: Response, actorType: ActorType) {
res.clearCookie(getSessionCookieName(actorType), { res.clearCookie(getSessionCookieName(actorType), getSessionCookieOptions(actorType))
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: actorType === 'admin' ? 'strict' : 'lax',
path: '/',
})
} }
@@ -49,7 +49,7 @@ describe('financialReportService', () => {
pricingRulesTotal: 10, pricingRulesTotal: 10,
depositAmount: 500, depositAmount: 500,
totalAmount: 640, totalAmount: 640,
source: 'MARKETPLACE', source: 'CARPLACE',
startDate: new Date('2026-06-18T10:00:00.000Z'), startDate: new Date('2026-06-18T10:00:00.000Z'),
endDate: new Date('2026-06-20T10:00:00.000Z'), endDate: new Date('2026-06-20T10:00:00.000Z'),
vehicle: { year: 2023, make: 'Renault', model: 'Clio', licensePlate: 'B-456' }, vehicle: { year: 2023, make: 'Renault', model: 'Clio', licensePlate: 'B-456' },
@@ -27,15 +27,15 @@ describe('platformContentService', () => {
it('returns cloned default homepage content when the file does not exist or cannot be parsed', async () => { it('returns cloned default homepage content when the file does not exist or cannot be parsed', async () => {
const service = await importFreshService() const service = await importFreshService()
const first = await service.getStorefrontHomepageContent() const first = await service.getCarplaceHomepageContent()
first.en.heroTitle = 'Mutated by test' first.en.heroTitle = 'Mutated by test'
const second = await service.getStorefrontHomepageContent() const second = await service.getCarplaceHomepageContent()
expect(second.en.sections).toContain('hero') expect(second.en.sections).toContain('hero')
expect(second.en.heroTitle).not.toBe('Mutated by test') expect(second.en.heroTitle).not.toBe('Mutated by test')
}) })
it('saves only the storefront homepage field while preserving unrelated platform content fields', async () => { it('saves only the Carplace homepage field while preserving unrelated platform content fields', async () => {
const dataPath = path.join(tempDir, 'apps/api/src/data/platform-content.json') const dataPath = path.join(tempDir, 'apps/api/src/data/platform-content.json')
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => { await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => {
await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true })) await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true }))
@@ -43,13 +43,13 @@ describe('platformContentService', () => {
}) })
const service = await importFreshService() const service = await importFreshService()
const homepage = await service.getStorefrontHomepageContent() const homepage = await service.getCarplaceHomepageContent()
homepage.en.heroTitle = 'A better storefront title' homepage.en.heroTitle = 'A better Carplace title'
await expect(service.saveStorefrontHomepageContent(homepage)).resolves.toBe(homepage) await expect(service.saveCarplaceHomepageContent(homepage)).resolves.toBe(homepage)
const raw = JSON.parse(await readFile(dataPath, 'utf8')) const raw = JSON.parse(await readFile(dataPath, 'utf8'))
expect(raw.otherSetting).toEqual({ keep: true }) expect(raw.otherSetting).toEqual({ keep: true })
expect(raw.storefrontHomepage.en.heroTitle).toBe('A better storefront title') expect(raw.carplaceHomepage.en.heroTitle).toBe('A better Carplace title')
}) })
}) })
@@ -1,6 +1,9 @@
import { mkdir, readFile, writeFile } from 'fs/promises' import { mkdir, readFile, writeFile } from 'fs/promises'
import path from 'path' import path from 'path'
import { cloneStorefrontHomepageContent, type StorefrontHomepageConfig } from '@rentaldrivego/types' import {
cloneCarplaceHomepageContent,
type CarplaceHomepageConfig,
} from '@rentaldrivego/types'
function resolveContentPath() { function resolveContentPath() {
const cwd = process.cwd() const cwd = process.cwd()
@@ -12,7 +15,7 @@ function resolveContentPath() {
const platformContentPath = resolveContentPath() const platformContentPath = resolveContentPath()
type PlatformContentFile = { type PlatformContentFile = {
storefrontHomepage?: StorefrontHomepageConfig carplaceHomepage?: CarplaceHomepageConfig
} }
async function readPlatformContentFile(): Promise<PlatformContentFile> { async function readPlatformContentFile(): Promise<PlatformContentFile> {
@@ -29,16 +32,16 @@ async function writePlatformContentFile(content: PlatformContentFile) {
await writeFile(platformContentPath, JSON.stringify(content, null, 2)) await writeFile(platformContentPath, JSON.stringify(content, null, 2))
} }
export async function getStorefrontHomepageContent() { export async function getCarplaceHomepageContent() {
const content = await readPlatformContentFile() const content = await readPlatformContentFile()
return content.storefrontHomepage ?? cloneStorefrontHomepageContent() return content.carplaceHomepage ?? cloneCarplaceHomepageContent()
} }
export async function saveStorefrontHomepageContent(homepage: StorefrontHomepageConfig) { export async function saveCarplaceHomepageContent(homepage: CarplaceHomepageConfig) {
const content = await readPlatformContentFile() const content = await readPlatformContentFile()
await writePlatformContentFile({ await writePlatformContentFile({
...content, ...content,
storefrontHomepage: homepage, carplaceHomepage: homepage,
}) })
return homepage return homepage
} }
@@ -16,7 +16,7 @@ describe('OpenAPI document boundary contract', () => {
}), }),
})) }))
expect(openApiDocument.tags).toEqual(expect.arrayContaining([ expect(openApiDocument.tags).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'Storefront' }), expect.objectContaining({ name: 'Carplace' }),
expect.objectContaining({ name: 'Subscriptions' }), expect.objectContaining({ name: 'Subscriptions' }),
expect.objectContaining({ name: 'Admin' }), expect.objectContaining({ name: 'Admin' }),
])) ]))
+27 -27
View File
@@ -119,7 +119,7 @@ export const openApiDocument: JsonObject = {
{ name: 'Companies', description: 'Company profile & settings' }, { name: 'Companies', description: 'Company profile & settings' },
{ name: 'Team', description: 'Employee management' }, { name: 'Team', description: 'Employee management' },
{ name: 'Subscriptions', description: 'Plan management & billing' }, { name: 'Subscriptions', description: 'Plan management & billing' },
{ name: 'Storefront', description: 'Public booking portal' }, { name: 'Carplace', description: 'Public booking portal' },
{ name: 'Site', description: 'White-label site API' }, { name: 'Site', description: 'White-label site API' },
{ name: 'Admin', description: 'Platform administration' }, { name: 'Admin', description: 'Platform administration' },
], ],
@@ -996,20 +996,20 @@ export const openApiDocument: JsonObject = {
}, },
// ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════
// MARKETPLACE (public — no auth required) // CARPLACE (public — no auth required)
// ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════
'/storefront/cities': { '/carplace/cities': {
get: { tags: ['Storefront'], summary: 'Cities with active companies', security: [], responses: { '200': ok } }, get: { tags: ['Carplace'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
}, },
'/storefront/companies': { '/carplace/companies': {
get: { tags: ['Storefront'], summary: 'List companies', security: [], responses: { '200': ok } }, get: { tags: ['Carplace'], summary: 'List companies', security: [], responses: { '200': ok } },
}, },
'/storefront/offers': { '/carplace/offers': {
get: { tags: ['Storefront'], summary: 'Public offers', security: [], responses: { '200': ok } }, get: { tags: ['Carplace'], summary: 'Public offers', security: [], responses: { '200': ok } },
}, },
'/storefront/search': { '/carplace/search': {
get: { get: {
tags: ['Storefront'], tags: ['Carplace'],
summary: 'Search available vehicles', summary: 'Search available vehicles',
security: [], security: [],
parameters: [ parameters: [
@@ -1021,30 +1021,30 @@ export const openApiDocument: JsonObject = {
responses: { '200': ok }, responses: { '200': ok },
}, },
}, },
'/storefront/reservations': { '/carplace/reservations': {
post: { tags: ['Storefront'], summary: 'Create storefront booking', security: [], responses: { '201': ok } }, post: { tags: ['Carplace'], summary: 'Create carplace booking', security: [], responses: { '201': ok } },
}, },
'/storefront/offers/{code}/validate': { '/carplace/offers/{code}/validate': {
post: { tags: ['Storefront'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, post: { tags: ['Carplace'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
}, },
'/storefront/review/{token}': { '/carplace/review/{token}': {
get: { tags: ['Storefront'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, get: { tags: ['Carplace'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
post: { tags: ['Storefront'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, post: { tags: ['Carplace'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
}, },
'/storefront/{slug}': { '/carplace/{slug}': {
get: { tags: ['Storefront'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, get: { tags: ['Carplace'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
}, },
'/storefront/{slug}/reviews': { '/carplace/{slug}/reviews': {
get: { tags: ['Storefront'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, get: { tags: ['Carplace'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
}, },
'/storefront/{slug}/vehicles': { '/carplace/{slug}/vehicles': {
get: { tags: ['Storefront'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, get: { tags: ['Carplace'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
}, },
'/storefront/{slug}/vehicles/{id}': { '/carplace/{slug}/vehicles/{id}': {
get: { tags: ['Storefront'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } }, get: { tags: ['Carplace'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
}, },
'/storefront/{slug}/offers': { '/carplace/{slug}/offers': {
get: { tags: ['Storefront'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, get: { tags: ['Carplace'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
}, },
// ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════
@@ -29,6 +29,10 @@ vi.mock('../../modules/auth/auth.company.service', () => ({
}), }),
})) }))
vi.mock('../../modules/auth/auth.account.service', () => ({
startAccount: vi.fn(),
}))
vi.mock('../../modules/auth/auth.renter.service', () => ({ vi.mock('../../modules/auth/auth.renter.service', () => ({
signupDisabled: vi.fn(() => { signupDisabled: vi.fn(() => {
const err = new Error('renter disabled') as Error & { statusCode?: number; code?: string } const err = new Error('renter disabled') as Error & { statusCode?: number; code?: string }
@@ -49,6 +53,7 @@ vi.mock('../../modules/auth/auth.renter.service', () => ({
import request from 'supertest' import request from 'supertest'
import { createApp } from '../../app' import { createApp } from '../../app'
import * as accountService from '../../modules/auth/auth.account.service'
import * as companyService from '../../modules/auth/auth.company.service' import * as companyService from '../../modules/auth/auth.company.service'
import * as renterService from '../../modules/auth/auth.renter.service' import * as renterService from '../../modules/auth/auth.renter.service'
@@ -90,6 +95,10 @@ const signupPayload = {
describe('auth API boundaries', () => { describe('auth API boundaries', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(accountService.startAccount).mockResolvedValue({
message: 'Account created. Please check your email to verify your address before signing in.',
email: 'owner@example.com',
} as never)
vi.mocked(companyService.signup).mockResolvedValue({ companyId: 'company_1', nextStep: 'workspace_created' } as never) vi.mocked(companyService.signup).mockResolvedValue({ companyId: 'company_1', nextStep: 'workspace_created' } as never)
vi.mocked(renterService.getMe).mockResolvedValue({ id: 'renter_1', savedCompanies: [] } as never) vi.mocked(renterService.getMe).mockResolvedValue({ id: 'renter_1', savedCompanies: [] } as never)
vi.mocked(renterService.updateMe).mockResolvedValue({ id: 'renter_1', firstName: 'Nora' } as never) vi.mocked(renterService.updateMe).mockResolvedValue({ id: 'renter_1', firstName: 'Nora' } as never)
@@ -117,6 +126,38 @@ describe('auth API boundaries', () => {
expect(companyService.signup).not.toHaveBeenCalled() expect(companyService.signup).not.toHaveBeenCalled()
}) })
it('POST /auth/account/start validates minimal signup and returns 201', async () => {
const res = await request(app).post('/api/v1/auth/account/start').send({
email: 'owner@example.com',
password: 'super-secret',
preferredLanguage: 'fr',
})
expect(res.status).toBe(201)
expect(res.body).toEqual({
data: {
message: 'Account created. Please check your email to verify your address before signing in.',
email: 'owner@example.com',
},
})
expect(accountService.startAccount).toHaveBeenCalledWith({
email: 'owner@example.com',
password: 'super-secret',
preferredLanguage: 'fr',
})
})
it('POST /auth/account/start rejects malformed minimal signup payloads before service execution', async () => {
const res = await request(app).post('/api/v1/auth/account/start').send({
email: 'not-email',
password: 'short',
})
expect(res.status).toBe(400)
expect(res.body.error).toBe('validation_error')
expect(accountService.startAccount).not.toHaveBeenCalled()
})
it('preserves removed company auth compatibility endpoints as explicit 410 responses', async () => { it('preserves removed company auth compatibility endpoints as explicit 410 responses', async () => {
const complete = await request(app).post('/api/v1/auth/company/complete-signup').send({}) const complete = await request(app).post('/api/v1/auth/company/complete-signup').send({})
const verify = await request(app).post('/api/v1/auth/company/verify-email').send({}) const verify = await request(app).post('/api/v1/auth/company/verify-email').send({})
@@ -12,6 +12,7 @@ vi.mock('../../lib/prisma', () => ({
prisma: { prisma: {
employee: { findUnique: vi.fn() }, employee: { findUnique: vi.fn() },
company: { findUnique: vi.fn() }, company: { findUnique: vi.fn() },
subscription: { findUnique: vi.fn() },
adminUser: { findUnique: vi.fn() }, adminUser: { findUnique: vi.fn() },
renter: { findUnique: vi.fn() }, renter: { findUnique: vi.fn() },
}, },
@@ -53,6 +54,7 @@ describe('auth middleware API boundaries', () => {
vi.clearAllMocks() vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret' process.env.JWT_SECRET = 'test-secret'
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test' process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'ACTIVE' } as never)
}) })
it('rejects protected company routes before service execution when no token is present', async () => { it('rejects protected company routes before service execution when no token is present', async () => {
@@ -94,7 +96,7 @@ describe('auth middleware API boundaries', () => {
expect(res.status).toBe(402) expect(res.status).toBe(402)
expect(res.body).toEqual(expect.objectContaining({ expect(res.body).toEqual(expect.objectContaining({
error: 'subscription_suspended', error: 'subscription_suspended',
billingUrl: 'https://dashboard.example.test/billing', billingUrl: 'https://dashboard.example.test/subscription',
})) }))
expect(vehicleService.listVehicles).not.toHaveBeenCalled() expect(vehicleService.listVehicles).not.toHaveBeenCalled()
}) })
@@ -40,12 +40,13 @@ vi.mock('../../modules/notifications/notification.service', () => ({
getRenterPreferences: vi.fn(), getRenterPreferences: vi.fn(),
setRenterPreferences: vi.fn(), setRenterPreferences: vi.fn(),
})) }))
vi.mock('../../modules/storefront/storefront.service', () => ({ vi.mock('../../modules/carplace/carplace.service', () => ({
getCities: vi.fn(), getCities: vi.fn(),
getListedCompanies: vi.fn(), getListedCompanies: vi.fn(),
searchVehicles: vi.fn(), searchVehicles: vi.fn(),
searchVehiclesPage: vi.fn(),
getVehicleDetail: vi.fn(), getVehicleDetail: vi.fn(),
createStorefrontReservation: vi.fn(), createCarplaceReservation: vi.fn(),
getCompanyPage: vi.fn(), getCompanyPage: vi.fn(),
getCompanyReviews: vi.fn(), getCompanyReviews: vi.fn(),
getCompanyVehicles: vi.fn(), getCompanyVehicles: vi.fn(),
@@ -61,11 +62,11 @@ import request from 'supertest'
import { createApp } from '../../app' import { createApp } from '../../app'
import * as employeeService from '../../modules/auth/auth.employee.service' import * as employeeService from '../../modules/auth/auth.employee.service'
import * as notificationService from '../../modules/notifications/notification.service' import * as notificationService from '../../modules/notifications/notification.service'
import * as storefrontService from '../../modules/storefront/storefront.service' import * as carplaceService from '../../modules/carplace/carplace.service'
const app = createApp() const app = createApp()
describe('employee, notification, and storefront API validation contracts', () => { describe('employee, notification, and carplace API validation contracts', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(employeeService.login).mockResolvedValue({ token: 'jwt', employee: { id: 'employee_1' } } as never) vi.mocked(employeeService.login).mockResolvedValue({ token: 'jwt', employee: { id: 'employee_1' } } as never)
@@ -73,8 +74,12 @@ describe('employee, notification, and storefront API validation contracts', () =
vi.mocked(employeeService.updateLanguage).mockResolvedValue({ language: 'ar' } as never) vi.mocked(employeeService.updateLanguage).mockResolvedValue({ language: 'ar' } as never)
vi.mocked(employeeService.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } as never) vi.mocked(employeeService.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } as never)
vi.mocked(notificationService.setPreferences).mockResolvedValue({ success: true } as never) vi.mocked(notificationService.setPreferences).mockResolvedValue({ success: true } as never)
vi.mocked(storefrontService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never) vi.mocked(carplaceService.searchVehiclesPage).mockResolvedValue({
vi.mocked(storefrontService.submitReview).mockResolvedValue({ id: 'review_1' } as never) items: [],
pagination: { page: 1, pageSize: 20, totalItems: 0, totalPages: 0 },
facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } },
} as never)
vi.mocked(carplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
}) })
it('normalizes employee login email before service execution', async () => { it('normalizes employee login email before service execution', async () => {
@@ -110,19 +115,20 @@ describe('employee, notification, and storefront API validation contracts', () =
expect(notificationService.setPreferences).not.toHaveBeenCalled() expect(notificationService.setPreferences).not.toHaveBeenCalled()
}) })
it('defaults storefront search pagination at the public route boundary', async () => { it('defaults carplace search pagination at the public route boundary', async () => {
const res = await request(app).get('/api/v1/storefront/search?city=Casablanca') const res = await request(app).get('/api/v1/carplace/search?city=Casablanca')
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(storefrontService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({ expect(carplaceService.searchVehiclesPage).toHaveBeenCalledWith(expect.objectContaining({
city: 'Casablanca', city: 'Casablanca',
page: 1, page: 1,
pageSize: 20, pageSize: 20,
})) }))
expect(res.body.data.pagination).toEqual({ page: 1, pageSize: 20, totalItems: 0, totalPages: 0 })
}) })
it('rejects out-of-range public review ratings before service execution', async () => { it('rejects out-of-range public review ratings before service execution', async () => {
const res = await request(app).post('/api/v1/storefront/review/token_123').send({ const res = await request(app).post('/api/v1/carplace/review/token_123').send({
overallRating: 6, overallRating: 6,
vehicleRating: 5, vehicleRating: 5,
serviceRating: 5, serviceRating: 5,
@@ -130,6 +136,6 @@ describe('employee, notification, and storefront API validation contracts', () =
}) })
expect(res.status).toBe(400) expect(res.status).toBe(400)
expect(storefrontService.submitReview).not.toHaveBeenCalled() expect(carplaceService.submitReview).not.toHaveBeenCalled()
}) })
}) })
@@ -30,12 +30,13 @@ vi.mock('../../modules/site/site.service', () => ({
handleContact: vi.fn(), handleContact: vi.fn(),
})) }))
vi.mock('../../modules/storefront/storefront.service', () => ({ vi.mock('../../modules/carplace/carplace.service', () => ({
getPublicOffers: vi.fn(), getPublicOffers: vi.fn(),
getCities: vi.fn(), getCities: vi.fn(),
getListedCompanies: vi.fn(), getListedCompanies: vi.fn(),
searchVehicles: vi.fn(), searchVehicles: vi.fn(),
createStorefrontReservation: vi.fn(), searchVehiclesPage: vi.fn(),
createCarplaceReservation: vi.fn(),
getReviewContext: vi.fn(), getReviewContext: vi.fn(),
submitReview: vi.fn(), submitReview: vi.fn(),
validateOfferCode: vi.fn(), validateOfferCode: vi.fn(),
@@ -49,7 +50,7 @@ vi.mock('../../modules/storefront/storefront.service', () => ({
import request from 'supertest' import request from 'supertest'
import { createApp } from '../../app' import { createApp } from '../../app'
import * as siteService from '../../modules/site/site.service' import * as siteService from '../../modules/site/site.service'
import * as storefrontService from '../../modules/storefront/storefront.service' import * as carplaceService from '../../modules/carplace/carplace.service'
const app = createApp() const app = createApp()
@@ -59,9 +60,13 @@ describe('public validation API contracts', () => {
vi.mocked(siteService.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never) vi.mocked(siteService.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never)
vi.mocked(siteService.initPayment).mockResolvedValue({ checkoutUrl: 'https://pay.example.test' } as never) vi.mocked(siteService.initPayment).mockResolvedValue({ checkoutUrl: 'https://pay.example.test' } as never)
vi.mocked(siteService.handleContact).mockResolvedValue({ success: true } as never) vi.mocked(siteService.handleContact).mockResolvedValue({ success: true } as never)
vi.mocked(storefrontService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never) vi.mocked(carplaceService.searchVehiclesPage).mockResolvedValue({
vi.mocked(storefrontService.createStorefrontReservation).mockResolvedValue({ id: 'reservation_1' } as never) items: [],
vi.mocked(storefrontService.submitReview).mockResolvedValue({ id: 'review_1' } as never) pagination: { page: 2, pageSize: 30, totalItems: 0, totalPages: 0 },
facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } },
} as never)
vi.mocked(carplaceService.createCarplaceReservation).mockResolvedValue({ id: 'reservation_1' } as never)
vi.mocked(carplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
}) })
it('rejects malformed public availability checks before site service execution', async () => { it('rejects malformed public availability checks before site service execution', async () => {
@@ -120,27 +125,28 @@ describe('public validation API contracts', () => {
}) })
}) })
it('coerces storefront search pagination and price filters', async () => { it('coerces carplace search pagination and price filters', async () => {
const res = await request(app).get('/api/v1/storefront/search?city=Rabat&maxPrice=500&page=2&pageSize=30') const res = await request(app).get('/api/v1/carplace/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(storefrontService.searchVehicles).toHaveBeenCalledWith({ expect(carplaceService.searchVehiclesPage).toHaveBeenCalledWith({
city: 'Rabat', city: 'Rabat',
maxPrice: 500, maxPrice: 500,
page: 2, page: 2,
pageSize: 30, pageSize: 30,
}) })
expect(res.body.data.pagination).toEqual({ page: 2, pageSize: 30, totalItems: 0, totalPages: 0 })
}) })
it('rejects oversized storefront pagination before search execution', async () => { it('rejects oversized carplace pagination before search execution', async () => {
const res = await request(app).get('/api/v1/storefront/search?pageSize=500') const res = await request(app).get('/api/v1/carplace/search?pageSize=500')
expect(res.status).toBe(400) expect(res.status).toBe(400)
expect(storefrontService.searchVehicles).not.toHaveBeenCalled() expect(carplaceService.searchVehiclesPage).not.toHaveBeenCalled()
}) })
it('rejects storefront reservations with invalid public contact data before service execution', async () => { it('rejects carplace reservations with invalid public contact data before service execution', async () => {
const res = await request(app).post('/api/v1/storefront/reservations').send({ const res = await request(app).post('/api/v1/carplace/reservations').send({
vehicleId: 'ckvvehicle000000000000001', vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars', companySlug: 'atlas-cars',
firstName: 'Aya', firstName: 'Aya',
@@ -151,13 +157,13 @@ describe('public validation API contracts', () => {
}) })
expect(res.status).toBe(400) expect(res.status).toBe(400)
expect(storefrontService.createStorefrontReservation).not.toHaveBeenCalled() expect(carplaceService.createCarplaceReservation).not.toHaveBeenCalled()
}) })
it('rejects invalid public review ratings before creating reviews', async () => { it('rejects invalid public review ratings before creating reviews', async () => {
const res = await request(app).post('/api/v1/storefront/review/token_1').send({ overallRating: 6 }) const res = await request(app).post('/api/v1/carplace/review/token_1').send({ overallRating: 6 })
expect(res.status).toBe(400) expect(res.status).toBe(400)
expect(storefrontService.submitReview).not.toHaveBeenCalled() expect(carplaceService.submitReview).not.toHaveBeenCalled()
}) })
}) })
@@ -8,6 +8,7 @@ vi.mock('../../lib/prisma', () => ({
prisma: { prisma: {
employee: { findUnique: vi.fn() }, employee: { findUnique: vi.fn() },
company: { findUnique: vi.fn() }, company: { findUnique: vi.fn() },
subscription: { findUnique: vi.fn() },
}, },
})) }))
vi.mock('../../modules/vehicles/vehicle.service', () => ({ vi.mock('../../modules/vehicles/vehicle.service', () => ({
@@ -57,6 +58,7 @@ beforeEach(() => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'employee_1', type: 'employee' } as never) vi.mocked(jwt.verify).mockReturnValue({ sub: 'employee_1', type: 'employee' } as never)
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee() as never) vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee() as never)
vi.mocked(prisma.company.findUnique).mockResolvedValue({ id: 'company_1', status: 'ACTIVE' } as never) vi.mocked(prisma.company.findUnique).mockResolvedValue({ id: 'company_1', status: 'ACTIVE' } as never)
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'ACTIVE' } as never)
}) })
describe('vehicle pricing API contracts', () => { describe('vehicle pricing API contracts', () => {
@@ -10,7 +10,7 @@ import { createApp } from '../../app'
const app = createApp() const app = createApp()
describe('employee and storefront public boundary smoke', () => { describe('employee and carplace public boundary smoke', () => {
it('rejects malformed employee login payloads without leaking internals', async () => { it('rejects malformed employee login payloads without leaking internals', async () => {
const res = await request(app).post('/api/v1/auth/employee/login').send({ email: 'not-an-email', password: 'x' }) const res = await request(app).post('/api/v1/auth/employee/login').send({ email: 'not-an-email', password: 'x' })
@@ -19,7 +19,7 @@ describe('employee and storefront public boundary smoke', () => {
}) })
it('rejects invalid public review payloads without executing private infrastructure', async () => { it('rejects invalid public review payloads without executing private infrastructure', async () => {
const res = await request(app).post('/api/v1/storefront/review/token_123').send({ overallRating: 0 }) const res = await request(app).post('/api/v1/carplace/review/token_123').send({ overallRating: 0 })
expect(res.status).toBe(400) expect(res.status).toBe(400)
expect(JSON.stringify(res.body)).not.toContain('Prisma') expect(JSON.stringify(res.body)).not.toContain('Prisma')
@@ -24,12 +24,12 @@ vi.mock('../../modules/site/site.service', () => ({
capturePaypal: vi.fn(), capturePaypal: vi.fn(),
handleContact: vi.fn(), handleContact: vi.fn(),
})) }))
vi.mock('../../modules/storefront/storefront.service', () => ({ vi.mock('../../modules/carplace/carplace.service', () => ({
getPublicOffers: vi.fn(), getPublicOffers: vi.fn(),
getCities: vi.fn(), getCities: vi.fn(),
getListedCompanies: vi.fn(), getListedCompanies: vi.fn(),
searchVehicles: vi.fn(), searchVehicles: vi.fn(),
createStorefrontReservation: vi.fn(), createCarplaceReservation: vi.fn(),
getReviewContext: vi.fn(), getReviewContext: vi.fn(),
submitReview: vi.fn(), submitReview: vi.fn(),
validateOfferCode: vi.fn(), validateOfferCode: vi.fn(),
@@ -43,7 +43,7 @@ vi.mock('../../modules/storefront/storefront.service', () => ({
import request from 'supertest' import request from 'supertest'
import { createApp } from '../../app' import { createApp } from '../../app'
import * as siteService from '../../modules/site/site.service' import * as siteService from '../../modules/site/site.service'
import * as storefrontService from '../../modules/storefront/storefront.service' import * as carplaceService from '../../modules/carplace/carplace.service'
const app = createApp() const app = createApp()
@@ -61,8 +61,8 @@ describe('public validation e2e smoke', () => {
expect(payment.status).toBe(400) expect(payment.status).toBe(400)
expect(siteService.initPayment).not.toHaveBeenCalled() expect(siteService.initPayment).not.toHaveBeenCalled()
const review = await request(app).post('/api/v1/storefront/review/token_1').send({ overallRating: 0 }) const review = await request(app).post('/api/v1/carplace/review/token_1').send({ overallRating: 0 })
expect(review.status).toBe(400) expect(review.status).toBe(400)
expect(storefrontService.submitReview).not.toHaveBeenCalled() expect(carplaceService.submitReview).not.toHaveBeenCalled()
}) })
}) })
@@ -8,6 +8,7 @@ vi.mock('../../lib/prisma', () => ({
prisma: { prisma: {
employee: { findUnique: vi.fn().mockResolvedValue({ id: 'employee_1', role: 'MANAGER', isActive: true, companyId: 'company_1', company: { id: 'company_1', status: 'ACTIVE' } }) }, employee: { findUnique: vi.fn().mockResolvedValue({ id: 'employee_1', role: 'MANAGER', isActive: true, companyId: 'company_1', company: { id: 'company_1', status: 'ACTIVE' } }) },
company: { findUnique: vi.fn().mockResolvedValue({ id: 'company_1', status: 'ACTIVE' }) }, company: { findUnique: vi.fn().mockResolvedValue({ id: 'company_1', status: 'ACTIVE' }) },
subscription: { findUnique: vi.fn().mockResolvedValue({ status: 'ACTIVE' }) },
}, },
})) }))
vi.mock('../../modules/vehicles/vehicle.service', () => ({ vi.mock('../../modules/vehicles/vehicle.service', () => ({
@@ -8,7 +8,7 @@ function uniqueEmail(prefix: string) {
} }
describe('Company signup API', () => { describe('Company signup API', () => {
it('creates new accounts with a 90-day trial period', async () => { it('creates new accounts with a 30-day trial period', async () => {
const startedAt = Date.now() const startedAt = Date.now()
const res = await request(app) const res = await request(app)
@@ -54,7 +54,7 @@ describe('Company signup API', () => {
const trialEndsAt = new Date(res.body.data.trialEndsAt).getTime() const trialEndsAt = new Date(res.body.data.trialEndsAt).getTime()
const trialDurationDays = (trialEndsAt - startedAt) / (24 * 60 * 60 * 1000) const trialDurationDays = (trialEndsAt - startedAt) / (24 * 60 * 60 * 1000)
expect(trialDurationDays).toBeGreaterThan(89.9) expect(trialDurationDays).toBeGreaterThan(29.9)
expect(trialDurationDays).toBeLessThan(90.1) expect(trialDurationDays).toBeLessThan(30.1)
}) })
}) })
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
describe('billing, storefront, and vehicle integration boundaries', () => { describe('billing, carplace, and vehicle integration boundaries', () => {
it('documents the Batch-10 DB-backed regression targets for the real integration environment', () => { it('documents the Batch-10 DB-backed regression targets for the real integration environment', () => {
expect([ expect([
'admin billing invoice lifecycle uses persisted line items and audit logs', 'admin billing invoice lifecycle uses persisted line items and audit logs',
'storefront company pages enrich vehicles with real availability records', 'carplace company pages enrich vehicles with real availability records',
'vehicle location settings persist through create/update/list flows', 'vehicle location settings persist through create/update/list flows',
]).toHaveLength(3) ]).toHaveLength(3)
}) })
@@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
describe('employee, storefront, and notification integration boundaries', () => { describe('employee, carplace, and notification integration boundaries', () => {
it('documents the Batch-17 DB-backed integration boundary', () => { it('documents the Batch-17 DB-backed integration boundary', () => {
expect({ expect({
employeeAuth: 'password reset/login flows require generated Prisma and mail-provider test doubles', employeeAuth: 'password reset/login flows require generated Prisma and mail-provider test doubles',
storefront: 'public booking/review flows require seeded company, vehicle, reservation, and token records', carplace: 'public booking/review flows require seeded company, vehicle, reservation, and token records',
notifications: 'preference persistence requires notificationPreference composite indexes', notifications: 'preference persistence requires notificationPreference composite indexes',
}).toEqual(expect.objectContaining({ }).toEqual(expect.objectContaining({
employeeAuth: expect.stringContaining('Prisma'), employeeAuth: expect.stringContaining('Prisma'),
storefront: expect.stringContaining('seeded'), carplace: expect.stringContaining('seeded'),
notifications: expect.stringContaining('composite'), notifications: expect.stringContaining('composite'),
})) }))
}) })
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { parseBody, parseQuery } from '../../http/validate' import { parseBody, parseQuery } from '../../http/validate'
import { storefrontReservationSchema, paginationSchema } from '../../modules/storefront/storefront.schemas' import { carplaceReservationSchema, paginationSchema } from '../../modules/carplace/carplace.schemas'
import { paySchema } from '../../modules/site/site.schemas' import { paySchema } from '../../modules/site/site.schemas'
function reqWith(body: unknown, query: unknown = {}) { function reqWith(body: unknown, query: unknown = {}) {
@@ -8,7 +8,7 @@ function reqWith(body: unknown, query: unknown = {}) {
} }
describe('public validation boundaries', () => { describe('public validation boundaries', () => {
it('keeps storefront pagination coercion consistent with route parsing', () => { it('keeps carplace pagination coercion consistent with route parsing', () => {
expect(parseQuery(paginationSchema, reqWith({}, { page: '4', pageSize: '25' }))).toEqual({ expect(parseQuery(paginationSchema, reqWith({}, { page: '4', pageSize: '25' }))).toEqual({
page: 4, page: 4,
pageSize: 25, pageSize: 25,
@@ -24,8 +24,8 @@ describe('public validation boundaries', () => {
}))).toThrow('Invalid enum value') }))).toThrow('Invalid enum value')
}) })
it('requires enough anonymous storefront reservation identity to create a booking request', () => { it('requires enough anonymous carplace reservation identity to create a booking request', () => {
const parsed = parseBody(storefrontReservationSchema, reqWith({ const parsed = parseBody(carplaceReservationSchema, reqWith({
vehicleId: 'ckvvehicle000000000000001', vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars', companySlug: 'atlas-cars',
firstName: 'Aya', firstName: 'Aya',
+15
View File
@@ -0,0 +1,15 @@
# Carplace application
NODE_ENV=production
CARPLACE_ASSET_PREFIX=/carplace
# Server-to-server API connection inside the container network.
API_INTERNAL_URL=http://api:4000/api/v1
API_URL=http://api:4000
# Browser requests use /carplace/api/v1 by default.
# Enable direct API mode only when CORS and cookie-domain settings are deliberate.
NEXT_PUBLIC_CARPLACE_DIRECT_API=false
NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
# Public dashboard action used by the Carplace header.
NEXT_PUBLIC_DASHBOARD_URL=/dashboard
+44
View File
@@ -0,0 +1,44 @@
/** @type {import('next').NextConfig} */
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
const CARPLACE_ASSET_PREFIX = '/carplace'
// In local development the homepage app serves /carplace from port 3000 while
// the Carplace dev server runs on port 3004. Emit absolute chunk/HMR URLs so
// the browser asks the Carplace server for its own assets instead of the
// homepage server.
const defaultCarplaceAssetPrefix = process.env.NODE_ENV !== 'production'
? 'http://localhost:3004'
: CARPLACE_ASSET_PREFIX
const carplaceAssetPrefix = normalizeAssetPrefix(
process.env.CARPLACE_ASSET_PREFIX ?? defaultCarplaceAssetPrefix,
CARPLACE_ASSET_PREFIX,
)
const securityHeaders = buildSecurityHeaders({ assetSources: [carplaceAssetPrefix] })
const nextConfig = {
...(carplaceAssetPrefix ? { assetPrefix: carplaceAssetPrefix } : {}),
images: {
remotePatterns: [{ protocol: 'https', hostname: 'res.cloudinary.com' }],
},
transpilePackages: ['@rentaldrivego/types'],
async headers() {
return [{ source: '/:path*', headers: securityHeaders }]
},
async rewrites() {
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://api:4000').replace(/\/api\/v1\/?$/, '')
return {
beforeFiles: [
{ source: '/carplace/api/:path*', destination: `${apiOrigin}/api/:path*` },
],
afterFiles: [
{ source: '/carplace', destination: '/' },
{ source: '/carplace/:path*', destination: '/:path*' },
],
fallback: [],
}
},
}
module.exports = nextConfig
@@ -1,5 +1,5 @@
{ {
"name": "@rentaldrivego/storefront", "name": "@rentaldrivego/carplace",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 302 B

After

Width:  |  Height:  |  Size: 302 B

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Some files were not shown because too many files have changed in this diff Show More