diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md new file mode 100644 index 0000000..aa942fb --- /dev/null +++ b/apps/dashboard/README.md @@ -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 ` 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. diff --git a/DOCKER.md b/docs/DOCKER.md similarity index 99% rename from DOCKER.md rename to docs/DOCKER.md index f60e904..f4191d1 100644 --- a/DOCKER.md +++ b/docs/DOCKER.md @@ -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: diff --git a/docs/project-design/README.md b/docs/README.md similarity index 100% rename from docs/project-design/README.md rename to docs/README.md diff --git a/dev_docker _run.md b/docs/dev_docker_run.md similarity index 100% rename from dev_docker _run.md rename to docs/dev_docker_run.md diff --git a/docs/project-design/API_REFACTOR_TASK_LIST.md b/docs/project-design/API_REFACTOR_TASK_LIST.md index 1aa8095..bedcd0b 100644 --- a/docs/project-design/API_REFACTOR_TASK_LIST.md +++ b/docs/project-design/API_REFACTOR_TASK_LIST.md @@ -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. diff --git a/docs/project-design/AUDIT.md b/docs/project-design/AUDIT.md index 391b261..0c7974f 100644 --- a/docs/project-design/AUDIT.md +++ b/docs/project-design/AUDIT.md @@ -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. diff --git a/B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md b/docs/project-design/B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md similarity index 100% rename from B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md rename to docs/project-design/B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md diff --git a/B2B_SUBSCRIPTION_EXECUTION_PLAN.md b/docs/project-design/B2B_SUBSCRIPTION_EXECUTION_PLAN.md similarity index 100% rename from B2B_SUBSCRIPTION_EXECUTION_PLAN.md rename to docs/project-design/B2B_SUBSCRIPTION_EXECUTION_PLAN.md diff --git a/docs/COOKIE_POLICY.md b/docs/project-design/COOKIE_POLICY.md similarity index 100% rename from docs/COOKIE_POLICY.md rename to docs/project-design/COOKIE_POLICY.md diff --git a/docs/project-design/FEATURES.md b/docs/project-design/FEATURES.md index e2cf1a2..9c907b0 100644 --- a/docs/project-design/FEATURES.md +++ b/docs/project-design/FEATURES.md @@ -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. diff --git a/docs/project-design/INTEGRATION.md b/docs/project-design/INTEGRATION.md index baf56f1..3e7afc2 100644 --- a/docs/project-design/INTEGRATION.md +++ b/docs/project-design/INTEGRATION.md @@ -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 ( -
-

Setting up your account…

-
- ) -} -``` - ---- - -## 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. diff --git a/NOTIFICATION_LOCALIZATION_POLICY.md b/docs/project-design/NOTIFICATION_LOCALIZATION_POLICY.md similarity index 100% rename from NOTIFICATION_LOCALIZATION_POLICY.md rename to docs/project-design/NOTIFICATION_LOCALIZATION_POLICY.md diff --git a/docs/project-design/PAGES.md b/docs/project-design/PAGES.md index 22ddc38..6bd0e2f 100644 --- a/docs/project-design/PAGES.md +++ b/docs/project-design/PAGES.md @@ -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` diff --git a/docs/security/VULNERABILITY_FIXES_2026-05-22.md b/docs/project-design/VULNERABILITY_FIXES_2026-05-22.md similarity index 100% rename from docs/security/VULNERABILITY_FIXES_2026-05-22.md rename to docs/project-design/VULNERABILITY_FIXES_2026-05-22.md diff --git a/docs/project-design/api-routes.md b/docs/project-design/api-routes.md index e7e8c91..5a347c1 100644 --- a/docs/project-design/api-routes.md +++ b/docs/project-design/api-routes.md @@ -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. diff --git a/command_Create_admin_account.md b/docs/project-design/command_Create_admin_account.md similarity index 100% rename from command_Create_admin_account.md rename to docs/project-design/command_Create_admin_account.md diff --git a/contrat_location_voiture_maroc_with_laws.md b/docs/project-design/contrat_location_voiture_maroc_with_laws.md similarity index 100% rename from contrat_location_voiture_maroc_with_laws.md rename to docs/project-design/contrat_location_voiture_maroc_with_laws.md diff --git a/review_management_process.md b/docs/project-design/review_management_process.md similarity index 100% rename from review_management_process.md rename to docs/project-design/review_management_process.md diff --git a/docs/project-design/schema.md b/docs/project-design/schema.md index cea15ba..cc69976 100644 --- a/docs/project-design/schema.md +++ b/docs/project-design/schema.md @@ -1,980 +1,778 @@ -# 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") -} +# Database Schema and Table Relationships + +This document explains the current Prisma schema, how the data is partitioned, and how the main tables relate to each other. + +Source of truth: + +- `packages/database/prisma/schema.prisma` + +## Design Principles + +The schema is built around a multi-tenant SaaS model with three distinct concerns: + +1. company operations for running a rental business +2. renter-facing booking and review flows +3. platform-level SaaS billing and administration + +Important design choices: + +- `Company` is the tenant root for nearly every business table. +- operational data and platform billing data are kept separate. +- reservation-time snapshots are stored explicitly so later changes to policies or prices do not rewrite history. +- some user concepts intentionally exist twice: + - `Renter` is a cross-company identity + - `Customer` is a company-owned CRM record +- status changes are modeled with enums rather than free-text fields where the workflow matters. + +## High-Level Domain Map + +```mermaid +erDiagram + Company ||--o| Subscription : has + Company ||--o| BrandSettings : has + Company ||--o| ContractSettings : has + Company ||--o| AccountingSettings : has + Company ||--o{ Employee : employs + Company ||--o{ Vehicle : owns + Company ||--o{ Offer : publishes + Company ||--o{ Customer : manages + Company ||--o{ Reservation : receives + Company ||--o{ RentalPayment : collects + Company ||--o{ InsurancePolicy : defines + Company ||--o{ PricingRule : defines + Company ||--o{ BillingAccount : bills + Company ||--o{ BillingInvoice : invoices + Company ||--o{ SubscriptionInvoice : tracks + Company ||--o{ Notification : receives + Company ||--o{ Complaint : tracks + + Vehicle ||--o{ Reservation : booked_in + Vehicle ||--o{ MaintenanceLog : has + Vehicle ||--o{ VehicleCalendarBlock : blocked_by + Offer ||--o{ OfferVehicle : applies_to + Vehicle ||--o{ OfferVehicle : offered_in + + Customer ||--o{ Reservation : books + Renter ||--o{ Reservation : owns + Reservation ||--o{ RentalPayment : paid_by + Reservation ||--o{ ReservationInsurance : includes + Reservation ||--o{ AdditionalDriver : adds + Reservation ||--o{ DamageInspection : inspected_by + Reservation ||--o{ DamageReport : reported_by + Reservation ||--o{ ReservationPhoto : photographed_by + Reservation ||--o| Review : reviewed_as + Reservation ||--o{ Complaint : disputed_in + + InsurancePolicy ||--o{ ReservationInsurance : snapshotted_into + Renter ||--o{ Review : writes + Review ||--o{ Complaint : escalates_to + + AdminUser ||--o{ AdminPermission : has + AdminUser ||--o{ AuditLog : writes ``` + +## Core Tenant Root + +### `Company` + +`Company` is the anchor row for tenant isolation. + +Key fields: + +- identity: `id`, `name`, `slug`, `email`, `phone` +- public/integration: `apiKey` +- SaaS state: `status`, `subscriptionPaymentRef` +- address: `address` JSON + +Key relations: + +- 1:1 with `Subscription` +- 1:1 with `BrandSettings` +- 1:1 with `ContractSettings` +- 1:1 with `AccountingSettings` +- 1:N with `Employee`, `Vehicle`, `Offer`, `Customer`, `Reservation`, `RentalPayment` +- 1:N with billing tables such as `BillingAccount`, `BillingInvoice`, `SubscriptionInvoice` +- 1:N with `InsurancePolicy`, `PricingRule`, `Notification`, `Complaint` + +Important uniqueness: + +- `slug` +- `email` +- `apiKey` + +## SaaS Subscription and Billing + +There are two billing layers in the schema: + +1. legacy provider-oriented subscription records +2. richer billing-account and billing-invoice records for platform finance operations + +### `Subscription` + +One row per company. + +Purpose: + +- current plan +- billing period +- lifecycle state +- trial dates +- renewal windows +- suspension and cancellation timing + +Relations: + +- belongs to one `Company` +- has many `SubscriptionInvoice` +- has many `SubscriptionEvent` +- can be referenced by many `BillingInvoice` +- can be referenced by many `BillingEvent` + +### `SubscriptionEvent` + +Append-only event log for the subscription lifecycle. + +Examples of what it represents: + +- trial started +- checkout created +- payment received +- plan changed +- grace period extended +- subscription suspended + +### `SubscriptionInvoice` + +Legacy or provider-facing subscription payment records. + +Purpose: + +- capture AmanPay or PayPal payment references +- track amount, currency, due date, paid date, and failure/void state +- optionally bridge to the richer `BillingInvoice` model via `billingInvoiceId` + +### `PaymentAttempt` + +Legacy payment-attempt rows tied to `SubscriptionInvoice`. + +This is narrower than the newer billing payment tables and exists for provider-level retry/failure tracking. + +### `BillingAccount` + +The primary finance entity for a company at the platform level. + +Purpose: + +- billing identity and address +- invoice terms +- default currency +- tax flags +- default payment method +- dunning pause state +- external provider customer ID + +Relations: + +- belongs to one `Company` +- has many `BillingPaymentMethod` +- has many `BillingInvoice` +- has many `BillingPaymentIntent` +- has many `BillingPaymentAttempt` +- has many `BillingCreditBalance`, `BillingCreditLedgerEntry`, `BillingCreditNote`, `BillingRefund`, `BillingEvent` + +### `BillingPaymentMethod` + +Stored billing method metadata for the company account. + +Examples: + +- card brand/last4/expiration +- ACH/bank/manual invoice style billing methods + +One billing account can have many payment methods; one may be selected as default. + +### `BillingInvoice` + +The main platform invoice table. + +Purpose: + +- invoice numbering and sequencing +- totals and tax math +- status transitions +- billing snapshot fields such as billing name and email +- finance/admin metadata + +Relations: + +- belongs to one `BillingAccount` +- belongs to one `Company` +- optionally belongs to one `Subscription` +- optionally has a linked legacy `SubscriptionInvoice` +- has many `BillingInvoiceLineItem` +- has many `BillingPaymentIntent` +- has many `BillingPaymentAttempt` +- has many `BillingTaxRecord` +- has many `BillingCreditNote` +- has many `BillingRefund` +- has many `BillingEvent` + +### Supporting billing tables + +- `BillingInvoiceLineItem`: immutable invoice lines with type, quantity, unit amount, and optional service period. +- `BillingPaymentIntent`: payment-intent level state for a billing invoice. +- `BillingPaymentAttempt`: actual collection attempts against an invoice. +- `BillingCreditBalance`: running credit balance per billing account and currency. +- `BillingCreditLedgerEntry`: credit ledger movement history. +- `BillingCreditNote`: credit documents issued against an invoice. +- `BillingRefund`: refund records for billing invoices. +- `BillingTaxRecord`: tax breakdown rows for an invoice. +- `BillingEvent`: append-only finance/subscription/company event log. + +## Branding and Company Configuration + +### `BrandSettings` + +One-to-one with `Company`. + +Purpose: + +- public display name, tagline, logo, favicon, hero image +- theme colors +- subdomain and custom domain +- public contact and social metadata +- default locale and currency +- company-owned payment provider settings for renter payments +- marketplace visibility and rating +- homepage/menu JSON configuration + +Important uniqueness: + +- `companyId` +- `subdomain` +- `customDomain` + +### `ContractSettings` + +One-to-one with `Company`. + +Purpose: + +- rental contract legal copy +- invoice/footer text +- fuel and late-fee policy +- tax settings +- numbering prefixes and running sequences +- additional-driver charging policy + +This table is used when generating operational rental documents. + +### `AccountingSettings` + +One-to-one with `Company`. + +Purpose: + +- reporting period +- fiscal-year start month +- accounting contact +- automatic report settings +- preferred export format + +### `PricingRule` + +Company-owned pricing rule definitions applied to reservations. + +Purpose: + +- define surcharge or discount logic +- express the rule type, condition, and adjustment + +These rules are copied into reservation snapshots through `Reservation.pricingRulesApplied` and `Reservation.pricingRulesTotal`. + +### `InsurancePolicy` + +Company-defined upsell or mandatory protection products. + +Purpose: + +- define policy name and type +- define flat, percent, or per-day charges +- mark required or optional coverage + +These are also snapshotted into reservations. + +## Staff, Fleet, and Commercial Catalog + +### `Employee` + +Company staff records. + +Purpose: + +- operational user identity +- role assignment +- preferred language +- password-reset support +- activation state + +Implementation note: + +- `clerkUserId` still exists as a required unique field in the schema, but Clerk is no longer an active auth dependency in this project. +- the field is currently populated with local/generated identifiers so existing schema constraints remain satisfied. + +Relations: + +- belongs to one `Company` +- has many `Notification` +- has many `NotificationPreference` + +Important uniqueness: + +- `clerkUserId` + +### `Vehicle` + +The core fleet entity. + +Purpose: + +- static vehicle information +- public listing content +- daily rate and status +- pickup/dropoff location rules + +Relations: + +- belongs to one `Company` +- has many `Reservation` +- has many `MaintenanceLog` +- has many `VehicleCalendarBlock` +- participates in many-to-many offers through `OfferVehicle` + +Soft deletion is implemented operationally by setting: + +- `status = OUT_OF_SERVICE` +- `isPublished = false` + +### `MaintenanceLog` + +One-to-many from `Vehicle`. + +Purpose: + +- maintenance type and description +- cost, mileage, performed date +- next due date or mileage + +### `VehicleCalendarBlock` + +One-to-many from `Vehicle`. + +Purpose: + +- manual or maintenance blocks on the reservation calendar +- date-range unavailability independent of reservations + +### `Offer` + +Company-defined promotional offers. + +Purpose: + +- percentage/fixed/free-day/special-rate discount logic +- validity window +- promo code +- public and featured visibility +- redemption caps + +Relations: + +- belongs to one `Company` +- has many `Reservation` +- many-to-many with `Vehicle` through `OfferVehicle` + +### `OfferVehicle` + +Join table between `Offer` and `Vehicle`. + +Composite primary key: + +- `(offerId, vehicleId)` + +## Renter Identity, Company CRM, and Reservation Aggregate + +The schema intentionally separates global renter identity from company-local customer records. + +### `Renter` + +Cross-company end-user identity. + +Purpose: + +- login identity +- app preferences and verification state +- push token +- reservation and review linkage across companies + +Relations: + +- has many `Reservation` +- has many `Review` +- has many `Notification` +- has many `NotificationPreference` +- has many saved companies through `RenterSavedCompany` + +### `RenterSavedCompany` + +Join table that stores renter bookmarks. + +Composite primary key: + +- `(renterId, companyId)` + +### `Customer` + +Company-owned CRM record for a person renting from that company. + +Purpose: + +- renter contact and profile data as understood by that company +- notes and flag state +- driver-license metadata +- approval and compliance state +- company-specific reservation history + +Relations: + +- belongs to one `Company` +- has many `Reservation` +- has many `Complaint` + +Important uniqueness: + +- `(companyId, email)` + +### `Reservation` + +This is the central transactional aggregate for rental operations. + +Core relations: + +- belongs to one `Company` +- belongs to one `Vehicle` +- belongs to one `Customer` +- optionally belongs to one `Renter` +- optionally belongs to one `Offer` + +Core business fields: + +- booking source +- date range +- pickup/return locations +- pricing fields: `dailyRate`, `discountAmount`, `totalDays`, `totalAmount`, `depositAmount` +- payment summary fields: `paymentStatus`, `paidAmount` +- lifecycle fields: `status`, `checkedInAt`, `checkedOutAt` +- cancellation fields +- contract and invoice numbering +- review token and review reminder timestamps +- extras and pricing-rule snapshots + +The reservation is the parent of most rental workflow records. + +### Reservation child tables + +- `RentalPayment`: individual payment rows for the reservation. +- `ReservationInsurance`: reservation-time snapshots of selected insurance products. +- `AdditionalDriver`: reservation-time records for extra drivers and approval status. +- `DamageReport`: pickup/dropoff summary damage records with photos and signatures. +- `DamageInspection`: structured inspection record per reservation and inspection type. +- `DamagePoint`: individual annotated damage points tied to one inspection. +- `ReservationPhoto`: ad hoc pickup/dropoff photo attachments. +- `Review`: at most one review per reservation. +- `Complaint`: operational or post-rental dispute record, optionally linked to reservation, review, and customer. + +This snapshot pattern is important. The reservation stores what was actually sold and reviewed at the time of booking, not just pointers to mutable company configuration. + +## Reservation-Adjacent Tables in Detail + +### `RentalPayment` + +Operational payment rows for rentals, not SaaS billing. + +Purpose: + +- amount, currency, type, status +- AmanPay or PayPal provider references +- paid timestamp + +Relations: + +- belongs to one `Company` +- belongs to one `Reservation` + +### `ReservationInsurance` + +Snapshot bridge between `Reservation` and `InsurancePolicy`. + +It copies: + +- policy name +- policy type +- charge type +- charge value +- total charge + +That prevents later edits to the base insurance policy from mutating historical reservation pricing. + +### `AdditionalDriver` + +Reservation child rows for additional drivers. + +Purpose: + +- identity and license details +- charge model +- approval requirement and approval outcome +- expiry flags + +### `DamageReport` + +One row per reservation and report type. + +Composite uniqueness: + +- `(reservationId, type)` + +Stores: + +- summary damages JSON +- photo URLs +- fuel level +- mileage +- inspection metadata + +### `DamageInspection` + +Structured pickup/dropoff inspection entity. + +Composite uniqueness: + +- `(reservationId, type)` + +Stores: + +- fuel and mileage +- general condition notes +- customer agreement state +- inspector identity + +### `DamagePoint` + +Granular diagram point rows tied to `DamageInspection`. + +Stores: + +- diagram view +- x/y coordinates +- damage type and severity +- free-text description +- whether the damage is pre-existing + +### `ReservationPhoto` + +Simple photo attachments tied to a reservation with type `PICKUP` or `DROPOFF`. + +### `Review` + +One review per reservation. + +Important uniqueness: + +- `reservationId` + +Purpose: + +- overall, vehicle, and service ratings +- comment +- moderation/publication flags +- company reply data +- optional feedback category + +Relations: + +- optionally linked to `Renter` +- can have many `Complaint` + +### `Complaint` + +Company-scoped issue-tracking table. + +Can be linked to: + +- a reservation +- a review +- a customer + +This supports both operational complaints and post-review escalations. + +## Notifications and Messaging + +### `Notification` + +A notification can target one of three audiences: + +- company-level +- employee-level +- renter-level + +Purpose: + +- delivery type +- title/body/data payload +- channel +- locale +- send/read/failure state +- provider message ID + +### `NotificationTemplate` + +Reusable message content keyed by: + +- template key +- channel +- locale +- version + +### `NotificationPreference` + +Per-recipient opt-in/out rows. + +The uniqueness rules are intentionally split: + +- unique per `(employeeId, notificationType, channel)` +- unique per `(renterId, notificationType, channel)` + +## Admin and Platform Governance + +### `AdminUser` + +Platform operator account. + +Purpose: + +- role +- password hash +- 2FA state +- login tracking +- password reset support + +Relations: + +- has many `AdminPermission` +- has many `AuditLog` + +### `AdminPermission` + +Resource/action override rows for an admin user. + +Important uniqueness: + +- `(adminUserId, resource)` + +### `AuditLog` + +Immutable log of admin-side actions. + +Stores: + +- actor +- action and resource +- optional company and resource IDs +- before/after JSON +- IP, user agent, and note + +## Pricing and Platform Content Configuration + +### `PricingConfig` + +Platform-managed plan pricing rows. + +Important uniqueness: + +- `(plan, billingPeriod)` + +### `PlanFeature` + +Platform-managed feature labels per plan, ordered by `sortOrder`. + +### `PricingPromotion` + +Platform-managed promotion codes for SaaS pricing. + +Purpose: + +- code and description +- discount type/value +- applicable plans/periods +- usage limits +- validity window + +## Relationship Summary by Cardinality + +### One-to-one + +- `Company` -> `Subscription` +- `Company` -> `BrandSettings` +- `Company` -> `ContractSettings` +- `Company` -> `AccountingSettings` + +### One-to-many + +- `Company` -> `Employee`, `Vehicle`, `Offer`, `Customer`, `Reservation`, `RentalPayment` +- `Company` -> `BillingAccount`, `BillingInvoice`, `SubscriptionInvoice`, `InsurancePolicy`, `PricingRule`, `Notification`, `Complaint` +- `Vehicle` -> `Reservation`, `MaintenanceLog`, `VehicleCalendarBlock` +- `Reservation` -> `RentalPayment`, `ReservationInsurance`, `AdditionalDriver`, `DamageReport`, `DamageInspection`, `ReservationPhoto`, `Complaint` +- `BillingAccount` -> `BillingPaymentMethod`, `BillingInvoice`, `BillingPaymentIntent`, `BillingPaymentAttempt`, `BillingCreditBalance`, `BillingCreditLedgerEntry`, `BillingCreditNote`, `BillingRefund`, `BillingEvent` + +### Many-to-many through join tables + +- `Offer` <-> `Vehicle` through `OfferVehicle` +- `Renter` <-> `Company` through `RenterSavedCompany` + +## Indexing and Uniqueness Rules Worth Remembering + +Operationally important uniqueness constraints: + +- `Company.slug` +- `Company.email` +- `Company.apiKey` +- `BrandSettings.subdomain` +- `BrandSettings.customDomain` +- `Employee.clerkUserId` +- `Customer(companyId, email)` +- `Reservation.contractNumber` +- `Reservation.invoiceNumber` +- `Reservation.reviewToken` +- `Review.reservationId` +- `OfferVehicle(offerId, vehicleId)` +- `RenterSavedCompany(renterId, companyId)` +- `ReservationInsurance(reservationId, insurancePolicyId)` +- `DamageReport(reservationId, type)` +- `DamageInspection(reservationId, type)` +- `PricingConfig(plan, billingPeriod)` +- `NotificationPreference` employee and renter uniqueness pairs + +## Practical Reading Guide + +If you need to understand the schema quickly, read it in this order: + +1. `Company` +2. `Subscription`, `BillingAccount`, `BillingInvoice` +3. `Employee`, `Vehicle`, `Offer` +4. `Customer`, `Renter` +5. `Reservation` and its child tables +6. `Notification*` +7. `Admin*`, `AuditLog`, `Pricing*` + +That order matches how the application is structured in the API: tenant first, operations second, finance/admin last. diff --git a/simple_robust_car_rental_process.md b/docs/project-design/simple_robust_car_rental_process.md similarity index 100% rename from simple_robust_car_rental_process.md rename to docs/project-design/simple_robust_car_rental_process.md diff --git a/project_design/rentalcardrive.png b/docs/rentalcardrive.png similarity index 100% rename from project_design/rentalcardrive.png rename to docs/rentalcardrive.png diff --git a/docs/project-design/stubs/EditMemberModal.tsx b/docs/stubs/EditMemberModal.tsx similarity index 100% rename from docs/project-design/stubs/EditMemberModal.tsx rename to docs/stubs/EditMemberModal.tsx diff --git a/docs/project-design/stubs/InviteModal.tsx b/docs/stubs/InviteModal.tsx similarity index 100% rename from docs/project-design/stubs/InviteModal.tsx rename to docs/stubs/InviteModal.tsx diff --git a/docs/project-design/stubs/PermissionsMatrix.tsx b/docs/stubs/PermissionsMatrix.tsx similarity index 100% rename from docs/project-design/stubs/PermissionsMatrix.tsx rename to docs/stubs/PermissionsMatrix.tsx diff --git a/docs/project-design/stubs/requireRole.ts b/docs/stubs/requireRole.ts similarity index 100% rename from docs/project-design/stubs/requireRole.ts rename to docs/stubs/requireRole.ts diff --git a/docs/project-design/stubs/team.ts b/docs/stubs/team.ts similarity index 100% rename from docs/project-design/stubs/team.ts rename to docs/stubs/team.ts diff --git a/docs/project-design/stubs/team.tsx b/docs/stubs/team.tsx similarity index 100% rename from docs/project-design/stubs/team.tsx rename to docs/stubs/team.tsx diff --git a/docs/project-design/stubs/teamService.ts b/docs/stubs/teamService.ts similarity index 100% rename from docs/project-design/stubs/teamService.ts rename to docs/stubs/teamService.ts diff --git a/docs/project-design/stubs/useTeam.ts b/docs/stubs/useTeam.ts similarity index 100% rename from docs/project-design/stubs/useTeam.ts rename to docs/stubs/useTeam.ts diff --git a/docs/project-design/stubs/webhooks.ts b/docs/stubs/webhooks.ts similarity index 100% rename from docs/project-design/stubs/webhooks.ts rename to docs/stubs/webhooks.ts diff --git a/project_design/EditMemberModal.tsx b/project_design/EditMemberModal.tsx deleted file mode 100644 index fb2c7c3..0000000 --- a/project_design/EditMemberModal.tsx +++ /dev/null @@ -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 - onDeactivate: (memberId: string) => Promise - onReactivate: (memberId: string) => Promise - onRemove: (memberId: string) => Promise -} - -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('idle') - const [confirm, setConfirm] = useState(null) - const [error, setError] = useState(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 ( -
{ if (e.target === e.currentTarget && !busy) onClose() }} - > -
- - {/* Member header */} -
-
- {initials} -
-
-

