update documentation

This commit is contained in:
root
2026-05-26 19:28:44 -04:00
parent 9ab73e8ce6
commit 1a1c418087
46 changed files with 2292 additions and 8089 deletions
+698
View File
@@ -0,0 +1,698 @@
# Dashboard App
This document explains the purpose, structure, runtime behavior, and main feature flows of the dashboard application in `apps/dashboard`.
Source of truth:
- `apps/dashboard/src/app/*`
- `apps/dashboard/src/components/*`
- `apps/dashboard/src/lib/*`
- `apps/dashboard/src/hooks/*`
- `apps/dashboard/src/middleware.ts`
- `apps/dashboard/next.config.js`
## Purpose
The dashboard is the internal company workspace for RentalDriveGo. It is the private B2B interface used by rental-company staff to run day-to-day operations.
The app covers:
- employee sign-in
- onboarding after company creation
- fleet management
- reservations and rental lifecycle operations
- online reservation intake
- customer CRM
- contracts and billing
- subscription management
- reviews, complaints, notifications, and settings
It is distinct from:
- the marketplace app, which is public and cross-company
- the company site app, which is public and company-branded
- the admin app, which is platform-internal rather than tenant-scoped
## Tech and Runtime
- framework: Next.js 14 App Router
- ui: React 18 + Tailwind CSS
- charts: Recharts
- realtime notifications: `socket.io-client`
- shared types: `@rentaldrivego/types`
- default dev port: `3001`
- base path: `/dashboard`
Defined in `package.json`:
- `npm run dev --workspace @rentaldrivego/dashboard`
- `npm run build --workspace @rentaldrivego/dashboard`
- `npm run start --workspace @rentaldrivego/dashboard`
## URL and Deployment Model
The app is designed to run under `/dashboard`, not at the domain root.
Key behavior from `next.config.js`:
- `basePath` is `/dashboard`
- browser API calls are proxied through `/dashboard/api/*`
- those calls are rewritten to the API origin configured by `API_INTERNAL_URL` or `API_URL`
- image loading allows Cloudinary plus `/storage/*` assets from the API host
- `DASHBOARD_ASSET_PREFIX` can force absolute JS/CSS asset URLs when the dashboard is served behind another frontend proxy
This lets the dashboard run:
- directly in dev on port `3001`
- behind a reverse proxy
- embedded under a shared marketplace/admin hostname while still loading its own chunks correctly
## Top-Level Structure
Main folders:
```text
apps/dashboard/
src/app/
(dashboard)/ private employee workspace
(public)/ public shell pages
onboarding/ post-signup setup
sign-in/ employee sign-in
sign-up/ company signup
forgot-password/
reset-password/
src/components/
layout/ sidebar, topbar, public shell
reservations/ inspections, photo flows, condition sheet
team/ invite/edit modals and permissions matrix
ui/ shared view components
src/hooks/
useTeam.ts team management data/actions
src/lib/
api.ts fetch wrapper and auth token handling
preferences.ts language/theme persistence scoped per employee
dashboardPaths.ts normalizes basePath-aware routes
urls.ts resolves marketplace/admin app links
src/middleware.ts auth gate and redirect logic
```
## Rendering Strategy
Most dashboard screens are client components.
That is a deliberate fit for the app because the dashboard relies heavily on:
- local auth state from `localStorage`
- cookie and embedded-host redirects
- optimistic UI updates
- direct REST fetches after mount
- websocket notification updates
- large interactive forms and modal-heavy workflows
The root `src/app/layout.tsx` is still server-rendered enough to:
- read the shared language cookie
- set `lang` and `dir`
- inject a theme bootstrapping script before hydration
## Auth and Session Model
The dashboard uses employee JWTs issued by the API.
### Login flow
`src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`:
1. submits credentials to `POST /auth/employee/login`
2. stores the returned token in `localStorage` under `employee_token`
3. stores the employee profile in `localStorage` under `employee_profile`
4. mirrors the token into the `employee_token` cookie
5. redirects to the requested dashboard path
The sign-in page also supports:
- language and theme query params
- embedded usage through `postMessage`
- redirect handoff to the admin app when an admin token is returned instead of an employee token
### Protected-route middleware
`src/middleware.ts` protects the app.
Important behavior:
- public dashboard routes are limited to sign-in and forgot-password flows
- all other `/dashboard/*` routes require the `employee_token` cookie
- unauthenticated users are redirected either to the marketplace root or to `/dashboard/sign-in`, depending on host context
- duplicate `/dashboard/dashboard` paths are normalized back to `/dashboard`
This means route protection is enforced before React renders the protected pages.
### Role gating
The dashboard does not depend only on route middleware. It also performs UI-level role gating.
Examples:
- the sidebar filters navigation items by employee role
- subscription access is restricted to `OWNER`
- manager/owner-only actions are hidden or disabled in feature pages
Role rank used in the shell:
- `OWNER`
- `MANAGER`
- `AGENT`
## Shell and Navigation
The private shell is defined by:
- `src/app/(dashboard)/layout.tsx`
- `src/components/layout/Sidebar.tsx`
- `src/components/layout/TopBar.tsx`
### Sidebar
Responsibilities:
- tenant branding fetch from `/companies/me/brand`
- current employee profile fetch from `/auth/employee/me`
- role-based navigation filtering
- language and theme controls
- logout handling
- communication with a parent frame when embedded
Main sections exposed in navigation:
- dashboard home
- fleet
- reservations
- online reservations
- customers
- offers
- team
- reports
- subscription
- billing
- contracts
- reviews
- complaints
- notifications
- settings
### Top bar
Responsibilities:
- current page title resolution based on normalized dashboard path
- unread notification count
- compact inbox dropdown
- websocket subscription to realtime notification events
Notification reads are synchronized through the API and local UI events.
## Shared State: Language and Theme
The dashboard uses `DashboardI18nProvider` in `src/components/I18nProvider.tsx`.
It handles:
- current language: `en`, `fr`, `ar`
- current theme: `light`, `dark`
- full in-app copy dictionaries
- persistence to cookies and `localStorage`
- per-employee scoped preferences using the decoded JWT subject
`src/lib/preferences.ts` is the key utility here. It stores both:
- shared values like `rentaldrivego-language`
- employee-scoped variants so one staff member does not override another on the same device
Arabic also flips layout direction through the root layout by setting `dir="rtl"`.
## Data Fetching Conventions
The dashboard centralizes API access in `src/lib/api.ts`.
### `apiFetch`
Browser fetch behavior:
- reads the JWT from `localStorage`
- injects `Authorization: Bearer <token>` when present
- defaults JSON requests to `Content-Type: application/json`
- preserves `FormData` for file uploads
- includes credentials for cookie-aware flows
- unwraps `{ data }` responses automatically
Browser base URL:
- `NEXT_PUBLIC_API_URL`
- fallback `/dashboard/api/v1`
### `apiFetchServer`
Server-side helper for routes/components that already have a token and need `cache: 'no-store'`.
### Pattern used across pages
Most pages follow the same client-side pattern:
1. mount
2. call one or more `apiFetch(...)` requests
3. store result in local component state
4. show loading and empty states
5. mutate through API actions
6. refetch or patch local state
There is intentionally very little abstraction beyond this. The code favors explicit page-level orchestration over a large client data framework.
## Feature Areas
### 1. Company signup and onboarding
Relevant routes:
- `src/app/sign-up/[[...sign-up]]/page.tsx`
- `src/app/onboarding/page.tsx`
The signup screen creates the company through:
- `POST /auth/company/signup`
The onboarding flow then completes the initial tenant setup in three steps:
1. basic company info via `PATCH /companies/me`
2. brand/public profile via `PATCH /companies/me/brand`
3. payment and marketplace settings via `PATCH /companies/me/brand`
This makes onboarding a dashboard-owned continuation of the API signup flow.
### 2. Dashboard home
Route:
- `src/app/(dashboard)/page.tsx`
Backed by:
- `GET /analytics/dashboard`
Primary contents:
- KPI cards
- source breakdown chart
- subscription-status banner
- recent reservations list
Revenue visibility is reduced for lower-privilege roles.
### 3. Fleet
Routes:
- `src/app/(dashboard)/fleet/page.tsx`
- `src/app/(dashboard)/fleet/[id]/page.tsx`
- `src/components/VehicleCalendar.tsx`
Backed by:
- `GET /vehicles`
- `POST /vehicles`
- `GET /vehicles/:id`
- `PATCH /vehicles/:id`
- `PATCH /vehicles/:id/status`
- `POST /vehicles/:id/photos`
- `DELETE /vehicles/:id/photos/:idx`
- `GET /vehicles/:id/maintenance`
- `POST /vehicles/:id/maintenance`
- `GET /vehicles/:id/calendar`
- `POST /vehicles/:id/calendar/blocks`
- `DELETE /vehicles/:id/calendar/blocks/:blockId`
Key responsibilities:
- create and edit vehicles
- upload vehicle images
- publish/unpublish vehicles
- change operational status
- record maintenance
- visualize reservations and manual blocks on a calendar
- configure pickup and dropoff location rules
### 4. Reservations
Routes:
- `src/app/(dashboard)/reservations/page.tsx`
- `src/app/(dashboard)/reservations/new/page.tsx`
- `src/app/(dashboard)/reservations/[id]/page.tsx`
Backed by:
- `GET /reservations`
- `POST /reservations`
- `GET /reservations/:id`
- `PATCH /reservations/:id`
- `POST /reservations/:id/confirm`
- `POST /reservations/:id/checkin`
- `POST /reservations/:id/checkout`
- `POST /reservations/:id/close`
- `POST /reservations/:id/extend`
- `PUT /reservations/:id/inspections/:type`
- `PATCH /reservations/:id/additional-drivers/:driverId/approval`
- `GET|POST|DELETE /reservations/:id/photos*`
This is the heaviest operational area of the dashboard.
Major workflows:
- create draft bookings
- choose existing or new customers
- upload and manage customer license images
- confirm/check-in/check-out/close rentals
- edit booking vs return data based on reservation workflow state
- complete check-in/check-out inspections
- upload pickup and dropoff photos
- extend rentals
- approve additional drivers
Supporting reservation UI components:
- `DamageInspectionCard.tsx`
- `ReservationPhotoSection.tsx`
- `VehicleConditionSheet.tsx`
### 5. Online reservations
Route:
- `src/app/(dashboard)/online-reservations/page.tsx`
Backed by:
- `GET /reservations?source=MARKETPLACE&status=DRAFT`
- `POST /reservations/:id/confirm`
- `POST /reservations/:id/cancel`
This page is the triage surface for new public booking requests before staff converts them into active operational reservations.
### 6. Customers
Route:
- `src/app/(dashboard)/customers/page.tsx`
Backed mainly by:
- `GET /customers`
The reservations-create flow also uses:
- `POST /customers`
- `PATCH /customers/:id`
- `POST /customers/:id/license-image`
The customer surface acts as a company-local CRM rather than a global renter account manager.
### 7. Offers
Route:
- `src/app/(dashboard)/offers/page.tsx`
Backed by:
- `GET /offers`
- `GET /offers/:id`
- `POST /offers`
- `PATCH /offers/:id`
- `DELETE /offers/:id`
- `GET /vehicles`
Purpose:
- create and edit discounts
- target specific vehicles
- manage promo details and active/public state
### 8. Team
Route:
- `src/app/(dashboard)/team/page.tsx`
Supporting hook and components:
- `src/hooks/useTeam.ts`
- `components/team/InviteModal.tsx`
- `components/team/EditMemberModal.tsx`
- `components/team/PermissionsMatrix.tsx`
Backed by:
- `GET /team`
- `GET /team/stats`
- `POST /team/invite`
- `PATCH /team/:id/role`
- `POST /team/:id/deactivate`
- `POST /team/:id/reactivate`
- `DELETE /team/:id`
This area centralizes employee lifecycle management.
### 9. Reports
Route:
- `src/app/(dashboard)/reports/page.tsx`
Backed by:
- `GET /analytics/report?period=...`
Purpose:
- revenue and operational analytics
- summarized reporting views
### 10. Subscription
Route:
- `src/app/(dashboard)/subscription/page.tsx`
Backed by:
- `GET /subscriptions/me`
- `GET /subscriptions/invoices`
- `GET /subscriptions/providers`
- `GET /subscriptions/plans`
- `GET /subscriptions/features`
- `POST /subscriptions/checkout`
- `POST /subscriptions/cancel`
- `POST /subscriptions/resume`
This is owner-only and manages the SaaS relationship between the company and RentalDriveGo.
Key functions:
- current plan visibility
- plan comparison
- provider availability checks
- checkout redirection
- invoice history
- cancel/resume lifecycle
### 11. Billing
Route:
- `src/app/(dashboard)/billing/page.tsx`
Backed by:
- `GET /reservations`
- `GET /payments/company`
- `POST /payments/reservations/:id/manual`
This page handles customer-side rental billing, not SaaS subscription billing.
It focuses on:
- reservation invoice state
- collected vs outstanding balances
- payment history
- manual payment recording
- quick jumps to reservation and contract pages
### 12. Contracts
Routes:
- `src/app/(dashboard)/contracts/page.tsx`
- `src/app/(dashboard)/contracts/[id]/page.tsx`
Backed by:
- `GET /reservations`
- `GET /reservations/:id/contract`
Purpose:
- list reservations with contract/invoice state
- generate and reopen rental-contract views
- present printable contract payloads for staff operations
### 13. Reviews and complaints
Routes:
- `src/app/(dashboard)/reviews/page.tsx`
- `src/app/(dashboard)/complaints/page.tsx`
Backed by:
- `GET /reviews`
- `GET /reviews/stats`
- `PATCH /reviews/:id/reply`
- `GET /complaints`
- `POST /complaints`
- `PATCH /complaints/:id`
These pages give staff post-rental quality and dispute management tools.
### 14. Notifications
Route:
- `src/app/(dashboard)/notifications/page.tsx`
Backed by:
- `GET /notifications/company`
- `GET /notifications/history`
- `GET /notifications/company/preferences`
- `POST /notifications/company/:id/read`
- `POST /notifications/company/read-all`
- `PATCH /notifications/company/preferences`
This page complements the top-bar notification dropdown by adding:
- full inbox view
- delivery history audit
- per-event, per-channel preference management
### 15. Settings
Route:
- `src/app/(dashboard)/settings/page.tsx`
Backed by:
- `GET|PATCH /companies/me/brand`
- `POST /companies/me/brand/logo`
- `POST /companies/me/brand/hero`
- `POST|DELETE /companies/me/brand/custom-domain`
- `GET|PATCH /companies/me/contract-settings`
- `GET|POST|PATCH /companies/me/insurance-policies`
- `GET|POST|PATCH /companies/me/pricing-rules`
- `GET|PATCH /companies/me/accounting-settings`
This is the broad configuration surface for the tenant.
It includes:
- public brand and marketplace profile
- payment account identifiers
- custom domain setup
- rental policies
- insurance products
- pricing rules
- accounting defaults
## Public Shell Pages
Public-facing dashboard pages live outside the private shell.
Notable routes:
- `/dashboard/sign-in`
- `/dashboard/sign-up`
- `/dashboard/forgot-password`
- `/dashboard/reset-password`
- `/dashboard/onboarding`
Layout:
- `src/app/(public)/layout.tsx`
- `src/components/layout/PublicShell.tsx`
- `src/components/layout/PublicHeader.tsx`
- `src/components/layout/PublicFooter.tsx`
These pages share the branding system and language/theme controls but do not render the private sidebar/topbar shell.
## Embedded and Cross-App Behavior
The dashboard supports embedded scenarios.
Examples in the code:
- sign-in posts messages to the parent frame
- logout posts an employee-logout event
- current embedded path is reported upward
It also coordinates with sibling apps:
- `marketplaceUrl` links staff back to the public marketplace domain
- `adminUrl` supports token handoff into the admin interface
- host-aware URL rewriting allows the same app to function under external domains, internal Docker hostnames, and proxied environments
## API Dependency Summary
The dashboard is a thin client over the company API. Its main backend dependencies are:
- auth: employee login and profile
- companies: brand, contract settings, pricing rules, accounting, domains
- vehicles: fleet CRUD, photos, maintenance, availability calendar
- reservations: booking lifecycle and inspections
- customers: CRM and license documents
- offers: promotions
- team: employee management
- analytics: dashboard and reports
- notifications: inbox, history, preferences
- subscriptions: SaaS plan management
- payments: rental payment recording
- reviews and complaints: post-rental follow-up
The frontend does not try to reproduce business rules locally. It delegates workflow authority to the API and reflects the resulting state.
## Design Characteristics
The current dashboard code favors:
- explicit page-local state over a global data layer
- direct REST integration over generated clients
- strong tenant scoping through backend auth rather than frontend assumptions
- rich operational forms over minimal CRUD scaffolds
- multilingual support directly in the app shell
Tradeoffs of this approach:
- easy to reason about per page
- low indirection when debugging API interactions
- more repeated fetch/state patterns than a centralized query layer would have
- large single-file page components in the heaviest screens
## Recommended Maintenance Rules
When changing the dashboard, keep these rules in mind:
1. Preserve the `/dashboard` base path and `toDashboardAppPath` normalization behavior.
2. Keep route protection aligned with `src/middleware.ts`.
3. If a new page depends on employee role, gate it both in navigation and in-page behavior.
4. Reuse `apiFetch` for auth and error handling consistency.
5. Keep language/theme strings inside the dashboard i18n system rather than scattering literals.
6. Update this README when a new major page, workflow, or integration pattern is added.
+1 -1
View File
@@ -212,7 +212,7 @@ npm run docker:prod:start:frontends
npm run docker:prod:start:pgmanage
npm run docker:prod:start:portainer
npm run docker:prod:start:all
cd /Users/larbi/Documents/car_management_system/live-src && npm run docker:prod:start:api && npm run docker:prod:start:frontends
npm run docker:prod:start:api && npm run docker:prod:start:frontends
```
Docker will:
+53 -429
View File
@@ -1,459 +1,83 @@
# API Refactor Task List
# API Refactor Status
This document turns the current API refactor direction into a concrete task list for this repo.
This file replaces the older pre-refactor task list that referenced route paths and module boundaries no longer present in the codebase.
Scope:
- modularize route and service structure
- strengthen validation boundaries
- add meaningful API test coverage
Source of truth:
Non-goals:
- rewrite the API framework
- switch ORM or database
- redesign product behavior
- `apps/api/src/app.ts`
- `apps/api/src/modules/*`
- `apps/api/src/http/*`
## Current State
## Refactor State
Main pressure points in the current API:
- large route files with mixed concerns:
- `apps/api/src/routes/reservations.ts`
- `apps/api/src/routes/site.ts`
- `apps/api/src/routes/marketplace.ts`
- `apps/api/src/routes/admin.ts`
- business logic split inconsistently between `routes/` and `services/`
- no real API test runner wired in `apps/api/package.json`
- ad hoc request parsing and response/error shaping across route files
- direct Prisma access from route handlers in many domains
The API has already been refactored into the modular structure the old task list was aiming for.
## Target Structure
Current structure includes:
Target layout after refactor:
- `modules/*` for route/service/repo/presenter grouping
- `http/errors` for centralized error types and middleware
- `http/validate` for request parsing helpers
- `http/respond` for success helpers
- module-level tests and integration tests under `apps/api/src/tests`
```text
apps/api/src/
modules/
customers/
customer.routes.ts
customer.schemas.ts
customer.service.ts
customer.repo.ts
customer.presenter.ts
customer.test.ts
vehicles/
companies/
reservations/
marketplace/
site/
http/
errors/
middleware/
respond/
validate/
lib/
services/
```
The older checklist items that referenced:
Rules for the target shape:
- route files parse input, call services, and return responses only
- services own business rules and transaction boundaries
- repos own Prisma access
- schemas own `params`, `query`, and `body` validation
- presenters own response DTO shaping
- `apps/api/src/routes/*.ts`
- missing test tooling
- missing shared validation helpers
- missing shared response helpers
## Phase 1: Foundation
are no longer accurate and should not be used as active work items.
### 1.1 Add API test tooling
## Remaining Follow-Up Work
Tasks:
- add `vitest` to the repo
- add `supertest` for HTTP integration tests
- add API test scripts to:
- `apps/api/package.json`
- root `package.json`
- add a basic test config under `apps/api`
- decide on test DB bootstrap using existing Docker test stack in `docker-compose.test.yml`
These are the only meaningful refactor follow-ups still worth tracking at a high level.
Acceptance criteria:
- `npm run test --workspace @rentaldrivego/api` exists
- one smoke test can boot the Express app and hit `/health`
- one authenticated test can hit a protected route
### 1. Keep OpenAPI coverage aligned with route growth
### 1.2 Centralize HTTP errors
The API now has:
Tasks:
- create `apps/api/src/http/errors/`
- add app error classes:
- `AppError`
- `ValidationError`
- `NotFoundError`
- `ConflictError`
- `ForbiddenError`
- `UnauthorizedError`
- add a single Express error middleware
- register it in `apps/api/src/index.ts`
- Swagger UI at `/docs`
- OpenAPI JSON at `/api/v1/openapi.json`
Acceptance criteria:
- route files stop hand-shaping most error JSON
- thrown app errors become consistent `{ error, message, statusCode }` responses
But the OpenAPI document does not yet mirror every newer route group perfectly. Continue updating:
### 1.3 Centralize request parsing helpers
- `apps/api/src/swagger/openapi.ts`
- route schemas
- module docs
Tasks:
- create `apps/api/src/http/validate/`
- add helpers for:
- `parseBody`
- `parseQuery`
- `parseParams`
- standardize `zod` parse error mapping to `400`
whenever new endpoints are added.
Acceptance criteria:
- new/updated route files no longer call `schema.parse(req.body)` inline repeatedly
- validation failures follow one shared response shape
### 2. Expand integration test coverage
### 1.4 Centralize response helpers
Integration tests exist, but the heaviest workflows still benefit from deeper coverage:
Tasks:
- create `apps/api/src/http/respond/`
- add helpers for:
- `ok`
- `created`
- `noContent`
- use the same success envelope conventions across refactored modules
- subscription billing transitions
- marketplace reservation intake
- public booking and payment initialization
- reservation inspection and close flows
- admin billing operations
Acceptance criteria:
- refactored routes send responses through shared helpers
### 3. Reduce remaining direct Prisma orchestration in large services
## Phase 2: First Safe Module Extractions
The route layer is already thin in the current API. The next cleanup target is inside larger service files where orchestration still mixes:
Goal:
- refactor smaller route files first to establish patterns before touching reservation flows
- multi-step workflow logic
- transaction boundaries
- some direct Prisma writes
### 2.1 Customers module
especially in:
Files in scope:
- `apps/api/src/routes/customers.ts`
- `apps/api/src/services/licenseValidationService.ts`
- customer-related Prisma access
- reservation flows
- subscription/billing flows
- admin billing flows
Tasks:
- create:
- `modules/customers/customer.routes.ts`
- `modules/customers/customer.schemas.ts`
- `modules/customers/customer.service.ts`
- `modules/customers/customer.repo.ts`
- `modules/customers/customer.presenter.ts`
- move all Prisma reads/writes out of the route file
- move license image upload orchestration into the service
- standardize customer DTO output
### 4. Keep docs aligned with disabled flows
Tests to add:
- list customers
- create customer
- update customer
- validate license
- approve license
- upload license image
Some legacy surfaces still exist as placeholders or schema remnants, for example:
Acceptance criteria:
- `routes/customers.ts` becomes a thin delegator or is replaced by module route registration
- no direct Prisma calls remain in the customer route layer
- disabled Clerk webhook endpoint
- legacy `clerkUserId` field on `Employee`
- disabled renter signup/login API endpoints
### 2.2 Vehicles module
Files in scope:
- `apps/api/src/routes/vehicles.ts`
- `apps/api/src/services/vehicleAvailabilityService.ts`
- `apps/api/src/lib/storage.ts`
Tasks:
- extract module files for vehicles
- isolate upload/photo operations from route code
- isolate availability query logic from route formatting
- create presenter functions for dashboard-facing vehicle responses
Tests to add:
- create vehicle
- update vehicle
- upload photos
- delete photo
- availability check
Acceptance criteria:
- vehicle upload and availability logic are both service-owned
### 2.3 Companies module
Files in scope:
- `apps/api/src/routes/companies.ts`
Tasks:
- extract company brand/profile subdomain flows into a module
- isolate brand asset upload logic
- separate subdomain availability checks from route code
Tests to add:
- get company profile
- update company profile
- upload logo
- upload hero image
- subdomain check
Acceptance criteria:
- no upload/storage branching remains in the route handler
## Phase 3: High-Value Reservation Refactor
This is the biggest and highest-risk slice.
### 3.1 Split reservations into sub-services
Files in scope:
- `apps/api/src/routes/reservations.ts`
- `apps/api/src/services/additionalDriverService.ts`
- `apps/api/src/services/insuranceService.ts`
- `apps/api/src/services/pricingRuleService.ts`
- `apps/api/src/services/licenseValidationService.ts`
- `apps/api/src/services/invoicePdfService.ts`
Target internal split:
- `reservation.service.ts`
- `reservation.lifecycle.service.ts`
- `reservation.pricing.service.ts`
- `reservation.insurance.service.ts`
- `reservation.additional-driver.service.ts`
- `reservation.document.service.ts`
- `reservation.inspection.service.ts`
- `reservation.presenter.ts`
- `reservation.repo.ts`
Tasks:
- extract create/update/confirm/check-in/check-out/close flows into dedicated service methods
- move `serializeReservationForDashboard` into a presenter
- move document number generation out of route code
- keep Prisma transactions in the service layer only
- remove route-local helper sprawl from `reservations.ts`
Tests to add:
- create reservation success
- create reservation conflict
- confirm reservation
- check-in reservation
- check-out reservation
- close reservation
- add additional driver requiring approval
- pricing rule application
- insurance application
Acceptance criteria:
- reservation route file becomes orchestration-only
- all state transitions are covered by integration tests
## Phase 4: Public-Surface Modules
These routes should move after internal domains are stabilized.
### 4.1 Marketplace module
Files in scope:
- `apps/api/src/routes/marketplace.ts`
Tasks:
- extract search/filter/offer validation into module files
- unify database-unavailable handling
- separate presenter logic from business logic
Tests to add:
- list cities
- search vehicles
- fetch vehicle details
- validate offer / promo path
### 4.2 Site module
Files in scope:
- `apps/api/src/routes/site.ts`
Tasks:
- extract public company site flows
- isolate booking flow for public site reservations
- unify maintenance-mode / database-unavailable behavior
Tests to add:
- fetch site brand
- fetch public vehicle list
- availability check
- create booking request
- payment-init guard paths
Acceptance criteria:
- public route handlers become small and deterministic
## Phase 5: Cross-Cutting Cleanup
### 5.1 Authentication and role boundaries
Files in scope:
- `apps/api/src/middleware/requireCompanyAuth.ts`
- `apps/api/src/middleware/requireRenterAuth.ts`
- `apps/api/src/middleware/requireAdminAuth.ts`
- `apps/api/src/middleware/requireRole.ts`
- `apps/api/src/middleware/requireTenant.ts`
- `apps/api/src/middleware/requireSubscription.ts`
Tasks:
- standardize auth failure responses
- document what request context each middleware guarantees
- remove duplicate assumptions in route files
Acceptance criteria:
- auth and permission expectations are explicit and shared
### 5.2 Upload policy standardization
Files in scope:
- `apps/api/src/routes/vehicles.ts`
- `apps/api/src/routes/companies.ts`
- `apps/api/src/routes/customers.ts`
- `apps/api/src/lib/storage.ts`
- `docker-compose.production.yml`
- `docker-compose.dev.yml`
Tasks:
- create shared upload policy helpers for image uploads
- standardize max file size, mime validation, and error responses
- centralize folder path rules for stored uploads
- keep runtime uploads out of the API source tree and out of the image filesystem
- require all uploaded files to be written to the configured storage root mounted from a Docker volume
- verify the API serves uploads from the volume-backed storage root only
- document the storage contract clearly:
- app code may generate URLs and read/write through the storage abstraction
- Docker volumes own persistence
- rebuilds and redeploys must not delete uploaded files
Acceptance criteria:
- upload endpoints share one validation policy
- uploaded files are not stored inside `apps/api/src`, `apps/api/dist`, or any other API-local runtime path
- production uploads persist in the named Docker volume mounted into the API container
- dev Docker uploads persist in the dev named Docker volume mounted into the API container
- storage behavior is environment-configured through the storage root, not hardcoded to an app-relative folder
### 5.3 Remove route-local DTO drift
Tasks:
- define presenter functions for each major domain
- stop returning raw Prisma records where API shape is intended to be stable
Acceptance criteria:
- DTO shape changes are isolated to presenters
## Testing Plan
### Priority 0: smoke coverage
Add first:
- `/health`
- authenticated customer list
- authenticated vehicle list
- reservation create
- site availability
### Priority 1: critical business flows
Add next:
- reservation state changes
- uploads
- payments init guards
- license approval paths
### Priority 2: regression coverage
Add later:
- analytics
- notifications
- admin routes
- subscriptions
### Test types
Use:
- unit tests for pure business logic
- integration tests for route + middleware + DB behavior
- a few contract-style tests for public endpoints
Avoid:
- brittle snapshots
- tests that assert Prisma internal shapes directly unless repo-level persistence behavior is the actual concern
## Suggested Execution Order
1. add test tooling and smoke tests
2. add shared error middleware and validation helpers
3. refactor customers
4. refactor vehicles
5. refactor companies
6. refactor reservations in slices
7. refactor marketplace
8. refactor site
9. refactor admin, notifications, analytics, subscriptions, payments
## Deliverables Checklist
- [x] API test runner added
- [x] shared error middleware added
- [x] shared validation helpers added
- [x] shared response helpers added
- [x] customers module extracted
- [x] vehicles module extracted
- [x] companies module extracted
- [x] reservations module extracted
- [x] marketplace module extracted
- [x] site module extracted
- [ ] upload validation standardized
- [x] auth/role middleware behavior documented
- [ ] critical booking flows covered by integration tests
## Status Update
Completed in the workspace:
- phases 1 through 5 are implemented
- the originally listed phase 9 domains are modularized, and the remaining route surface was also cleared for:
- `team`
- `offers`
- `notifications`
- `analytics`
- `subscriptions`
- `payments`
- `admin`
- `auth.*`
- `webhooks`
- `apps/api/src/routes/` has been cleared and route registration now points at module routers
- shared HTTP helpers are in place under:
- `apps/api/src/http/errors/`
- `apps/api/src/http/validate/`
- `apps/api/src/http/respond/`
- the Docker-backed API integration workflow is wired and passing
Current automated integration coverage includes:
- health
- customers
- vehicles
- reservations
- payments
- subscriptions
- notifications
- admin
Still intentionally open:
- upload policy standardization from section `5.2`
- any remaining Priority 1 integration gaps not yet covered by the current suite, especially upload and license-approval paths if they are still required as dedicated HTTP flows
## Completion Criteria
The refactor is considered successful when:
- route files are thin and mostly declarative
- business rules live in services, not route handlers
- Prisma access lives in repos or well-defined service boundaries
- request validation is explicit for `params`, `query`, and `body`
- core booking, customer, and upload flows have automated coverage
- API error and success envelopes are consistent across modules
Those should stay clearly marked in docs so design specs do not drift back toward removed implementations.
+29 -45
View File
@@ -1,57 +1,41 @@
## Project Design Audit
# Documentation Audit
Date: 2026-05-01
Date: 2026-05-26
### What I checked
## Purpose
- Compared `project_design` team-related source stubs against the live implementation in:
- `apps/dashboard/src/components/team/*`
- `apps/dashboard/src/app/(dashboard)/dashboard/team/page.tsx`
- `apps/api/src/routes/team.ts`
- `apps/api/src/routes/webhooks.ts`
- `apps/api/src/services/teamService.ts`
- `apps/api/src/middleware/requireRole.ts`
- Scanned the repo for obvious page- and feature-level gaps against:
- `project_design/README.md`
- `project_design/FEATURES.md`
- `project_design/PAGES.md`
- `project_design/advanced-features.md`
This note records the cleanup applied to the `docs/project-design` folder after comparing it with the live codebase.
### Added to the project
## Drift That Was Removed
- Copied the design documentation into `docs/project-design/`
- Copied the design source stubs into `docs/project-design/stubs/`
- Copied `project_design/rentalcardrive.png` into `apps/public-site/public/rentalcardrive.png`
The older design docs included several features or assumptions that are not active in the current implementation:
### Confirmed as already implemented in the app code
- Clerk-based employee auth and webhooks
- Clerk-based team invite acceptance
- renter self-service signup/login as an active user flow
- renter email verification as an active flow
- a separate white-label company public-site frontend app
- multi-currency SaaS subscription checkout beyond `MAD`
- marketing routes such as `/about`, `/contact`, and `/blog`
- outdated API file paths under `apps/api/src/routes/*`
- Team API route exists: `apps/api/src/routes/team.ts`
- Clerk invitation webhook route exists: `apps/api/src/routes/webhooks.ts`
- Team service exists: `apps/api/src/services/teamService.ts`
- Role middleware exists: `apps/api/src/middleware/requireRole.ts`
- Dashboard team UI exists:
- `apps/dashboard/src/app/(dashboard)/dashboard/team/page.tsx`
- `apps/dashboard/src/components/team/InviteModal.tsx`
- `apps/dashboard/src/components/team/EditMemberModal.tsx`
- `apps/dashboard/src/components/team/PermissionsMatrix.tsx`
- `apps/dashboard/src/hooks/useTeam.ts`
## Current Documentation Rule
These are not exact copies of the `project_design` stubs, but they cover the same implementation area and appear to be the working versions.
The `docs/project-design` files should describe only one of two things:
### Obvious gaps against the design docs
1. current implemented behavior
2. explicit future plans, clearly labeled as plans
- Public-site pages from the page spec are missing or not present as app routes:
- `/pricing`
- `/about`
- `/blog`
- Renter account routes described in the page spec are not present as standalone routes:
- `/renter/notifications`
- `/renter/saved-companies`
- `/renter/profile`
- The marketplace renter dashboard exists, but it is not split into the separate pages described in `project_design/PAGES.md`.
- The design document should not be treated as "fully implemented" yet. There are implemented areas, but the repo still has page-level and spec-level gaps.
They should not describe removed systems as if they are still live.
### Notes
## Current References
- The git worktree already had many unrelated local changes before this audit. I did not overwrite or revert them.
- This audit is a practical coverage check, not a formal end-to-end verification of every feature in `project_design/FEATURES.md`.
Use these documents as the current implementation references:
- `api-routes.md`
- `schema.md`
- `FEATURES.md`
- `PAGES.md`
- `INTEGRATION.md`
Use the execution-plan documents only as future/planning material, not as evidence that a feature is already shipped.
+118 -276
View File
@@ -1,310 +1,152 @@
# Features & Priorities — RentalDriveGo
# Features — Current Product Scope
## MVP (Phase 1) — Must Ship Before Launch
This document lists the features that are active in the current codebase.
### Subscription & Billing
- [x] Company signup → plan selection → payment via AmanPay or PayPal
- [x] 14-day free trial (no payment required until trial ends)
- [x] Company status: PENDING → TRIALING → ACTIVE / PAST_DUE / SUSPENDED
- [x] Subscription gate middleware on all dashboard API routes
- [x] Trial / past-due banners in dashboard
- [x] Suspended account blocks dashboard; public site stays up
- [x] Manual renewal flow: company pays via AmanPay (card/cash/e-wallet) or PayPal
- [x] Invoice history (AmanPay transaction IDs or PayPal capture IDs)
- [x] Supported currencies: MAD, USD, EUR
Source of truth:
### Tenant Isolation (Fleet Management)
- [x] Every Prisma query scoped by `companyId`
- [x] `requireTenant` middleware attaches `companyId` from authenticated employee
- [x] Vehicle, customer, reservation IDs validated against `companyId` before any operation
- [x] No cross-company data exposure — enforced at API level, not just UI
- [x] Add / edit / archive vehicles
- [x] Vehicle photos (Cloudinary)
- [x] Published / unpublished toggle (controls marketplace + public site visibility)
- [x] Vehicle categories, specs, features list
- `apps/marketplace`
- `apps/dashboard`
- `apps/admin`
- `apps/api`
- `packages/database/prisma/schema.prisma`
### Offers / Promotions System
- [x] Create offer (%, fixed amount, free day, special rate)
- [x] Valid from / until dates
- [x] Applicable to: all vehicles / by category / specific vehicles
- [x] Optional promo code + max redemptions
- [x] Minimum rental days
- [x] `isPublic` flag — show on global marketplace
- [x] `isFeatured` flag — featured carousel placement (plan-gated: GROWTH+)
- [x] Offer applied at booking (discount calculated, stored on Reservation)
- [x] Offer performance stats (redemptions, revenue impact)
- [x] Offer banner image upload
## Active Applications
### Global Marketplace (RentalDriveGo.com/explore)
- [x] Featured offers carousel on /explore home
- [x] Search vehicles by city, dates, category, price range
- [x] Company cards (logo, rating, offer badge)
- [x] Company marketplace profile page (vehicles + offers + reviews)
- [x] Vehicle detail page (photos, specs, redirect CTA to company site)
- [x] Cross-company availability check
- [x] Filter by category, transmission, price, features
- [x] Marketplace listing toggle per company (opt out)
The current workspace contains four active apps:
### Renter Account System
- [x] Renter signup / login (own auth, separate from Employee/Clerk)
- [x] Email verification
- [x] Renter profile (name, phone, locale, currency)
- [x] Cross-company reservation history in /renter/dashboard
- [x] Save / unsave companies
- [x] Guest booking (no account required, but prompted to sign up after)
- [x] When renter books, auto-create Customer record in that company's CRM
- `apps/marketplace`
- `apps/dashboard`
- `apps/admin`
- `apps/api`
### Online Reservations (all sources)
- [x] Booking from dashboard (employee creates manually)
- [x] Booking from company public site (BookingSource: PUBLIC_SITE)
- [x] Booking from global marketplace (BookingSource: MARKETPLACE)
- [x] Reservation lifecycle: DRAFT → CONFIRMED → ACTIVE → COMPLETED
- [x] Offer / promo code applied at booking step
- [x] Availability check before confirming
- [x] Booking confirmation (number, summary, company contact)
- [x] Cancel with reason + `cancelledBy` (COMPANY / RENTER / SYSTEM)
There is no separate frontend app for a white-label company public site in this repo at the moment.
### Branding & White-Label Public Site
- [x] Company display name, logo, tagline, hero image
- [x] Primary brand color picker
- [x] Subdomain: `{slug}.RentalDriveGo.com` (claimed during onboarding)
- [x] Real-time subdomain availability check
- [x] Vercel wildcard domain routing (`*.RentalDriveGo.com`)
- [x] Company public site: home with offers, vehicle listing, vehicle detail, booking, confirmation
- [x] Offers page on public site
- [x] "Powered by RentalDriveGo" badge (removable on Pro)
- [x] Site language + currency preference
## Active Platform Features
### Payments — Rental (AmanPay + PayPal)
- [x] Company connects their own AmanPay merchant account (Merchant ID + Secret Key)
- [x] Company connects their own PayPal business account
- [x] Collect payment at booking (marketplace redirect + public site)
- [x] AmanPay widget: national card, international card, cash (3000+ points), e-wallet
- [x] PayPal buttons component (standard checkout)
- [x] If no payment method configured: "Contact to reserve" + pay on pickup flow
- [x] Payment status per reservation
- [x] Refund on cancellation (via AmanPay or PayPal API)
- [x] Guest checkout (no renter account required)
### Company signup and employee access
### Notifications — Full System
- [x] **Email** (Resend) — all notification types, rich HTML templates
- [x] **SMS** (Twilio) — confirmations, reminders, payment alerts
- [x] **WhatsApp** (Twilio WhatsApp API) — confirmations, reminders (key for AR/FR)
- [x] **In-App** (Socket.io + Redis) — real-time bell icon in dashboard + renter site
- [x] **Push** (Firebase FCM) — renter PWA push notifications
- [x] `Notification` table — logs every delivery attempt per channel
- [x] `NotificationPreference` table — per-user opt-in/out per type + channel
- [x] Preferences UI: matrix toggle (notification type × channel)
- [x] All company events: new booking, cancelled, payment received, trial ending, maintenance due, offer expiring, new review
- [x] All renter events: confirmed, 24h reminder, 2h reminder, vehicle ready, return reminder, cancelled, refund, new offer from saved company, review request
- [x] Scheduled notifications via cron (reminders, trial ending, maintenance due)
- Company signup through the dashboard sign-up flow backed by `POST /auth/company/signup`
- Employee sign-in with local email/password auth
- Employee password reset flow
- Team invitation flow that creates a pending employee and sends a reset-password invite link
- Employee roles: `OWNER`, `MANAGER`, `AGENT`
### Reviews
- [x] Renter submits review (only after reservation COMPLETED)
- [x] 15 stars overall + vehicle + service
- [x] Text comment
- [x] Company can reply to review
- [x] Reviews shown on marketplace company profile
- [x] Average rating on company card + brand settings
### Multi-tenant company operations
### Customer CRM
- [x] Auto-created from any booking source
- [x] Company-scoped view (never cross-company)
- [x] Linked to global Renter account (if booked as logged-in renter)
- [x] Rental history, notes, flag/blacklist
- Company-scoped vehicles, customers, reservations, offers, payments, complaints, and notifications
- API tenant isolation through employee auth plus `companyId` scoping
- Company profile, brand, contract settings, insurance policies, pricing rules, and accounting settings
- Public API key generation/regeneration for company integrations
### Auth & Onboarding
- [x] Company employee auth via Clerk
- [x] Renter auth via own JWT (separate)
- [x] Onboarding step 2: branding + subdomain
- [x] Onboarding step 4: "Your vehicles now appear on the RentalDriveGo marketplace"
- [x] Role system: OWNER / MANAGER / AGENT
### Dashboard operations
- Dashboard home with KPI cards and booking-source analytics
- Fleet management
- Vehicle photo uploads
- Vehicle publish/unpublish
- Vehicle status management
- Vehicle maintenance logs
- Vehicle calendar blocks
- Reservation list, detail, create, update, confirm, check-in, check-out, close, extend, cancel
- Reservation pickup/dropoff inspections
- Reservation photo uploads
- Additional-driver approval workflow
- Online reservation intake queue
- Customer CRM
- Offer management
- Team management
- Company billing page for rental payments
- Contract generation/viewing
- Reviews and complaints management
- Notification inbox and preferences
- Subscription management
### Platform Admin Panel
- [x] `AdminUser` model: email, password (bcrypt), role (SUPER_ADMIN/ADMIN/SUPPORT/FINANCE/VIEWER), 2FA (TOTP), last login tracking
- [x] `AdminPermission` model: per-admin resource+action overrides on top of role defaults
- [x] `AuditLog` model: every admin action logged (action, resource, before/after JSON, IP, note)
- [x] Admin JWT auth (separate from Clerk and renter JWT) — 8h expiry
- [x] TOTP 2FA: setup with QR code, verify, enforce for ADMIN+ roles
- [x] Role hierarchy: VIEWER < FINANCE < SUPPORT < ADMIN < SUPER_ADMIN
- [x] Admin role access matrix: 17 dashboard features × 5 roles
- [x] `admin.RentalDriveGo.com` separate app (Next.js)
- [x] **Company management**: list, search/filter, create, edit, suspend, reactivate, delete
- [x] **Company staff management**: list employees per company, add employee, edit (name/email/role), deactivate/reactivate, reset password
- [x] **Impersonation**: ADMIN+ can log in as any company employee (Clerk impersonation session), with dashboard banner
- [x] **Subscription override**: force status, change plan, add free period with auto-revert
- [x] Manual invoice mark-as-paid
- [x] **Renter management**: list, search, view history, block/unblock
- [x] **Audit log UI**: searchable, filterable by admin/action/company, CSV export
- [x] Admin user management (SUPER_ADMIN only): create/edit/deactivate admins, set roles
- [x] Platform metrics: MRR, churn, active companies, marketplace stats, notification delivery rates
- [x] Prisma seed creates first SUPER_ADMIN from env vars
- [x] Employee role control matrix (OWNER/MANAGER/AGENT) with 17 feature permissions documented
### Marketplace and public platform
---
- Public marketing homepage
- Public pricing page
- Public features page
- Public marketplace/explore flow
- Explore company profile pages under `/explore/[slug]`
- Public review submission page via review token
- Public legal/app policy pages
- Public platform content loaded from `/site/platform/*` API endpoints
### Subscription and billing
### Advanced Rental Operations (Phase 1)
- SaaS trial and subscription lifecycle
- Subscription status handling including `TRIALING`, `ACTIVE`, `PAYMENT_PENDING`, `PAST_DUE`, `SUSPENDED`, `CANCELLED`, `EXPIRED`, `PAUSED`, `UNPAID`
- Plan pricing loaded from DB or fallback config
- AmanPay and PayPal provider support for SaaS subscription checkout
- Subscription invoice history
- Platform billing accounts, billing invoices, billing events, refunds, credit notes, and tax records
#### Vehicle Damage Inspection
- [x] Interactive top-down SVG damage map in check-in/check-out workflow
- [x] 22 clickable zones (hood, roof, doors, fenders, bumpers, wheels, windshield, mirrors, interior, undercarriage)
- [x] 5 severity levels per zone: SCRATCH, DENT, CRACK, MISSING, OTHER — each shown in distinct color
- [x] Optional note per damaged zone
- [x] Optional damage photos upload (Cloudinary)
- [x] `DamageReport` model — one per check-in, one per check-out (`@@unique([reservationId, type])`)
- [x] Fuel level and odometer recorded at each inspection
- [x] Customer acknowledgement timestamp + name
- [x] Damage map rendered in PDF contract (static SVG with color markers) — CHECKIN + CHECKOUT side by side
### Rental payments
#### Insurance Policies
- [x] `InsurancePolicy` model: name, type (CDW, SCDW, THEFT, THIRD_PARTY, FULL, BASIC, ROADSIDE, PERSONAL, CUSTOM)
- [x] Three charge types: PER_DAY, PER_RENTAL (flat), PERCENTAGE_OF_RENTAL
- [x] Required vs optional flag — required policies auto-apply, cannot be opted out
- [x] `ReservationInsurance` junction: snapshot of policy terms locked at booking time
- [x] `insuranceTotal` cached on Reservation for fast invoice generation
- [x] Insurance line items in customer invoice with charge type label
- [x] Insurance shown on rental contract
- AmanPay payment initialization and webhook handling
- PayPal payment initialization and capture handling
- Manual rental payment recording
- Reservation refund support for successful online payments
- Payment status tracking on reservations
#### Second / Additional Driver
- [x] `AdditionalDriver` model: full personal details + license info
- [x] Charge types: FREE, PER_DAY, FLAT
- [x] Company configures default charge in `ContractSettings`
- [x] License validation applied to additional drivers (same 3-month rule)
- [x] Additional driver(s) listed on contract + invoice line item (if charged)
- [x] Employee can approve/deny flagged additional driver license
### Notifications
#### Driver License Validation
- [x] `licenseExpiry`, `licenseIssuedAt`, `licenseCountry`, `licenseNumber`, `licenseCategory` fields on Customer
- [x] `LicenseStatus` enum: PENDING, VALID, EXPIRING, APPROVED, DENIED, EXPIRED
- [x] **3-month rule**: if license expires within 90 days → status=EXPIRING → requires employee approval
- [x] **Expired**: status=EXPIRED → automatic flag → employee must explicitly approve or deny before confirmation
- [x] License validation badge on customer record (green/amber/red)
- [x] Warning banner on reservation detail when customer has EXPIRING or EXPIRED license
- [x] Approve / Deny buttons for OWNER and MANAGER roles only
- [x] Approval logged: who approved, when, and note
- Email notifications
- SMS notifications
- WhatsApp notifications
- In-app notifications
- Notification templates and per-channel preferences
- Notification history for company users
#### Age-Based & Experience-Based Pricing Rules
- [x] `PricingRule` model: configurable per company
- [x] Conditions: AGE_LESS_THAN, AGE_GREATER_THAN, LICENSE_YEARS_LESS_THAN, LICENSE_YEARS_GREATER_THAN
- [x] **Default rule: Under-25 surcharge** (+15% of base rental, configurable)
- [x] **Default rule: 5+ years experience discount** (5%, configurable)
- [x] Adjustment types: PERCENTAGE, FLAT_PER_DAY, FLAT_TOTAL
- [x] Rules applied to primary driver AND all additional drivers (each rule applied once even if multiple drivers qualify)
- [x] Rules shown as line items on invoice (surcharges positive, discounts negative)
- [x] Snapshot of applied rules stored in `Reservation.pricingRulesApplied` JSON
### Reservation-adjacent advanced operations
#### Fuel / Gasoline Policy (Structured)
- [x] `FuelPolicyType` enum: FULL_TO_FULL, FULL_TO_EMPTY, SAME_TO_SAME, PREPAID, FREE
- [x] Multilingual policy description auto-generated from type (EN/FR/AR)
- [x] Optional custom note added by company
- [x] Fuel level recorded at check-in AND check-out via `DamageReport.fuelLevel`
- [x] Policy printed on contract in customer's language
- Insurance policy configuration
- Reservation insurance snapshots
- Additional-driver charging rules
- Driver license validation and approval states
- Pricing rules and reservation-time pricing-rule snapshots
- Damage inspections and damage points
- Contract settings for fuel policy, tax, numbering, and additional-driver behavior
- Reporting and export-oriented accounting defaults
#### Full Invoice Price Breakdown
- [x] Line items: base rental × days, per insurance policy, additional driver (if charged), pricing rule surcharges, pricing rule discounts, security deposit (refundable label)
- [x] Category labels per line: RENTAL, INSURANCE, ADDITIONAL_DRIVER, SURCHARGE, DISCOUNT, DEPOSIT
- [x] Tax/VAT line (if `ContractSettings.showTax = true`)
- [x] Subtotal → discounts → surcharges → insurance → tax → deposit → GRAND TOTAL
- [x] All amounts formatted in company's currency and locale
### Admin app
#### Billing Period Reporting (Accountant Export)
- [x] Reports page at `/dashboard/reports`
- [x] Preset periods: This Week, This Month, This Year, Custom range
- [x] Summary cards: total bookings, base revenue, discounts given, insurance collected, additional driver revenue, net collected
- [x] Full reservations table: contract#, invoice#, customer, vehicle, plate, dates, days, base, discount, insurance, additional driver, pricing adjustments, total, payment status, source
- [x] Export as CSV (one click) — for import into accounting software (QuickBooks, Sage, etc.)
- [x] Report generated on-demand from DB — no pre-computed tables
- Admin login and password reset
- Admin dashboard
- Company management
- Renter management
- Admin-user management
- Audit-log views
- Billing operations
- Pricing configuration and promotions
- Notification review
- Marketplace/site config management
---
## Present But Not Active Product Features
## Phase 2 — Post-Launch
These exist partially in code or schema, but they are not active end-user product flows and should not be treated as shipped features.
### Fleet Enhancements
- [ ] Maintenance log with cost tracking
- [ ] Maintenance due alerts (date + mileage)
- [ ] Vehicle availability calendar view in dashboard
- Renter self-service signup
- Renter self-service login
- Renter email verification flow
- Clerk-based employee auth
- Clerk webhooks
- Clerk invitation acceptance
- Admin impersonation through Clerk sessions
- Multi-currency SaaS checkout beyond `MAD`
- Separate white-label company-site frontend app
### CRM Enhancements
- [ ] Repeat customer detection + discount
- [ ] Customer communication log (emails sent)
- [ ] Customer lifetime value stat
## Explicitly Out Of Scope For Current Docs
### Analytics
- [ ] Revenue chart (day/week/month)
- [ ] Fleet utilization rate + underperforming vehicles alert
- [ ] Offer ROI analysis (revenue gained vs discounted)
- [ ] Marketplace impressions per vehicle
- [ ] CSV export
The following old design ideas should not be treated as active features unless code is added later:
### Renter Experience
- [ ] Renter "Continue with Google" auth
- [ ] Favorite vehicles across companies
- [ ] Renter profile completion % indicator
- [ ] Booking modification request (change dates)
- marketing blog
- standalone about/contact page set
- Google renter auth
- automatic custom-domain provisioning
- Zapier/webhook marketplace integrations beyond the current API/webhook handlers
### Public Site Enhancements
- [ ] About page with company description + team photo
- [ ] Google Maps embed for pickup location
- [ ] "Request to book" mode (no immediate payment)
- [ ] WhatsApp floating button
## Notes
---
## Phase 3 — Growth
### Custom Domain (Pro)
- [ ] Company enters `cars.acme.com` in /dashboard/brand
- [ ] Vercel API auto-provisions domain
- [ ] DNS instructions shown
- [ ] SSL automatic
### Marketplace Enhancements
- [ ] City/region pages (`/explore/cities/paris`)
- [ ] Renter 2FA
- [ ] Sponsored listing (pay-per-click for marketplace placement)
- [ ] AI-powered vehicle recommendations based on renter history
### Advanced Offers
- [ ] Flash sales (limited time countdown timer)
- [ ] Loyalty program (repeat renter discounts)
- [ ] Bundle offers (car + extras)
### Integrations
- [ ] Google Calendar sync
- [ ] QuickBooks export
- [ ] Zapier webhooks
### Documents — Rental Contracts & Customer Bills
- [x] `ContractSettings` model: per-company legal name, RC/ICE number, tax ID, custom terms, fuel/deposit/damage policies, additional clauses, signature block, footer notes, invoice tax settings, numbering prefixes
- [x] `contractNumber` + `invoiceNumber` fields on Reservation — assigned once via atomic DB transaction, never reset
- [x] `checkInFuelLevel` / `checkOutFuelLevel` fields for contract accuracy
- [x] **Rental Contract PDF** — branded with company logo + name + primary color, contains: customer details, vehicle data + cover photo, rental dates/locations, financial table (rate × days discount + deposit), all policy clauses, full T&Cs, signature lines (company + customer)
- [x] **Customer Invoice PDF** — branded, contains: company legal info + tax ID, bill-to block, line items table (vehicle, deposit, tax if configured), totals (subtotal, discount, tax, grand total), payment details (AmanPay/PayPal transaction ID, paid date), PAID/PENDING stamp
- [x] **Both documents support EN / FR / AR** — all labels, date formats, and currency format per company locale
- [x] **On-demand generation** — PDFs are NEVER stored. Generated fresh from DB data on every request. No Cloudinary PDF upload.
- [x] **Streaming PDF response**`Content-Type: application/pdf`, `Content-Disposition: inline` (view in browser tab) or `attachment` (download)
- [x] **Print from browser** — user opens PDF in new tab and prints via native browser print dialog
- [x] **Email with PDF attachment** — Resend sends email with both documents as attached PDFs
- [x] **Send contract + invoice together** — single API call generates both and attaches both to one email
- [x] **Custom recipient email** — override the customer's email address when sending (for forwarding)
- [x] **Renter self-download** — renter can download their own contract/invoice from `/renter/dashboard`
- [x] **Settings preview** — preview contract/invoice PDF with real company branding + sample data before finalizing settings
- [x] **Automatic triggers**: invoice sent on payment confirmed; contract sent on check-in completed
### Multi-location per company
- [ ] Multiple pickup/return locations
- [ ] Location-aware vehicle assignment
- [ ] Distance filter on marketplace
---
## Out of Scope
- GPS / telematics
- Driver license OCR / ID verification
- Insurance products
- Fuel management
- Peer-to-peer (this is B2B SaaS + B2C marketplace, not P2P)
- Native mobile app (PWA covers push notifications)
- The database still contains legacy fields such as `Employee.clerkUserId`, but those fields are no longer evidence of active Clerk integration.
- Some renter-facing `/renter/*` pages exist in the marketplace app, but the auth entrypoints they depend on are not active. They are therefore not listed as active product scope here.
+108 -149
View File
@@ -1,182 +1,141 @@
# Staff Management Integration Guide
# Team Management Integration — Current Implementation
## 1. Register the routes in your Express app
This document describes the current team-management integration in the live codebase.
```ts
// apps/api/src/index.ts (or server.ts)
Source of truth:
import express from 'express'
import teamRouter from './routes/team'
import webhookRouter from './routes/webhooks'
- `apps/api/src/modules/team/team.routes.ts`
- `apps/api/src/services/teamService.ts`
- `apps/dashboard/src/hooks/useTeam.ts`
- `apps/dashboard/src/app/(dashboard)/team/page.tsx`
- `apps/dashboard/src/app/reset-password/*`
const app = express()
## Current Model
// Webhook MUST use raw body — register BEFORE express.json()
app.use(
'/webhooks',
express.raw({ type: 'application/json' }),
webhookRouter
)
Team management is local to RentalDriveGo. It no longer depends on Clerk.
// All other routes use JSON
app.use(express.json())
The live flow is:
// Team routes — mounted under /api/v1/team (matches api-routes.md)
app.use('/api/v1/team', teamRouter)
1. an owner invites a staff member from the dashboard
2. the API creates a pending `Employee`
3. the API generates a password-reset token
4. an email is sent with a reset-password link
5. the invited employee sets a password through the dashboard reset-password page
6. the employee can then sign in through the standard employee login flow
There is no webhook step in the active implementation.
## Backend Integration
The team router is already mounted by the main API app in `apps/api/src/app.ts` under:
- `/api/v1/team`
The public webhook placeholder under `/api/v1/webhooks/clerk` is intentionally disabled and should not be used for team onboarding.
### Team endpoints
- `GET /api/v1/team`
- `GET /api/v1/team/stats`
- `POST /api/v1/team/invite`
- `PATCH /api/v1/team/:id/role`
- `POST /api/v1/team/:id/deactivate`
- `POST /api/v1/team/:id/reactivate`
- `DELETE /api/v1/team/:id`
### Auth and authorization
These routes are protected by:
- employee JWT auth
- tenant resolution
- subscription guard
- role checks inside the router and service
Important rules in the current implementation:
- only the `OWNER` can invite, change roles, deactivate/reactivate, or remove team members
- invited team members cannot be created with the `OWNER` role
- the account owner cannot be deactivated or removed through team endpoints
## Invite Email Flow
Invitations are generated in `apps/api/src/services/teamService.ts`.
Current behavior:
- a pending employee row is created
- `passwordResetToken` and `passwordResetExpiresAt` are populated
- the invite email links directly to the dashboard reset-password page
Expected URL shape:
```text
{DASHBOARD_URL}/reset-password?token=...
```
---
## 2. Install the Clerk webhook (Clerk Dashboard → Webhooks)
1. Go to **clerk.com** → your app → **Webhooks****Add endpoint**
2. URL: `https://api.RentalDriveGo.com/webhooks/clerk`
3. Events to subscribe: `user.created`
4. Copy the **Signing Secret** and add to `.env`:
Relevant environment variables:
```env
CLERK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
DASHBOARD_URL=https://your-host/dashboard
NEXT_PUBLIC_DASHBOARD_URL=https://your-host/dashboard
NEXT_PUBLIC_API_URL=https://your-host/api/v1
```
5. Install `svix` for signature verification:
## Dashboard Integration
```bash
npm install svix
```
The team UI lives here:
---
- `apps/dashboard/src/app/(dashboard)/team/page.tsx`
- `apps/dashboard/src/hooks/useTeam.ts`
- `apps/dashboard/src/components/team/InviteModal.tsx`
- `apps/dashboard/src/components/team/EditMemberModal.tsx`
- `apps/dashboard/src/components/team/PermissionsMatrix.tsx`
## 3. Environment variables needed
The dashboard consumes the team API directly through `apiFetch(...)`.
```env
# Clerk
CLERK_SECRET_KEY=sk_live_...
CLERK_WEBHOOK_SECRET=whsec_...
### Dashboard page behavior
# Frontend
NEXT_PUBLIC_API_URL=https://api.RentalDriveGo.com/api/v1
- loads members from `/team`
- loads summary counts from `/team/stats`
- opens invite/edit flows through local modals
- updates local state after invite, role change, deactivate/reactivate, and removal
# Dashboard URL (used in invite redirect)
DASHBOARD_URL=https://app.RentalDriveGo.com
```
## Password Setup / Invite Acceptance
---
The active invite-acceptance mechanism is the dashboard reset-password flow, not a Clerk callback flow.
## 4. Add the page to the Next.js dashboard app
Relevant pages:
```
apps/dashboard/
├── app/
│ └── dashboard/
│ └── team/
│ └── page.tsx ← copy frontend/pages/team.tsx here
├── components/
│ └── team/
│ ├── InviteModal.tsx ← copy from frontend/components/team/
│ ├── EditMemberModal.tsx
│ └── PermissionsMatrix.tsx
└── hooks/
└── useTeam.ts ← copy from frontend/hooks/
```
- `apps/dashboard/src/app/reset-password/page.tsx`
- `apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx`
Add the route to your dashboard sidebar navigation:
This means:
```tsx
// components/Sidebar.tsx — add to nav items
{ href: '/dashboard/team', label: 'Team', icon: UsersIcon }
```
- there is no `__clerk_ticket`
- there is no Clerk redirect callback
- there is no employee activation webhook
---
Invite completion is simply password setup using the token created by the API.
## 5. requireRole middleware placement
## Request Typing
The `requireRole` middleware is already applied inside the team router.
Only expose it if you need it elsewhere (e.g., `PATCH /pricing-rules``requireRole('MANAGER')`):
The request object is extended in the API so team routes can rely on:
```ts
import { requireRole } from './middleware/requireRole'
- `req.employee`
- `req.company`
- `req.companyId`
// Example: license approval endpoint (OWNER or MANAGER only)
router.post(
'/customers/:id/approve-license',
requireRole('MANAGER'),
approveLicenseHandler
)
```
Those values are attached by the company auth and tenant middleware before team handlers run.
---
## What Was Removed
## 6. Invite acceptance flow (frontend)
These older integration assumptions are no longer valid:
When a user clicks the magic link in their invitation email, Clerk redirects them to:
- Clerk webhooks
- Clerk invitation acceptance
- Clerk `user.created` activation flow
- `/webhooks/clerk` as a required part of team onboarding
- `@clerk/nextjs` integration in the dashboard team flow
```
https://app.RentalDriveGo.com/onboarding/accept-invite?__clerk_ticket=...
```
Create this page in your Next.js app:
```tsx
// app/onboarding/accept-invite/page.tsx
'use client'
import { useEffect } from 'react'
import { useClerk } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'
export default function AcceptInvite() {
const { handleRedirectCallback } = useClerk()
const router = useRouter()
useEffect(() => {
handleRedirectCallback({}).then(() => {
// Clerk webhook fires user.created → activates Employee record in DB
// Redirect to dashboard after a short delay
setTimeout(() => router.replace('/dashboard'), 1000)
})
}, [])
return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-sm text-zinc-500">Setting up your account</p>
</div>
)
}
```
---
## 7. Prisma type extensions (optional, for `req.employee`)
To get TypeScript to recognize `req.companyId` and `req.employee` on Express requests,
add a type declaration file:
```ts
// types/express.d.ts
import { Employee, Company } from '@prisma/client'
declare global {
namespace Express {
interface Request {
companyId: string
company: Company
employee: Employee
}
}
}
```
---
## 8. Summary of files delivered
| File | Purpose |
|------|---------|
| `backend/services/teamService.ts` | All business logic — invite, update role, deactivate, remove, Clerk webhook handler |
| `backend/routes/team.ts` | Express router — all `/team` endpoints |
| `backend/routes/webhooks.ts` | Clerk webhook listener — activates employee on invite acceptance |
| `backend/middleware/requireRole.ts` | Role-rank guard middleware — reusable across all routers |
| `frontend/hooks/useTeam.ts` | React data hook — fetch, invite, updateRole, deactivate, reactivate, remove |
| `frontend/pages/team.tsx` | Full `/dashboard/team` Next.js page |
| `frontend/components/team/InviteModal.tsx` | Invite new member modal with role picker |
| `frontend/components/team/EditMemberModal.tsx` | Edit role, deactivate, reactivate, remove modal with confirmation step |
| `frontend/components/team/PermissionsMatrix.tsx` | Static role × feature permissions table |
If this functionality returns in the future, it should be documented as a new integration rather than assumed from this file.
+179 -316
View File
@@ -1,316 +1,179 @@
# Pages Specification — RentalDriveGo
---
## LAYER 1A — Marketing Site (RentalDriveGo.com)
### `/` — Home
Sections:
1. Hero — "Run your rental car business like a pro" + CTA
2. Two-column value: "For rental companies" / "For renters"
3. Social proof bar
4. Pain points (for companies)
5. Features grid — fleet, photos, offers, site, CRM, analytics
6. **Marketplace callout** — "Your vehicle photos appear on the RentalDriveGo global marketplace automatically"
7. How it works — 3 steps
8. Pricing teaser (AmanPay / PayPal accepted badges shown)
9. Testimonials
10. Final CTA
### `/pricing` — Pricing
- Three plan cards (Starter / Growth / Pro) with monthly/annual toggle
- **Payment method icons:** AmanPay logo + PayPal logo
- "Pay securely with AmanPay (cards, cash, e-wallet) or PayPal"
- Supported currencies: MAD, USD, EUR
- FAQ section
### `/features`, `/about`, `/contact`, `/blog` — standard
---
## LAYER 1B — Global Marketplace (RentalDriveGo.com/explore)
> **Design principle:** This is a DISCOVERY layer. No booking form, no payment form.
> Every CTA leads to the company's own site.
### `/explore` — Marketplace Home
Sections:
1. **Search Hero**
- Headline: "Find your next rental — from trusted local companies"
- Search bar: [City / Location] [Pick-up date] [Return date] [Search]
- Popular cities quick links
2. **Featured Offers Carousel**
- "🔥 Current Deals" header
- Horizontal scroll of offer cards:
- Vehicle photo (from company upload)
- Company logo + name
- Offer title + discount badge (e.g. "20% OFF")
- Valid until date
- **"View Deal →"** → links to `/explore/[slug]#offers`
3. **All Available Vehicles**
- Grid of vehicle cards showing ALL published vehicles from ALL companies:
- **Vehicle photo** (first photo from `Vehicle.photos[]`)
- Make, model, year, category badge
- Company logo + name (clickable)
- Daily rate + currency
- Active offer badge (if applicable, e.g. "20% OFF until July 31")
- Star rating from reviews
- **"Book at [Company Name] →"** — on click: redirect to `{slug}.RentalDriveGo.com/book?vehicleId=X&from=Y&to=Z&ref=marketplace`
- Filter panel: category, transmission, fuel type, price range, city, offer only
- Sort: price (low-high, high-low), rating, newest
4. **Browse by Category**
- Economy · Compact · SUV · Luxury · Van · Truck · Electric
5. **Browse by Company**
- Company cards: logo, name, city, rating, vehicle count, active offer badge
- **"View Fleet →"** → `/explore/[slug]`
6. **How It Works** (for first-time visitors)
- 3 steps: 1. Browse vehicles → 2. Click to go to company site → 3. Book & pay directly
### `/explore?...` — Search Results
- Same vehicle card grid, filtered by search params
- No booking happens here — every card links to company site with pre-filled params
- Availability indicator on each card ("3 available for your dates" vs "Check availability")
### `/explore/[slug]` — Company Marketplace Profile
This is the company's listing on the RentalDriveGo marketplace (RentalDriveGo-styled, not their branded site).
Sections:
1. Company header — logo, display name, city/country, rating, phone, "Save" heart
2. **Current Offers section** — offer cards with discount badges
3. **Available Vehicles grid** — all published vehicles with photos
- Each card: photo(s), specs, daily rate, offer badge, **"Book at [Company Name] →"** redirect button
4. **Reviews** — renter reviews with star ratings + company replies
5. Contact info — email, phone, WhatsApp link (opens `wa.me/...`)
6. "Visit their website →" link to `{slug}.RentalDriveGo.com`
### `/explore/[slug]/vehicles/[id]` — Vehicle Detail (Marketplace)
- **Photo gallery** (all `Vehicle.photos[]` uploaded by company)
- Full specs: make, model, year, color, category, seats, transmission, fuel, features
- Daily rate + active offer badge + calculated total for selected dates
- Availability calendar (shows blocked dates — unavailable for booking)
- Company info sidebar: logo, name, rating, phone, WhatsApp
- **BIG CTA: "Book at [Company Name] →"**
- Redirects to: `{slug}.RentalDriveGo.com/book?vehicleId=[id]&from=[startDate]&to=[endDate]&ref=marketplace`
- Opens in same tab
### No `/explore/[slug]/book` page
There is no booking flow on the marketplace. Booking happens entirely on the company's site.
---
## LAYER 1B — Renter Account
### `/renter/signup`, `/renter/login`
Standard auth pages. Login with Google optional.
### `/renter/dashboard` — My Rentals
- Cross-company reservations list (tabs: Upcoming / Active / Past / Cancelled)
- Each card: company logo, vehicle photo, dates, status, "View" → links to company site booking detail
### `/renter/notifications` — Notification Inbox
- All in-app notifications (new booking, reminders, offers, etc.)
### `/renter/saved-companies`
- Saved companies with "New Offer" badges if they have active offers
- "View Fleet →" links to marketplace profile
### `/renter/profile`
- Personal info, locale, currency preference
- Notification preferences matrix (Email / SMS / WhatsApp / Push per event type)
---
## LAYER 2 — Company Dashboard (app.RentalDriveGo.com)
### Auth
#### `/signup` — Signup Wizard
1. Account (name, email, password)
2. Company (name, country, phone)
3. Plan selection (Starter / Growth / Pro + Monthly / Annual + currency: MAD / USD / EUR)
4. Payment:
- **Option A: Pay with AmanPay** → shows AmanPay widget (card, cash, e-wallet)
- **Option B: Pay with PayPal** → redirect to PayPal, return here
- AmanPay and PayPal logos shown prominently
- "Supported: Visa, Mastercard, CMI, cash at 3000+ points, PayPal"
#### `/onboarding` — 4-Step Wizard
1. Company profile (address, phone)
2. Branding (logo, color, subdomain)
3. **Add first vehicle + upload photos** — emphasize: "Photos appear on the global marketplace"
4. Launch: "Your site is live at [slug].RentalDriveGo.com" + "Your vehicles are now visible on RentalDriveGo.com/explore"
### Dashboard Pages
#### `/dashboard` — Home
- Subscription status banner (trial / past-due / renewal due)
- KPI cards + active offers widget + booking source breakdown
#### `/dashboard/fleet` — Fleet Management
- Vehicle list with status + published/unpublished badge
- **Photos column** — thumbnail of first photo, "0 photos" warning badge if none uploaded
- Vehicle detail:
- **Photos section at top** — drag-drop upload area
- "Photos appear on marketplace" callout
- Upload multiple photos (Cloudinary)
- Reorder photos (first = cover image)
- Delete photos
- Specs + maintenance + reservation history
#### `/dashboard/offers` — Offers
- Create / manage promotional offers
- `isPublic` toggle: "Show on global marketplace"
- `isFeatured` toggle: "Feature on explore homepage" (GROWTH+ plan badge)
#### `/dashboard/reservations`
- Source filter includes MARKETPLACE (bookings from renter redirected from explore)
#### `/dashboard/billing` — Billing & Plan
- Current plan + status
- **"Renew Subscription"** button (manual renewal — AmanPay or PayPal)
- Invoice history (with AmanPay transaction IDs or PayPal capture IDs)
- Payment methods accepted: AmanPay badge + PayPal badge
#### `/dashboard/settings` — Settings
- **Payments tab** ← UPDATED:
- "Your rental payment methods" section (what renters use to pay YOU)
- AmanPay section: Merchant ID input + Secret Key input + "Test connection" button
- PayPal section: PayPal business email input + "Connect PayPal" button
- Active payment methods shown with green/grey status dots
- "If no payment method configured, renters can only make reservation requests (pay on pickup)"
#### `/dashboard/notifications` — Notification Center
- Inbox tab (in-app notification log)
- Preferences tab (Email / SMS / In-App / Push matrix per event type)
---
## LAYER 3 — Company Public Site ({slug}.RentalDriveGo.com)
> All booking and payment happens here. Company's brand. Company's AmanPay/PayPal.
### `/` — Home
- Company header: logo + name + nav
- Hero: tagline + hero image + "Browse Our Vehicles" CTA
- **Active Offers section** — offer banners with countdown (valid until X days)
- Vehicle grid (all published vehicles with photos)
- Footer: contact + social + "Powered by RentalDriveGo" (Pro removes)
### `/vehicles/[id]` — Vehicle Detail
- Photo gallery (all company-uploaded photos, full lightbox)
- Specs + features list
- Daily rate + active offer badge
- Availability calendar
- "Book Now" CTA
### `/offers` — All Offers
- All active offers with banner images, descriptions, discount badges, valid until
- "Book with this offer" → pre-fills booking form with offer applied
### `/book` — Booking Flow
The booking form accepts a `?vehicleId=X&from=Y&to=Z&ref=marketplace&offerId=O` querystring.
When `ref=marketplace` is present, the reservation is tagged as source=MARKETPLACE.
**Step 1 — Vehicle & Dates**
- Vehicle card (pre-filled if from marketplace redirect or offer click)
- Date range picker
- Active offer applied (if applicable) + promo code input
- Price breakdown: base rate discount = total
**Step 2 — Your Details**
- First name, last name, email, phone (required)
- Driver license (optional, company-configurable)
- Renter account login prompt (optional — "Save this booking to your RentalDriveGo account")
**Step 3 — Payment**
- Order summary card
- **Payment method selection:**
- **AmanPay tab** (if company has AmanPay configured):
- AmanPay widget loads inline (card national, card international, cash)
- Powered by AmanPay + M2T + PCI-DSS badge
- **PayPal tab** (if company has PayPal configured):
- PayPal buttons component
- If only one method: show directly without tabs
- If none: "Contact us to reserve" + company phone/WhatsApp
- "Confirm & Pay" / "Confirm Reservation"
### `/book/confirmation` — Booking Confirmed
- Booking reference number
- Full summary
- Company contact info
- "Download receipt" (PDF, optional)
- "Track this booking" → link to renter account
- Notifications already sent automatically
### `/contact` — Contact
- Contact form → email via Resend to company
---
## Admin Panel (admin.RentalDriveGo.com — internal)
> Separate app from the company dashboard. Own auth (JWT + TOTP 2FA). Only RentalDriveGo staff.
### Auth
- `/admin/login` — email + password + optional 2FA code field (shown if 2FA enabled)
- `/admin/login/2fa` — TOTP entry step
### `/admin` — Overview Dashboard
- Platform KPI cards: Total Companies, Active, Trialing, Suspended, MRR, Churn
- Recent signups feed
- Quick links to all sections
### `/admin/companies` — Company List
- Search (name, email, slug), filter (plan, status, country), sort (name, MRR, created)
- Table: Logo, Name, Plan badge, Status badge, Vehicle count, Employee count, MRR, Actions
- Actions: View | Edit | Suspend/Reactivate | Delete
- Bulk: Suspend selected, Export CSV
### `/admin/companies/:id` — Company Detail
- Tabs: Overview | Staff | Subscription | Activity | Settings
- **Overview**: brand info, legal info, payment accounts status
- **Staff tab**:
- Employee table: name, email, role badge, status (Active/Deactivated), last login
- Actions per employee: Edit | Change Role | Deactivate/Reactivate | Reset Password | Impersonate
- "Add Employee" button → modal: name, email, role → sends Clerk invitation
- Role dropdown: OWNER / MANAGER / AGENT with permission matrix tooltip
- **Subscription tab**:
- Current plan, status, billing period, last payment, next renewal
- Invoice history table
- Override controls: force status, change plan, mark invoice paid, add free period
- **Activity tab**: recent reservations, offers, audit events for this company
### `/admin/renters` — Renter Accounts
- Search by name, email, phone
- Filter: blocked / unblocked
- Table: name, email, phone, joined, reservation count, blocked badge
- Actions: View | Block/Unblock
### `/admin/metrics` — Platform Analytics
- MRR trend chart
- New companies per week
- Subscription breakdown by plan (donut chart)
- Marketplace: impressions, bookings, top companies
- Notification delivery rates per channel
### `/admin/users` — Admin User Management (SUPER_ADMIN only)
- List of admin accounts: name, email, role, 2FA status, last login
- Create admin: name, email, role → password emailed
- Edit role | Deactivate | View audit trail
### `/admin/audit-logs` — Audit Log
- Searchable, filterable (by admin, action, company, date range)
- Columns: Timestamp, Admin, Action, Target, Before/After (expandable), IP, Note
- Export CSV button
### `/admin/settings` — Admin Panel Settings
- Admin account: update own name, email, password
- 2FA: setup QR code, verify, enable/disable
# Pages and Route Inventory — Current Apps
This document lists the pages that are actually present in the current frontend apps.
Source of truth:
- `apps/marketplace/src/app`
- `apps/dashboard/src/app`
- `apps/admin/src/app`
## Marketplace App
App:
- `apps/marketplace`
### Public routes
- `/`
- `/pricing`
- `/features`
- `/explore`
- `/explore/[slug]`
- `/review`
- `/company-workspace`
- `/platform-operations`
- `/sign-in`
- `/app-privacy-en`
- `/app-privacy-fr`
- `/app-privacy-ar`
- `/app-tc-en`
- `/app-tc-fr`
- `/app-tc-ar`
- `/footer/[slug]`
### Active route behavior
- `/` is the public marketing home.
- `/pricing` consumes platform pricing from `/site/platform/pricing`.
- `/explore` is the public marketplace search/discovery page.
- `/explore/[slug]` is the company marketplace profile page.
- `/review` is the review submission page driven by a reservation review token.
### Not present in the current marketplace app
- `/about`
- `/contact`
- `/blog`
- `/explore/[slug]/vehicles/[id]`
- a full company public-site booking frontend under its own app
### Renter routes present in code but not active product entrypoints
These routes exist in the marketplace app:
- `/renter/dashboard`
- `/renter/notifications`
- `/renter/profile`
- `/renter/saved-companies`
- `/renter/sign-in`
- `/renter/sign-up`
However:
- `/renter/sign-in` redirects to the dashboard sign-in page
- `/renter/sign-up` redirects to the dashboard sign-in page
- API renter signup/login endpoints are disabled
Because of that, the renter self-service area should be treated as dormant or incomplete, not as an active supported user flow.
## Dashboard App
App:
- `apps/dashboard`
Base path:
- `/dashboard`
### Public/auth routes
- `/dashboard/sign-in`
- `/dashboard/sign-up`
- `/dashboard/forgot-password`
- `/dashboard/reset-password`
- `/dashboard/onboarding`
- `/dashboard/onboarding/accept-invite`
### Private company workspace routes
- `/dashboard`
- `/dashboard/fleet`
- `/dashboard/fleet/[id]`
- `/dashboard/reservations`
- `/dashboard/reservations/new`
- `/dashboard/reservations/[id]`
- `/dashboard/online-reservations`
- `/dashboard/customers`
- `/dashboard/offers`
- `/dashboard/team`
- `/dashboard/reports`
- `/dashboard/subscription`
- `/dashboard/billing`
- `/dashboard/contracts`
- `/dashboard/contracts/[id]`
- `/dashboard/reviews`
- `/dashboard/complaints`
- `/dashboard/notifications`
- `/dashboard/settings`
### Route responsibilities
- `/dashboard` shows KPIs and booking-source analytics.
- `/dashboard/fleet*` manages vehicles, maintenance, and calendar blocks.
- `/dashboard/reservations*` manages the booking lifecycle and inspection workflows.
- `/dashboard/online-reservations` handles public/marketplace booking intake.
- `/dashboard/customers` is the company CRM view.
- `/dashboard/offers` manages promotions.
- `/dashboard/team` manages employees.
- `/dashboard/reports` shows analytics/reporting views.
- `/dashboard/subscription` manages SaaS plan state and invoices.
- `/dashboard/billing` manages rental payment collection and balances.
- `/dashboard/contracts*` opens rental contracts.
- `/dashboard/reviews` and `/dashboard/complaints` handle post-rental follow-up.
- `/dashboard/notifications` shows notification inbox/history/preferences.
- `/dashboard/settings` manages brand, domains, policies, insurance, pricing, and accounting settings.
## Admin App
App:
- `apps/admin`
### Public routes
- `/`
- `/login`
- `/forgot-password`
- `/reset-password`
- `/auth-redirect`
### Private admin dashboard routes
- `/dashboard`
- `/dashboard/companies`
- `/dashboard/companies/[id]`
- `/dashboard/renters`
- `/dashboard/admin-users`
- `/dashboard/audit-logs`
- `/dashboard/billing`
- `/dashboard/pricing`
- `/dashboard/notifications`
- `/dashboard/site-config`
- `/dashboard/containers`
### Route responsibilities
- `/dashboard` is the admin overview.
- `/dashboard/companies*` manages tenant companies.
- `/dashboard/renters` inspects and blocks/unblocks renter accounts.
- `/dashboard/admin-users` manages admin operators.
- `/dashboard/audit-logs` reviews admin activity.
- `/dashboard/billing` manages platform billing operations.
- `/dashboard/pricing` edits platform pricing, features, and promotions.
- `/dashboard/notifications` inspects platform notifications.
- `/dashboard/site-config` edits marketplace/site configuration content.
## Deliberately Not Listed
This document excludes:
- API endpoints
- dormant backend-only concepts that do not have an active frontend route
- design-spec routes that are not present in the current app trees
For backend route inventory, use:
- `docs/project-design/api-routes.md`
+329 -431
View File
@@ -1,498 +1,396 @@
# API Routes Inventory — RentalDriveGo (Complete)
# API Architecture and Route Design
Base URL: `https://api.RentalDriveGo.com/api/v1`
This document explains how the API is structured today, how requests move through the stack, and what each route group is responsible for.
## Middleware Legend
- 🔒 `requireCompanyAuth` — valid Clerk JWT (Employee)
- 🏢 `requireTenant` — attaches `req.company` + `req.companyId` + `req.employee`
- 💳 `requireSubscription` — blocks SUSPENDED/PENDING companies
- 🎫 `requireRenterAuth` — valid Renter JWT
- 🔑 `requireApiKey` — company public API key in `x-api-key` header
- 👑 `requireAdmin` — RentalDriveGo super-admin role
Source of truth for the runtime wiring:
> **Tenant isolation rule:** Every 🏢 route must use `where: { companyId: req.companyId }` in every query.
- `apps/api/src/app.ts`
- `apps/api/src/modules/*`
- `apps/api/src/middleware/*`
- `apps/api/src/swagger/openapi.ts`
---
## Runtime Overview
## Auth — Company Employees
The API is an Express application mounted under `/api/v1` with a few non-versioned utility endpoints:
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/auth/company/signup` | — | Create Clerk user + Company + AmanPay/PayPal customer |
| POST | `/auth/company/verify-email` | — | Verify email token |
- `GET /health` returns process health.
- `GET /docs` serves Swagger UI.
- `GET /api/v1/openapi.json` returns the generated OpenAPI document.
- `/storage/*` serves uploaded assets such as logos, hero images, vehicle photos, and reservation/customer documents.
*(Clerk handles login/logout/session refresh on the frontend)*
The app boot sequence in `apps/api/src/app.ts` is intentionally ordered:
---
1. CORS is applied first.
2. storage guards are applied before static serving so private customer license files are never anonymously retrievable.
3. Swagger UI is mounted before Helmet so the UI assets are not blocked by CSP.
4. webhook routes that require raw payload handling are mounted before `express.json()`.
5. Helmet, request logging, JSON parsing, and the module routers are mounted.
6. the centralized error middleware converts validation, Prisma, and application errors into consistent JSON responses.
## Auth — Renters
## Request Lifecycle
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/auth/renter/signup` | — | Create renter account |
| POST | `/auth/renter/login` | — | Email + password → JWT |
| POST | `/auth/renter/logout` | 🎫 | Invalidate token |
| POST | `/auth/renter/verify-email` | — | Email verification token |
| POST | `/auth/renter/forgot-password` | — | Send reset email |
| POST | `/auth/renter/reset-password` | — | Reset with token |
| GET | `/auth/renter/me` | 🎫 | Get renter profile |
| PATCH | `/auth/renter/me` | 🎫 | Update profile (name, phone, locale, currency) |
| POST | `/auth/renter/me/fcm-token` | 🎫 | Register FCM push token |
Most internal company routes follow the same pattern:
---
1. Express router receives the request.
2. Zod schemas validate `req.params`, `req.query`, and `req.body` through `parseParams`, `parseQuery`, and `parseBody`.
3. auth middleware resolves the caller identity.
4. tenant middleware resolves the company record.
5. subscription middleware blocks suspended or pending companies where required.
6. role middleware enforces employee or admin permissions.
7. the route handler calls a service function.
8. the service applies business rules and calls a repo or Prisma directly.
9. a presenter may normalize the payload for frontend consumption.
10. `ok()` or `created()` wraps successful responses in `{ "data": ... }`.
## Subscriptions (AmanPay/PayPal subscription — RentalDriveGo collects)
Common exceptions:
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/subscriptions/plans` | — | List plans + prices (public) |
| GET | `/subscriptions/me` | 🔒🏢 | Current subscription |
| POST | `/subscriptions/checkout` | 🔒🏢 | Create AmanPay/PayPal Checkout session |
| POST | `/subscriptions/portal` | 🔒🏢 | Open AmanPay/PayPal Customer Portal |
| GET | `/subscriptions/invoices` | 🔒🏢💳 | Invoice history |
| POST | `/subscriptions/webhook` | — (sig) | AmanPay/PayPal subscription webhook |
- `GET /health` and `GET /api/v1/docs` return raw JSON, not the `{ data }` envelope.
- webhook endpoints return minimal receipt payloads.
- some public endpoints return graceful fallback responses if the database is unavailable.
---
## Security and Access Model
## Companies
There are four main access patterns.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/companies/me` | 🔒🏢 | Company profile |
| PATCH | `/companies/me` | 🔒🏢💳 | Update profile |
| GET | `/companies/me/brand` | 🔒🏢 | Brand settings |
| PATCH | `/companies/me/brand` | 🔒🏢💳 | Update branding |
| POST | `/companies/me/brand/logo` | 🔒🏢💳 | Upload logo |
| POST | `/companies/me/brand/hero` | 🔒🏢💳 | Upload hero image |
| POST | `/companies/me/brand/subdomain/check` | 🔒🏢💳 | Check subdomain availability |
| POST | `/companies/me/brand/custom-domain` | 🔒🏢💳 | Add custom domain (Pro) |
| GET | `/companies/me/brand/custom-domain/status` | 🔒🏢💳 | Domain verification status |
| DELETE | `/companies/me/brand/custom-domain` | 🔒🏢💳 | Remove custom domain |
| GET | `/companies/me/api-key` | 🔒🏢💳 | Get public API key |
| POST | `/companies/me/api-key/regenerate` | 🔒🏢💳 | Regenerate API key |
| GET | `/companies/me/stripe-connect` | 🔒🏢💳 | Connect status |
| POST | `/companies/me/stripe-connect` | 🔒🏢💳 | Start Connect OAuth |
| POST | `/companies/me/stripe-connect/webhook` | — (sig) | Connect events |
### Employee JWT
---
`requireCompanyAuth` validates a Bearer token with `type === "employee"`, loads the employee, and attaches:
## Team
- `req.employee`
- `req.company`
- `req.companyId`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/team` | 🔒🏢💳 | List employees |
| POST | `/team/invite` | 🔒🏢💳 | Invite by email |
| PATCH | `/team/:id/role` | 🔒🏢💳 | Change role |
| DELETE | `/team/:id` | 🔒🏢💳 | Remove employee |
This is the default for dashboard/company management routes.
---
### Tenant Resolution
## Vehicles (Company fleet — strictly isolated)
`requireTenant` reloads the company from `req.companyId` and guarantees the tenant context exists. Company-scoped modules then query by `companyId`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/vehicles` | 🔒🏢💳 | List `?status=&category=&published=` |
| POST | `/vehicles` | 🔒🏢💳 | Add vehicle (checks plan limit) |
| GET | `/vehicles/:id` | 🔒🏢💳 | Detail (validates companyId) |
| PATCH | `/vehicles/:id` | 🔒🏢💳 | Update |
| DELETE | `/vehicles/:id` | 🔒🏢💳 | Archive |
| POST | `/vehicles/:id/photos` | 🔒🏢💳 | Upload photos (Cloudinary) |
| DELETE | `/vehicles/:id/photos/:idx` | 🔒🏢💳 | Remove photo |
| PATCH | `/vehicles/:id/publish` | 🔒🏢💳 | Toggle published |
| GET | `/vehicles/:id/availability` | 🔒🏢💳 | `?startDate=&endDate=` |
| GET | `/vehicles/:id/reservations` | 🔒🏢💳 | Reservation history |
| GET | `/vehicles/:id/maintenance` | 🔒🏢💳 | Maintenance log |
| POST | `/vehicles/:id/maintenance` | 🔒🏢💳 | Add maintenance entry |
### Subscription Guard
---
`requireSubscription` blocks company routes when the company status is `PENDING` or `SUSPENDED`. Public site and marketplace routes are intentionally not behind this guard.
## Offers (Company promotional offers)
### Role-Based Access Control
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/offers` | 🔒🏢💳 | List all offers `?active=&public=` |
| POST | `/offers` | 🔒🏢💳 | Create offer |
| GET | `/offers/:id` | 🔒🏢💳 | Offer detail |
| PATCH | `/offers/:id` | 🔒🏢💳 | Update offer |
| DELETE | `/offers/:id` | 🔒🏢💳 | Delete offer |
| POST | `/offers/:id/activate` | 🔒🏢💳 | Activate offer |
| POST | `/offers/:id/deactivate` | 🔒🏢💳 | Deactivate offer |
| POST | `/offers/:id/feature` | 🔒🏢💳 | Toggle featured (plan-gated) |
| GET | `/offers/:id/stats` | 🔒🏢💳 | Redemption stats, revenue impact |
Employee roles are hierarchical:
---
- `OWNER`
- `MANAGER`
- `AGENT`
## Reservations (Company view — includes all sources)
Admin roles are hierarchical:
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/reservations` | 🔒🏢💳 | List `?status=&vehicleId=&source=&startDate=&endDate=` |
| POST | `/reservations` | 🔒🏢💳 | Create (dashboard) |
| GET | `/reservations/:id` | 🔒🏢💳 | Detail |
| PATCH | `/reservations/:id` | 🔒🏢💳 | Update |
| POST | `/reservations/:id/confirm` | 🔒🏢💳 | Confirm + charge |
| POST | `/reservations/:id/checkin` | 🔒🏢💳 | Check in `{ mileage }` |
| POST | `/reservations/:id/checkout` | 🔒🏢💳 | Check out + complete `{ mileage }` |
| POST | `/reservations/:id/cancel` | 🔒🏢💳 | Cancel `{ reason }` |
| POST | `/reservations/:id/no-show` | 🔒🏢💳 | Mark no-show |
| POST | `/reservations/:id/reply-review` | 🔒🏢💳 | Reply to renter review |
- `SUPER_ADMIN`
- `ADMIN`
- `SUPPORT`
- `FINANCE`
- `VIEWER`
---
### Renter JWT
## Customers (Company CRM)
`requireRenterAuth` validates a Bearer token with `type === "renter"` and attaches `req.renterId`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/customers` | 🔒🏢💳 | List `?q=&flagged=` |
| POST | `/customers` | 🔒🏢💳 | Create |
| GET | `/customers/:id` | 🔒🏢💳 | Detail + reservation history |
| PATCH | `/customers/:id` | 🔒🏢💳 | Update |
| POST | `/customers/:id/flag` | 🔒🏢💳 | Flag `{ reason }` |
| DELETE | `/customers/:id/flag` | 🔒🏢💳 | Remove flag |
`optionalRenterAuth` is used on marketplace routes so public reads still work without a token.
---
### API Key
## Payments (AmanPay/PayPal rental — rental revenue)
`requireApiKey` accepts a company public API key in `x-api-key`. This is the low-friction integration path for public company-site style calls.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/payments` | 🔒🏢💳 | List payments |
| GET | `/payments/:id` | 🔒🏢💳 | Payment detail |
| POST | `/payments/:id/refund` | 🔒🏢💳 | Issue refund |
| POST | `/payments/webhook` | — (sig) | AmanPay/PayPal rental webhook |
## Implementation Pattern
---
The API codebase is organized per module. The most common pattern is:
## Analytics
- `*.routes.ts`: route definitions and middleware composition
- `*.schemas.ts`: Zod input validation
- `*.service.ts`: business rules and orchestration
- `*.repo.ts`: Prisma access helpers
- `*.presenter.ts`: response shaping
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/analytics/summary` | 🔒🏢💳 | KPIs `?period=7d|30d|90d` |
| GET | `/analytics/revenue` | 🔒🏢💳 | Revenue time series |
| GET | `/analytics/vehicles` | 🔒🏢💳 | Per-vehicle performance |
| GET | `/analytics/utilization` | 🔒🏢💳 | Fleet utilization |
| GET | `/analytics/offers` | 🔒🏢💳 | Offer performance + redemptions |
| GET | `/analytics/sources` | 🔒🏢💳 | Booking source breakdown |
The `vehicles` module is representative:
---
- `vehicle.routes.ts` mounts auth, tenant, and subscription guards once for the router.
- route handlers validate input and call service functions.
- `vehicle.service.ts` contains business rules such as publish-state normalization and availability calculation.
- `vehicle.repo.ts` owns the Prisma queries and enforces tenant filters in reads.
- `vehicle.presenter.ts` shapes the response returned to clients.
## Notifications — Company
Some modules split specialized workflows into additional services instead of one large file. The reservations module is the clearest example:
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/notifications/company` | 🔒🏢💳 | In-app notification log `?unread=true` |
| POST | `/notifications/company/:id/read` | 🔒🏢💳 | Mark as read |
| POST | `/notifications/company/read-all` | 🔒🏢💳 | Mark all read |
| GET | `/notifications/company/preferences` | 🔒🏢💳 | Get notification preferences |
| PATCH | `/notifications/company/preferences` | 🔒🏢💳 | Update preferences per type+channel |
- `reservation.service.ts` handles CRUD and list behavior
- `reservation.lifecycle.service.ts` handles confirm/checkin/checkout/close/extend/cancel transitions
- `reservation.inspection.service.ts` handles pickup/dropoff inspections
- `reservation.additional-driver.service.ts` handles approval workflows
- `reservation.photo.service.ts` handles pickup/dropoff photo uploads
- `reservation.document.service.ts` generates contract and billing payloads
---
## Route Groups
## Notifications — Renter
The table below describes the current route groups mounted in `app.ts`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/notifications/renter` | 🎫 | Renter in-app notifications |
| POST | `/notifications/renter/:id/read` | 🎫 | Mark as read |
| POST | `/notifications/renter/read-all` | 🎫 | Mark all read |
| GET | `/notifications/renter/preferences` | 🎫 | Get preferences |
| PATCH | `/notifications/renter/preferences` | 🎫 | Update preferences |
| Base path | Access | Purpose | Notes |
| --- | --- | --- | --- |
| `/health` | Public | Process health | Non-versioned utility endpoint |
| `/docs` | Public | Swagger UI | Backed by `openapi.ts` |
| `/api/v1/openapi.json` | Public | OpenAPI JSON | Generated from Zod schemas and manual path entries |
| `/api/v1/auth/company` | Public | Company signup | `complete-signup` and `verify-email` now return disabled errors |
| `/api/v1/auth/employee` | Public plus employee JWT | Employee login, password reset, profile, language | Dashboard/staff authentication path |
| `/api/v1/auth/renter` | Renter JWT for profile routes | Renter profile and push token | `signup` and `login` are currently disabled in this codebase |
| `/api/v1/admin` | Admin JWT | Platform operations | Includes company admin, billing, subscriptions, pricing, audit, and admin-user management |
| `/api/v1/marketplace` | Public, optional renter JWT | Marketplace discovery and reservation intake | Designed for discovery and lead capture across companies |
| `/api/v1/site` | Public | White-label company site APIs | Drives each company-branded booking site |
| `/api/v1/subscriptions` | Mixed | Plan listing, webhooks, subscription lifecycle | Public, webhook, and authenticated sub-routers share the same prefix |
| `/api/v1/vehicles` | Employee JWT + tenant + subscription | Fleet management | Includes photos, status, calendar blocks, maintenance, and availability |
| `/api/v1/reservations` | Employee JWT + tenant + subscription | Reservation operations | Central booking lifecycle for internal staff |
| `/api/v1/team` | Employee JWT + tenant + subscription | Employee/team management | Owner-controlled invite, role, activation, removal |
| `/api/v1/customers` | Employee JWT + tenant + subscription | Company CRM and license handling | Includes protected license-image retrieval and approval flows |
| `/api/v1/offers` | Employee JWT + tenant + subscription | Promotional offers | Company marketing and promo codes |
| `/api/v1/analytics` | Employee JWT + tenant + subscription | Dashboard metrics and reports | Includes summary, dashboard, source, and report routes |
| `/api/v1/notifications` | Employee or renter JWT depending on route | Notification inbox and preferences | Separate company and renter surfaces |
| `/api/v1/companies` | Employee JWT + tenant + subscription | Company profile and settings | Brand, domains, contract settings, insurance policies, pricing rules, accounting, API key |
| `/api/v1/payments` | Mixed | Rental payment webhooks and company payment actions | Webhooks are public; operational routes are company-authenticated |
| `/api/v1/reviews` | Employee JWT + tenant + subscription | Review moderation | Company-side review visibility and replies |
| `/api/v1/complaints` | Employee JWT + tenant + subscription | Complaint handling | Internal complaint tracking |
| `/api/v1/webhooks` | Public | External webhook receivers | Currently includes `/clerk` placeholder |
---
## Functional Breakdown by Module
## Renter — Marketplace Actions
### Company and employee onboarding
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/renter/reservations` | 🎫 | All reservations (cross-company) |
| GET | `/renter/reservations/:id` | 🎫 | Reservation detail |
| POST | `/renter/reservations/:id/cancel` | 🎫 | Cancel `{ reason }` |
| GET | `/renter/saved-companies` | 🎫 | Saved companies |
| POST | `/renter/saved-companies/:slug` | 🎫 | Save company |
| DELETE | `/renter/saved-companies/:slug` | 🎫 | Unsave company |
| POST | `/renter/reviews` | 🎫 | Submit review (completed reservation only) |
| GET | `/renter/reviews` | 🎫 | Renter's review history |
- `POST /api/v1/auth/company/signup` creates the company tenant, owner employee, default subscription state, and related baseline records in one transaction.
- The signup flow generates a unique company slug, hashes the owner password, starts a 90-day trial window, and sends account-created notifications.
- Employee auth supports login, forgot-password, reset-password, profile read, and language updates.
---
### Company profile and business configuration
## Global Marketplace API (public, optional renter auth)
The `companies` module owns tenant-level settings:
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/marketplace/offers` | opt 🎫 | Featured + recent offers across all companies |
| GET | `/marketplace/companies` | opt 🎫 | Company list `?city=&rating=&hasOffer=` |
| GET | `/marketplace/search` | opt 🎫 | Search vehicles `?city=&startDate=&endDate=&category=&maxPrice=` |
| GET | `/marketplace/[slug]` | opt 🎫 | Company marketplace profile |
| GET | `/marketplace/[slug]/vehicles` | opt 🎫 | Published vehicles (no internal fields) |
| GET | `/marketplace/[slug]/offers` | opt 🎫 | Active public offers |
| POST | `/marketplace/[slug]/book` | opt 🎫 | Book (renter auth preferred, guest allowed) |
| GET | `/marketplace/[slug]/reviews` | opt 🎫 | Company reviews |
| POST | `/marketplace/offers/:code/validate` | opt 🎫 | Validate promo code |
- company identity
- brand settings and asset upload
- subdomain/custom-domain configuration
- contract settings used by rental documents
- insurance policies
- pricing rules
- accounting settings
- public API key generation/regeneration
---
This module is where most long-lived company configuration lives.
## Company Public Site API (slug-resolved, no user auth)
### Fleet management
> Used by SSR pages on `{slug}.RentalDriveGo.com`. Never subscription-gated.
The `vehicles` module handles:
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/site/:slug/brand` | 🔑 | Branding for SSR |
| GET | `/site/:slug/vehicles` | 🔑 | Published vehicles |
| GET | `/site/:slug/vehicles/:id` | 🔑 | Vehicle detail + availability |
| GET | `/site/:slug/offers` | 🔑 | Active offers |
| POST | `/site/:slug/availability` | 🔑 | Date range availability check |
| POST | `/site/:slug/book` | 🔑 | Create booking + payment |
| POST | `/site/:slug/book/validate-code` | 🔑 | Validate promo code |
| GET | `/site/:slug/booking/:id` | 🔑 | Booking status (confirmation page) |
| POST | `/site/:slug/contact` | 🔑 | Contact form → email to company |
- CRUD for vehicles
- publish/unpublish
- explicit status updates
- photo upload and deletion
- availability checks
- monthly vehicle calendar events
- manual calendar blocks
- maintenance logs
---
Uploads are persisted through the storage service, then surfaced under `/storage/*`.
## Admin (RentalDriveGo internal)
### Reservations and rental workflow
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/admin/companies` | 👑 | All companies |
| GET | `/admin/companies/:id` | 👑 | Company detail + subscription |
| PATCH | `/admin/companies/:id/status` | 👑 | Force status |
| GET | `/admin/offers/featured` | 👑 | All featured offers |
| GET | `/admin/metrics` | 👑 | Platform MRR, churn, signups |
| GET | `/admin/notifications/stats` | 👑 | Delivery rates per channel |
The reservation aggregate is the center of the operational API.
---
Important transitions:
## Standard Response Formats
1. create draft reservation
2. confirm reservation after license checks
3. check in and move the reservation to `ACTIVE`
4. check out and mark the reservation `COMPLETED`
5. close the reservation operationally after post-rental tasks are done
The lifecycle service also handles:
- extensions with conflict checks
- cancellations
- vehicle status synchronization
- review token generation
- review request email dispatch on close
The reservations module also owns:
- contract and billing payload generation
- pickup/dropoff inspections
- additional-driver approval
- pickup/dropoff photo upload
### Customer CRM and license compliance
Customers are company-scoped CRM records, even when a renter identity also exists. The `customers` module supports:
- customer CRUD
- flag/unflag flows
- license image upload and protected retrieval
- license validation and manager approval
This is intentionally separate from renter identity because one renter may interact with multiple companies while each company still needs its own operational customer record.
### Marketplace and public site
The public surface is split in two modules on purpose.
`marketplace` is the cross-company discovery layer:
- featured/public offers
- marketplace cities
- listed companies
- vehicle search
- marketplace reservation intake
- review submission by token
- company public pages under `/:slug`
`site` is the white-label company booking API:
- company brand payload
- published vehicle catalog
- booking options
- date-range availability
- promo validation
- booking creation
- payment initialization
- booking lookup
- company contact form
Both modules deliberately include graceful degradation paths when the database is unavailable so public pages can fail softer than internal operations.
### Payments
Rental payments are company-side operational payments, distinct from SaaS subscription billing.
The `payments` module handles:
- AmanPay and PayPal rental webhooks
- listing payments by company or reservation
- initializing online charges
- capturing PayPal orders
- recording manual payments
- refunding successful online payments
Payment success updates both the payment record and the reservation paid amount.
### Subscriptions and SaaS billing
The `subscriptions` module owns the platform billing lifecycle:
- public plan/provider/feature listing
- company subscription read endpoints
- trial start
- checkout
- plan changes
- cancellation/resume/reactivation
- provider webhooks
- admin overrides
It also exposes background-job style functions for:
- trial expiration
- payment-pending timeout
- past-due timeout
- suspension timeout
- period-end cancellation
### Notifications
Notifications are multi-channel and multi-audience:
- company inbox
- renter inbox
- unread counts
- preferences per type and channel
- notification history
Templates and preference records live in the database; delivery orchestration lives in `services/notificationService.ts`.
### Admin surface
The admin router is broad because it covers platform operations across multiple domains:
- admin authentication and 2FA
- company listing, inspection, status changes, deletion, impersonation
- renter blocking/unblocking
- platform metrics
- notification inspection
- audit log access
- admin-user and permission management
- billing account and billing invoice operations
- pricing config, plan features, and promotions
- subscription overrides and extensions
- marketplace homepage configuration
This is the only route group allowed to work across tenants.
## Important Workflow Examples
### 1. Company signup
`POST /api/v1/auth/company/signup`
- validates the payload with Zod
- checks for duplicate company and owner email addresses
- generates a unique slug
- hashes the owner password
- creates the tenant, owner employee, and initial subscription state in one transaction
- sends localized account-created notifications
### 2. Public booking from a company site
`POST /api/v1/site/:slug/book`
- resolves the company from the slug
- verifies vehicle availability for the requested date range
- upserts the company-scoped customer record
- computes total days and base rental price
- applies promo code discount if present
- applies pricing rules
- stores a draft reservation
- attaches reservation insurance snapshots and additional-driver snapshots
- triggers license validation flags
Payment is then started with `POST /api/v1/site/:slug/booking/:id/pay`.
### 3. Reservation lifecycle after booking
- reservation starts in `DRAFT`
- staff confirms it after license compliance checks
- vehicle status moves to `RESERVED`
- checkin moves reservation to `ACTIVE` and vehicle to `RENTED`
- checkout moves reservation to `COMPLETED`, records mileage, and generates a review token
- close marks the operational workflow complete and can send the review request email
## Error Handling and Response Shape
The API standardizes error handling in `http/errors/errorMiddleware.ts`.
Special handling exists for:
- Zod validation errors -> `400 validation_error`
- Prisma `P2025` -> `404 not_found`
- Prisma `P2002` -> `409 conflict`
- custom `AppError` subclasses -> explicit status and machine-readable code
Successful route handlers usually return:
```json
// Success
{ "data": { ... } }
// Paginated
{ "data": [...], "total": 47, "page": 1, "pageSize": 20, "totalPages": 3 }
// Error
{ "error": "vehicle_not_found", "message": "Vehicle not found or access denied", "statusCode": 404 }
// Subscription error
{ "error": "subscription_suspended", "message": "...", "statusCode": 402, "billingUrl": "https://..." }
{
"data": {}
}
```
## Common Query Params
`page`, `pageSize` (max 100), `sortBy`, `sortOrder` (asc|desc), `q` (search)
Common deviations:
---
- `/health`
- `/api/v1/docs`
- webhook receipt payloads
- file downloads such as admin invoice PDFs
## Payment Routes — AmanPay + PayPal
## Documentation Artifacts
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/payments/amanpay-webhook` | — (HMAC sig) | AmanPay event webhook (subscriptions + rentals) |
| POST | `/payments/paypal/create-order` | 🔒🏢 or 🎫 | Create PayPal order (subscription or rental) |
| POST | `/payments/paypal/capture` | 🔒🏢 or 🎫 | Capture PayPal order after approval |
| POST | `/payments/paypal/refund` | 🔒🏢💳 | Refund PayPal capture |
| GET | `/payments` | 🔒🏢💳 | Payment history (AmanPay + PayPal combined) |
| GET | `/payments/:id` | 🔒🏢💳 | Payment detail |
| POST | `/payments/:id/refund` | 🔒🏢💳 | Refund (AmanPay or PayPal auto-detected) |
There are two documentation layers in the codebase:
---
1. runtime API docs from Swagger/OpenAPI at `/docs` and `/api/v1/openapi.json`
2. this markdown document, which explains design decisions, route grouping, and request flow
## Documents — Contracts & Invoices (On-Demand PDF Generation)
> **Design rule:** PDFs are NEVER stored. Every request hits the DB, assembles data, renders PDF via `@react-pdf/renderer`, and streams the result. Reference numbers (`contractNumber`, `invoiceNumber`) ARE stored in the DB and assigned once via atomic transaction.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/documents/reservations/:id/contract` | 🔒🏢💳 | Generate + stream contract PDF. `?download=true` forces file download vs inline view. |
| GET | `/documents/reservations/:id/invoice` | 🔒🏢💳 | Generate + stream invoice PDF. |
| POST | `/documents/reservations/:id/send` | 🔒🏢💳 | Generate + email PDF(s) as attachment. Body: `{ type: "contract"|"invoice"|"both", email?: string }` |
| GET | `/documents/settings` | 🔒🏢💳 | Get company's contract settings (terms, prefixes, tax config) |
| PATCH | `/documents/settings` | 🔒🏢💳 | Update contract settings. Sequence counters are protected from API reset. |
| GET | `/documents/settings/preview-contract` | 🔒🏢💳 | Preview contract PDF with sample data (for settings page preview) |
| GET | `/documents/settings/preview-invoice` | 🔒🏢💳 | Preview invoice PDF with sample data |
| GET | `/renter/reservations/:id/contract` | 🎫 | Renter downloads their own contract (renterId must match) |
| GET | `/renter/reservations/:id/invoice` | 🎫 | Renter downloads their own invoice |
---
## Damage Inspections
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/reservations/:id/inspections` | 🔒🏢💳 | Create check-in or check-out inspection (with damage points) |
| GET | `/reservations/:id/inspections` | 🔒🏢💳 | Get both inspections (checkin + checkout) |
| GET | `/reservations/:id/inspections/checkin` | 🔒🏢💳 | Check-in inspection detail |
| GET | `/reservations/:id/inspections/checkout` | 🔒🏢💳 | Check-out inspection detail |
---
## Billing (per-reservation line items)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/reservations/:id/billing` | 🔒🏢💳 | Get full itemized billing breakdown (JSON — for invoice preview) |
| PATCH | `/reservations/:id/billing` | 🔒🏢💳 | Add damage charges and extras to reservation |
---
## Accounting Reports
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/accounting/settings` | 🔒🏢💳 | Get company accounting settings |
| PATCH | `/accounting/settings` | 🔒🏢💳 | Update accounting settings (period, accountant, taxes, fuel policy) |
| GET | `/accounting/report` | 🔒🏢💳 | Get accounting report as JSON `?period=MONTHLY&from=&to=` |
| GET | `/accounting/report/pdf` | 🔒🏢💳 | Stream accounting report PDF |
| GET | `/accounting/report/csv` | 🔒🏢💳 | Stream CSV file |
| POST | `/accounting/report/send` | 🔒🏢💳 | Email report to accountant `{ period, from, to, format }` |
| GET | `/accounting/report/periods` | 🔒🏢💳 | List available periods with labels and date ranges |
---
## Admin Panel Routes (admin.RentalDriveGo.com → api.RentalDriveGo.com/admin)
> All require admin JWT. Role hierarchy: VIEWER < FINANCE < SUPPORT < ADMIN < SUPER_ADMIN
### Admin Auth
| Method | Path | Role | Description |
|--------|------|------|-------------|
| POST | `/admin/auth/login` | — | Login `{ email, password, totpCode? }` |
| POST | `/admin/auth/logout` | VIEWER | Invalidate token |
| GET | `/admin/auth/me` | VIEWER | Current admin profile |
| PATCH | `/admin/auth/me` | VIEWER | Update name/password |
| POST | `/admin/auth/2fa/setup` | VIEWER | Generate TOTP secret + QR code |
| POST | `/admin/auth/2fa/verify` | VIEWER | Verify and enable 2FA |
### Admin User Management (SUPER_ADMIN only)
| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/admin/users` | SUPER_ADMIN | List admin users |
| POST | `/admin/users` | SUPER_ADMIN | Create admin user |
| PATCH | `/admin/users/:id` | SUPER_ADMIN | Edit name, role |
| POST | `/admin/users/:id/deactivate` | SUPER_ADMIN | Deactivate admin |
| POST | `/admin/users/:id/reset-password` | SUPER_ADMIN | Force password reset |
| PATCH | `/admin/users/:id/permissions` | SUPER_ADMIN | Override permissions |
### Company Management (SUPPORT+)
| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/admin/companies` | SUPPORT | List all companies |
| POST | `/admin/companies` | SUPPORT | Create company |
| GET | `/admin/companies/:id` | SUPPORT | Company detail |
| PATCH | `/admin/companies/:id` | SUPPORT | Edit company |
| POST | `/admin/companies/:id/suspend` | SUPPORT | Suspend `{ reason }` |
| POST | `/admin/companies/:id/reactivate` | SUPPORT | Reactivate |
| DELETE | `/admin/companies/:id` | ADMIN | Delete company |
| PATCH | `/admin/companies/:id/subscription` | SUPPORT | Override subscription |
| POST | `/admin/companies/:id/invoices/:invoiceId/mark-paid` | ADMIN | Manual mark paid |
### Company Staff Management (SUPPORT+)
| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/admin/companies/:id/employees` | SUPPORT | List employees |
| POST | `/admin/companies/:id/employees` | SUPPORT | Add employee |
| GET | `/admin/companies/:id/employees/:empId` | SUPPORT | Employee detail |
| PATCH | `/admin/companies/:id/employees/:empId` | SUPPORT | Edit employee |
| PATCH | `/admin/companies/:id/employees/:empId/role` | SUPPORT | Change role |
| POST | `/admin/companies/:id/employees/:empId/deactivate` | SUPPORT | Deactivate |
| POST | `/admin/companies/:id/employees/:empId/reactivate` | SUPPORT | Reactivate |
| POST | `/admin/companies/:id/employees/:empId/reset-password` | SUPPORT | Send reset |
| POST | `/admin/companies/:id/employees/:empId/impersonate` | ADMIN | Generate impersonation token |
### Renters, Metrics, Audit
| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/admin/renters` | SUPPORT | List renters |
| PATCH | `/admin/renters/:id` | SUPPORT | Update renter |
| POST | `/admin/renters/:id/block` | SUPPORT | Block `{ reason }` |
| POST | `/admin/renters/:id/unblock` | SUPPORT | Unblock |
| GET | `/admin/metrics` | FINANCE | Platform KPIs |
| GET | `/admin/metrics/subscriptions` | FINANCE | Plan breakdown |
| GET | `/admin/audit-logs` | ADMIN | Paginated audit log |
| GET | `/admin/audit-logs/export` | ADMIN | CSV export |
---
## Advanced Features — New Routes
### Damage Reports
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/reservations/:id/damage-reports` | 🔒🏢💳 | Get check-in + check-out damage reports |
| POST | `/reservations/:id/damage-reports` | 🔒🏢💳 | Save damage report. Body: `{ type: "CHECKIN"|"CHECKOUT", damages: DamageZone[], fuelLevel, mileage, photos[], inspectedBy, customerSignedAt, customerName }` |
| PATCH | `/reservations/:id/damage-reports/:type` | 🔒🏢💳 | Update a damage report (before finalizing) |
### Insurance Policies (Company configuration)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/insurance-policies` | 🔒🏢💳 | List company's insurance policies |
| POST | `/insurance-policies` | 🔒🏢💳 | Create policy |
| PATCH | `/insurance-policies/:id` | 🔒🏢💳 | Update policy |
| DELETE | `/insurance-policies/:id` | 🔒🏢💳 | Deactivate policy |
| GET | `/reservations/:id/insurances` | 🔒🏢💳 | Insurance selections for a reservation |
| POST | `/reservations/:id/insurances` | 🔒🏢💳 | Apply insurance(s) to reservation. Body: `{ policyIds: string[] }` |
### Additional Drivers
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/reservations/:id/additional-drivers` | 🔒🏢💳 | List additional drivers |
| POST | `/reservations/:id/additional-drivers` | 🔒🏢💳 | Add additional driver. Body: `{ firstName, lastName, driverLicense, licenseExpiry, dateOfBirth, ... }` |
| PATCH | `/reservations/:id/additional-drivers/:did` | 🔒🏢💳 | Update driver info |
| DELETE | `/reservations/:id/additional-drivers/:did` | 🔒🏢💳 | Remove additional driver |
| POST | `/reservations/:id/additional-drivers/:did/approve-license` | 🔒🏢💳 (OWNER/MANAGER) | Approve flagged license. Body: `{ decision: "APPROVE"|"DENY", note }` |
### Driver License Validation
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/customers/:id/validate-license` | 🔒🏢💳 | Validate license expiry + flag if <3 months. Returns `{ status, daysUntilExpiry, requiresApproval }` |
| POST | `/customers/:id/approve-license` | 🔒🏢💳 (OWNER/MANAGER) | Approve or deny flagged license. Body: `{ decision: "APPROVE"|"DENY", note }` |
### Pricing Rules
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/pricing-rules` | 🔒🏢💳 | List company's pricing rules |
| POST | `/pricing-rules` | 🔒🏢💳 | Create rule (age surcharge, license discount, etc.) |
| PATCH | `/pricing-rules/:id` | 🔒🏢💳 | Update rule |
| DELETE | `/pricing-rules/:id` | 🔒🏢💳 | Deactivate rule |
| POST | `/pricing-rules/preview` | 🔒🏢💳 | Preview rules applied to a hypothetical booking. Body: `{ customerId, additionalDrivers, dailyRate, totalDays }` |
### Financial Reporting (Accountant Export)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/reports/financial` | 🔒🏢💳 | Financial report. `?period=WEEK|MONTH|YEAR&startDate=&endDate=&format=JSON|CSV` |
| GET | `/reports/financial/summary` | 🔒🏢💳 | Aggregate totals only (for dashboard KPIs) |
### Admin Panel Routes
Base path: `/api/v1/admin/` — requires `requireAdminAuth` middleware (separate JWT from company auth)
| Method | Path | Role | Description |
|--------|------|------|-------------|
| POST | `/admin/auth/login` | — | Admin login |
| GET | `/admin/auth/me` | Any | Admin profile |
| GET | `/admin/companies` | Any | All companies `?q=&status=&plan=` |
| POST | `/admin/companies` | ADMIN+ | Create company |
| GET | `/admin/companies/:id` | Any | Full company detail |
| PATCH | `/admin/companies/:id` | ADMIN+ | Edit company profile |
| PATCH | `/admin/companies/:id/status` | ADMIN+ | Change status |
| PATCH | `/admin/companies/:id/plan` | ADMIN+ | Change plan (bypasses payment) |
| DELETE | `/admin/companies/:id` | SUPER_ADMIN | Hard delete |
| POST | `/admin/companies/:id/impersonate` | ADMIN+ | Get 30-min impersonation token |
| GET | `/admin/companies/:id/employees` | Any | List employees |
| POST | `/admin/companies/:id/employees` | ADMIN+ | Add employee |
| PATCH | `/admin/companies/:id/employees/:eid` | ADMIN+ | Edit employee |
| PATCH | `/admin/companies/:id/employees/:eid/role` | ADMIN+ | Change role |
| DELETE | `/admin/companies/:id/employees/:eid` | ADMIN+ | Deactivate |
| POST | `/admin/companies/:id/employees/:eid/reset-password` | ADMIN+ | Trigger reset email |
| GET | `/admin/metrics` | Any | Platform MRR, churn, signups |
| GET | `/admin/admins` | SUPER_ADMIN | List admin users |
| POST | `/admin/admins` | SUPER_ADMIN | Create admin |
| PATCH | `/admin/admins/:id/role` | SUPER_ADMIN | Change admin role |
| DELETE | `/admin/admins/:id` | SUPER_ADMIN | Deactivate admin |
| GET | `/admin/audit-log` | SUPER_ADMIN | Audit log `?adminId=&targetType=&startDate=&endDate=` |
The OpenAPI file is useful for request/response contracts, but it is not yet a perfect mirror of every newer route. This document should be updated together with `app.ts`, route files, and the OpenAPI generator whenever route groups change.
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

-268
View File
@@ -1,268 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import type { TeamMember } from '../../hooks/useTeam'
interface Props {
member: TeamMember | null
open: boolean
onClose: () => void
onUpdateRole: (memberId: string, role: 'MANAGER' | 'AGENT') => Promise<void>
onDeactivate: (memberId: string) => Promise<void>
onReactivate: (memberId: string) => Promise<void>
onRemove: (memberId: string) => Promise<void>
}
const ROLE_OPTIONS = [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
]
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
export default function EditMemberModal({
member,
open,
onClose,
onUpdateRole,
onDeactivate,
onReactivate,
onRemove,
}: Props) {
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [actionState, setActionState] = useState<ActionState>('idle')
const [confirm, setConfirm] = useState<ConfirmAction>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (member && open) {
setRole(member.role === 'OWNER' ? 'MANAGER' : (member.role as 'MANAGER' | 'AGENT'))
setActionState('idle')
setConfirm(null)
setError(null)
}
}, [member, open])
if (!open || !member) return null
const isPending = member.invitationStatus === 'pending'
const isActive = member.isActive
const handleSave = async () => {
if (role === member.role) { onClose(); return }
setActionState('saving')
setError(null)
try {
await onUpdateRole(member.id, role)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
}
const handleDeactivate = async () => {
setActionState('deactivating')
setError(null)
try {
await onDeactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleReactivate = async () => {
setActionState('reactivating')
setError(null)
try {
await onReactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleRemove = async () => {
setActionState('removing')
setError(null)
try {
await onRemove(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
{/* Member header */}
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
{initials}
</div>
<div>
<p className="font-medium text-zinc-900 dark:text-white text-sm">
{member.firstName} {member.lastName}
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</p>
</div>
<div className="ml-auto">
{isPending && (
<span className="text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border border-amber-100 dark:border-amber-900/50">
Pending invite
</span>
)}
{!isPending && !isActive && (
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
Deactivated
</span>
)}
</div>
</div>
{/* Confirm overlay */}
{confirm && (
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
{confirm === 'deactivate' && 'Deactivate this member?'}
{confirm === 'remove' && 'Permanently remove this member?'}
{confirm === 'reactivate' && 'Reactivate this member?'}
</p>
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
{confirm === 'deactivate' && "They'll immediately lose dashboard access. You can reactivate anytime."}
{confirm === 'remove' && "This will delete the employee record and revoke their Clerk access. This cannot be undone."}
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
</p>
<div className="flex gap-2">
<button
onClick={() => setConfirm(null)}
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
Cancel
</button>
<button
onClick={
confirm === 'deactivate' ? handleDeactivate :
confirm === 'reactivate' ? handleReactivate :
handleRemove
}
disabled={busy}
className={[
'flex-1 text-xs px-3 py-1.5 rounded-lg font-medium disabled:opacity-50',
confirm === 'reactivate'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'bg-red-600 text-white hover:bg-red-700',
].join(' ')}
>
{busy ? 'Working…' : (
confirm === 'deactivate' ? 'Deactivate' :
confirm === 'reactivate' ? 'Reactivate' :
'Remove permanently'
)}
</button>
</div>
</div>
)}
{/* Role selector (hidden for pending invites — role locked until accepted) */}
{!isPending && isActive && (
<div className="mb-4">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
Role
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-2.5 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="text-sm font-medium text-zinc-900 dark:text-white">{option.label}</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{option.description}</div>
</button>
))}
</div>
</div>
)}
{/* Error */}
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Footer actions */}
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
{/* Danger zone */}
<div className="flex gap-2 mr-auto">
{isActive && !isPending && (
<button
onClick={() => setConfirm('deactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Deactivate
</button>
)}
{!isActive && (
<button
onClick={() => setConfirm('reactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
Reactivate
</button>
)}
<button
onClick={() => setConfirm('remove')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Remove
</button>
</div>
<button
onClick={onClose}
disabled={busy}
className="px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50"
>
Cancel
</button>
{isActive && !isPending && (
<button
onClick={handleSave}
disabled={busy}
className="px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50"
>
{actionState === 'saving' ? 'Saving…' : 'Save changes'}
</button>
)}
</div>
</div>
</div>
)
}
-310
View File
@@ -1,310 +0,0 @@
# Features & Priorities — RentalDriveGo
## MVP (Phase 1) — Must Ship Before Launch
### Subscription & Billing
- [x] Company signup → plan selection → payment via AmanPay or PayPal
- [x] 14-day free trial (no payment required until trial ends)
- [x] Company status: PENDING → TRIALING → ACTIVE / PAST_DUE / SUSPENDED
- [x] Subscription gate middleware on all dashboard API routes
- [x] Trial / past-due banners in dashboard
- [x] Suspended account blocks dashboard; public site stays up
- [x] Manual renewal flow: company pays via AmanPay (card/cash/e-wallet) or PayPal
- [x] Invoice history (AmanPay transaction IDs or PayPal capture IDs)
- [x] Supported currencies: MAD, USD, EUR
### Tenant Isolation (Fleet Management)
- [x] Every Prisma query scoped by `companyId`
- [x] `requireTenant` middleware attaches `companyId` from authenticated employee
- [x] Vehicle, customer, reservation IDs validated against `companyId` before any operation
- [x] No cross-company data exposure — enforced at API level, not just UI
- [x] Add / edit / archive vehicles
- [x] Vehicle photos (Cloudinary)
- [x] Published / unpublished toggle (controls marketplace + public site visibility)
- [x] Vehicle categories, specs, features list
### Offers / Promotions System
- [x] Create offer (%, fixed amount, free day, special rate)
- [x] Valid from / until dates
- [x] Applicable to: all vehicles / by category / specific vehicles
- [x] Optional promo code + max redemptions
- [x] Minimum rental days
- [x] `isPublic` flag — show on global marketplace
- [x] `isFeatured` flag — featured carousel placement (plan-gated: GROWTH+)
- [x] Offer applied at booking (discount calculated, stored on Reservation)
- [x] Offer performance stats (redemptions, revenue impact)
- [x] Offer banner image upload
### Global Marketplace (RentalDriveGo.com/explore)
- [x] Featured offers carousel on /explore home
- [x] Search vehicles by city, dates, category, price range
- [x] Company cards (logo, rating, offer badge)
- [x] Company marketplace profile page (vehicles + offers + reviews)
- [x] Vehicle detail page (photos, specs, redirect CTA to company site)
- [x] Cross-company availability check
- [x] Filter by category, transmission, price, features
- [x] Marketplace listing toggle per company (opt out)
### Renter Account System
- [x] Renter signup / login (own auth, separate from Employee/Clerk)
- [x] Email verification
- [x] Renter profile (name, phone, locale, currency)
- [x] Cross-company reservation history in /renter/dashboard
- [x] Save / unsave companies
- [x] Guest booking (no account required, but prompted to sign up after)
- [x] When renter books, auto-create Customer record in that company's CRM
### Online Reservations (all sources)
- [x] Booking from dashboard (employee creates manually)
- [x] Booking from company public site (BookingSource: PUBLIC_SITE)
- [x] Booking from global marketplace (BookingSource: MARKETPLACE)
- [x] Reservation lifecycle: DRAFT → CONFIRMED → ACTIVE → COMPLETED
- [x] Offer / promo code applied at booking step
- [x] Availability check before confirming
- [x] Booking confirmation (number, summary, company contact)
- [x] Cancel with reason + `cancelledBy` (COMPANY / RENTER / SYSTEM)
### Branding & White-Label Public Site
- [x] Company display name, logo, tagline, hero image
- [x] Primary brand color picker
- [x] Subdomain: `{slug}.RentalDriveGo.com` (claimed during onboarding)
- [x] Real-time subdomain availability check
- [x] Vercel wildcard domain routing (`*.RentalDriveGo.com`)
- [x] Company public site: home with offers, vehicle listing, vehicle detail, booking, confirmation
- [x] Offers page on public site
- [x] "Powered by RentalDriveGo" badge (removable on Pro)
- [x] Site language + currency preference
### Payments — Rental (AmanPay + PayPal)
- [x] Company connects their own AmanPay merchant account (Merchant ID + Secret Key)
- [x] Company connects their own PayPal business account
- [x] Collect payment at booking (marketplace redirect + public site)
- [x] AmanPay widget: national card, international card, cash (3000+ points), e-wallet
- [x] PayPal buttons component (standard checkout)
- [x] If no payment method configured: "Contact to reserve" + pay on pickup flow
- [x] Payment status per reservation
- [x] Refund on cancellation (via AmanPay or PayPal API)
- [x] Guest checkout (no renter account required)
### Notifications — Full System
- [x] **Email** (Resend) — all notification types, rich HTML templates
- [x] **SMS** (Twilio) — confirmations, reminders, payment alerts
- [x] **WhatsApp** (Twilio WhatsApp API) — confirmations, reminders (key for AR/FR)
- [x] **In-App** (Socket.io + Redis) — real-time bell icon in dashboard + renter site
- [x] **Push** (Firebase FCM) — renter PWA push notifications
- [x] `Notification` table — logs every delivery attempt per channel
- [x] `NotificationPreference` table — per-user opt-in/out per type + channel
- [x] Preferences UI: matrix toggle (notification type × channel)
- [x] All company events: new booking, cancelled, payment received, trial ending, maintenance due, offer expiring, new review
- [x] All renter events: confirmed, 24h reminder, 2h reminder, vehicle ready, return reminder, cancelled, refund, new offer from saved company, review request
- [x] Scheduled notifications via cron (reminders, trial ending, maintenance due)
### Reviews
- [x] Renter submits review (only after reservation COMPLETED)
- [x] 15 stars overall + vehicle + service
- [x] Text comment
- [x] Company can reply to review
- [x] Reviews shown on marketplace company profile
- [x] Average rating on company card + brand settings
### Customer CRM
- [x] Auto-created from any booking source
- [x] Company-scoped view (never cross-company)
- [x] Linked to global Renter account (if booked as logged-in renter)
- [x] Rental history, notes, flag/blacklist
### Auth & Onboarding
- [x] Company employee auth via Clerk
- [x] Renter auth via own JWT (separate)
- [x] Onboarding step 2: branding + subdomain
- [x] Onboarding step 4: "Your vehicles now appear on the RentalDriveGo marketplace"
- [x] Role system: OWNER / MANAGER / AGENT
### Platform Admin Panel
- [x] `AdminUser` model: email, password (bcrypt), role (SUPER_ADMIN/ADMIN/SUPPORT/FINANCE/VIEWER), 2FA (TOTP), last login tracking
- [x] `AdminPermission` model: per-admin resource+action overrides on top of role defaults
- [x] `AuditLog` model: every admin action logged (action, resource, before/after JSON, IP, note)
- [x] Admin JWT auth (separate from Clerk and renter JWT) — 8h expiry
- [x] TOTP 2FA: setup with QR code, verify, enforce for ADMIN+ roles
- [x] Role hierarchy: VIEWER < FINANCE < SUPPORT < ADMIN < SUPER_ADMIN
- [x] Admin role access matrix: 17 dashboard features × 5 roles
- [x] `admin.RentalDriveGo.com` separate app (Next.js)
- [x] **Company management**: list, search/filter, create, edit, suspend, reactivate, delete
- [x] **Company staff management**: list employees per company, add employee, edit (name/email/role), deactivate/reactivate, reset password
- [x] **Impersonation**: ADMIN+ can log in as any company employee (Clerk impersonation session), with dashboard banner
- [x] **Subscription override**: force status, change plan, add free period with auto-revert
- [x] Manual invoice mark-as-paid
- [x] **Renter management**: list, search, view history, block/unblock
- [x] **Audit log UI**: searchable, filterable by admin/action/company, CSV export
- [x] Admin user management (SUPER_ADMIN only): create/edit/deactivate admins, set roles
- [x] Platform metrics: MRR, churn, active companies, marketplace stats, notification delivery rates
- [x] Prisma seed creates first SUPER_ADMIN from env vars
- [x] Employee role control matrix (OWNER/MANAGER/AGENT) with 17 feature permissions documented
---
### Advanced Rental Operations (Phase 1)
#### Vehicle Damage Inspection
- [x] Interactive top-down SVG damage map in check-in/check-out workflow
- [x] 22 clickable zones (hood, roof, doors, fenders, bumpers, wheels, windshield, mirrors, interior, undercarriage)
- [x] 5 severity levels per zone: SCRATCH, DENT, CRACK, MISSING, OTHER — each shown in distinct color
- [x] Optional note per damaged zone
- [x] Optional damage photos upload (Cloudinary)
- [x] `DamageReport` model — one per check-in, one per check-out (`@@unique([reservationId, type])`)
- [x] Fuel level and odometer recorded at each inspection
- [x] Customer acknowledgement timestamp + name
- [x] Damage map rendered in PDF contract (static SVG with color markers) — CHECKIN + CHECKOUT side by side
#### Insurance Policies
- [x] `InsurancePolicy` model: name, type (CDW, SCDW, THEFT, THIRD_PARTY, FULL, BASIC, ROADSIDE, PERSONAL, CUSTOM)
- [x] Three charge types: PER_DAY, PER_RENTAL (flat), PERCENTAGE_OF_RENTAL
- [x] Required vs optional flag — required policies auto-apply, cannot be opted out
- [x] `ReservationInsurance` junction: snapshot of policy terms locked at booking time
- [x] `insuranceTotal` cached on Reservation for fast invoice generation
- [x] Insurance line items in customer invoice with charge type label
- [x] Insurance shown on rental contract
#### Second / Additional Driver
- [x] `AdditionalDriver` model: full personal details + license info
- [x] Charge types: FREE, PER_DAY, FLAT
- [x] Company configures default charge in `ContractSettings`
- [x] License validation applied to additional drivers (same 3-month rule)
- [x] Additional driver(s) listed on contract + invoice line item (if charged)
- [x] Employee can approve/deny flagged additional driver license
#### Driver License Validation
- [x] `licenseExpiry`, `licenseIssuedAt`, `licenseCountry`, `licenseNumber`, `licenseCategory` fields on Customer
- [x] `LicenseStatus` enum: PENDING, VALID, EXPIRING, APPROVED, DENIED, EXPIRED
- [x] **3-month rule**: if license expires within 90 days → status=EXPIRING → requires employee approval
- [x] **Expired**: status=EXPIRED → automatic flag → employee must explicitly approve or deny before confirmation
- [x] License validation badge on customer record (green/amber/red)
- [x] Warning banner on reservation detail when customer has EXPIRING or EXPIRED license
- [x] Approve / Deny buttons for OWNER and MANAGER roles only
- [x] Approval logged: who approved, when, and note
#### Age-Based & Experience-Based Pricing Rules
- [x] `PricingRule` model: configurable per company
- [x] Conditions: AGE_LESS_THAN, AGE_GREATER_THAN, LICENSE_YEARS_LESS_THAN, LICENSE_YEARS_GREATER_THAN
- [x] **Default rule: Under-25 surcharge** (+15% of base rental, configurable)
- [x] **Default rule: 5+ years experience discount** (5%, configurable)
- [x] Adjustment types: PERCENTAGE, FLAT_PER_DAY, FLAT_TOTAL
- [x] Rules applied to primary driver AND all additional drivers (each rule applied once even if multiple drivers qualify)
- [x] Rules shown as line items on invoice (surcharges positive, discounts negative)
- [x] Snapshot of applied rules stored in `Reservation.pricingRulesApplied` JSON
#### Fuel / Gasoline Policy (Structured)
- [x] `FuelPolicyType` enum: FULL_TO_FULL, FULL_TO_EMPTY, SAME_TO_SAME, PREPAID, FREE
- [x] Multilingual policy description auto-generated from type (EN/FR/AR)
- [x] Optional custom note added by company
- [x] Fuel level recorded at check-in AND check-out via `DamageReport.fuelLevel`
- [x] Policy printed on contract in customer's language
#### Full Invoice Price Breakdown
- [x] Line items: base rental × days, per insurance policy, additional driver (if charged), pricing rule surcharges, pricing rule discounts, security deposit (refundable label)
- [x] Category labels per line: RENTAL, INSURANCE, ADDITIONAL_DRIVER, SURCHARGE, DISCOUNT, DEPOSIT
- [x] Tax/VAT line (if `ContractSettings.showTax = true`)
- [x] Subtotal → discounts → surcharges → insurance → tax → deposit → GRAND TOTAL
- [x] All amounts formatted in company's currency and locale
#### Billing Period Reporting (Accountant Export)
- [x] Reports page at `/dashboard/reports`
- [x] Preset periods: This Week, This Month, This Year, Custom range
- [x] Summary cards: total bookings, base revenue, discounts given, insurance collected, additional driver revenue, net collected
- [x] Full reservations table: contract#, invoice#, customer, vehicle, plate, dates, days, base, discount, insurance, additional driver, pricing adjustments, total, payment status, source
- [x] Export as CSV (one click) — for import into accounting software (QuickBooks, Sage, etc.)
- [x] Report generated on-demand from DB — no pre-computed tables
---
## Phase 2 — Post-Launch
### Fleet Enhancements
- [ ] Maintenance log with cost tracking
- [ ] Maintenance due alerts (date + mileage)
- [ ] Vehicle availability calendar view in dashboard
### CRM Enhancements
- [ ] Repeat customer detection + discount
- [ ] Customer communication log (emails sent)
- [ ] Customer lifetime value stat
### Analytics
- [ ] Revenue chart (day/week/month)
- [ ] Fleet utilization rate + underperforming vehicles alert
- [ ] Offer ROI analysis (revenue gained vs discounted)
- [ ] Marketplace impressions per vehicle
- [ ] CSV export
### Renter Experience
- [ ] Renter "Continue with Google" auth
- [ ] Favorite vehicles across companies
- [ ] Renter profile completion % indicator
- [ ] Booking modification request (change dates)
### Public Site Enhancements
- [ ] About page with company description + team photo
- [ ] Google Maps embed for pickup location
- [ ] "Request to book" mode (no immediate payment)
- [ ] WhatsApp floating button
---
## Phase 3 — Growth
### Custom Domain (Pro)
- [ ] Company enters `cars.acme.com` in /dashboard/brand
- [ ] Vercel API auto-provisions domain
- [ ] DNS instructions shown
- [ ] SSL automatic
### Marketplace Enhancements
- [ ] City/region pages (`/explore/cities/paris`)
- [ ] Renter 2FA
- [ ] Sponsored listing (pay-per-click for marketplace placement)
- [ ] AI-powered vehicle recommendations based on renter history
### Advanced Offers
- [ ] Flash sales (limited time countdown timer)
- [ ] Loyalty program (repeat renter discounts)
- [ ] Bundle offers (car + extras)
### Integrations
- [ ] Google Calendar sync
- [ ] QuickBooks export
- [ ] Zapier webhooks
### Documents — Rental Contracts & Customer Bills
- [x] `ContractSettings` model: per-company legal name, RC/ICE number, tax ID, custom terms, fuel/deposit/damage policies, additional clauses, signature block, footer notes, invoice tax settings, numbering prefixes
- [x] `contractNumber` + `invoiceNumber` fields on Reservation — assigned once via atomic DB transaction, never reset
- [x] `checkInFuelLevel` / `checkOutFuelLevel` fields for contract accuracy
- [x] **Rental Contract PDF** — branded with company logo + name + primary color, contains: customer details, vehicle data + cover photo, rental dates/locations, financial table (rate × days discount + deposit), all policy clauses, full T&Cs, signature lines (company + customer)
- [x] **Customer Invoice PDF** — branded, contains: company legal info + tax ID, bill-to block, line items table (vehicle, deposit, tax if configured), totals (subtotal, discount, tax, grand total), payment details (AmanPay/PayPal transaction ID, paid date), PAID/PENDING stamp
- [x] **Both documents support EN / FR / AR** — all labels, date formats, and currency format per company locale
- [x] **On-demand generation** — PDFs are NEVER stored. Generated fresh from DB data on every request. No Cloudinary PDF upload.
- [x] **Streaming PDF response**`Content-Type: application/pdf`, `Content-Disposition: inline` (view in browser tab) or `attachment` (download)
- [x] **Print from browser** — user opens PDF in new tab and prints via native browser print dialog
- [x] **Email with PDF attachment** — Resend sends email with both documents as attached PDFs
- [x] **Send contract + invoice together** — single API call generates both and attaches both to one email
- [x] **Custom recipient email** — override the customer's email address when sending (for forwarding)
- [x] **Renter self-download** — renter can download their own contract/invoice from `/renter/dashboard`
- [x] **Settings preview** — preview contract/invoice PDF with real company branding + sample data before finalizing settings
- [x] **Automatic triggers**: invoice sent on payment confirmed; contract sent on check-in completed
### Multi-location per company
- [ ] Multiple pickup/return locations
- [ ] Location-aware vehicle assignment
- [ ] Distance filter on marketplace
---
## Out of Scope
- GPS / telematics
- Driver license OCR / ID verification
- Insurance products
- Fuel management
- Peer-to-peer (this is B2B SaaS + B2C marketplace, not P2P)
- Native mobile app (PWA covers push notifications)
-182
View File
@@ -1,182 +0,0 @@
# Staff Management — Integration Guide
## 1. Register the routes in your Express app
```ts
// apps/api/src/index.ts (or server.ts)
import express from 'express'
import teamRouter from './routes/team'
import webhookRouter from './routes/webhooks'
const app = express()
// Webhook MUST use raw body — register BEFORE express.json()
app.use(
'/webhooks',
express.raw({ type: 'application/json' }),
webhookRouter
)
// All other routes use JSON
app.use(express.json())
// Team routes — mounted under /api/v1/team (matches api-routes.md)
app.use('/api/v1/team', teamRouter)
```
---
## 2. Install the Clerk webhook (Clerk Dashboard → Webhooks)
1. Go to **clerk.com** → your app → **Webhooks****Add endpoint**
2. URL: `https://api.RentalDriveGo.com/webhooks/clerk`
3. Events to subscribe: `user.created`
4. Copy the **Signing Secret** and add to `.env`:
```env
CLERK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
```
5. Install `svix` for signature verification:
```bash
npm install svix
```
---
## 3. Environment variables needed
```env
# Clerk
CLERK_SECRET_KEY=sk_live_...
CLERK_WEBHOOK_SECRET=whsec_...
# Frontend
NEXT_PUBLIC_API_URL=https://api.RentalDriveGo.com/api/v1
# Dashboard URL (used in invite redirect)
DASHBOARD_URL=https://app.RentalDriveGo.com
```
---
## 4. Add the page to the Next.js dashboard app
```
apps/dashboard/
├── app/
│ └── dashboard/
│ └── team/
│ └── page.tsx ← copy frontend/pages/team.tsx here
├── components/
│ └── team/
│ ├── InviteModal.tsx ← copy from frontend/components/team/
│ ├── EditMemberModal.tsx
│ └── PermissionsMatrix.tsx
└── hooks/
└── useTeam.ts ← copy from frontend/hooks/
```
Add the route to your dashboard sidebar navigation:
```tsx
// components/Sidebar.tsx — add to nav items
{ href: '/dashboard/team', label: 'Team', icon: UsersIcon }
```
---
## 5. requireRole middleware placement
The `requireRole` middleware is already applied inside the team router.
Only expose it if you need it elsewhere (e.g., `PATCH /pricing-rules``requireRole('MANAGER')`):
```ts
import { requireRole } from './middleware/requireRole'
// Example: license approval endpoint (OWNER or MANAGER only)
router.post(
'/customers/:id/approve-license',
requireRole('MANAGER'),
approveLicenseHandler
)
```
---
## 6. Invite acceptance flow (frontend)
When a user clicks the magic link in their invitation email, Clerk redirects them to:
```
https://app.RentalDriveGo.com/onboarding/accept-invite?__clerk_ticket=...
```
Create this page in your Next.js app:
```tsx
// app/onboarding/accept-invite/page.tsx
'use client'
import { useEffect } from 'react'
import { useClerk } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'
export default function AcceptInvite() {
const { handleRedirectCallback } = useClerk()
const router = useRouter()
useEffect(() => {
handleRedirectCallback({}).then(() => {
// Clerk webhook fires user.created → activates Employee record in DB
// Redirect to dashboard after a short delay
setTimeout(() => router.replace('/dashboard'), 1000)
})
}, [])
return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-sm text-zinc-500">Setting up your account</p>
</div>
)
}
```
---
## 7. Prisma type extensions (optional, for `req.employee`)
To get TypeScript to recognize `req.companyId` and `req.employee` on Express requests,
add a type declaration file:
```ts
// types/express.d.ts
import { Employee, Company } from '@prisma/client'
declare global {
namespace Express {
interface Request {
companyId: string
company: Company
employee: Employee
}
}
}
```
---
## 8. Summary of files delivered
| File | Purpose |
|------|---------|
| `backend/services/teamService.ts` | All business logic — invite, update role, deactivate, remove, Clerk webhook handler |
| `backend/routes/team.ts` | Express router — all `/team` endpoints |
| `backend/routes/webhooks.ts` | Clerk webhook listener — activates employee on invite acceptance |
| `backend/middleware/requireRole.ts` | Role-rank guard middleware — reusable across all routers |
| `frontend/hooks/useTeam.ts` | React data hook — fetch, invite, updateRole, deactivate, reactivate, remove |
| `frontend/pages/team.tsx` | Full `/dashboard/team` Next.js page |
| `frontend/components/team/InviteModal.tsx` | Invite new member modal with role picker |
| `frontend/components/team/EditMemberModal.tsx` | Edit role, deactivate, reactivate, remove modal with confirmation step |
| `frontend/components/team/PermissionsMatrix.tsx` | Static role × feature permissions table |
-205
View File
@@ -1,205 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import type { InvitePayload } from '../../hooks/useTeam'
interface Props {
open: boolean
onClose: () => void
onInvite: (payload: InvitePayload) => Promise<void>
}
const ROLE_OPTIONS = [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet ops — no billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
},
]
export default function InviteModal({ open, onClose, onInvite }: Props) {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset form when modal closes
useEffect(() => {
if (!open) {
setTimeout(() => {
setFirstName('')
setLastName('')
setEmail('')
setRole('AGENT')
setError(null)
}, 200)
}
}, [open])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
onClose()
} catch (err: any) {
setError(err.message ?? 'Failed to send invitation')
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div className="mb-5">
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">Invite a team member</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
They'll receive an email invitation to join your dashboard
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
First name
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Youssef"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Last name
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Benali"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
</div>
{/* Email */}
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Email address
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="youssef@yourcompany.com"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
{/* Role */}
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Role
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-3 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-zinc-900 dark:text-white">
{option.label}
</span>
{role === option.value && (
<span className="w-4 h-4 rounded-full bg-zinc-900 dark:bg-white flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white dark:text-zinc-900" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</span>
)}
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
{option.description}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{option.permissions.slice(0, 3).map((p) => (
<span
key={p}
className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400"
>
{p}
</span>
))}
{option.permissions.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
+{option.permissions.length - 3} more
</span>
)}
</div>
</button>
))}
</div>
</div>
{/* Error */}
{error && (
<div className="px-3 py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Footer */}
<div className="flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Sending' : 'Send invite '}
</button>
</div>
</form>
</div>
</div>
)
}
-316
View File
@@ -1,316 +0,0 @@
# Pages Specification — RentalDriveGo
---
## LAYER 1A — Marketing Site (RentalDriveGo.com)
### `/` — Home
Sections:
1. Hero — "Run your rental car business like a pro" + CTA
2. Two-column value: "For rental companies" / "For renters"
3. Social proof bar
4. Pain points (for companies)
5. Features grid — fleet, photos, offers, site, CRM, analytics
6. **Marketplace callout** — "Your vehicle photos appear on the RentalDriveGo global marketplace automatically"
7. How it works — 3 steps
8. Pricing teaser (AmanPay / PayPal accepted badges shown)
9. Testimonials
10. Final CTA
### `/pricing` — Pricing
- Three plan cards (Starter / Growth / Pro) with monthly/annual toggle
- **Payment method icons:** AmanPay logo + PayPal logo
- "Pay securely with AmanPay (cards, cash, e-wallet) or PayPal"
- Supported currencies: MAD, USD, EUR
- FAQ section
### `/features`, `/about`, `/contact`, `/blog` — standard
---
## LAYER 1B — Global Marketplace (RentalDriveGo.com/explore)
> **Design principle:** This is a DISCOVERY layer. No booking form, no payment form.
> Every CTA leads to the company's own site.
### `/explore` — Marketplace Home
Sections:
1. **Search Hero**
- Headline: "Find your next rental — from trusted local companies"
- Search bar: [City / Location] [Pick-up date] [Return date] [Search]
- Popular cities quick links
2. **Featured Offers Carousel**
- "🔥 Current Deals" header
- Horizontal scroll of offer cards:
- Vehicle photo (from company upload)
- Company logo + name
- Offer title + discount badge (e.g. "20% OFF")
- Valid until date
- **"View Deal →"** → links to `/explore/[slug]#offers`
3. **All Available Vehicles**
- Grid of vehicle cards showing ALL published vehicles from ALL companies:
- **Vehicle photo** (first photo from `Vehicle.photos[]`)
- Make, model, year, category badge
- Company logo + name (clickable)
- Daily rate + currency
- Active offer badge (if applicable, e.g. "20% OFF until July 31")
- Star rating from reviews
- **"Book at [Company Name] →"** — on click: redirect to `{slug}.RentalDriveGo.com/book?vehicleId=X&from=Y&to=Z&ref=marketplace`
- Filter panel: category, transmission, fuel type, price range, city, offer only
- Sort: price (low-high, high-low), rating, newest
4. **Browse by Category**
- Economy · Compact · SUV · Luxury · Van · Truck · Electric
5. **Browse by Company**
- Company cards: logo, name, city, rating, vehicle count, active offer badge
- **"View Fleet →"** → `/explore/[slug]`
6. **How It Works** (for first-time visitors)
- 3 steps: 1. Browse vehicles → 2. Click to go to company site → 3. Book & pay directly
### `/explore?...` — Search Results
- Same vehicle card grid, filtered by search params
- No booking happens here — every card links to company site with pre-filled params
- Availability indicator on each card ("3 available for your dates" vs "Check availability")
### `/explore/[slug]` — Company Marketplace Profile
This is the company's listing on the RentalDriveGo marketplace (RentalDriveGo-styled, not their branded site).
Sections:
1. Company header — logo, display name, city/country, rating, phone, "Save" heart
2. **Current Offers section** — offer cards with discount badges
3. **Available Vehicles grid** — all published vehicles with photos
- Each card: photo(s), specs, daily rate, offer badge, **"Book at [Company Name] →"** redirect button
4. **Reviews** — renter reviews with star ratings + company replies
5. Contact info — email, phone, WhatsApp link (opens `wa.me/...`)
6. "Visit their website →" link to `{slug}.RentalDriveGo.com`
### `/explore/[slug]/vehicles/[id]` — Vehicle Detail (Marketplace)
- **Photo gallery** (all `Vehicle.photos[]` uploaded by company)
- Full specs: make, model, year, color, category, seats, transmission, fuel, features
- Daily rate + active offer badge + calculated total for selected dates
- Availability calendar (shows blocked dates — unavailable for booking)
- Company info sidebar: logo, name, rating, phone, WhatsApp
- **BIG CTA: "Book at [Company Name] →"**
- Redirects to: `{slug}.RentalDriveGo.com/book?vehicleId=[id]&from=[startDate]&to=[endDate]&ref=marketplace`
- Opens in same tab
### No `/explore/[slug]/book` page
There is no booking flow on the marketplace. Booking happens entirely on the company's site.
---
## LAYER 1B — Renter Account
### `/renter/signup`, `/renter/login`
Standard auth pages. Login with Google optional.
### `/renter/dashboard` — My Rentals
- Cross-company reservations list (tabs: Upcoming / Active / Past / Cancelled)
- Each card: company logo, vehicle photo, dates, status, "View" → links to company site booking detail
### `/renter/notifications` — Notification Inbox
- All in-app notifications (new booking, reminders, offers, etc.)
### `/renter/saved-companies`
- Saved companies with "New Offer" badges if they have active offers
- "View Fleet →" links to marketplace profile
### `/renter/profile`
- Personal info, locale, currency preference
- Notification preferences matrix (Email / SMS / WhatsApp / Push per event type)
---
## LAYER 2 — Company Dashboard (app.RentalDriveGo.com)
### Auth
#### `/signup` — Signup Wizard
1. Account (name, email, password)
2. Company (name, country, phone)
3. Plan selection (Starter / Growth / Pro + Monthly / Annual + currency: MAD / USD / EUR)
4. Payment:
- **Option A: Pay with AmanPay** → shows AmanPay widget (card, cash, e-wallet)
- **Option B: Pay with PayPal** → redirect to PayPal, return here
- AmanPay and PayPal logos shown prominently
- "Supported: Visa, Mastercard, CMI, cash at 3000+ points, PayPal"
#### `/onboarding` — 4-Step Wizard
1. Company profile (address, phone)
2. Branding (logo, color, subdomain)
3. **Add first vehicle + upload photos** — emphasize: "Photos appear on the global marketplace"
4. Launch: "Your site is live at [slug].RentalDriveGo.com" + "Your vehicles are now visible on RentalDriveGo.com/explore"
### Dashboard Pages
#### `/dashboard` — Home
- Subscription status banner (trial / past-due / renewal due)
- KPI cards + active offers widget + booking source breakdown
#### `/dashboard/fleet` — Fleet Management
- Vehicle list with status + published/unpublished badge
- **Photos column** — thumbnail of first photo, "0 photos" warning badge if none uploaded
- Vehicle detail:
- **Photos section at top** — drag-drop upload area
- "Photos appear on marketplace" callout
- Upload multiple photos (Cloudinary)
- Reorder photos (first = cover image)
- Delete photos
- Specs + maintenance + reservation history
#### `/dashboard/offers` — Offers
- Create / manage promotional offers
- `isPublic` toggle: "Show on global marketplace"
- `isFeatured` toggle: "Feature on explore homepage" (GROWTH+ plan badge)
#### `/dashboard/reservations`
- Source filter includes MARKETPLACE (bookings from renter redirected from explore)
#### `/dashboard/billing` — Billing & Plan
- Current plan + status
- **"Renew Subscription"** button (manual renewal — AmanPay or PayPal)
- Invoice history (with AmanPay transaction IDs or PayPal capture IDs)
- Payment methods accepted: AmanPay badge + PayPal badge
#### `/dashboard/settings` — Settings
- **Payments tab** ← UPDATED:
- "Your rental payment methods" section (what renters use to pay YOU)
- AmanPay section: Merchant ID input + Secret Key input + "Test connection" button
- PayPal section: PayPal business email input + "Connect PayPal" button
- Active payment methods shown with green/grey status dots
- "If no payment method configured, renters can only make reservation requests (pay on pickup)"
#### `/dashboard/notifications` — Notification Center
- Inbox tab (in-app notification log)
- Preferences tab (Email / SMS / In-App / Push matrix per event type)
---
## LAYER 3 — Company Public Site ({slug}.RentalDriveGo.com)
> All booking and payment happens here. Company's brand. Company's AmanPay/PayPal.
### `/` — Home
- Company header: logo + name + nav
- Hero: tagline + hero image + "Browse Our Vehicles" CTA
- **Active Offers section** — offer banners with countdown (valid until X days)
- Vehicle grid (all published vehicles with photos)
- Footer: contact + social + "Powered by RentalDriveGo" (Pro removes)
### `/vehicles/[id]` — Vehicle Detail
- Photo gallery (all company-uploaded photos, full lightbox)
- Specs + features list
- Daily rate + active offer badge
- Availability calendar
- "Book Now" CTA
### `/offers` — All Offers
- All active offers with banner images, descriptions, discount badges, valid until
- "Book with this offer" → pre-fills booking form with offer applied
### `/book` — Booking Flow
The booking form accepts a `?vehicleId=X&from=Y&to=Z&ref=marketplace&offerId=O` querystring.
When `ref=marketplace` is present, the reservation is tagged as source=MARKETPLACE.
**Step 1 — Vehicle & Dates**
- Vehicle card (pre-filled if from marketplace redirect or offer click)
- Date range picker
- Active offer applied (if applicable) + promo code input
- Price breakdown: base rate discount = total
**Step 2 — Your Details**
- First name, last name, email, phone (required)
- Driver license (optional, company-configurable)
- Renter account login prompt (optional — "Save this booking to your RentalDriveGo account")
**Step 3 — Payment**
- Order summary card
- **Payment method selection:**
- **AmanPay tab** (if company has AmanPay configured):
- AmanPay widget loads inline (card national, card international, cash)
- Powered by AmanPay + M2T + PCI-DSS badge
- **PayPal tab** (if company has PayPal configured):
- PayPal buttons component
- If only one method: show directly without tabs
- If none: "Contact us to reserve" + company phone/WhatsApp
- "Confirm & Pay" / "Confirm Reservation"
### `/book/confirmation` — Booking Confirmed
- Booking reference number
- Full summary
- Company contact info
- "Download receipt" (PDF, optional)
- "Track this booking" → link to renter account
- Notifications already sent automatically
### `/contact` — Contact
- Contact form → email via Resend to company
---
## Admin Panel (admin.RentalDriveGo.com — internal)
> Separate app from the company dashboard. Own auth (JWT + TOTP 2FA). Only RentalDriveGo staff.
### Auth
- `/admin/login` — email + password + optional 2FA code field (shown if 2FA enabled)
- `/admin/login/2fa` — TOTP entry step
### `/admin` — Overview Dashboard
- Platform KPI cards: Total Companies, Active, Trialing, Suspended, MRR, Churn
- Recent signups feed
- Quick links to all sections
### `/admin/companies` — Company List
- Search (name, email, slug), filter (plan, status, country), sort (name, MRR, created)
- Table: Logo, Name, Plan badge, Status badge, Vehicle count, Employee count, MRR, Actions
- Actions: View | Edit | Suspend/Reactivate | Delete
- Bulk: Suspend selected, Export CSV
### `/admin/companies/:id` — Company Detail
- Tabs: Overview | Staff | Subscription | Activity | Settings
- **Overview**: brand info, legal info, payment accounts status
- **Staff tab**:
- Employee table: name, email, role badge, status (Active/Deactivated), last login
- Actions per employee: Edit | Change Role | Deactivate/Reactivate | Reset Password | Impersonate
- "Add Employee" button → modal: name, email, role → sends Clerk invitation
- Role dropdown: OWNER / MANAGER / AGENT with permission matrix tooltip
- **Subscription tab**:
- Current plan, status, billing period, last payment, next renewal
- Invoice history table
- Override controls: force status, change plan, mark invoice paid, add free period
- **Activity tab**: recent reservations, offers, audit events for this company
### `/admin/renters` — Renter Accounts
- Search by name, email, phone
- Filter: blocked / unblocked
- Table: name, email, phone, joined, reservation count, blocked badge
- Actions: View | Block/Unblock
### `/admin/metrics` — Platform Analytics
- MRR trend chart
- New companies per week
- Subscription breakdown by plan (donut chart)
- Marketplace: impressions, bookings, top companies
- Notification delivery rates per channel
### `/admin/users` — Admin User Management (SUPER_ADMIN only)
- List of admin accounts: name, email, role, 2FA status, last login
- Create admin: name, email, role → password emailed
- Edit role | Deactivate | View audit trail
### `/admin/audit-logs` — Audit Log
- Searchable, filterable (by admin, action, company, date range)
- Columns: Timestamp, Admin, Action, Target, Before/After (expandable), IP, Note
- Export CSV button
### `/admin/settings` — Admin Panel Settings
- Admin account: update own name, email, password
- 2FA: setup QR code, verify, enable/disable
-115
View File
@@ -1,115 +0,0 @@
'use client'
type Access = 'full' | 'partial' | 'none'
interface Feature {
label: string
manager: Access
managerNote?: string
agent: Access
agentNote?: string
}
const FEATURES: Feature[] = [
{ label: 'View fleet & vehicles', manager: 'full', agent: 'full' },
{ label: 'Add / edit vehicles', manager: 'full', agent: 'none' },
{ label: 'Publish / unpublish vehicle', manager: 'full', agent: 'none' },
{ label: 'Upload vehicle photos', manager: 'full', agent: 'none' },
{ label: 'View reservations', manager: 'full', agent: 'full' },
{ label: 'Create reservations', manager: 'full', agent: 'full' },
{ label: 'Cancel reservations', manager: 'full', agent: 'partial', agentNote: 'Own only' },
{ label: 'Check-in / check-out', manager: 'full', agent: 'full' },
{ label: 'Damage inspection', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — view', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — edit', manager: 'full', agent: 'partial', agentNote: 'Notes only' },
{ label: 'Approve driver licenses', manager: 'full', agent: 'none' },
{ label: 'Flag / blacklist customer', manager: 'full', agent: 'none' },
{ label: 'Offers & promotions', manager: 'full', agent: 'none' },
{ label: 'Analytics & reports', manager: 'full', agent: 'none' },
{ label: 'Contract & invoice PDF', manager: 'full', agent: 'full' },
{ label: 'Billing & subscription', manager: 'none', agent: 'none' },
{ label: 'Invite / remove staff', manager: 'none', agent: 'none' },
{ label: 'Brand & site settings', manager: 'none', agent: 'none' },
{ label: 'Payment settings', manager: 'none', agent: 'none' },
]
function AccessCell({ access, note }: { access: Access; note?: string }) {
if (access === 'full') {
return (
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-xs text-zinc-700 dark:text-zinc-300">Full</span>
</div>
)
}
if (access === 'partial') {
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2.5 h-0.5 rounded bg-amber-500" />
</div>
<span className="text-xs text-amber-700 dark:text-amber-400">{note ?? 'Limited'}</span>
</div>
)
}
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
</div>
<span className="text-xs text-zinc-400 dark:text-zinc-600">No access</span>
</div>
)
}
export default function PermissionsMatrix() {
return (
<div>
<div className="mb-4">
<h2 className="text-base font-medium text-zinc-900 dark:text-white">Role permissions</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
What each role can do across the dashboard. Owners have full access to everything.
</p>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
{/* Header */}
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
Feature
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Manager
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Agent
</div>
</div>
{/* Rows */}
{FEATURES.map((feature, i) => (
<div
key={feature.label}
className={[
'grid grid-cols-[1fr_120px_120px]',
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
i % 2 === 0 ? '' : 'bg-zinc-50/50 dark:bg-zinc-800/20',
].join(' ')}
>
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
{feature.label}
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.manager} note={feature.managerNote} />
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.agent} note={feature.agentNote} />
</div>
</div>
))}
</div>
</div>
)
}
-109
View File
@@ -1,109 +0,0 @@
# 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Marketplace
## Platform Model
**Rental Companies (B2B)** — pay RentalDriveGoa subscription (AmanPay or PayPal), get:
- A fully private dashboard (zero data overlap with any other company)
- Fleet management with vehicle photo upload (photos auto-appear on global marketplace)
- Promotional offers management
- A white-label site at `company.RentalDriveGo.com` where renters book and pay
**Renters (B2C)** — free account, can:
- Browse ALL companies' vehicles and offers on `RentalDriveGo.com/explore`
- Click a vehicle → **redirected to the company's site** to book and pay
- Receive full notifications (Email + SMS + WhatsApp + Push + In-App)
- Track all bookings across companies in one account
---
## ⚠️ Payment Providers — Stripe Has Been Removed
| Provider | Purpose |
|----------|---------|
| **AmanPay** (amanpay.net) | Primary. National/international cards, cash (3000+ points), e-wallet. Moroccan PCI-DSS Level-1 PSP. |
| **PayPal** | Secondary. Global coverage. |
**Two payment contexts:**
1. Company pays RentalDriveGosubscription → RentalDriveGo's own AmanPay/PayPal account
2. Renter pays company for rental → Company's own AmanPay merchant + PayPal account (direct, no intermediary)
---
## 🌐 Marketplace is Discovery Only — No Booking on RentalDriveGo.com/explore
The global marketplace shows all vehicles with photos from all companies. When a renter clicks "Book this vehicle", they are **redirected to the company's branded site** (`{slug}.RentalDriveGo.com/book?vehicleId=X&from=Y&to=Z&ref=marketplace`). The booking form is pre-filled. All payment happens on the company site using the company's own payment accounts.
---
## 📸 Vehicle Photos → Marketplace Visibility
```
Company uploads photos in /dashboard/fleet
→ Stored in Cloudinary
→ Vehicle.photos[] holds the URLs
→ First photo = cover image on marketplace cards
→ All photos shown in marketplace vehicle gallery
→ isPublished=true makes vehicle appear on marketplace
```
---
## 🏗 Five-Layer Architecture
```
Layer 1A: RentalDriveGo.com — B2B marketing site
Layer 1B: RentalDriveGo.com/explore — B2C marketplace (discovery + redirect only)
Layer 2: app.RentalDriveGo.com — Company dashboard (private, subscription-gated)
Layer 3: {slug}.RentalDriveGo.com — Company branded site (booking + payment here)
Layer 4: api.RentalDriveGo.com — REST API
```
---
## 📁 Project Structure
```
rental-car-site/
├── README.md
├── docs/
│ ├── ARCHITECTURE.md ← Payment providers, marketplace redirect model, photo flow
│ ├── DESIGN_SYSTEM.md
│ ├── PAGES.md ← All pages. Marketplace = discovery only. Booking = company site.
│ ├── FEATURES.md
│ └── DEPLOYMENT.md
└── skills/
├── rental-car-website/
├── rental-car-components/
├── rental-car-backend/
│ └── references/
│ ├── schema.md ← AmanPay/PayPal fields, no Stripe
│ ├── api-routes.md ← All routes
│ ├── payment-service.md ← AmanPay + PayPal implementation ← KEY FILE
│ ├── subscription-service.md ← Manual renewal, plan prices in MAD/USD/EUR
│ ├── subdomain-service.md ← Marketplace redirect + company site
│ └── notification-service.md ← All 5 channels
└── rental-car-i18n/
```
---
## 🛠 Tech Stack
| Concern | Technology |
|---------|-----------|
| Subscription payment | AmanPay (primary) + PayPal (secondary) |
| Rental payment | Company's own AmanPay merchant + PayPal |
| PDF generation | `@react-pdf/renderer` — server-side, on-demand, never stored |
| Damage diagrams | SVG top-down car map (22 zones, React interactive + PDF static render) |
| Insurance | Per-company configurable policies with per-day/flat/% charge types |
| Pricing rules | Age/experience-based surcharges & discounts (configurable per company) |
| Email | Resend |
| SMS + WhatsApp | Twilio |
| Push | Firebase FCM |
| Real-time in-app | Socket.io + Redis |
| Images | Cloudinary |
| Company auth | Clerk |
| Renter auth | JWT (custom) |
| API | Node.js + Express + Prisma + PostgreSQL |
| Frontend | Next.js 14 + React 18 + Vite |
| Deployment | Vercel + Railway |
File diff suppressed because it is too large Load Diff
-498
View File
@@ -1,498 +0,0 @@
# API Routes Inventory — RentalDriveGo (Complete)
Base URL: `https://api.RentalDriveGo.com/api/v1`
## Middleware Legend
- 🔒 `requireCompanyAuth` — valid Clerk JWT (Employee)
- 🏢 `requireTenant` — attaches `req.company` + `req.companyId` + `req.employee`
- 💳 `requireSubscription` — blocks SUSPENDED/PENDING companies
- 🎫 `requireRenterAuth` — valid Renter JWT
- 🔑 `requireApiKey` — company public API key in `x-api-key` header
- 👑 `requireAdmin` — RentalDriveGo super-admin role
> **Tenant isolation rule:** Every 🏢 route must use `where: { companyId: req.companyId }` in every query.
---
## Auth — Company Employees
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/auth/company/signup` | — | Create Clerk user + Company + AmanPay/PayPal customer |
| POST | `/auth/company/verify-email` | — | Verify email token |
*(Clerk handles login/logout/session refresh on the frontend)*
---
## Auth — Renters
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/auth/renter/signup` | — | Create renter account |
| POST | `/auth/renter/login` | — | Email + password → JWT |
| POST | `/auth/renter/logout` | 🎫 | Invalidate token |
| POST | `/auth/renter/verify-email` | — | Email verification token |
| POST | `/auth/renter/forgot-password` | — | Send reset email |
| POST | `/auth/renter/reset-password` | — | Reset with token |
| GET | `/auth/renter/me` | 🎫 | Get renter profile |
| PATCH | `/auth/renter/me` | 🎫 | Update profile (name, phone, locale, currency) |
| POST | `/auth/renter/me/fcm-token` | 🎫 | Register FCM push token |
---
## Subscriptions (AmanPay/PayPal subscription — RentalDriveGo collects)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/subscriptions/plans` | — | List plans + prices (public) |
| GET | `/subscriptions/me` | 🔒🏢 | Current subscription |
| POST | `/subscriptions/checkout` | 🔒🏢 | Create AmanPay/PayPal Checkout session |
| POST | `/subscriptions/portal` | 🔒🏢 | Open AmanPay/PayPal Customer Portal |
| GET | `/subscriptions/invoices` | 🔒🏢💳 | Invoice history |
| POST | `/subscriptions/webhook` | — (sig) | AmanPay/PayPal subscription webhook |
---
## Companies
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/companies/me` | 🔒🏢 | Company profile |
| PATCH | `/companies/me` | 🔒🏢💳 | Update profile |
| GET | `/companies/me/brand` | 🔒🏢 | Brand settings |
| PATCH | `/companies/me/brand` | 🔒🏢💳 | Update branding |
| POST | `/companies/me/brand/logo` | 🔒🏢💳 | Upload logo |
| POST | `/companies/me/brand/hero` | 🔒🏢💳 | Upload hero image |
| POST | `/companies/me/brand/subdomain/check` | 🔒🏢💳 | Check subdomain availability |
| POST | `/companies/me/brand/custom-domain` | 🔒🏢💳 | Add custom domain (Pro) |
| GET | `/companies/me/brand/custom-domain/status` | 🔒🏢💳 | Domain verification status |
| DELETE | `/companies/me/brand/custom-domain` | 🔒🏢💳 | Remove custom domain |
| GET | `/companies/me/api-key` | 🔒🏢💳 | Get public API key |
| POST | `/companies/me/api-key/regenerate` | 🔒🏢💳 | Regenerate API key |
| GET | `/companies/me/stripe-connect` | 🔒🏢💳 | Connect status |
| POST | `/companies/me/stripe-connect` | 🔒🏢💳 | Start Connect OAuth |
| POST | `/companies/me/stripe-connect/webhook` | — (sig) | Connect events |
---
## Team
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/team` | 🔒🏢💳 | List employees |
| POST | `/team/invite` | 🔒🏢💳 | Invite by email |
| PATCH | `/team/:id/role` | 🔒🏢💳 | Change role |
| DELETE | `/team/:id` | 🔒🏢💳 | Remove employee |
---
## Vehicles (Company fleet — strictly isolated)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/vehicles` | 🔒🏢💳 | List `?status=&category=&published=` |
| POST | `/vehicles` | 🔒🏢💳 | Add vehicle (checks plan limit) |
| GET | `/vehicles/:id` | 🔒🏢💳 | Detail (validates companyId) |
| PATCH | `/vehicles/:id` | 🔒🏢💳 | Update |
| DELETE | `/vehicles/:id` | 🔒🏢💳 | Archive |
| POST | `/vehicles/:id/photos` | 🔒🏢💳 | Upload photos (Cloudinary) |
| DELETE | `/vehicles/:id/photos/:idx` | 🔒🏢💳 | Remove photo |
| PATCH | `/vehicles/:id/publish` | 🔒🏢💳 | Toggle published |
| GET | `/vehicles/:id/availability` | 🔒🏢💳 | `?startDate=&endDate=` |
| GET | `/vehicles/:id/reservations` | 🔒🏢💳 | Reservation history |
| GET | `/vehicles/:id/maintenance` | 🔒🏢💳 | Maintenance log |
| POST | `/vehicles/:id/maintenance` | 🔒🏢💳 | Add maintenance entry |
---
## Offers (Company promotional offers)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/offers` | 🔒🏢💳 | List all offers `?active=&public=` |
| POST | `/offers` | 🔒🏢💳 | Create offer |
| GET | `/offers/:id` | 🔒🏢💳 | Offer detail |
| PATCH | `/offers/:id` | 🔒🏢💳 | Update offer |
| DELETE | `/offers/:id` | 🔒🏢💳 | Delete offer |
| POST | `/offers/:id/activate` | 🔒🏢💳 | Activate offer |
| POST | `/offers/:id/deactivate` | 🔒🏢💳 | Deactivate offer |
| POST | `/offers/:id/feature` | 🔒🏢💳 | Toggle featured (plan-gated) |
| GET | `/offers/:id/stats` | 🔒🏢💳 | Redemption stats, revenue impact |
---
## Reservations (Company view — includes all sources)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/reservations` | 🔒🏢💳 | List `?status=&vehicleId=&source=&startDate=&endDate=` |
| POST | `/reservations` | 🔒🏢💳 | Create (dashboard) |
| GET | `/reservations/:id` | 🔒🏢💳 | Detail |
| PATCH | `/reservations/:id` | 🔒🏢💳 | Update |
| POST | `/reservations/:id/confirm` | 🔒🏢💳 | Confirm + charge |
| POST | `/reservations/:id/checkin` | 🔒🏢💳 | Check in `{ mileage }` |
| POST | `/reservations/:id/checkout` | 🔒🏢💳 | Check out + complete `{ mileage }` |
| POST | `/reservations/:id/cancel` | 🔒🏢💳 | Cancel `{ reason }` |
| POST | `/reservations/:id/no-show` | 🔒🏢💳 | Mark no-show |
| POST | `/reservations/:id/reply-review` | 🔒🏢💳 | Reply to renter review |
---
## Customers (Company CRM)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/customers` | 🔒🏢💳 | List `?q=&flagged=` |
| POST | `/customers` | 🔒🏢💳 | Create |
| GET | `/customers/:id` | 🔒🏢💳 | Detail + reservation history |
| PATCH | `/customers/:id` | 🔒🏢💳 | Update |
| POST | `/customers/:id/flag` | 🔒🏢💳 | Flag `{ reason }` |
| DELETE | `/customers/:id/flag` | 🔒🏢💳 | Remove flag |
---
## Payments (AmanPay/PayPal rental — rental revenue)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/payments` | 🔒🏢💳 | List payments |
| GET | `/payments/:id` | 🔒🏢💳 | Payment detail |
| POST | `/payments/:id/refund` | 🔒🏢💳 | Issue refund |
| POST | `/payments/webhook` | — (sig) | AmanPay/PayPal rental webhook |
---
## Analytics
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/analytics/summary` | 🔒🏢💳 | KPIs `?period=7d|30d|90d` |
| GET | `/analytics/revenue` | 🔒🏢💳 | Revenue time series |
| GET | `/analytics/vehicles` | 🔒🏢💳 | Per-vehicle performance |
| GET | `/analytics/utilization` | 🔒🏢💳 | Fleet utilization |
| GET | `/analytics/offers` | 🔒🏢💳 | Offer performance + redemptions |
| GET | `/analytics/sources` | 🔒🏢💳 | Booking source breakdown |
---
## Notifications — Company
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/notifications/company` | 🔒🏢💳 | In-app notification log `?unread=true` |
| POST | `/notifications/company/:id/read` | 🔒🏢💳 | Mark as read |
| POST | `/notifications/company/read-all` | 🔒🏢💳 | Mark all read |
| GET | `/notifications/company/preferences` | 🔒🏢💳 | Get notification preferences |
| PATCH | `/notifications/company/preferences` | 🔒🏢💳 | Update preferences per type+channel |
---
## Notifications — Renter
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/notifications/renter` | 🎫 | Renter in-app notifications |
| POST | `/notifications/renter/:id/read` | 🎫 | Mark as read |
| POST | `/notifications/renter/read-all` | 🎫 | Mark all read |
| GET | `/notifications/renter/preferences` | 🎫 | Get preferences |
| PATCH | `/notifications/renter/preferences` | 🎫 | Update preferences |
---
## Renter — Marketplace Actions
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/renter/reservations` | 🎫 | All reservations (cross-company) |
| GET | `/renter/reservations/:id` | 🎫 | Reservation detail |
| POST | `/renter/reservations/:id/cancel` | 🎫 | Cancel `{ reason }` |
| GET | `/renter/saved-companies` | 🎫 | Saved companies |
| POST | `/renter/saved-companies/:slug` | 🎫 | Save company |
| DELETE | `/renter/saved-companies/:slug` | 🎫 | Unsave company |
| POST | `/renter/reviews` | 🎫 | Submit review (completed reservation only) |
| GET | `/renter/reviews` | 🎫 | Renter's review history |
---
## Global Marketplace API (public, optional renter auth)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/marketplace/offers` | opt 🎫 | Featured + recent offers across all companies |
| GET | `/marketplace/companies` | opt 🎫 | Company list `?city=&rating=&hasOffer=` |
| GET | `/marketplace/search` | opt 🎫 | Search vehicles `?city=&startDate=&endDate=&category=&maxPrice=` |
| GET | `/marketplace/[slug]` | opt 🎫 | Company marketplace profile |
| GET | `/marketplace/[slug]/vehicles` | opt 🎫 | Published vehicles (no internal fields) |
| GET | `/marketplace/[slug]/offers` | opt 🎫 | Active public offers |
| POST | `/marketplace/[slug]/book` | opt 🎫 | Book (renter auth preferred, guest allowed) |
| GET | `/marketplace/[slug]/reviews` | opt 🎫 | Company reviews |
| POST | `/marketplace/offers/:code/validate` | opt 🎫 | Validate promo code |
---
## Company Public Site API (slug-resolved, no user auth)
> Used by SSR pages on `{slug}.RentalDriveGo.com`. Never subscription-gated.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/site/:slug/brand` | 🔑 | Branding for SSR |
| GET | `/site/:slug/vehicles` | 🔑 | Published vehicles |
| GET | `/site/:slug/vehicles/:id` | 🔑 | Vehicle detail + availability |
| GET | `/site/:slug/offers` | 🔑 | Active offers |
| POST | `/site/:slug/availability` | 🔑 | Date range availability check |
| POST | `/site/:slug/book` | 🔑 | Create booking + payment |
| POST | `/site/:slug/book/validate-code` | 🔑 | Validate promo code |
| GET | `/site/:slug/booking/:id` | 🔑 | Booking status (confirmation page) |
| POST | `/site/:slug/contact` | 🔑 | Contact form → email to company |
---
## Admin (RentalDriveGo internal)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/admin/companies` | 👑 | All companies |
| GET | `/admin/companies/:id` | 👑 | Company detail + subscription |
| PATCH | `/admin/companies/:id/status` | 👑 | Force status |
| GET | `/admin/offers/featured` | 👑 | All featured offers |
| GET | `/admin/metrics` | 👑 | Platform MRR, churn, signups |
| GET | `/admin/notifications/stats` | 👑 | Delivery rates per channel |
---
## Standard Response Formats
```json
// Success
{ "data": { ... } }
// Paginated
{ "data": [...], "total": 47, "page": 1, "pageSize": 20, "totalPages": 3 }
// Error
{ "error": "vehicle_not_found", "message": "Vehicle not found or access denied", "statusCode": 404 }
// Subscription error
{ "error": "subscription_suspended", "message": "...", "statusCode": 402, "billingUrl": "https://..." }
```
## Common Query Params
`page`, `pageSize` (max 100), `sortBy`, `sortOrder` (asc|desc), `q` (search)
---
## Payment Routes — AmanPay + PayPal
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/payments/amanpay-webhook` | — (HMAC sig) | AmanPay event webhook (subscriptions + rentals) |
| POST | `/payments/paypal/create-order` | 🔒🏢 or 🎫 | Create PayPal order (subscription or rental) |
| POST | `/payments/paypal/capture` | 🔒🏢 or 🎫 | Capture PayPal order after approval |
| POST | `/payments/paypal/refund` | 🔒🏢💳 | Refund PayPal capture |
| GET | `/payments` | 🔒🏢💳 | Payment history (AmanPay + PayPal combined) |
| GET | `/payments/:id` | 🔒🏢💳 | Payment detail |
| POST | `/payments/:id/refund` | 🔒🏢💳 | Refund (AmanPay or PayPal auto-detected) |
---
## Documents — Contracts & Invoices (On-Demand PDF Generation)
> **Design rule:** PDFs are NEVER stored. Every request hits the DB, assembles data, renders PDF via `@react-pdf/renderer`, and streams the result. Reference numbers (`contractNumber`, `invoiceNumber`) ARE stored in the DB and assigned once via atomic transaction.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/documents/reservations/:id/contract` | 🔒🏢💳 | Generate + stream contract PDF. `?download=true` forces file download vs inline view. |
| GET | `/documents/reservations/:id/invoice` | 🔒🏢💳 | Generate + stream invoice PDF. |
| POST | `/documents/reservations/:id/send` | 🔒🏢💳 | Generate + email PDF(s) as attachment. Body: `{ type: "contract"|"invoice"|"both", email?: string }` |
| GET | `/documents/settings` | 🔒🏢💳 | Get company's contract settings (terms, prefixes, tax config) |
| PATCH | `/documents/settings` | 🔒🏢💳 | Update contract settings. Sequence counters are protected from API reset. |
| GET | `/documents/settings/preview-contract` | 🔒🏢💳 | Preview contract PDF with sample data (for settings page preview) |
| GET | `/documents/settings/preview-invoice` | 🔒🏢💳 | Preview invoice PDF with sample data |
| GET | `/renter/reservations/:id/contract` | 🎫 | Renter downloads their own contract (renterId must match) |
| GET | `/renter/reservations/:id/invoice` | 🎫 | Renter downloads their own invoice |
---
## Damage Inspections
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/reservations/:id/inspections` | 🔒🏢💳 | Create check-in or check-out inspection (with damage points) |
| GET | `/reservations/:id/inspections` | 🔒🏢💳 | Get both inspections (checkin + checkout) |
| GET | `/reservations/:id/inspections/checkin` | 🔒🏢💳 | Check-in inspection detail |
| GET | `/reservations/:id/inspections/checkout` | 🔒🏢💳 | Check-out inspection detail |
---
## Billing (per-reservation line items)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `//:id/billing` | 🔒🏢💳 | Get full itemized billing breakdown (JSON — for invoice preview) |
| PATCH | `/reservations/:id/billing` | 🔒🏢💳 | Add damage charges and extras to reservation |
---
## Accounting Reports
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/accounting/settings` | 🔒🏢💳 | Get company accounting settings |
| PATCH | `/accounting/settings` | 🔒🏢💳 | Update accounting settings (period, accountant, taxes, fuel policy) |
| GET | `/accounting/report` | 🔒🏢💳 | Get accounting report as JSON `?period=MONTHLY&from=&to=` |
| GET | `/accounting/report/pdf` | 🔒🏢💳 | Stream accounting report PDF |
| GET | `/accounting/report/csv` | 🔒🏢💳 | Stream CSV file |
| POST | `/accounting/report/send` | 🔒🏢💳 | Email report to accountant `{ period, from, to, format }` |
| GET | `/accounting/report/periods` | 🔒🏢💳 | List available periods with labels and date ranges |
---
## Admin Panel Routes (admin.RentalDriveGo.com → api.RentalDriveGo.com/admin)
> All require admin JWT. Role hierarchy: VIEWER < FINANCE < SUPPORT < ADMIN < SUPER_ADMIN
### Admin Auth
| Method | Path | Role | Description |
|--------|------|------|-------------|
| POST | `/admin/auth/login` | — | Login `{ email, password, totpCode? }` |
| POST | `/admin/auth/logout` | VIEWER | Invalidate token |
| GET | `/admin/auth/me` | VIEWER | Current admin profile |
| PATCH | `/admin/auth/me` | VIEWER | Update name/password |
| POST | `/admin/auth/2fa/setup` | VIEWER | Generate TOTP secret + QR code |
| POST | `/admin/auth/2fa/verify` | VIEWER | Verify and enable 2FA |
### Admin User Management (SUPER_ADMIN only)
| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/admin/users` | SUPER_ADMIN | List admin users |
| POST | `/admin/users` | SUPER_ADMIN | Create admin user |
| PATCH | `/admin/users/:id` | SUPER_ADMIN | Edit name, role |
| POST | `/admin/users/:id/deactivate` | SUPER_ADMIN | Deactivate admin |
| POST | `/admin/users/:id/reset-password` | SUPER_ADMIN | Force password reset |
| PATCH | `/admin/users/:id/permissions` | SUPER_ADMIN | Override permissions |
### Company Management (SUPPORT+)
| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/admin/companies` | SUPPORT | List all companies |
| POST | `/admin/companies` | SUPPORT | Create company |
| GET | `/admin/companies/:id` | SUPPORT | Company detail |
| PATCH | `/admin/companies/:id` | SUPPORT | Edit company |
| POST | `/admin/companies/:id/suspend` | SUPPORT | Suspend `{ reason }` |
| POST | `/admin/companies/:id/reactivate` | SUPPORT | Reactivate |
| DELETE | `/admin/companies/:id` | ADMIN | Delete company |
| PATCH | `/admin/companies/:id/subscription` | SUPPORT | Override subscription |
| POST | `/admin/companies/:id/invoices/:invoiceId/mark-paid` | ADMIN | Manual mark paid |
### Company Staff Management (SUPPORT+)
| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/admin/companies/:id/employees` | SUPPORT | List employees |
| POST | `/admin/companies/:id/employees` | SUPPORT | Add employee |
| GET | `/admin/companies/:id/employees/:empId` | SUPPORT | Employee detail |
| PATCH | `/admin/companies/:id/employees/:empId` | SUPPORT | Edit employee |
| PATCH | `/admin/companies/:id/employees/:empId/role` | SUPPORT | Change role |
| POST | `/admin/companies/:id/employees/:empId/deactivate` | SUPPORT | Deactivate |
| POST | `/admin/companies/:id/employees/:empId/reactivate` | SUPPORT | Reactivate |
| POST | `/admin/companies/:id/employees/:empId/reset-password` | SUPPORT | Send reset |
| POST | `/admin/companies/:id/employees/:empId/impersonate` | ADMIN | Generate impersonation token |
### Renters, Metrics, Audit
| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/admin/renters` | SUPPORT | List renters |
| PATCH | `/admin/renters/:id` | SUPPORT | Update renter |
| POST | `/admin/renters/:id/block` | SUPPORT | Block `{ reason }` |
| POST | `/admin/renters/:id/unblock` | SUPPORT | Unblock |
| GET | `/admin/metrics` | FINANCE | Platform KPIs |
| GET | `/admin/metrics/subscriptions` | FINANCE | Plan breakdown |
| GET | `/admin/audit-logs` | ADMIN | Paginated audit log |
| GET | `/admin/audit-logs/export` | ADMIN | CSV export |
---
## Advanced Features — New Routes
### Damage Reports
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `//:id/damage-reports` | 🔒🏢💳 | Get check-in + check-out damage reports |
| POST | `/reservations/:id/damage-reports` | 🔒🏢💳 | Save damage report. Body: `{ type: "CHECKIN"|"CHECKOUT", damages: DamageZone[], fuelLevel, mileage, photos[], inspectedBy, customerSignedAt, customerName }` |
| PATCH | `//:id/damage-reports/:type` | 🔒🏢💳 | Update a damage report (before finalizing) |
### Insurance Policies (Company configuration)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/insurance-policies` | 🔒🏢💳 | List company's insurance policies |
| POST | `/insurance-policies` | 🔒🏢💳 | Create policy |
| PATCH | `/insurance-policies/:id` | 🔒🏢💳 | Update policy |
| DELETE | `/insurance-policies/:id` | 🔒🏢💳 | Deactivate policy |
| GET | `/reservations/:id/insurances` | 🔒🏢💳 | Insurance selections for a reservation |
| POST | `//:id/insurances` | 🔒🏢💳 | Apply insurance(s) to reservation. Body: `{ policyIds: string[] }` |
### Additional Drivers
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/reservations/:id/additional-drivers` | 🔒🏢💳 | List additional drivers |
| POST | `/reservations/:id/additional-drivers` | 🔒🏢💳 | Add additional driver. Body: `{ firstName, lastName, driverLicense, licenseExpiry, dateOfBirth, ... }` |
| PATCH | `/reservations/:id/additional-drivers/:did` | 🔒🏢💳 | Update driver info |
| DELETE | `/reservations/:id/additional-drivers/:did` | 🔒🏢💳 | Remove additional driver |
| POST | `/reservations/:id/additional-drivers/:did/approve-license` | 🔒🏢💳 (OWNER/MANAGER) | Approve flagged license. Body: `{ decision: "APPROVE"|"DENY", note }` |
### Driver License Validation
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/customers/:id/validate-license` | 🔒🏢💳 | Validate license expiry + flag if <3 months. Returns `{ status, daysUntilExpiry, requiresApproval }` |
| POST | `/customers/:id/approve-license` | 🔒🏢💳 (OWNER/MANAGER) | Approve or deny flagged license. Body: `{ decision: "APPROVE"|"DENY", note }` |
### Pricing Rules
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/pricing-rules` | 🔒🏢💳 | List company's pricing rules |
| POST | `/pricing-rules` | 🔒🏢💳 | Create rule (age surcharge, license discount, etc.) |
| PATCH | `/pricing-rules/:id` | 🔒🏢💳 | Update rule |
| DELETE | `/pricing-rules/:id` | 🔒🏢💳 | Deactivate rule |
| POST | `/pricing-rules/preview` | 🔒🏢💳 | Preview rules applied to a hypothetical booking. Body: `{ customerId, additionalDrivers, dailyRate, totalDays }` |
### Financial Reporting (Accountant Export)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/reports/financial` | 🔒🏢💳 | Financial report. `?period=WEEK|MONTH|YEAR&startDate=&endDate=&format=JSON|CSV` |
| GET | `/reports/financial/summary` | 🔒🏢💳 | Aggregate totals only (for dashboard KPIs) |
### Admin Panel Routes
Base path: `/api/v1/admin/` — requires `requireAdminAuth` middleware (separate JWT from company auth)
| Method | Path | Role | Description |
|--------|------|------|-------------|
| POST | `/admin/auth/login` | — | Admin login |
| GET | `/admin/auth/me` | Any | Admin profile |
| GET | `/admin/companies` | Any | All companies `?q=&status=&plan=` |
| POST | `/admin/companies` | ADMIN+ | Create company |
| GET | `/admin/companies/:id` | Any | Full company detail |
| PATCH | `/admin/companies/:id` | ADMIN+ | Edit company profile |
| PATCH | `/admin/companies/:id/status` | ADMIN+ | Change status |
| PATCH | `/admin/companies/:id/plan` | ADMIN+ | Change plan (bypasses payment) |
| DELETE | `/admin/companies/:id` | SUPER_ADMIN | Hard delete |
| POST | `/admin/companies/:id/impersonate` | ADMIN+ | Get 30-min impersonation token |
| GET | `/admin/companies/:id/employees` | Any | List employees |
| POST | `/admin/companies/:id/employees` | ADMIN+ | Add employee |
| PATCH | `/admin/companies/:id/employees/:eid` | ADMIN+ | Edit employee |
| PATCH | `/admin/companies/:id/employees/:eid/role` | ADMIN+ | Change role |
| DELETE | `/admin/companies/:id/employees/:eid` | ADMIN+ | Deactivate |
| POST | `/admin/companies/:id/employees/:eid/reset-password` | ADMIN+ | Trigger reset email |
| GET | `/admin/metrics` | Any | Platform MRR, churn, signups |
| GET | `/admin/admins` | SUPER_ADMIN | List admin users |
| POST | `/admin/admins` | SUPER_ADMIN | Create admin |
| PATCH | `/admin/admins/:id/role` | SUPER_ADMIN | Change admin role |
| DELETE | `/admin/admins/:id` | SUPER_ADMIN | Deactivate admin |
| GET | `/admin/audit-log` | SUPER_ADMIN | Audit log `?adminId=&targetType=&startDate=&endDate=` |
-47
View File
@@ -1,47 +0,0 @@
import { Request, Response, NextFunction } from 'express'
import { EmployeeRole } from '@prisma/client'
// Role hierarchy: OWNER > MANAGER > AGENT
const ROLE_RANK: Record<EmployeeRole, number> = {
OWNER: 3,
MANAGER: 2,
AGENT: 1,
}
/**
* requireRole(minimumRole)
* Middleware that blocks requests from employees whose role rank
* is below the required minimum.
*
* Usage:
* router.post('/invite', requireRole('OWNER'), handler)
* router.patch('/something', requireRole('MANAGER'), handler)
*/
export function requireRole(minimumRole: EmployeeRole) {
return (req: Request, res: Response, next: NextFunction) => {
const employee = req.employee
if (!employee) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Authentication required',
statusCode: 401,
})
}
const employeeRank = ROLE_RANK[employee.role as EmployeeRole] ?? 0
const requiredRank = ROLE_RANK[minimumRole] ?? 99
if (employeeRank < requiredRank) {
return res.status(403).json({
error: 'forbidden',
message: `This action requires the ${minimumRole} role or higher`,
statusCode: 403,
requiredRole: minimumRole,
yourRole: employee.role,
})
}
next()
}
}
-980
View File
@@ -1,980 +0,0 @@
# Prisma Schema — RentalDriveGo (Complete)
> Stripe has been removed. Payments use AmanPay and PayPal.
```prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ═══════════════════════════════════════════════════════════════
// B2B — COMPANY SIDE
// ═══════════════════════════════════════════════════════════════
model Company {
id String @id @default(cuid())
name String
slug String @unique
email String @unique
phone String?
address Json?
status CompanyStatus @default(PENDING)
// RentalDriveGo billing — company pays subscription via AmanPay or PayPal
// No third-party customer ID needed — we manage payment references ourselves
subscriptionPaymentRef String? // last AmanPay/PayPal transaction ref
// Public API key
apiKey String @unique @default(cuid())
subscription Subscription?
brand BrandSettings?
employees Employee[]
vehicles Vehicle[]
offers Offer[]
reservations Reservation[]
customers Customer[]
rentalPayments RentalPayment[]
subscriptionInvoices SubscriptionInvoice[]
contractSettings ContractSettings?
accountingSettings AccountingSettings?
notifications Notification[] @relation("CompanyNotifications")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("companies")
}
enum CompanyStatus {
PENDING
TRIALING
ACTIVE
PAST_DUE
SUSPENDED
CANCELLED
}
// ─── Subscription ─────────────────────────────────────────────
model Subscription {
id String @id @default(cuid())
companyId String @unique
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
plan Plan @default(STARTER)
billingPeriod BillingPeriod @default(MONTHLY)
status SubscriptionStatus @default(TRIALING)
currency String @default("MAD") // MAD, USD, EUR
trialStartAt DateTime?
trialEndAt DateTime?
currentPeriodStart DateTime?
currentPeriodEnd DateTime?
cancelledAt DateTime?
cancelAtPeriodEnd Boolean @default(false)
invoices SubscriptionInvoice[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("subscriptions")
}
enum Plan { STARTER GROWTH PRO }
enum BillingPeriod { MONTHLY ANNUAL }
enum SubscriptionStatus { TRIALING ACTIVE PAST_DUE CANCELLED UNPAID }
// ─── Subscription Invoice ──────────────────────────────────────
// Records each subscription payment (AmanPay or PayPal)
model SubscriptionInvoice {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id])
amount Int // in smallest unit (centimes for MAD)
currency String @default("MAD")
status InvoiceStatus
// Payment provider reference — exactly one is set
amanpayTransactionId String? @unique
paypalCaptureId String? @unique
paymentProvider PaymentProvider @default(AMANPAY)
paidAt DateTime?
createdAt DateTime @default(now())
@@index([companyId])
@@map("subscription_invoices")
}
enum InvoiceStatus { PENDING PAID FAILED REFUNDED }
enum FuelPolicyType { FULL_TO_FULL FULL_TO_EMPTY SAME_TO_SAME PREPAID FREE }
enum PaymentProvider { AMANPAY PAYPAL }
// ─── Brand Settings ───────────────────────────────────────────
model BrandSettings {
id String @id @default(cuid())
companyId String @unique
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
displayName String
tagline String?
logoUrl String?
faviconUrl String?
heroImageUrl String?
primaryColor String @default("#1A56DB")
accentColor String @default("#F59E0B")
subdomain String @unique
customDomain String? @unique
customDomainVerified Boolean @default(false)
customDomainAddedAt DateTime?
// Public contact info
publicEmail String?
publicPhone String?
publicAddress String?
publicCity String?
publicCountry String?
websiteUrl String?
whatsappNumber String?
facebookUrl String?
instagramUrl String?
// Locale + currency for public site
defaultLocale String @default("en")
defaultCurrency String @default("MAD")
// Company's own payment accounts (for receiving rental payments)
amanpayMerchantId String? // company's AmanPay merchant ID
amanpaySecretKey String? // encrypted at rest
paypalEmail String? // company's PayPal business email
paypalMerchantId String? // optional PayPal merchant ID
paymentMethodsEnabled PaymentProvider[] // which methods they have set up
// Marketplace
isListedOnMarketplace Boolean @default(true)
marketplaceRating Float?
updatedAt DateTime @updatedAt
@@map("brand_settings")
}
// ─── Employee ─────────────────────────────────────────────────
model Employee {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
clerkUserId String @unique
firstName String
lastName String
email String
phone String?
role EmployeeRole @default(AGENT)
isActive Boolean @default(true)
notifications Notification[] @relation("EmployeeNotifications")
notificationPreferences NotificationPreference[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@map("employees")
}
enum EmployeeRole { OWNER MANAGER AGENT }
// ─── Vehicle ──────────────────────────────────────────────────
// Strictly company-scoped. Photos uploaded by the company appear
// on their public site AND on the global marketplace.
model Vehicle {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
make String
model String
year Int
color String
licensePlate String
vin String?
category VehicleCategory @default(ECONOMY)
seats Int @default(5)
transmission Transmission @default(AUTOMATIC)
fuelType FuelType @default(GASOLINE)
features String[] // ["AC", "GPS", "Bluetooth", "Baby seat"]
dailyRate Int // in smallest currency unit
status VehicleStatus @default(AVAILABLE)
// Photos uploaded via Cloudinary — these URLs are shown on the marketplace
photos String[] // Cloudinary HTTPS URLs, first photo is the "cover"
mileage Int?
notes String?
isPublished Boolean @default(true) // appears on public site + marketplace
reservations Reservation[]
maintenance MaintenanceLog[]
offerVehicles OfferVehicle[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([companyId, status])
@@index([companyId, isPublished])
@@map("vehicles")
}
enum VehicleStatus { AVAILABLE RENTED MAINTENANCE OUT_OF_SERVICE }
enum VehicleCategory { ECONOMY COMPACT MIDSIZE FULLSIZE SUV LUXURY VAN TRUCK }
enum Transmission { AUTOMATIC MANUAL }
enum FuelType { GASOLINE DIESEL ELECTRIC HYBRID }
// ─── Offer ────────────────────────────────────────────────────
model Offer {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
title String
description String?
termsAndConds String?
imageUrl String? // offer banner (Cloudinary)
type OfferType
discountValue Int
specialRate Int?
appliesToAll Boolean @default(true)
categories VehicleCategory[]
minRentalDays Int?
maxRentalDays Int?
promoCode String? @unique
maxRedemptions Int?
redemptionCount Int @default(0)
validFrom DateTime
validUntil DateTime
isActive Boolean @default(true)
isPublic Boolean @default(true) // shown on global marketplace
isFeatured Boolean @default(false) // featured carousel (GROWTH+ plan)
vehicles OfferVehicle[]
reservations Reservation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([isPublic, isActive, validUntil])
@@map("offers")
}
enum OfferType { PERCENTAGE FIXED_AMOUNT FREE_DAY SPECIAL_RATE }
model OfferVehicle {
offerId String
offer Offer @relation(fields: [offerId], references: [id], onDelete: Cascade)
vehicleId String
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
@@id([offerId, vehicleId])
@@map("offer_vehicles")
}
// ═══════════════════════════════════════════════════════════════
// B2C — RENTER SIDE
// ═══════════════════════════════════════════════════════════════
model Renter {
id String @id @default(cuid())
firstName String
lastName String
email String @unique
phone String?
passwordHash String
driverLicense String?
dateOfBirth DateTime?
nationality String?
preferredLocale String @default("en")
preferredCurrency String @default("MAD")
fcmToken String?
emailVerified Boolean @default(false)
phoneVerified Boolean @default(false)
isActive Boolean @default(true)
reservations Reservation[] @relation("RenterReservations")
savedCompanies RenterSavedCompany[]
reviews Review[]
notifications Notification[] @relation("RenterNotifications")
notificationPreferences NotificationPreference[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("renters")
}
model RenterSavedCompany {
renterId String
renter Renter @relation(fields: [renterId], references: [id], onDelete: Cascade)
companyId String
savedAt DateTime @default(now())
@@id([renterId, companyId])
@@map("renter_saved_companies")
}
// Customer = company-scoped CRM record. Auto-created when someone books with a company.
model Customer {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
renterId String? // linked to global Renter (if booked as logged-in renter)
firstName String
lastName String
email String
phone String?
driverLicense String?
dateOfBirth DateTime?
nationality String?
address Json?
notes String?
flagged Boolean @default(false)
flagReason String?
// ── Driver license details & validation ───────────────────
licenseExpiry DateTime?
licenseIssuedAt DateTime?
licenseCountry String?
licenseNumber String?
licenseCategory String? // "B", "A", "BE", etc.
licenseExpired Boolean @default(false)
licenseExpiringSoon Boolean @default(false) // <3 months validity
licenseValidationStatus LicenseStatus @default(PENDING)
licenseApprovedBy String? // Employee name
licenseApprovedAt DateTime?
licenseApprovalNote String?
reservations Reservation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([companyId, email])
@@index([companyId])
@@map("customers")
}
// ─── Reservation ──────────────────────────────────────────────
model Reservation {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
vehicleId String
vehicle Vehicle @relation(fields: [vehicleId], references: [id])
customerId String
customer Customer @relation(fields: [customerId], references: [id])
renterId String? // global renter account
renter Renter? @relation("RenterReservations", fields: [renterId], references: [id])
offerId String?
offer Offer? @relation(fields: [offerId], references: [id])
promoCodeUsed String?
// Where the booking originated
source BookingSource @default(DASHBOARD)
// For marketplace bookings: renter selected vehicle on explore, was redirected here
marketplaceRef String? // vehicle ID + dates encoded from marketplace redirect
status ReservationStatus @default(DRAFT)
startDate DateTime
endDate DateTime
pickupLocation String?
returnLocation String?
dailyRate Int
discountAmount Int @default(0)
totalDays Int
totalAmount Int
depositAmount Int @default(0)
checkedInAt DateTime?
checkedOutAt DateTime?
checkInMileage Int?
checkOutMileage Int?
notes String?
cancelReason String?
cancelledBy CancelledBy?
// Document reference numbers — assigned once, stored permanently
// PDFs are NEVER stored — generated fresh from DB data on every request
contractNumber String? @unique // e.g. "CNT-2024-00042"
invoiceNumber String? @unique // e.g. "INV-2024-00042"
// Additional check-in data needed for contract
// check-in fuel moved to DamageReport.fuelLevel — kept for backward compat
checkInFuelLevel String?
checkOutFuelLevel String?
// ── Insurance, additional driver, pricing, damage ─────────
insurances ReservationInsurance[]
insuranceTotal Int @default(0)
additionalDrivers AdditionalDriver[]
additionalDriverTotal Int @default(0)
pricingRulesApplied Json? // [{ruleId, name, type, amount}] snapshot at booking
pricingRulesTotal Int @default(0)
damageReports DamageReport[]
rentalPayments RentalPayment[]
inspections DamageInspection[]
review Review?
// Damage charge (added by employee after checkout inspection)
damageChargeAmount Int?
damageChargeNote String?
// Extras (GPS, baby seat, insurance, extra driver, etc.) — JSON array
extras Json? // ReservationExtra[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([companyId, status])
@@index([vehicleId, startDate, endDate])
@@index([renterId])
@@map("reservations")
}
enum ReservationStatus { DRAFT CONFIRMED ACTIVE COMPLETED CANCELLED NO_SHOW }
enum BookingSource { DASHBOARD PUBLIC_SITE MARKETPLACE API }
enum CancelledBy { COMPANY RENTER SYSTEM }
enum LicenseStatus { PENDING VALID EXPIRING APPROVED DENIED EXPIRED }
enum InsuranceType { CDW SCDW THEFT THIRD_PARTY FULL BASIC ROADSIDE PERSONAL CUSTOM }
enum InsuranceChargeType { PER_DAY PER_RENTAL PERCENTAGE_OF_RENTAL }
enum DamageReportType { CHECKIN CHECKOUT }
enum PricingRuleType { SURCHARGE DISCOUNT }
enum PricingCondition { AGE_LESS_THAN AGE_GREATER_THAN LICENSE_YEARS_LESS_THAN LICENSE_YEARS_GREATER_THAN }
enum PriceAdjustmentType { PERCENTAGE FLAT_PER_DAY FLAT_TOTAL }
enum AdditionalDriverCharge { FREE PER_DAY FLAT }
enum AdminRole { SUPER_ADMIN ADMIN SUPPORT FINANCE VIEWER }
// ─── Rental Payment ───────────────────────────────────────────
// Payment from renter to company. No Stripe — uses AmanPay or PayPal.
model RentalPayment {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
reservationId String
reservation Reservation @relation(fields: [reservationId], references: [id])
amount Int // in smallest unit
currency String @default("MAD")
status PaymentStatus @default(PENDING)
type PaymentType @default(CHARGE)
paymentProvider PaymentProvider
// Exactly one of these is set:
amanpayTransactionId String? @unique
paypalCaptureId String? @unique
paymentMethod String? // "card_national", "card_international", "cash", "ewallet", "paypal"
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([reservationId])
@@map("rental_payments")
}
enum PaymentStatus { PENDING SUCCEEDED FAILED REFUNDED PARTIALLY_REFUNDED }
enum PaymentType { CHARGE DEPOSIT REFUND }
// ─── Review ───────────────────────────────────────────────────
model Review {
id String @id @default(cuid())
reservationId String @unique
reservation Reservation @relation(fields: [reservationId], references: [id])
renterId String
renter Renter @relation(fields: [renterId], references: [id])
companyId String
overallRating Int
vehicleRating Int?
serviceRating Int?
comment String?
isPublished Boolean @default(true)
companyReply String?
companyRepliedAt DateTime?
createdAt DateTime @default(now())
@@index([companyId])
@@map("reviews")
}
// ═══════════════════════════════════════════════════════════════
// NOTIFICATIONS
// ═══════════════════════════════════════════════════════════════
model Notification {
id String @id @default(cuid())
companyId String?
company Company? @relation("CompanyNotifications", fields: [companyId], references: [id], onDelete: Cascade)
employeeId String?
employee Employee? @relation("EmployeeNotifications", fields: [employeeId], references: [id], onDelete: Cascade)
renterId String?
renter Renter? @relation("RenterNotifications", fields: [renterId], references: [id], onDelete: Cascade)
type NotificationType
title String
body String
data Json?
channel NotificationChannel
status NotificationStatus @default(PENDING)
sentAt DateTime?
failReason String?
readAt DateTime?
providerMessageId String?
createdAt DateTime @default(now())
@@index([companyId])
@@index([renterId])
@@map("notifications")
}
enum NotificationType {
NEW_BOOKING BOOKING_CANCELLED PAYMENT_RECEIVED PAYMENT_FAILED
SUBSCRIPTION_TRIAL_ENDING SUBSCRIPTION_SUSPENDED
VEHICLE_MAINTENANCE_DUE OFFER_EXPIRING NEW_REVIEW_RECEIVED
BOOKING_CONFIRMED PICKUP_REMINDER_24H PICKUP_REMINDER_2H
VEHICLE_READY RETURN_REMINDER BOOKING_CANCELLED_BY_COMPANY
REFUND_PROCESSED NEW_OFFER_FROM_SAVED_COMPANY REVIEW_REQUEST
}
enum NotificationChannel { EMAIL SMS WHATSAPP IN_APP PUSH }
enum NotificationStatus { PENDING SENT DELIVERED FAILED READ }
model NotificationPreference {
id String @id @default(cuid())
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: Cascade)
renterId String?
renter Renter? @relation(fields: [renterId], references: [id], onDelete: Cascade)
notificationType NotificationType
channel NotificationChannel
enabled Boolean @default(true)
@@unique([employeeId, notificationType, channel])
@@unique([renterId, notificationType, channel])
@@map("notification_preferences")
}
// ─── Maintenance Log ──────────────────────────────────────────
model MaintenanceLog {
id String @id @default(cuid())
vehicleId String
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
type String
description String?
cost Int?
mileage Int?
performedAt DateTime
nextDueAt DateTime?
nextDueMileage Int?
createdAt DateTime @default(now())
@@index([vehicleId])
@@map("maintenance_logs")
}
// ═══════════════════════════════════════════════════════════════
// DOCUMENTS — Rental Contracts & Customer Invoices
// ═══════════════════════════════════════════════════════════════
// PDFs are NEVER stored. All fields here are DATA, not files.
// contractNumber and invoiceNumber are assigned once and immutable.
// ─── Contract Settings ────────────────────────────────────────
// Per-company contract customization. One record per company.
// Auto-created with defaults if not yet configured.
model ContractSettings {
id String @id @default(cuid())
companyId String @unique
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
// Legal identity (may differ from brand display name)
legalName String?
registrationNumber String? // RC / SIRET / ICE
taxId String? // TVA / ICE number
// Contract text (company writes their own)
terms String @default("")
fuelPolicy String @default("The vehicle must be returned with the same fuel level as at pickup.")
depositPolicy String @default("The security deposit is refundable within 7 days after return, subject to vehicle inspection.")
lateFeePolicy String @default("Late returns will incur a charge of 1 additional day rate per hour of delay.")
damagePolicy String @default("The renter is liable for any damage not covered by insurance.")
additionalClauses String[] @default([])
// Contract options
signatureRequired Boolean @default(true)
contractFooterNote String?
// Invoice options
invoiceFooterNote String?
showTax Boolean @default(false)
taxRate Float? // 0.20 = 20%
taxLabel String? // "TVA" | "VAT" | "Tax"
// Sequential numbering — incremented atomically via DB transaction
contractPrefix String @default("CNT") // CNT-2024-00001
invoicePrefix String @default("INV") // INV-2024-00001
// Structured fuel policy
fuelPolicyType FuelPolicyType @default(FULL_TO_FULL)
fuelPolicyNote String?
fuelChargePerLiter Int? // in cents per liter
fuelShortfallFee Int? // flat fee in cents if not returned full
lateFeePerHour Int? // in cents per hour of late return
// Multiple tax lines as JSON: [{ label, rate, appliesToCategories? }]
taxLines Json?
lastContractSeq Int @default(0) // NEVER reset via API
lastInvoiceSeq Int @default(0) // NEVER reset via API
// Additional driver pricing
additionalDriverCharge AdditionalDriverCharge @default(FREE)
additionalDriverDailyRate Int @default(0) // in smallest unit
additionalDriverFlatRate Int @default(0)
updatedAt DateTime @updatedAt
@@map("contract_settings")
}
// ─── Insurance Policy ─────────────────────────────────────────
model InsurancePolicy {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
name String
description String?
type InsuranceType
chargeType InsuranceChargeType
chargeValue Int // per-day rate, flat fee, or percentage (0-100)
isRequired Boolean @default(false)
isActive Boolean @default(true)
sortOrder Int @default(0)
reservations ReservationInsurance[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([companyId, isActive])
@@map("insurance_policies")
}
model ReservationInsurance {
id String @id @default(cuid())
reservationId String
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
insurancePolicyId String
insurancePolicy InsurancePolicy @relation(fields: [insurancePolicyId], references: [id])
// Snapshot at booking time
policyName String
policyType InsuranceType
chargeType InsuranceChargeType
chargeValue Int
totalCharge Int
@@unique([reservationId, insurancePolicyId])
@@map("reservation_insurances")
}
// ─── Additional Driver ────────────────────────────────────────
model AdditionalDriver {
id String @id @default(cuid())
reservationId String
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
companyId String
firstName String
lastName String
email String?
phone String?
driverLicense String
licenseExpiry DateTime?
licenseIssuedAt DateTime?
dateOfBirth DateTime?
nationality String?
chargeType AdditionalDriverCharge @default(FREE)
chargeValue Int @default(0)
totalCharge Int @default(0)
licenseExpired Boolean @default(false)
licenseExpiringSoon Boolean @default(false)
requiresApproval Boolean @default(false)
approvedBy String?
approvedAt DateTime?
approvalNote String?
createdAt DateTime @default(now())
@@index([reservationId])
@@index([companyId])
@@map("additional_drivers")
}
// ─── Damage Report ────────────────────────────────────────────
model DamageReport {
id String @id @default(cuid())
reservationId String
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
companyId String
type DamageReportType
damages Json // DamageZone[] — see advanced-features.md
photos String[] // Cloudinary URLs (optional damage photos)
fuelLevel FuelLevel @default(FULL)
mileage Int?
inspectedAt DateTime @default(now())
inspectedBy String?
customerSignedAt DateTime?
customerName String?
@@unique([reservationId, type])
@@index([companyId])
@@map("damage_reports")
}
// ─── Pricing Rule ─────────────────────────────────────────────
model PricingRule {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
name String
type PricingRuleType
condition PricingCondition
conditionValue Int // age or years threshold
adjustmentType PriceAdjustmentType
adjustmentValue Int // percentage (0-100) or flat amount in smallest unit
isActive Boolean @default(true)
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@map("pricing_rules")
}
// ═══════════════════════════════════════════════════════════════
// DAMAGE INSPECTION SYSTEM
// ═══════════════════════════════════════════════════════════════
model DamageInspection {
id String @id @default(cuid())
reservationId String
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
companyId String
type InspectionType
mileage Int?
fuelLevel FuelLevel
fuelCharge Int? // in cents (calculated at checkout)
generalCondition String?
employeeNotes String?
customerAgreed Boolean @default(false)
inspectedAt DateTime @default(now())
inspectedBy String? // employeeId
damagePoints DamagePoint[]
createdAt DateTime @default(now())
@@unique([reservationId, type])
@@index([reservationId])
@@index([companyId])
@@map("damage_inspections")
}
enum InspectionType { CHECKIN CHECKOUT }
enum FuelLevel {
FULL SEVEN_EIGHTHS THREE_QUARTERS FIVE_EIGHTHS
HALF THREE_EIGHTHS QUARTER ONE_EIGHTH EMPTY
}
model DamagePoint {
id String @id @default(cuid())
inspectionId String
inspection DamageInspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
viewType DiagramView
x Float // 0-100 percentage of diagram width
y Float // 0-100 percentage of diagram height
damageType DamageType
severity DamageSeverity @default(MINOR)
description String?
isPreExisting Boolean @default(false)
@@index([inspectionId])
@@map("damage_points")
}
enum DiagramView { TOP FRONT REAR LEFT RIGHT }
enum DamageType { SCRATCH DENT CRACK CHIP MISSING STAIN OTHER }
enum DamageSeverity { MINOR MODERATE MAJOR }
// ═══════════════════════════════════════════════════════════════
// ACCOUNTING SETTINGS
// ═══════════════════════════════════════════════════════════════
model AccountingSettings {
id String @id @default(cuid())
companyId String @unique
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
reportingPeriod ReportingPeriod @default(MONTHLY)
fiscalYearStart Int @default(1) // 1=Jan
currency String @default("MAD")
accountantEmail String?
accountantName String?
autoSendReport Boolean @default(false)
reportFormat ReportFormat @default(PDF)
updatedAt DateTime @updatedAt
@@map("accounting_settings")
}
enum ReportingPeriod { WEEKLY MONTHLY QUARTERLY ANNUAL }
enum ReportFormat { PDF CSV BOTH }
// ═══════════════════════════════════════════════════════════════
// ADMIN PANEL
// ═══════════════════════════════════════════════════════════════
model AdminUser {
id String @id @default(cuid())
email String @unique
firstName String
lastName String
passwordHash String
role AdminRole @default(SUPPORT)
isActive Boolean @default(true)
totpSecret String?
totpEnabled Boolean @default(false)
lastLoginAt DateTime?
lastLoginIp String?
auditLogs AuditLog[]
permissions AdminPermission[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("admin_users")
}
enum AdminRole { SUPER_ADMIN ADMIN SUPPORT FINANCE VIEWER }
model AdminPermission {
id String @id @default(cuid())
adminUserId String
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
resource AdminResource
actions AdminAction[]
@@unique([adminUserId, resource])
@@map("admin_permissions")
}
enum AdminResource {
COMPANIES COMPANY_EMPLOYEES SUBSCRIPTIONS PAYMENTS
OFFERS RENTERS ADMIN_USERS AUDIT_LOGS PLATFORM_METRICS
}
enum AdminAction { READ CREATE UPDATE DELETE SUSPEND IMPERSONATE }
model AuditLog {
id String @id @default(cuid())
adminUserId String
adminUser AdminUser @relation(fields: [adminUserId], references: [id])
action String
resource String
resourceId String?
companyId String?
before Json?
after Json?
ipAddress String?
userAgent String?
note String?
createdAt DateTime @default(now())
@@index([adminUserId])
@@index([companyId])
@@index([action])
@@map("audit_logs")
}
```
-169
View File
@@ -1,169 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express'
import { z } from 'zod'
import {
listEmployees,
inviteEmployee,
updateEmployeeRole,
deactivateEmployee,
reactivateEmployee,
removeEmployee,
} from '../services/teamService'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { requireRole } from '../middleware/requireRole'
const router = Router()
// All team routes require a valid company employee session + active subscription
router.use(requireCompanyAuth, requireTenant, requireSubscription)
// ─── Validation schemas ───────────────────────────────────────
const inviteSchema = z.object({
firstName: z.string().min(1).max(64),
lastName: z.string().min(1).max(64),
email: z.string().email(),
role: z.enum(['MANAGER', 'AGENT']), // OWNER cannot be invited
})
const updateRoleSchema = z.object({
role: z.enum(['MANAGER', 'AGENT']),
})
// ─── GET /team ────────────────────────────────────────────────
// List all employees for this company.
// All roles can view the team list.
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const members = await listEmployees(req.companyId)
res.json({ data: members })
} catch (err) {
next(err)
}
})
// ─── GET /team/stats ──────────────────────────────────────────
// Quick counts for dashboard stat cards.
router.get('/stats', async (req: Request, res: Response, next: NextFunction) => {
try {
const members = await listEmployees(req.companyId)
const stats = {
total: members.length,
active: members.filter((m) => m.isActive && m.invitationStatus === 'accepted').length,
pending: members.filter((m) => m.invitationStatus === 'pending').length,
inactive: members.filter((m) => !m.isActive && m.invitationStatus === 'accepted').length,
}
res.json({ data: stats })
} catch (err) {
next(err)
}
})
// ─── POST /team/invite ────────────────────────────────────────
// Invite a new employee by email. OWNER only.
router.post(
'/invite',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const body = inviteSchema.parse(req.body)
const result = await inviteEmployee(
req.companyId,
req.employee.id,
body
)
res.status(201).json({ data: result })
} catch (err) {
next(err)
}
}
)
// ─── PATCH /team/:id/role ─────────────────────────────────────
// Change an employee's role. OWNER only.
router.patch(
'/:id/role',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const body = updateRoleSchema.parse(req.body)
const updated = await updateEmployeeRole(
req.companyId,
req.employee.id,
req.employee.role,
req.params.id,
body
)
res.json({ data: updated })
} catch (err) {
next(err)
}
}
)
// ─── POST /team/:id/deactivate ────────────────────────────────
// Suspend access without deleting the record. OWNER only.
router.post(
'/:id/deactivate',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const updated = await deactivateEmployee(
req.companyId,
req.employee.role,
req.params.id
)
res.json({ data: updated })
} catch (err) {
next(err)
}
}
)
// ─── POST /team/:id/reactivate ────────────────────────────────
// Restore a deactivated employee's access. OWNER only.
router.post(
'/:id/reactivate',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const updated = await reactivateEmployee(
req.companyId,
req.employee.role,
req.params.id
)
res.json({ data: updated })
} catch (err) {
next(err)
}
}
)
// ─── DELETE /team/:id ─────────────────────────────────────────
// Permanently remove an employee + revoke their Clerk session/invite. OWNER only.
router.delete(
'/:id',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await removeEmployee(
req.companyId,
req.employee.role,
req.params.id
)
res.json({ data: result })
} catch (err) {
next(err)
}
}
)
export default router
-337
View File
@@ -1,337 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import { useTeam, TeamMember } from '../hooks/useTeam'
import InviteModal from '../components/team/InviteModal'
import EditMemberModal from '../components/team/EditMemberModal'
import PermissionsMatrix from '../components/team/PermissionsMatrix'
import { useEmployee } from '@/hooks/useEmployee' // your existing auth hook
// ─── Helpers ─────────────────────────────────────────────────
function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
}
function timeAgo(dateStr: string | null) {
if (!dateStr) return 'Never'
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 2) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
if (days < 30) return `${days}d ago`
return new Date(dateStr).toLocaleDateString('en', { month: 'short', day: 'numeric' })
}
const AVATAR_BG: string[] = [
'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300',
'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300',
'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300',
'bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300',
]
function avatarColor(id: string) {
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
return AVATAR_BG[hash % AVATAR_BG.length]
}
function RoleBadge({ role }: { role: string }) {
const styles: Record<string, string> = {
OWNER: 'bg-violet-50 dark:bg-violet-950/40 text-violet-700 dark:text-violet-300 border-violet-100 dark:border-violet-900/50',
MANAGER: 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border-blue-100 dark:border-blue-900/50',
AGENT: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-700',
}
return (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${styles[role] ?? styles.AGENT}`}>
{role.charAt(0) + role.slice(1).toLowerCase()}
</span>
)
}
function StatusBadge({ member }: { member: TeamMember }) {
if (member.invitationStatus === 'pending') {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border-amber-100 dark:border-amber-900/50">
Pending invite
</span>
)
}
if (!member.isActive) {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
Deactivated
</span>
)
}
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
Active
</span>
)
}
// ─── Main page ────────────────────────────────────────────────
export default function TeamPage() {
const { employee: currentEmployee } = useEmployee()
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [inviteOpen, setInviteOpen] = useState(false)
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
const [toast, setToast] = useState<string | null>(null)
const isOwner = currentEmployee?.role === 'OWNER'
function showToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const filtered = useMemo(() => {
return members.filter((m) => {
const q = search.toLowerCase()
const matchQ = !q ||
`${m.firstName} ${m.lastName}`.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q)
const matchRole = !roleFilter || m.role === roleFilter
const matchStatus =
!statusFilter ||
(statusFilter === 'active' && m.isActive && m.invitationStatus === 'accepted') ||
(statusFilter === 'pending' && m.invitationStatus === 'pending') ||
(statusFilter === 'inactive' && !m.isActive && m.invitationStatus === 'accepted')
return matchQ && matchRole && matchStatus
})
}, [members, search, roleFilter, statusFilter])
// ── Handlers with toasts ───────────────────────────────────
const handleInvite: typeof invite = async (payload) => {
await invite(payload)
showToast(`Invite sent to ${payload.email}`)
}
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
await updateRole(id, role)
showToast('Role updated')
}
const handleDeactivate = async (id: string) => {
await deactivate(id)
showToast('Member deactivated')
}
const handleReactivate = async (id: string) => {
await reactivate(id)
showToast('Member reactivated')
}
const handleRemove = async (id: string) => {
await remove(id)
showToast('Member removed')
}
// ── Render ─────────────────────────────────────────────────
return (
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
{/* Page header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">Team</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Manage your employees and their access levels
</p>
</div>
{isOwner && (
<button
onClick={() => setInviteOpen(true)}
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Invite member
</button>
)}
</div>
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ label: 'Total members', value: stats.total },
{ label: 'Active', value: stats.active },
{ label: 'Pending invite', value: stats.pending },
{ label: 'Deactivated', value: stats.inactive },
].map((s) => (
<div
key={s.label}
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 flex flex-col gap-1"
>
<span className="text-xs text-zinc-500 dark:text-zinc-400">{s.label}</span>
<span className="text-2xl font-medium text-zinc-900 dark:text-white">{s.value}</span>
</div>
))}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2 mb-4">
<div className="relative flex-1 min-w-48">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 14 14">
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.2" />
<path d="M10 10L13 13" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
<input
type="text"
placeholder="Search by name or email…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">All roles</option>
<option value="OWNER">Owner</option>
<option value="MANAGER">Manager</option>
<option value="AGENT">Agent</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="inactive">Deactivated</option>
</select>
</div>
{/* Table */}
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-10">
{loading ? (
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
<span className="text-sm">Loading team</span>
</div>
) : error ? (
<div className="py-12 text-center text-sm text-red-500">{error}</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">No members match your filters</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Member</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Role</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Status</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">Last active</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{filtered.map((member, i) => (
<tr
key={member.id}
className={[
'border-b border-zinc-100 dark:border-zinc-800 last:border-0',
'hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors',
].join(' ')}
>
{/* Member */}
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${avatarColor(member.id)}`}>
{initials(member)}
</div>
<div>
<div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName}
{member.id === currentEmployee?.id && (
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
</div>
</div>
</td>
{/* Role */}
<td className="px-4 py-3">
<RoleBadge role={member.role} />
</td>
{/* Status */}
<td className="px-4 py-3">
<StatusBadge member={member} />
</td>
{/* Last active */}
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-zinc-400 dark:text-zinc-500">
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)}
</span>
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
{isOwner && member.role !== 'OWNER' && (
<button
onClick={() => setEditTarget(member)}
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Manage
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Permissions matrix */}
<PermissionsMatrix />
{/* Modals */}
<InviteModal
open={inviteOpen}
onClose={() => setInviteOpen(false)}
onInvite={handleInvite}
/>
<EditMemberModal
member={editTarget}
open={!!editTarget}
onClose={() => setEditTarget(null)}
onUpdateRole={handleUpdateRole}
onDeactivate={handleDeactivate}
onReactivate={handleReactivate}
onRemove={handleRemove}
/>
{/* Toast */}
{toast && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 duration-200">
<svg className="w-4 h-4 text-green-400 dark:text-green-600" fill="none" viewBox="0 0 16 16">
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{toast}
</div>
)}
</div>
)
}
-365
View File
@@ -1,365 +0,0 @@
import { PrismaClient, EmployeeRole } from '@prisma/client'
import { clerkClient } from '@clerk/clerk-sdk-node'
const prisma = new PrismaClient()
// ─── Types ────────────────────────────────────────────────────
export interface InvitePayload {
firstName: string
lastName: string
email: string
role: EmployeeRole
}
export interface UpdateRolePayload {
role: EmployeeRole
}
export interface TeamMember {
id: string
clerkUserId: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
createdAt: Date
updatedAt: Date
// hydrated from Clerk
lastActiveAt?: Date | null
invitationStatus?: 'accepted' | 'pending' | 'revoked'
}
// ─── List employees ───────────────────────────────────────────
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
const employees = await prisma.employee.findMany({
where: { companyId },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
})
// Hydrate last-active timestamps from Clerk in parallel
const hydrated = await Promise.allSettled(
employees.map(async (emp) => {
try {
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
return {
...emp,
lastActiveAt: clerkUser.lastActiveAt
? new Date(clerkUser.lastActiveAt)
: null,
invitationStatus: 'accepted' as const,
}
} catch {
// Clerk user not yet accepted invitation
return {
...emp,
lastActiveAt: null,
invitationStatus: 'pending' as const,
}
}
})
)
return hydrated.map((r) =>
r.status === 'fulfilled' ? r.value : (r as any).value
)
}
// ─── Get single employee (scoped to company) ──────────────────
export async function getEmployee(companyId: string, employeeId: string) {
const employee = await prisma.employee.findFirst({
where: { id: employeeId, companyId },
})
if (!employee) {
throw Object.assign(new Error('Employee not found'), { statusCode: 404, code: 'employee_not_found' })
}
return employee
}
// ─── Invite ───────────────────────────────────────────────────
// 1. Send a Clerk invitation (creates the Clerk user + sends email)
// 2. Create the Employee row immediately so the company sees it in the list
// with isActive=false until they accept and log in (Clerk webhook sets isActive=true)
export async function inviteEmployee(
companyId: string,
inviterId: string,
payload: InvitePayload
) {
const { firstName, lastName, email, role } = payload
// Owners cannot be invited — must be the account creator
if (role === 'OWNER') {
throw Object.assign(
new Error('Cannot invite a member with the OWNER role'),
{ statusCode: 400, code: 'invalid_role' }
)
}
// Check for existing active employee with that email in this company
const existing = await prisma.employee.findFirst({
where: { companyId, email },
})
if (existing) {
throw Object.assign(
new Error('An employee with this email already exists in your team'),
{ statusCode: 409, code: 'employee_already_exists' }
)
}
// Get the company's subdomain/name for the invitation email redirect URL
const company = await prisma.company.findUniqueOrThrow({
where: { id: companyId },
include: { brand: { select: { subdomain: true, displayName: true } } },
})
// Send Clerk invitation
// Clerk will create the user and email them a magic link
let clerkInvitation: Awaited<ReturnType<typeof clerkClient.invitations.createInvitation>>
try {
clerkInvitation = await clerkClient.invitations.createInvitation({
emailAddress: email,
redirectUrl: `${process.env.DASHBOARD_URL}/onboarding/accept-invite`,
publicMetadata: {
companyId,
companyName: company.brand?.displayName ?? company.name,
role,
invitedBy: inviterId,
},
})
} catch (err: any) {
// Clerk throws if email already has a Clerk account with a pending invite
if (err?.errors?.[0]?.code === 'duplicate_record') {
throw Object.assign(
new Error('This email already has a pending invitation'),
{ statusCode: 409, code: 'duplicate_invitation' }
)
}
throw err
}
// Create the Employee record with a placeholder clerkUserId
// It will be updated when the user accepts the invite (via Clerk webhook)
const employee = await prisma.employee.create({
data: {
companyId,
clerkUserId: `pending_${clerkInvitation.id}`, // replaced by webhook
firstName,
lastName,
email,
role,
isActive: false, // activated on invite acceptance
},
})
return {
employee,
invitationId: clerkInvitation.id,
}
}
// ─── Update role ──────────────────────────────────────────────
export async function updateEmployeeRole(
companyId: string,
requesterId: string,
requesterRole: EmployeeRole,
employeeId: string,
payload: UpdateRolePayload
) {
const target = await getEmployee(companyId, employeeId)
// Only OWNER can change roles
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can change team member roles'),
{ statusCode: 403, code: 'forbidden' }
)
}
// Cannot change an OWNER's role (there must always be one owner)
if (target.role === 'OWNER') {
throw Object.assign(
new Error('Cannot change the role of the account owner'),
{ statusCode: 400, code: 'cannot_change_owner_role' }
)
}
// Cannot assign OWNER role via this endpoint
if (payload.role === 'OWNER') {
throw Object.assign(
new Error('Cannot assign the OWNER role via this endpoint'),
{ statusCode: 400, code: 'invalid_role' }
)
}
// Prevent self-role-change (edge case)
if (target.id === requesterId) {
throw Object.assign(
new Error('You cannot change your own role'),
{ statusCode: 400, code: 'cannot_change_own_role' }
)
}
const updated = await prisma.employee.update({
where: { id: employeeId },
data: { role: payload.role },
})
return updated
}
// ─── Deactivate ───────────────────────────────────────────────
export async function deactivateEmployee(
companyId: string,
requesterRole: EmployeeRole,
employeeId: string
) {
const target = await getEmployee(companyId, employeeId)
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can deactivate team members'),
{ statusCode: 403, code: 'forbidden' }
)
}
if (target.role === 'OWNER') {
throw Object.assign(
new Error('Cannot deactivate the account owner'),
{ statusCode: 400, code: 'cannot_deactivate_owner' }
)
}
// Revoke Clerk session so they are immediately signed out
if (!target.clerkUserId.startsWith('pending_')) {
try {
await clerkClient.users.banUser(target.clerkUserId)
} catch {
// Non-fatal — still deactivate in DB
}
}
const updated = await prisma.employee.update({
where: { id: employeeId },
data: { isActive: false },
})
return updated
}
// ─── Reactivate ───────────────────────────────────────────────
export async function reactivateEmployee(
companyId: string,
requesterRole: EmployeeRole,
employeeId: string
) {
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can reactivate team members'),
{ statusCode: 403, code: 'forbidden' }
)
}
const target = await getEmployee(companyId, employeeId)
if (!target.clerkUserId.startsWith('pending_')) {
try {
await clerkClient.users.unbanUser(target.clerkUserId)
} catch {
// Non-fatal
}
}
const updated = await prisma.employee.update({
where: { id: employeeId },
data: { isActive: true },
})
return updated
}
// ─── Remove (hard delete) ─────────────────────────────────────
// Only OWNER can remove. Removes employee row + revokes Clerk invite if pending.
export async function removeEmployee(
companyId: string,
requesterRole: EmployeeRole,
employeeId: string
) {
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can remove team members'),
{ statusCode: 403, code: 'forbidden' }
)
}
const target = await getEmployee(companyId, employeeId)
if (target.role === 'OWNER') {
throw Object.assign(
new Error('Cannot remove the account owner'),
{ statusCode: 400, code: 'cannot_remove_owner' }
)
}
// Revoke pending Clerk invitation
if (target.clerkUserId.startsWith('pending_')) {
const inviteId = target.clerkUserId.replace('pending_', '')
try {
await clerkClient.invitations.revokeInvitation(inviteId)
} catch {
// Already accepted or expired — safe to ignore
}
} else {
// Ban the Clerk user so they can't log in
try {
await clerkClient.users.banUser(target.clerkUserId)
} catch {
// Non-fatal
}
}
await prisma.employee.delete({ where: { id: employeeId } })
return { success: true }
}
// ─── Clerk webhook handler ────────────────────────────────────
// Called when a user accepts their invitation. Updates the Employee row
// with the real Clerk user ID and activates the account.
export async function handleInvitationAccepted(
clerkUserId: string,
email: string,
companyId: string,
role: EmployeeRole
) {
const pendingEmployee = await prisma.employee.findFirst({
where: {
companyId,
email,
clerkUserId: { startsWith: 'pending_' },
},
})
if (!pendingEmployee) return null
const activated = await prisma.employee.update({
where: { id: pendingEmployee.id },
data: {
clerkUserId,
isActive: true,
role, // in case it was updated between invite and acceptance
},
})
return activated
}
-172
View File
@@ -1,172 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
export type InvitationStatus = 'accepted' | 'pending' | 'revoked'
export interface TeamMember {
id: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
createdAt: string
updatedAt: string
lastActiveAt: string | null
invitationStatus: InvitationStatus
}
export interface TeamStats {
total: number
active: number
pending: number
inactive: number
}
export interface InvitePayload {
firstName: string
lastName: string
email: string
role: 'MANAGER' | 'AGENT'
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://api.RentalDriveGo.com/api/v1'
async function apiFetch<T>(
path: string,
options?: RequestInit
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
...options,
})
const json = await res.json()
if (!res.ok) {
const err = new Error(json.message ?? 'Request failed') as any
err.code = json.error
err.statusCode = res.status
throw err
}
return json.data as T
}
// ─── useTeam ─────────────────────────────────────────────────
export function useTeam() {
const [members, setMembers] = useState<TeamMember[]>([])
const [stats, setStats] = useState<TeamStats>({ total: 0, active: 0, pending: 0, inactive: 0 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchMembers = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [membersData, statsData] = await Promise.all([
apiFetch<TeamMember[]>('/team'),
apiFetch<TeamStats>('/team/stats'),
])
setMembers(membersData)
setStats(statsData)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchMembers() }, [fetchMembers])
// ── Invite ─────────────────────────────────────────────────
const invite = useCallback(async (payload: InvitePayload) => {
const result = await apiFetch<{ employee: TeamMember; invitationId: string }>(
'/team/invite',
{ method: 'POST', body: JSON.stringify(payload) }
)
// Optimistically add the pending member
setMembers((prev) => [...prev, result.employee])
setStats((prev) => ({
...prev,
total: prev.total + 1,
pending: prev.pending + 1,
}))
return result
}, [])
// ── Update role ────────────────────────────────────────────
const updateRole = useCallback(async (memberId: string, role: 'MANAGER' | 'AGENT') => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, role: updated.role } : m))
)
return updated
}, [])
// ── Deactivate ─────────────────────────────────────────────
const deactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/deactivate`, {
method: 'POST',
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, isActive: false } : m))
)
setStats((prev) => ({ ...prev, active: prev.active - 1, inactive: prev.inactive + 1 }))
return updated
}, [])
// ── Reactivate ─────────────────────────────────────────────
const reactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/reactivate`, {
method: 'POST',
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, isActive: true } : m))
)
setStats((prev) => ({ ...prev, active: prev.active + 1, inactive: prev.inactive - 1 }))
return updated
}, [])
// ── Remove ─────────────────────────────────────────────────
const remove = useCallback(async (memberId: string) => {
await apiFetch<{ success: boolean }>(`/team/${memberId}`, {
method: 'DELETE',
})
const target = members.find((m) => m.id === memberId)
setMembers((prev) => prev.filter((m) => m.id !== memberId))
setStats((prev) => ({
...prev,
total: prev.total - 1,
active: target?.isActive ? prev.active - 1 : prev.active,
pending: target?.invitationStatus === 'pending' ? prev.pending - 1 : prev.pending,
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
? prev.inactive - 1
: prev.inactive,
}))
}, [members])
return {
members,
stats,
loading,
error,
refetch: fetchMembers,
invite,
updateRole,
deactivate,
reactivate,
remove,
}
}
-82
View File
@@ -1,82 +0,0 @@
import { Router, Request, Response } from 'express'
import { Webhook } from 'svix'
import { handleInvitationAccepted } from '../services/teamService'
import { EmployeeRole } from '@prisma/client'
const router = Router()
/**
* POST /webhooks/clerk
*
* Handles Clerk webhook events. Registered in the Clerk dashboard.
* Uses svix signature verification — raw body must be preserved
* (use express.raw() before this route, not express.json()).
*
* Events handled:
* - user.created → when an invited employee accepts and creates their account
*/
router.post(
'/clerk',
async (req: Request, res: Response) => {
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET
if (!webhookSecret) {
console.error('CLERK_WEBHOOK_SECRET is not set')
return res.status(500).json({ error: 'Webhook secret not configured' })
}
// Verify signature
const wh = new Webhook(webhookSecret)
let event: any
try {
event = wh.verify(req.body, {
'svix-id': req.headers['svix-id'] as string,
'svix-timestamp': req.headers['svix-timestamp'] as string,
'svix-signature': req.headers['svix-signature'] as string,
})
} catch (err) {
console.error('Clerk webhook signature verification failed:', err)
return res.status(400).json({ error: 'Invalid webhook signature' })
}
const { type, data } = event
// ── user.created ──────────────────────────────────────────
// Fired when an invited employee accepts their invite and creates
// their Clerk account. publicMetadata contains companyId + role
// that we embedded when creating the invitation.
if (type === 'user.created') {
const clerkUserId: string = data.id
const email: string = data.email_addresses?.[0]?.email_address ?? ''
const meta = data.public_metadata ?? {}
const companyId: string | undefined = meta.companyId
const role: EmployeeRole | undefined = meta.role
if (companyId && role && email) {
try {
const employee = await handleInvitationAccepted(
clerkUserId,
email,
companyId,
role
)
if (employee) {
console.log(
`Employee activated: ${employee.firstName} ${employee.lastName} (${employee.id})`
)
}
} catch (err) {
// Log but do not return 500 — Clerk will retry on 5xx
console.error('Failed to activate employee from Clerk webhook:', err)
}
}
}
res.json({ received: true })
}
)
export default router