update documentation
This commit is contained in:
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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] 1–5 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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
+777
-979
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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] 1–5 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)
|
||||
@@ -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 |
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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=` |
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user