- {member.firstName} {member.lastName} -

-

{member.email}

-
-
- {isPending && ( - - Pending invite - - )} - {!isPending && !isActive && ( - - Deactivated - - )} -
-
- - {/* Confirm overlay */} - {confirm && ( -
-

- {confirm === 'deactivate' && 'Deactivate this member?'} - {confirm === 'remove' && 'Permanently remove this member?'} - {confirm === 'reactivate' && 'Reactivate this member?'} -

-

- {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."} -

-
- - -
-
- )} - - {/* Role selector (hidden for pending invites — role locked until accepted) */} - {!isPending && isActive && ( -
- -
- {ROLE_OPTIONS.map((option) => ( - - ))} -
-
- )} - - {/* Error */} - {error && ( -
-

{error}

-
- )} - - {/* Footer actions */} -
- {/* Danger zone */} -
- {isActive && !isPending && ( - - )} - {!isActive && ( - - )} - -
- - - {isActive && !isPending && ( - - )} -
-
-
- ) -} diff --git a/project_design/FEATURES.md b/project_design/FEATURES.md deleted file mode 100644 index e2cf1a2..0000000 --- a/project_design/FEATURES.md +++ /dev/null @@ -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) diff --git a/project_design/INTEGRATION.md b/project_design/INTEGRATION.md deleted file mode 100644 index baf56f1..0000000 --- a/project_design/INTEGRATION.md +++ /dev/null @@ -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 ( -
-

Setting up your account…

-
- ) -} -``` - ---- - -## 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 | diff --git a/project_design/InviteModal.tsx b/project_design/InviteModal.tsx deleted file mode 100644 index 0870168..0000000 --- a/project_design/InviteModal.tsx +++ /dev/null @@ -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 -} - -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(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 ( -
{ if (e.target === e.currentTarget) onClose() }} - > -
-
-

Invite a team member

-

- They'll receive an email invitation to join your dashboard -

-
- -
- {/* Name row */} -
-
- - 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" - /> -
-
- - 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" - /> -
-
- - {/* Email */} -
- - 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" - /> -
- - {/* Role */} -
- -
- {ROLE_OPTIONS.map((option) => ( - - ))} -
-
- - {/* Error */} - {error && ( -
-

{error}

-
- )} - - {/* Footer */} -
- - -
-
-
-
- ) -} diff --git a/project_design/PAGES.md b/project_design/PAGES.md deleted file mode 100644 index 22ddc38..0000000 --- a/project_design/PAGES.md +++ /dev/null @@ -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 diff --git a/project_design/PermissionsMatrix.tsx b/project_design/PermissionsMatrix.tsx deleted file mode 100644 index 887c1da..0000000 --- a/project_design/PermissionsMatrix.tsx +++ /dev/null @@ -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 ( -
- - - - Full -
- ) - } - if (access === 'partial') { - return ( -
-
-
-
- {note ?? 'Limited'} -
- ) - } - return ( -
-
-
-
- No access -
- ) -} - -export default function PermissionsMatrix() { - return ( -
-
-

Role permissions

-

- What each role can do across the dashboard. Owners have full access to everything. -

-
- -
- {/* Header */} -
-
- Feature -
-
- Manager -
-
- Agent -
-
- - {/* Rows */} - {FEATURES.map((feature, i) => ( -
-
- {feature.label} -
-
- -
-
- -
-
- ))} -
-
- ) -} diff --git a/project_design/README.md b/project_design/README.md deleted file mode 100644 index 7d72de2..0000000 --- a/project_design/README.md +++ /dev/null @@ -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 | diff --git a/project_design/advanced-features.md b/project_design/advanced-features.md deleted file mode 100644 index 2fd822c..0000000 --- a/project_design/advanced-features.md +++ /dev/null @@ -1,1308 +0,0 @@ ---- -name: rental-car-advanced-features -description: Build advanced rental operations features for RentalDriveGo. Use this skill when the user asks about: vehicle damage diagrams/inspection (contract car layout), insurance policies and charges, second driver (additional driver) with optional charge, driver license validation (expiry check, 3-month rule, flagging), age-based pricing rules (25+ discount, license+5 surcharge), gasoline/fuel policies (FULL_TO_FULL etc.), billing period reporting (weekly/monthly/yearly export for accountants), or the platform admin panel (managing tenants, staff, roles, permissions). This skill works alongside document-service.md and schema.md. ---- - -# Skill: RentalDriveGo Advanced Features - -## Features Covered - -1. Vehicle Damage Inspection Map (contract car diagram) -2. Insurance Policies & Per-Reservation Charges -3. Second / Additional Driver -4. Driver License Validation (expiry + 3-month rule + flagging) -5. Age-Based Pricing Rules (25+ discount, license+5 surcharge) -6. Fuel/Gasoline Policy (structured types) -7. Billing Period Reporting (accountant export) -8. Platform Admin Panel (tenant + staff + role management) - ---- - -## 1. Vehicle Damage Inspection Map - -### What it is -An interactive SVG car diagram embedded in the check-in/check-out workflow (dashboard + printable on contract). Staff marks pre-existing damage zones before handing over the vehicle. At return, the same diagram shows original vs new damage. - -### Data model - -```prisma -// Damage report: one per check-in, one per check-out -model DamageReport { - id String @id @default(cuid()) - reservationId String - reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade) - companyId String // denormalized for tenant scoping - type DamageReportType // CHECKIN | CHECKOUT - - // Array of marked damage zones - // Each zone: { zone: string, severity: string, note: string } - // zone values: "hood", "roof", "trunk", "front-left-door", "front-right-door", - // "rear-left-door", "rear-right-door", "front-left-fender", "front-right-fender", - // "rear-left-fender", "rear-right-fender", "front-bumper", "rear-bumper", - // "windshield", "rear-window", "left-mirror", "right-mirror", - // "front-left-wheel", "front-right-wheel", "rear-left-wheel", "rear-right-wheel" - // severity: "SCRATCH" | "DENT" | "CRACK" | "MISSING" | "OTHER" - damages Json // DamageZone[] - - // Photos of damage (Cloudinary URLs, optional) - photos String[] - - // Fuel level at this inspection point - fuelLevel FuelLevel - - // Odometer reading - mileage Int? - - // Captured at - inspectedAt DateTime @default(now()) - inspectedBy String? // Employee name who did the inspection - - // Customer acknowledgement - customerSignedAt DateTime? - customerName String? // printed name of who acknowledged - - createdAt DateTime @default(now()) - - @@unique([reservationId, type]) - @@index([companyId]) - @@map("damage_reports") -} - -enum DamageReportType { CHECKIN CHECKOUT } -enum FuelLevel { FULL THREE_QUARTERS HALF QUARTER EMPTY } -``` - -Add to `Reservation` model: -```prisma - damageReports DamageReport[] - // Remove old checkInFuelLevel/checkOutFuelLevel String? — replaced by DamageReport.fuelLevel -``` - -### Damage zone TypeScript type - -```typescript -// types/damage.ts -export interface DamageZone { - zone: VehicleZone - severity: 'SCRATCH' | 'DENT' | 'CRACK' | 'MISSING' | 'OTHER' - note?: string -} - -export type VehicleZone = - | 'hood' | 'roof' | 'trunk' - | 'front-left-door' | 'front-right-door' - | 'rear-left-door' | 'rear-right-door' - | 'front-left-fender' | 'front-right-fender' - | 'rear-left-fender' | 'rear-right-fender' - | 'front-bumper' | 'rear-bumper' - | 'windshield' | 'rear-window' - | 'left-mirror' | 'right-mirror' - | 'front-left-wheel' | 'front-right-wheel' - | 'rear-left-wheel' | 'rear-right-wheel' - | 'interior' | 'under-hood' | 'undercarriage' - -// Coordinates on the SVG diagram for each zone -// Used by the interactive damage map UI and the PDF render -export const DAMAGE_ZONE_COORDS: Record = { - 'hood': { x: 48, y: 8, w: 64, h: 28 }, - 'roof': { x: 48, y: 52, w: 64, h: 56 }, - 'trunk': { x: 48, y: 124, w: 64, h: 28 }, - 'front-bumper': { x: 48, y: 0, w: 64, h: 10 }, - 'rear-bumper': { x: 48, y: 150, w: 64, h: 10 }, - 'front-left-fender': { x: 20, y: 10, w: 30, h: 35 }, - 'front-right-fender': { x: 110, y: 10, w: 30, h: 35 }, - 'front-left-door': { x: 20, y: 52, w: 30, h: 30 }, - 'front-right-door': { x: 110, y: 52, w: 30, h: 30 }, - 'rear-left-door': { x: 20, y: 82, w: 30, h: 30 }, - 'rear-right-door': { x: 110, y: 82, w: 30, h: 30 }, - 'rear-left-fender': { x: 20, y: 112, w: 30, h: 35 }, - 'rear-right-fender': { x: 110, y: 112, w: 30, h: 35 }, - 'windshield': { x: 52, y: 38, w: 56, h: 18 }, - 'rear-window': { x: 52, y: 104, w: 56, h: 18 }, - 'left-mirror': { x: 8, y: 50, w: 14, h: 10 }, - 'right-mirror': { x: 138, y: 50, w: 14, h: 10 }, - 'front-left-wheel': { x: 10, y: 24, w: 22, h: 26 }, - 'front-right-wheel': { x: 128, y: 24, w: 22, h: 26 }, - 'rear-left-wheel': { x: 10, y: 110, w: 22, h: 26 }, - 'rear-right-wheel': { x: 128, y: 110, w: 22, h: 26 }, - 'interior': { x: 55, y: 58, w: 50, h: 44 }, - 'under-hood': { x: 52, y: 10, w: 56, h: 20 }, - 'undercarriage': { x: 52, y: 130, w: 56, h: 20 }, -} - -// Severity colors for the SVG overlay -export const SEVERITY_COLORS = { - SCRATCH: '#F59E0B', // amber - DENT: '#EF4444', // red - CRACK: '#8B5CF6', // purple - MISSING: '#374151', // dark gray - OTHER: '#6B7280', // gray -} -``` - -### Interactive Damage Map UI (React Component) - -```tsx -// components/reservations/DamageMap.tsx -import { useState } from 'react' -import { DAMAGE_ZONE_COORDS, SEVERITY_COLORS, VehicleZone, DamageZone } from '@/types/damage' - -interface DamageMapProps { - damages: DamageZone[] - onChange?: (damages: DamageZone[]) => void // undefined = read-only - readonly?: boolean - primaryColor?: string -} - -export function DamageMap({ damages, onChange, readonly, primaryColor = '#1A56DB' }: DamageMapProps) { - const [selected, setSelected] = useState(null) - const [severity, setSeverity] = useState('SCRATCH') - const [note, setNote] = useState('') - - const getDamageForZone = (zone: VehicleZone) => - damages.find(d => d.zone === zone) - - const handleZoneClick = (zone: VehicleZone) => { - if (readonly || !onChange) return - setSelected(zone) - const existing = getDamageForZone(zone) - if (existing) { - setSeverity(existing.severity) - setNote(existing.note ?? '') - } else { - setSeverity('SCRATCH') - setNote('') - } - } - - const applyDamage = () => { - if (!selected || !onChange) return - const updated = damages.filter(d => d.zone !== selected) - updated.push({ zone: selected, severity, note }) - onChange(updated) - setSelected(null) - } - - const clearZone = (zone: VehicleZone) => { - if (!onChange) return - onChange(damages.filter(d => d.zone !== zone)) - } - - return ( -
- {/* SVG Car Diagram — top-down view */} -
- - - {/* Car body — top-down silhouette */} - - - {/* Damage zone hit areas — transparent clickable rects */} - {(Object.entries(DAMAGE_ZONE_COORDS) as [VehicleZone, any][]).map(([zone, coords]) => { - const damage = getDamageForZone(zone) - return ( - handleZoneClick(zone)} className={readonly ? '' : 'cursor-pointer'}> - - {/* Damage indicator dot */} - {damage && ( - - )} - - ) - })} - - - {/* Direction labels */} -
FRONT
-
REAR
-
- - {/* Severity legend */} -
- {(Object.entries(SEVERITY_COLORS) as [DamageZone['severity'], string][]).map(([s, color]) => ( - - - {s} - - ))} -
- - {/* Zone editor (appears when zone is clicked, in edit mode) */} - {selected && !readonly && ( -
-

- {selected.replace(/-/g, ' ')} -

-
- {(['SCRATCH', 'DENT', 'CRACK', 'MISSING', 'OTHER'] as const).map(s => ( - - ))} -
- setNote(e.target.value)} - className="w-full text-sm border border-gray-200 rounded px-2 py-1 mb-2" /> -
- - - -
-
- )} - - {/* Damage list */} - {damages.length > 0 && ( -
-

Marked damages ({damages.length})

-
- {damages.map(d => ( -
-
- - {d.zone.replace(/-/g, ' ')} - — {d.severity} - {d.note && "{d.note}"} -
- {!readonly && ( - - )} -
- ))} -
-
- )} -
- ) -} - -// Simplified SVG path for top-down car silhouette -function CarTopDownSVG({ primaryColor }: { primaryColor: string }) { - return ( - - {/* Outer car body */} - - {/* Hood section */} - - {/* Windshield */} - - {/* Roof / cabin */} - - {/* Rear window */} - - {/* Trunk */} - - {/* Rear bumper */} - - {/* Front bumper */} - - {/* Mirrors */} - - - {/* Wheels */} - - - - - {/* Center line */} - - - ) -} -``` - -### Damage Map in the PDF Contract - -In `RentalContractPDF.tsx`, add a damage section after the vehicle block using `@react-pdf/renderer` SVG primitives (the interactive React SVG won't work in the PDF renderer — use static `Svg`, `Rect`, `Circle`, `Line` elements from `@react-pdf/renderer`): - -```tsx -// In RentalContractPDF.tsx — add after vehicle section -import { Svg, Rect, Circle, Line, Path, G, Text as SvgText } from '@react-pdf/renderer' - -function DamageMapPDF({ damages, label }: { damages: DamageZone[]; label: string }) { - return ( - - {label} - - {/* SVG car diagram — static top-down view */} - - {/* Repeat the CarTopDownSVG shapes using Svg primitives */} - - - - - - - - - - - {/* Damage markers */} - {damages.map((d, i) => { - const coords = DAMAGE_ZONE_COORDS[d.zone] - return ( - - - - - ) - })} - - - {/* Damage list */} - - {damages.length === 0 - ? ✓ No damage recorded - : damages.map((d, i) => ( - - - - {d.zone.replace(/-/g, ' ')} - {` — ${d.severity}${d.note ? ` (${d.note})` : ''}`} - - - )) - } - - - - ) -} - -// Usage in the contract — two damage sections: -// 1. At check-in: -// 2. At checkout: -// Both are shown on the contract, side by side if space, or stacked. -``` - ---- - -## 2. Insurance Policies - -### Data model - -```prisma -// ─── Insurance Policy ───────────────────────────────────────── -// Company configures insurance options available to renters. -// At booking, renter selects one (or NONE if all are optional). - -model InsurancePolicy { - id String @id @default(cuid()) - companyId String - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - - name String // e.g. "Basic Coverage", "Full Coverage", "CDW", "SCDW" - description String? // what it covers (shown to customer at booking) - type InsuranceType - - // Pricing — one of the following applies: - chargeType InsuranceChargeType - chargeValue Int // in smallest currency unit - // Examples: - // chargeType=PER_DAY, chargeValue=3000 → 30 MAD/day - // chargeType=PER_RENTAL, chargeValue=15000 → 150 MAD flat - // chargeType=PERCENTAGE_OF_RENTAL, chargeValue=10 → 10% of base rental - - isRequired Boolean @default(false) // if true, auto-applied, customer cannot opt out - isActive Boolean @default(true) - sortOrder Int @default(0) // display order at booking - - reservations ReservationInsurance[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([companyId]) - @@index([companyId, isActive]) - @@map("insurance_policies") -} - -enum InsuranceType { - CDW // Collision Damage Waiver - SCDW // Super CDW (lower excess) - THEFT // Theft protection - THIRD_PARTY // Third-party liability - FULL // Full coverage bundle - BASIC // Basic minimal coverage - ROADSIDE // Roadside assistance - PERSONAL // Personal accident insurance - CUSTOM // Company-defined name -} - -enum InsuranceChargeType { - PER_DAY - PER_RENTAL // flat fee per reservation - PERCENTAGE_OF_RENTAL // % of base rental amount -} - -// ─── Reservation Insurance ──────────────────────────────────── -// Junction: which insurance(s) were selected for a reservation - -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 of policy at time of booking (prices may change later) - policyName String - policyType InsuranceType - chargeType InsuranceChargeType - chargeValue Int // locked at booking time - totalCharge Int // calculated total for this reservation (in smallest unit) - - @@unique([reservationId, insurancePolicyId]) - @@map("reservation_insurances") -} -``` - -Add to `Reservation` model: -```prisma - insurances ReservationInsurance[] - insuranceTotal Int @default(0) // sum of all insurance charges (cached) -``` - -### Insurance charge calculation - -```typescript -// services/insuranceService.ts - -export function calculateInsuranceCharge( - policy: InsurancePolicy, - totalDays: number, - baseRentalAmount: number // dailyRate × totalDays, in smallest unit -): number { - switch (policy.chargeType) { - case 'PER_DAY': - return policy.chargeValue * totalDays - case 'PER_RENTAL': - return policy.chargeValue - case 'PERCENTAGE_OF_RENTAL': - return Math.round(baseRentalAmount * policy.chargeValue / 100) - default: - return 0 - } -} - -export async function applyInsurancesToReservation( - reservationId: string, - companyId: string, - selectedPolicyIds: string[], - totalDays: number, - baseRentalAmount: number -) { - // Always include required policies - const allPolicies = await prisma.insurancePolicy.findMany({ - where: { companyId, isActive: true } - }) - - const required = allPolicies.filter(p => p.isRequired) - const selected = allPolicies.filter(p => selectedPolicyIds.includes(p.id) && !p.isRequired) - const toApply = [...required, ...selected] - - const records = toApply.map(policy => ({ - reservationId, - insurancePolicyId: policy.id, - policyName: policy.name, - policyType: policy.type, - chargeType: policy.chargeType, - chargeValue: policy.chargeValue, - totalCharge: calculateInsuranceCharge(policy, totalDays, baseRentalAmount) - })) - - const insuranceTotal = records.reduce((sum, r) => sum + r.totalCharge, 0) - - await prisma.$transaction([ - prisma.reservationInsurance.createMany({ data: records }), - prisma.reservation.update({ - where: { id: reservationId }, - data: { insuranceTotal } - }) - ]) - - return { records, insuranceTotal } -} -``` - ---- - -## 3. Second / Additional Driver - -### Data model - -```prisma -model AdditionalDriver { - id String @id @default(cuid()) - reservationId String - reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade) - companyId String // tenant scoping - - firstName String - lastName String - email String? - phone String? - driverLicense String - licenseExpiry Date? - dateOfBirth DateTime? - nationality String? - - // Charge for adding this driver - chargeType AdditionalDriverCharge @default(FREE) - chargeValue Int @default(0) // flat or per-day in smallest unit - totalCharge Int @default(0) - - // Validation flags - licenseExpired Boolean @default(false) - licenseExpiringSoon Boolean @default(false) // < 3 months validity remaining - requiresApproval Boolean @default(false) // flagged for manual approval - approvedBy String? // Employee who approved a flagged license - approvedAt DateTime? - approvalNote String? - - createdAt DateTime @default(now()) - - @@index([reservationId]) - @@index([companyId]) - @@map("additional_drivers") -} - -enum AdditionalDriverCharge { - FREE - PER_DAY // charge per rental day - FLAT // one-time flat charge per reservation -} -``` - -Add to company `ContractSettings`: -```prisma - // Additional driver pricing (company sets these) - additionalDriverCharge AdditionalDriverCharge @default(FREE) - additionalDriverDailyRate Int @default(0) - additionalDriverFlatRate Int @default(0) -``` - -Add to `Reservation`: -```prisma - additionalDrivers AdditionalDriver[] - additionalDriverTotal Int @default(0) -``` - ---- - -## 4. Driver License Validation - -### Fields on Customer model (additions) - -```prisma -// Add to model Customer { ... } - licenseExpiry DateTime? // expiry date of driver's license - licenseIssuedAt DateTime? // issue date - licenseCountry String? // issuing country - licenseNumber String? // license number (may differ from driverLicense ID) - licenseCategory String? // e.g. "B", "B+E", "A" (for motorcycle) - - // Validation flags - licenseExpired Boolean @default(false) - licenseExpiringSoon Boolean @default(false) // < 3 months validity - licenseValidationStatus LicenseStatus @default(PENDING) - licenseApprovedBy String? // Employee who approved/denied - licenseApprovedAt DateTime? - licenseApprovalNote String? -``` - -```prisma -enum LicenseStatus { - PENDING // not yet checked - VALID // checked and valid (>3 months remaining) - EXPIRING // flagged: <3 months remaining — awaiting employee decision - APPROVED // expired/expiring but manually approved by employee - DENIED // employee denied the booking due to license - EXPIRED // license has already expired -} -``` - -### License validation service - -```typescript -// services/licenseValidationService.ts - -const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000 - -export interface LicenseValidationResult { - status: 'VALID' | 'EXPIRING' | 'EXPIRED' - daysUntilExpiry: number | null - requiresApproval: boolean - message: string -} - -export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult { - if (!licenseExpiry) { - return { - status: 'VALID', // no expiry stored — cannot determine - daysUntilExpiry: null, - requiresApproval: false, - message: 'No expiry date on record' - } - } - - const now = new Date() - const expiryMs = licenseExpiry.getTime() - const daysUntilExpiry = Math.ceil((expiryMs - now.getTime()) / (1000 * 60 * 60 * 24)) - - if (expiryMs <= now.getTime()) { - return { - status: 'EXPIRED', - daysUntilExpiry: daysUntilExpiry, // negative number - requiresApproval: true, - message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` - } - } - - if (expiryMs - now.getTime() < THREE_MONTHS_MS) { - return { - status: 'EXPIRING', - daysUntilExpiry, - requiresApproval: true, - message: `License expires in ${daysUntilExpiry} day(s) — approval required` - } - } - - return { - status: 'VALID', - daysUntilExpiry, - requiresApproval: false, - message: `Valid — expires in ${daysUntilExpiry} day(s)` - } -} - -// Called when creating/updating a customer or adding an additional driver -export async function validateAndFlagLicense(customerId: string) { - const customer = await prisma.customer.findUniqueOrThrow({ - where: { id: customerId } - }) - - const result = validateLicense(customer.licenseExpiry) - - const status: LicenseStatus = - result.status === 'EXPIRED' ? 'EXPIRED' : - result.status === 'EXPIRING' ? 'EXPIRING' : 'VALID' - - await prisma.customer.update({ - where: { id: customerId }, - data: { - licenseExpired: result.status === 'EXPIRED', - licenseExpiringSoon: result.status === 'EXPIRING', - licenseValidationStatus: status, - } - }) - - return result -} -``` - -### License approval UI (dashboard) - -When a reservation is created for a customer with `licenseValidationStatus: EXPIRING | EXPIRED`: -- Show a yellow (EXPIRING) or red (EXPIRED) banner in the reservation detail page -- Banner text: "⚠️ Customer's license expires in X days — please verify before confirming" -- Two action buttons: **[Approve and Continue]** / **[Deny Reservation]** -- Clicking Approve: `PATCH /customers/:id/license-approval { decision: 'APPROVE', note: '...' }` -- Clicking Deny: opens cancel reservation flow - ---- - -## 5. Age-Based Pricing Rules - -### Data model - -```prisma -model PricingRule { - id String @id @default(cuid()) - companyId String - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - - name String // e.g. "Young Driver Surcharge", "Senior Discount" - type PricingRuleType - condition PricingCondition - conditionValue Int // e.g. 25 for "age < 25", 70 for "age > 70" - - adjustmentType PriceAdjustmentType - adjustmentValue Int // percentage or flat in smallest unit - // For LICENSE_YEARS condition: conditionValue = minimum years since license issue date - - isActive Boolean @default(true) - description String? // shown to customer at booking - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([companyId]) - @@map("pricing_rules") -} - -enum PricingRuleType { - SURCHARGE // adds cost - DISCOUNT // reduces cost -} - -enum PricingCondition { - AGE_LESS_THAN // customer age (years) < conditionValue - AGE_GREATER_THAN // customer age > conditionValue - LICENSE_YEARS_LESS_THAN // years since license issue < conditionValue - LICENSE_YEARS_GREATER_THAN -} - -enum PriceAdjustmentType { - PERCENTAGE // % of base daily rate - FLAT_PER_DAY // flat amount per day in smallest unit - FLAT_TOTAL // flat amount per reservation -} -``` - -Add to `Reservation`: -```prisma - pricingRulesApplied Json? // Array of { ruleId, name, type, amount } — snapshot at booking - pricingRulesTotal Int @default(0) // net surcharges minus discounts -``` - -### Built-in rules (pre-configured defaults companies can edit) - -```typescript -// The system ships with these as suggested defaults (companies can modify or delete): -const DEFAULT_PRICING_RULES = [ - { - name: 'Young Driver Surcharge (Under 25)', - type: 'SURCHARGE', - condition: 'AGE_LESS_THAN', - conditionValue: 25, - adjustmentType: 'PERCENTAGE', - adjustmentValue: 15, // +15% of base rate - description: 'Additional charge for drivers under 25 years of age' - }, - { - name: 'Experienced License Discount (5+ Years)', - type: 'DISCOUNT', - condition: 'LICENSE_YEARS_GREATER_THAN', - conditionValue: 5, - adjustmentType: 'PERCENTAGE', - adjustmentValue: 5, // -5% of base rate - description: 'Discount for drivers with 5+ years of driving experience' - }, -] -``` - -### Rule application service - -```typescript -// services/pricingRuleService.ts - -export async function applyPricingRules( - companyId: string, - customerId: string, - additionalDrivers: { dateOfBirth?: Date | null; licenseIssuedAt?: Date | null }[], - dailyRate: number, - totalDays: number -): Promise<{ applied: any[]; total: number }> { - - const rules = await prisma.pricingRule.findMany({ - where: { companyId, isActive: true } - }) - - const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } }) - const allDrivers = [customer, ...additionalDrivers] - - const applied: any[] = [] - const baseAmount = dailyRate * totalDays - - for (const driver of allDrivers) { - for (const rule of rules) { - const driverAge = driver.dateOfBirth - ? Math.floor((Date.now() - driver.dateOfBirth.getTime()) / (365.25 * 24 * 60 * 60 * 1000)) - : null - - const licenseYears = (driver as any).licenseIssuedAt - ? Math.floor((Date.now() - (driver as any).licenseIssuedAt.getTime()) / (365.25 * 24 * 60 * 60 * 1000)) - : null - - let conditionMet = false - if (rule.condition === 'AGE_LESS_THAN' && driverAge !== null) - conditionMet = driverAge < rule.conditionValue - else if (rule.condition === 'AGE_GREATER_THAN' && driverAge !== null) - conditionMet = driverAge > rule.conditionValue - else if (rule.condition === 'LICENSE_YEARS_LESS_THAN' && licenseYears !== null) - conditionMet = licenseYears < rule.conditionValue - else if (rule.condition === 'LICENSE_YEARS_GREATER_THAN' && licenseYears !== null) - conditionMet = licenseYears > rule.conditionValue - - if (!conditionMet) continue - - let amount = 0 - if (rule.adjustmentType === 'PERCENTAGE') - amount = Math.round(baseAmount * rule.adjustmentValue / 100) - else if (rule.adjustmentType === 'FLAT_PER_DAY') - amount = rule.adjustmentValue * totalDays - else - amount = rule.adjustmentValue - - if (rule.type === 'DISCOUNT') amount = -amount - - applied.push({ ruleId: rule.id, name: rule.name, type: rule.type, amount }) - } - } - - // Deduplicate: each rule type applied once even if multiple drivers trigger it - const deduped = applied.reduce((acc: any[], item) => { - if (!acc.find(a => a.ruleId === item.ruleId)) acc.push(item) - return acc - }, []) - - const total = deduped.reduce((sum, r) => sum + r.amount, 0) - return { applied: deduped, total } -} -``` - ---- - -## 6. Fuel / Gasoline Policy (Structured) - -Replace the free-text `fuelPolicy` string in `ContractSettings` with a structured enum. - -```prisma -// In ContractSettings — replace fuelPolicy String with: - fuelPolicyType FuelPolicyType @default(FULL_TO_FULL) - fuelPolicyNote String? // optional extra note - prepaidFuelPrice Int? // price per liter in smallest unit (for PREPAID type) -``` - -```prisma -enum FuelPolicyType { - FULL_TO_FULL // Customer receives full, must return full. Extra charges if returned low. - FULL_TO_EMPTY // Customer receives full, keeps all fuel (no refund for unused fuel). - SAME_TO_SAME // Returns vehicle with same level as received. - PREPAID // Customer pays for a full tank upfront, returns at any level. - FREE // Fuel included in rental price. -} -``` - -```typescript -// lib/fuelPolicyLabels.ts -export const FUEL_POLICY_LABELS: Record> = { - FULL_TO_FULL: { - en: 'Full-to-Full: Return vehicle with a full tank. A refueling fee applies if returned with less fuel.', - fr: 'Plein-à-Plein: Retournez le véhicule avec le plein. Des frais de carburant s\'appliquent sinon.', - ar: 'ممتلئ إلى ممتلئ: يجب إعادة السيارة بخزان ممتلئ. رسوم إضافية في حال الإعادة بمستوى أقل.' - }, - FULL_TO_EMPTY: { - en: 'Full-to-Empty: Vehicle is provided with a full tank. No refund for unused fuel upon return.', - fr: 'Plein-à-Vide: Le véhicule est fourni avec un plein. Aucun remboursement pour carburant non utilisé.', - ar: 'ممتلئ إلى فارغ: تُسلَّم السيارة بخزان ممتلئ. لا استرداد للوقود غير المستخدم.' - }, - SAME_TO_SAME: { - en: 'Same-to-Same: Return vehicle with the same fuel level as at pickup.', - fr: 'Même niveau: Retournez avec le même niveau de carburant qu\'au départ.', - ar: 'نفس المستوى: أعد السيارة بنفس مستوى الوقود عند الاستلام.' - }, - PREPAID: { - en: 'Prepaid Fuel: You pre-purchase a full tank at a fixed rate. Return at any fuel level.', - fr: 'Carburant prépayé: Vous prépayez un plein à tarif fixe. Retour à n\'importe quel niveau.', - ar: 'الوقود المدفوع مسبقاً: تدفع مقدماً لخزان كامل بسعر ثابت. العودة بأي مستوى.' - }, - FREE: { - en: 'Fuel Included: Fuel cost is included in the rental price.', - fr: 'Carburant inclus: Le coût du carburant est inclus dans le tarif de location.', - ar: 'الوقود مشمول: تكلفة الوقود مشمولة في سعر الإيجار.' - } -} -``` - ---- - -## 7. Billing Period Reporting (Accountant Export) - -Companies need to export their financial data by week, month, or year. - -### Report model (optional — or generate entirely on-demand) - -```prisma -// No model needed — reports are generated on-demand from Reservation + RentalPayment data -// The API query uses date range filters -``` - -### Report API - -```typescript -// GET /api/v1/reports/financial -// Query params: period=WEEK|MONTH|YEAR, startDate, endDate, format=JSON|CSV - -export async function generateFinancialReport( - companyId: string, - startDate: Date, - endDate: Date -) { - const reservations = await prisma.reservation.findMany({ - where: { - companyId, - status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, - startDate: { gte: startDate }, - endDate: { lte: endDate } - }, - include: { - vehicle: { select: { make: true, model: true, year: true, licensePlate: true } }, - customer: { select: { firstName: true, lastName: true, email: true } }, - rentalPayments: { where: { status: 'SUCCEEDED' } }, - insurances: true, - additionalDrivers: true, - }, - orderBy: { startDate: 'asc' } - }) - - // Aggregate totals - const summary = { - totalReservations: reservations.length, - totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0), - totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0), - totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0), - totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0), - totalPricingRulesAdjustments: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0), - totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0), - totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0), - averageRentalDays: reservations.length > 0 - ? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0, - } - - // Per-reservation rows for the table / CSV - const rows = reservations.map(r => ({ - reservationId: r.id, - contractNumber: r.contractNumber ?? '—', - invoiceNumber: r.invoiceNumber ?? '—', - customerName: `${r.customer.firstName} ${r.customer.lastName}`, - vehicle: `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`, - plate: r.vehicle.licensePlate, - startDate: r.startDate.toISOString().split('T')[0], - endDate: r.endDate.toISOString().split('T')[0], - days: r.totalDays, - dailyRate: r.dailyRate, - baseAmount: r.dailyRate * r.totalDays, - discount: r.discountAmount, - insurance: r.insuranceTotal, - additionalDriver: r.additionalDriverTotal, - pricingAdj: r.pricingRulesTotal, - deposit: r.depositAmount, - totalAmount: r.totalAmount, - paymentStatus: r.rentalPayments[0]?.status ?? 'UNPAID', - paymentMethod: r.rentalPayments[0]?.paymentMethod ?? '—', - source: r.source, - })) - - return { summary, rows, period: { startDate, endDate } } -} -``` - -### CSV export - -```typescript -// lib/csvExport.ts -export function toCsv(rows: Record[]): string { - if (rows.length === 0) return '' - const headers = Object.keys(rows[0]) - const lines = [ - headers.join(','), - ...rows.map(row => - headers.map(h => { - const val = row[h] ?? '' - return typeof val === 'string' && val.includes(',') ? `"${val}"` : String(val) - }).join(',') - ) - ] - return lines.join('\n') -} -``` - -### Dashboard: Reports Page - -Located at `/dashboard/reports` (new page). - -``` -┌─────────────────────────────────────────────────────┐ -│ Financial Reports [Export CSV] │ -│ │ -│ Period: [This Week ▼] [This Month ▼] [This Year ▼] │ -│ Or custom: [From date] → [To date] [Apply] │ -│ │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ Summary Cards (6 cards in a row) │ │ -│ │ Total Bookings · Revenue · Discounts · Insurance│ │ -│ │ Additional Drivers · Net Collected │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ Reservations Table (all in period) │ -│ Contract# · Invoice# · Customer · Vehicle · Dates │ -│ Days · Base · Discount · Insurance · AddDriver │ -│ PricingAdj · Total · Payment Status · Source │ -└─────────────────────────────────────────────────────┘ -``` - ---- - -## 8. Platform Admin Panel - -The platform administrator (RentalDriveGo team) manages all companies and their staff. - -### Admin model - -```prisma -model AdminUser { - id String @id @default(cuid()) - email String @unique - firstName String - lastName String - passwordHash String - role AdminRole @default(SUPPORT) - isActive Boolean @default(true) - lastLoginAt DateTime? - - auditLogs AdminAuditLog[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("admin_users") -} - -enum AdminRole { - SUPER_ADMIN // full access: create/edit/delete companies, manage other admins - ADMIN // create/edit companies, manage staff, cannot delete or manage other admins - SUPPORT // read-only access to companies + reservations, can impersonate -} - -model AdminAuditLog { - id String @id @default(cuid()) - adminId String - admin AdminUser @relation(fields: [adminId], references: [id]) - action String // e.g. "CREATE_COMPANY", "SUSPEND_COMPANY", "EDIT_EMPLOYEE" - targetType String // "Company" | "Employee" | "Reservation" - targetId String - before Json? // state before change - after Json? // state after change - ip String? - createdAt DateTime @default(now()) - - @@index([adminId]) - @@index([targetType, targetId]) - @@map("admin_audit_logs") -} -``` - -### Admin API routes - -``` -Base: https://admin.RentalDriveGo.com/api/v1/ (separate app) -OR: https://api.RentalDriveGo.com/api/v1/admin/ (preferred — same API, admin middleware) - -Middleware: requireAdminAuth → attaches req.admin (AdminUser) -``` - -| Method | Path | Role | Description | -|--------|------|------|-------------| -| POST | `/admin/auth/login` | — | Admin login (email + password) | -| GET | `/admin/auth/me` | Any admin | Current admin profile | -| **Companies** | | | | -| GET | `/admin/companies` | Any | List all companies + search/filter | -| POST | `/admin/companies` | ADMIN+ | Create company (name, slug, email, plan) | -| GET | `/admin/companies/:id` | Any | Full company detail | -| PATCH | `/admin/companies/:id` | ADMIN+ | Edit company profile | -| PATCH | `/admin/companies/:id/status` | ADMIN+ | Change status (ACTIVE/SUSPENDED) | -| PATCH | `/admin/companies/:id/plan` | ADMIN+ | Change plan without payment | -| DELETE | `/admin/companies/:id` | SUPER_ADMIN | Hard delete company (irreversible) | -| POST | `/admin/companies/:id/impersonate` | ADMIN+ | Generate impersonation token | -| **Staff** | | | | -| GET | `/admin/companies/:id/employees` | Any | List employees | -| POST | `/admin/companies/:id/employees` | ADMIN+ | Add employee (name, email, role) | -| 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 employee | -| POST | `/admin/companies/:id/employees/:eid/reset-password` | ADMIN+ | Trigger password reset email | -| **Platform** | | | | -| GET | `/admin/metrics` | Any | MRR, ARR, churn, signups, active | -| GET | `/admin/admins` | SUPER_ADMIN | List admin users | -| POST | `/admin/admins` | SUPER_ADMIN | Create admin user | -| PATCH | `/admin/admins/:id` | SUPER_ADMIN | Edit 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 | Full audit log | - -### Impersonation - -Admins can impersonate any company employee for support purposes: - -```typescript -// POST /admin/companies/:id/impersonate -// Returns a short-lived JWT that grants dashboard access as the company OWNER -// Duration: 30 minutes max -// All actions logged in AdminAuditLog -// Dashboard shows "⚠️ Impersonation Mode — RentalDriveGo Admin" banner - -async function impersonateCompany(req, res) { - const company = await prisma.company.findUniqueOrThrow({ - where: { id: req.params.id }, - include: { employees: { where: { role: 'OWNER' } } } - }) - - await prisma.adminAuditLog.create({ - data: { - adminId: req.admin.id, - action: 'IMPERSONATE_COMPANY', - targetType: 'Company', - targetId: company.id, - ip: req.ip - } - }) - - // Generate a short-lived JWT signed with JWT_SECRET - const token = jwt.sign( - { companyId: company.id, employeeId: company.employees[0]?.id, isImpersonation: true }, - process.env.JWT_SECRET!, - { expiresIn: '30m' } - ) - - res.json({ data: { token, expiresIn: 1800 } }) -} -``` - -### Admin UI (admin.RentalDriveGo.com) - -Separate Next.js app with its own auth (email + password, NO Clerk). - -| Page | Description | -|------|-------------| -| `/` → redirect to `/companies` | | -| `/login` | Admin login | -| `/companies` | Searchable table: name, slug, plan, status, employees count, vehicles count, MRR, created. Actions: Edit, Suspend, Impersonate | -| `/companies/new` | Create company form | -| `/companies/:id` | Company profile: overview, subscription, employees, vehicles, reservations, audit log | -| `/companies/:id/employees` | Employee list + add/edit/deactivate/change role | -| `/metrics` | Platform KPIs: MRR, ARR, churn rate, new signups chart, top companies | -| `/admins` | Admin user management (SUPER_ADMIN only) | -| `/audit-log` | Full audit log with filters | - ---- - -## Invoice: Full Price Breakdown - -The `CustomerInvoicePDF` line items now must include all charge types: - -```typescript -// Full line items array for invoice generation -const lineItems = [ - // 1. Base rental - { - description: `${vehicle.year} ${vehicle.make} ${vehicle.model} — ${totalDays} day(s) × ${fmt(dailyRate)}/day`, - qty: totalDays, unitPrice: dailyRate, total: dailyRate * totalDays, - category: 'RENTAL' - }, - - // 2. Insurance lines (one per policy selected) - ...insurances.map(ins => ({ - description: `${ins.policyName} (${insuranceChargeLabel(ins.chargeType, ins.chargeValue, totalDays, locale)})`, - qty: ins.chargeType === 'PER_DAY' ? totalDays : 1, - unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, - total: ins.totalCharge, - category: 'INSURANCE' - })), - - // 3. Additional driver(s) - ...additionalDrivers.filter(d => d.totalCharge > 0).map(d => ({ - description: `Additional Driver — ${d.firstName} ${d.lastName}`, - qty: d.chargeType === 'PER_DAY' ? totalDays : 1, - unitPrice: d.chargeType === 'PER_DAY' ? d.chargeValue : d.totalCharge, - total: d.totalCharge, - category: 'ADDITIONAL_DRIVER' - })), - - // 4. Pricing rule adjustments (surcharges as positive, discounts as negative) - ...(pricingRulesApplied ?? []).map((rule: any) => ({ - description: rule.name, - qty: 1, unitPrice: rule.amount, total: rule.amount, - category: rule.amount > 0 ? 'SURCHARGE' : 'DISCOUNT' - })), - - // 5. Deposit (always last, marked as refundable) - ...(depositAmount > 0 ? [{ - description: locale === 'ar' ? 'تأمين (قابل للاسترداد)' : locale === 'fr' ? 'Caution (remboursable)' : 'Security Deposit (refundable)', - qty: 1, unitPrice: depositAmount, total: depositAmount, - category: 'DEPOSIT' - }] : []), -] - -// Totals -const subtotal = dailyRate * totalDays -const discountTotal = -Math.abs(discountAmount + pricingDiscountsTotal) -const surchargeTotal = pricingSurchargesTotal + pricingRulesTotal -const insuranceTotal = insurances.reduce((s, i) => s + i.totalCharge, 0) -const additionalDriverTotal = additionalDrivers.reduce((s, d) => s + d.totalCharge, 0) -const taxAmount = showTax ? Math.round(subtotal * (taxRate ?? 0)) : 0 -const grandTotal = subtotal + discountTotal + surchargeTotal + insuranceTotal + additionalDriverTotal + taxAmount + depositAmount -``` - ---- - -## Updated ContractSettings model (additions to existing) - -```prisma -// ADD to model ContractSettings — replacing fuelPolicy String: - fuelPolicyType FuelPolicyType @default(FULL_TO_FULL) - fuelPolicyNote String? - - // Additional driver config - additionalDriverCharge AdditionalDriverCharge @default(FREE) - additionalDriverDailyRate Int @default(0) - additionalDriverFlatRate Int @default(0) -``` - -## Updated Reservation model (additional fields) - -```prisma -// ADD to model Reservation: - insurances ReservationInsurance[] - insuranceTotal Int @default(0) - additionalDrivers AdditionalDriver[] - additionalDriverTotal Int @default(0) - pricingRulesApplied Json? // snapshot of applied pricing rules - pricingRulesTotal Int @default(0) - damageReports DamageReport[] -``` - -## New Company relations (add to model Company) - -```prisma - insurancePolicies InsurancePolicy[] - pricingRules PricingRule[] -``` diff --git a/project_design/api-routes.md b/project_design/api-routes.md deleted file mode 100644 index 816527f..0000000 --- a/project_design/api-routes.md +++ /dev/null @@ -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=` | diff --git a/project_design/requireRole.ts b/project_design/requireRole.ts deleted file mode 100644 index cc20597..0000000 --- a/project_design/requireRole.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Request, Response, NextFunction } from 'express' -import { EmployeeRole } from '@prisma/client' - -// Role hierarchy: OWNER > MANAGER > AGENT -const ROLE_RANK: Record = { - 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() - } -} diff --git a/project_design/schema.md b/project_design/schema.md deleted file mode 100644 index cea15ba..0000000 --- a/project_design/schema.md +++ /dev/null @@ -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") -} -``` diff --git a/project_design/team.ts b/project_design/team.ts deleted file mode 100644 index 8c28b51..0000000 --- a/project_design/team.ts +++ /dev/null @@ -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 diff --git a/project_design/team.tsx b/project_design/team.tsx deleted file mode 100644 index fda1fe0..0000000 --- a/project_design/team.tsx +++ /dev/null @@ -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 = { - 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 ( - - {role.charAt(0) + role.slice(1).toLowerCase()} - - ) -} - -function StatusBadge({ member }: { member: TeamMember }) { - if (member.invitationStatus === 'pending') { - return ( - - Pending invite - - ) - } - if (!member.isActive) { - return ( - - Deactivated - - ) - } - return ( - - Active - - ) -} - -// ─── 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('') - const [statusFilter, setStatusFilter] = useState('') - const [inviteOpen, setInviteOpen] = useState(false) - const [editTarget, setEditTarget] = useState(null) - const [toast, setToast] = useState(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 ( -
- - {/* Page header */} -
-
-

Team

-

- Manage your employees and their access levels -

-
- {isOwner && ( - - )} -
- - {/* Stat cards */} -
- {[ - { label: 'Total members', value: stats.total }, - { label: 'Active', value: stats.active }, - { label: 'Pending invite', value: stats.pending }, - { label: 'Deactivated', value: stats.inactive }, - ].map((s) => ( -
- {s.label} - {s.value} -
- ))} -
- - {/* Filters */} -
-
- - - - - 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" - /> -
- - -
- - {/* Table */} -
- {loading ? ( -
-
- Loading team… -
- ) : error ? ( -
{error}
- ) : filtered.length === 0 ? ( -
-

No members match your filters

-
- ) : ( - - - - - - - - - - - {filtered.map((member, i) => ( - - {/* Member */} - - - {/* Role */} - - - {/* Status */} - - - {/* Last active */} - - - {/* Actions */} - - - ))} - -
MemberRoleStatusLast active -
-
-
- {initials(member)} -
-
-
- {member.firstName} {member.lastName} - {member.id === currentEmployee?.id && ( - (you) - )} -
-
{member.email}
-
-
-
- - - - - - {member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)} - - - {isOwner && member.role !== 'OWNER' && ( - - )} -
- )} -
- - {/* Permissions matrix */} - - - {/* Modals */} - setInviteOpen(false)} - onInvite={handleInvite} - /> - setEditTarget(null)} - onUpdateRole={handleUpdateRole} - onDeactivate={handleDeactivate} - onReactivate={handleReactivate} - onRemove={handleRemove} - /> - - {/* Toast */} - {toast && ( -
- - - - {toast} -
- )} -
- ) -} diff --git a/project_design/teamService.ts b/project_design/teamService.ts deleted file mode 100644 index 6201100..0000000 --- a/project_design/teamService.ts +++ /dev/null @@ -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 { - 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> - - 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 -} diff --git a/project_design/useTeam.ts b/project_design/useTeam.ts deleted file mode 100644 index e69aeae..0000000 --- a/project_design/useTeam.ts +++ /dev/null @@ -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( - path: string, - options?: RequestInit -): Promise { - 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([]) - const [stats, setStats] = useState({ total: 0, active: 0, pending: 0, inactive: 0 }) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchMembers = useCallback(async () => { - setLoading(true) - setError(null) - try { - const [membersData, statsData] = await Promise.all([ - apiFetch('/team'), - apiFetch('/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(`/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(`/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(`/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, - } -} diff --git a/project_design/webhooks.ts b/project_design/webhooks.ts deleted file mode 100644 index 8f75775..0000000 --- a/project_design/webhooks.ts +++ /dev/null @@ -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