fixing platform admin

This commit is contained in:
root
2026-05-06 22:58:23 -04:00
parent 695a7f7cc7
commit 750ae56a29
175 changed files with 31249 additions and 328 deletions
+57
View File
@@ -0,0 +1,57 @@
## Project Design Audit
Date: 2026-05-01
### What I checked
- 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`
### Added to the project
- 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`
### Confirmed as already implemented in the app code
- 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`
These are not exact copies of the `project_design` stubs, but they cover the same implementation area and appear to be the working versions.
### Obvious gaps against the design docs
- 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.
### Notes
- 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`.
+310
View File
@@ -0,0 +1,310 @@
# Features & Priorities — RentalDriveGo
## MVP (Phase 1) — Must Ship Before Launch
### Subscription & Billing
- [x] Company signup → plan selection → payment via AmanPay or PayPal
- [x] 14-day free trial (no payment required until trial ends)
- [x] Company status: PENDING → TRIALING → ACTIVE / PAST_DUE / SUSPENDED
- [x] Subscription gate middleware on all dashboard API routes
- [x] Trial / past-due banners in dashboard
- [x] Suspended account blocks dashboard; public site stays up
- [x] Manual renewal flow: company pays via AmanPay (card/cash/e-wallet) or PayPal
- [x] Invoice history (AmanPay transaction IDs or PayPal capture IDs)
- [x] Supported currencies: MAD, USD, EUR
### Tenant Isolation (Fleet Management)
- [x] Every Prisma query scoped by `companyId`
- [x] `requireTenant` middleware attaches `companyId` from authenticated employee
- [x] Vehicle, customer, reservation IDs validated against `companyId` before any operation
- [x] No cross-company data exposure — enforced at API level, not just UI
- [x] Add / edit / archive vehicles
- [x] Vehicle photos (Cloudinary)
- [x] Published / unpublished toggle (controls marketplace + public site visibility)
- [x] Vehicle categories, specs, features list
### Offers / Promotions System
- [x] Create offer (%, fixed amount, free day, special rate)
- [x] Valid from / until dates
- [x] Applicable to: all vehicles / by category / specific vehicles
- [x] Optional promo code + max redemptions
- [x] Minimum rental days
- [x] `isPublic` flag — show on global marketplace
- [x] `isFeatured` flag — featured carousel placement (plan-gated: GROWTH+)
- [x] Offer applied at booking (discount calculated, stored on Reservation)
- [x] Offer performance stats (redemptions, revenue impact)
- [x] Offer banner image upload
### Global Marketplace (RentalDriveGo.com/explore)
- [x] Featured offers carousel on /explore home
- [x] Search vehicles by city, dates, category, price range
- [x] Company cards (logo, rating, offer badge)
- [x] Company marketplace profile page (vehicles + offers + reviews)
- [x] Vehicle detail page (photos, specs, redirect CTA to company site)
- [x] Cross-company availability check
- [x] Filter by category, transmission, price, features
- [x] Marketplace listing toggle per company (opt out)
### Renter Account System
- [x] Renter signup / login (own auth, separate from Employee/Clerk)
- [x] Email verification
- [x] Renter profile (name, phone, locale, currency)
- [x] Cross-company reservation history in /renter/dashboard
- [x] Save / unsave companies
- [x] Guest booking (no account required, but prompted to sign up after)
- [x] When renter books, auto-create Customer record in that company's CRM
### Online Reservations (all sources)
- [x] Booking from dashboard (employee creates manually)
- [x] Booking from company public site (BookingSource: PUBLIC_SITE)
- [x] Booking from global marketplace (BookingSource: MARKETPLACE)
- [x] Reservation lifecycle: DRAFT → CONFIRMED → ACTIVE → COMPLETED
- [x] Offer / promo code applied at booking step
- [x] Availability check before confirming
- [x] Booking confirmation (number, summary, company contact)
- [x] Cancel with reason + `cancelledBy` (COMPANY / RENTER / SYSTEM)
### Branding & White-Label Public Site
- [x] Company display name, logo, tagline, hero image
- [x] Primary brand color picker
- [x] Subdomain: `{slug}.RentalDriveGo.com` (claimed during onboarding)
- [x] Real-time subdomain availability check
- [x] Vercel wildcard domain routing (`*.RentalDriveGo.com`)
- [x] Company public site: home with offers, vehicle listing, vehicle detail, booking, confirmation
- [x] Offers page on public site
- [x] "Powered by RentalDriveGo" badge (removable on Pro)
- [x] Site language + currency preference
### Payments — Rental (AmanPay + PayPal)
- [x] Company connects their own AmanPay merchant account (Merchant ID + Secret Key)
- [x] Company connects their own PayPal business account
- [x] Collect payment at booking (marketplace redirect + public site)
- [x] AmanPay widget: national card, international card, cash (3000+ points), e-wallet
- [x] PayPal buttons component (standard checkout)
- [x] If no payment method configured: "Contact to reserve" + pay on pickup flow
- [x] Payment status per reservation
- [x] Refund on cancellation (via AmanPay or PayPal API)
- [x] Guest checkout (no renter account required)
### Notifications — Full System
- [x] **Email** (Resend) — all notification types, rich HTML templates
- [x] **SMS** (Twilio) — confirmations, reminders, payment alerts
- [x] **WhatsApp** (Twilio WhatsApp API) — confirmations, reminders (key for AR/FR)
- [x] **In-App** (Socket.io + Redis) — real-time bell icon in dashboard + renter site
- [x] **Push** (Firebase FCM) — renter PWA push notifications
- [x] `Notification` table — logs every delivery attempt per channel
- [x] `NotificationPreference` table — per-user opt-in/out per type + channel
- [x] Preferences UI: matrix toggle (notification type × channel)
- [x] All company events: new booking, cancelled, payment received, trial ending, maintenance due, offer expiring, new review
- [x] All renter events: confirmed, 24h reminder, 2h reminder, vehicle ready, return reminder, cancelled, refund, new offer from saved company, review request
- [x] Scheduled notifications via cron (reminders, trial ending, maintenance due)
### Reviews
- [x] Renter submits review (only after reservation COMPLETED)
- [x] 15 stars overall + vehicle + service
- [x] Text comment
- [x] Company can reply to review
- [x] Reviews shown on marketplace company profile
- [x] Average rating on company card + brand settings
### Customer CRM
- [x] Auto-created from any booking source
- [x] Company-scoped view (never cross-company)
- [x] Linked to global Renter account (if booked as logged-in renter)
- [x] Rental history, notes, flag/blacklist
### Auth & Onboarding
- [x] Company employee auth via Clerk
- [x] Renter auth via own JWT (separate)
- [x] Onboarding step 2: branding + subdomain
- [x] Onboarding step 4: "Your vehicles now appear on the RentalDriveGo marketplace"
- [x] Role system: OWNER / MANAGER / AGENT
### Platform Admin Panel
- [x] `AdminUser` model: email, password (bcrypt), role (SUPER_ADMIN/ADMIN/SUPPORT/FINANCE/VIEWER), 2FA (TOTP), last login tracking
- [x] `AdminPermission` model: per-admin resource+action overrides on top of role defaults
- [x] `AuditLog` model: every admin action logged (action, resource, before/after JSON, IP, note)
- [x] Admin JWT auth (separate from Clerk and renter JWT) — 8h expiry
- [x] TOTP 2FA: setup with QR code, verify, enforce for ADMIN+ roles
- [x] Role hierarchy: VIEWER < FINANCE < SUPPORT < ADMIN < SUPER_ADMIN
- [x] Admin role access matrix: 17 dashboard features × 5 roles
- [x] `admin.RentalDriveGo.com` separate app (Next.js)
- [x] **Company management**: list, search/filter, create, edit, suspend, reactivate, delete
- [x] **Company staff management**: list employees per company, add employee, edit (name/email/role), deactivate/reactivate, reset password
- [x] **Impersonation**: ADMIN+ can log in as any company employee (Clerk impersonation session), with dashboard banner
- [x] **Subscription override**: force status, change plan, add free period with auto-revert
- [x] Manual invoice mark-as-paid
- [x] **Renter management**: list, search, view history, block/unblock
- [x] **Audit log UI**: searchable, filterable by admin/action/company, CSV export
- [x] Admin user management (SUPER_ADMIN only): create/edit/deactivate admins, set roles
- [x] Platform metrics: MRR, churn, active companies, marketplace stats, notification delivery rates
- [x] Prisma seed creates first SUPER_ADMIN from env vars
- [x] Employee role control matrix (OWNER/MANAGER/AGENT) with 17 feature permissions documented
---
### Advanced Rental Operations (Phase 1)
#### Vehicle Damage Inspection
- [x] Interactive top-down SVG damage map in check-in/check-out workflow
- [x] 22 clickable zones (hood, roof, doors, fenders, bumpers, wheels, windshield, mirrors, interior, undercarriage)
- [x] 5 severity levels per zone: SCRATCH, DENT, CRACK, MISSING, OTHER — each shown in distinct color
- [x] Optional note per damaged zone
- [x] Optional damage photos upload (Cloudinary)
- [x] `DamageReport` model — one per check-in, one per check-out (`@@unique([reservationId, type])`)
- [x] Fuel level and odometer recorded at each inspection
- [x] Customer acknowledgement timestamp + name
- [x] Damage map rendered in PDF contract (static SVG with color markers) — CHECKIN + CHECKOUT side by side
#### Insurance Policies
- [x] `InsurancePolicy` model: name, type (CDW, SCDW, THEFT, THIRD_PARTY, FULL, BASIC, ROADSIDE, PERSONAL, CUSTOM)
- [x] Three charge types: PER_DAY, PER_RENTAL (flat), PERCENTAGE_OF_RENTAL
- [x] Required vs optional flag — required policies auto-apply, cannot be opted out
- [x] `ReservationInsurance` junction: snapshot of policy terms locked at booking time
- [x] `insuranceTotal` cached on Reservation for fast invoice generation
- [x] Insurance line items in customer invoice with charge type label
- [x] Insurance shown on rental contract
#### Second / Additional Driver
- [x] `AdditionalDriver` model: full personal details + license info
- [x] Charge types: FREE, PER_DAY, FLAT
- [x] Company configures default charge in `ContractSettings`
- [x] License validation applied to additional drivers (same 3-month rule)
- [x] Additional driver(s) listed on contract + invoice line item (if charged)
- [x] Employee can approve/deny flagged additional driver license
#### Driver License Validation
- [x] `licenseExpiry`, `licenseIssuedAt`, `licenseCountry`, `licenseNumber`, `licenseCategory` fields on Customer
- [x] `LicenseStatus` enum: PENDING, VALID, EXPIRING, APPROVED, DENIED, EXPIRED
- [x] **3-month rule**: if license expires within 90 days → status=EXPIRING → requires employee approval
- [x] **Expired**: status=EXPIRED → automatic flag → employee must explicitly approve or deny before confirmation
- [x] License validation badge on customer record (green/amber/red)
- [x] Warning banner on reservation detail when customer has EXPIRING or EXPIRED license
- [x] Approve / Deny buttons for OWNER and MANAGER roles only
- [x] Approval logged: who approved, when, and note
#### Age-Based & Experience-Based Pricing Rules
- [x] `PricingRule` model: configurable per company
- [x] Conditions: AGE_LESS_THAN, AGE_GREATER_THAN, LICENSE_YEARS_LESS_THAN, LICENSE_YEARS_GREATER_THAN
- [x] **Default rule: Under-25 surcharge** (+15% of base rental, configurable)
- [x] **Default rule: 5+ years experience discount** (5%, configurable)
- [x] Adjustment types: PERCENTAGE, FLAT_PER_DAY, FLAT_TOTAL
- [x] Rules applied to primary driver AND all additional drivers (each rule applied once even if multiple drivers qualify)
- [x] Rules shown as line items on invoice (surcharges positive, discounts negative)
- [x] Snapshot of applied rules stored in `Reservation.pricingRulesApplied` JSON
#### Fuel / Gasoline Policy (Structured)
- [x] `FuelPolicyType` enum: FULL_TO_FULL, FULL_TO_EMPTY, SAME_TO_SAME, PREPAID, FREE
- [x] Multilingual policy description auto-generated from type (EN/FR/AR)
- [x] Optional custom note added by company
- [x] Fuel level recorded at check-in AND check-out via `DamageReport.fuelLevel`
- [x] Policy printed on contract in customer's language
#### Full Invoice Price Breakdown
- [x] Line items: base rental × days, per insurance policy, additional driver (if charged), pricing rule surcharges, pricing rule discounts, security deposit (refundable label)
- [x] Category labels per line: RENTAL, INSURANCE, ADDITIONAL_DRIVER, SURCHARGE, DISCOUNT, DEPOSIT
- [x] Tax/VAT line (if `ContractSettings.showTax = true`)
- [x] Subtotal → discounts → surcharges → insurance → tax → deposit → GRAND TOTAL
- [x] All amounts formatted in company's currency and locale
#### Billing Period Reporting (Accountant Export)
- [x] Reports page at `/dashboard/reports`
- [x] Preset periods: This Week, This Month, This Year, Custom range
- [x] Summary cards: total bookings, base revenue, discounts given, insurance collected, additional driver revenue, net collected
- [x] Full reservations table: contract#, invoice#, customer, vehicle, plate, dates, days, base, discount, insurance, additional driver, pricing adjustments, total, payment status, source
- [x] Export as CSV (one click) — for import into accounting software (QuickBooks, Sage, etc.)
- [x] Report generated on-demand from DB — no pre-computed tables
---
## Phase 2 — Post-Launch
### Fleet Enhancements
- [ ] Maintenance log with cost tracking
- [ ] Maintenance due alerts (date + mileage)
- [ ] Vehicle availability calendar view in dashboard
### CRM Enhancements
- [ ] Repeat customer detection + discount
- [ ] Customer communication log (emails sent)
- [ ] Customer lifetime value stat
### Analytics
- [ ] Revenue chart (day/week/month)
- [ ] Fleet utilization rate + underperforming vehicles alert
- [ ] Offer ROI analysis (revenue gained vs discounted)
- [ ] Marketplace impressions per vehicle
- [ ] CSV export
### Renter Experience
- [ ] Renter "Continue with Google" auth
- [ ] Favorite vehicles across companies
- [ ] Renter profile completion % indicator
- [ ] Booking modification request (change dates)
### Public Site Enhancements
- [ ] About page with company description + team photo
- [ ] Google Maps embed for pickup location
- [ ] "Request to book" mode (no immediate payment)
- [ ] WhatsApp floating button
---
## Phase 3 — Growth
### Custom Domain (Pro)
- [ ] Company enters `cars.acme.com` in /dashboard/brand
- [ ] Vercel API auto-provisions domain
- [ ] DNS instructions shown
- [ ] SSL automatic
### Marketplace Enhancements
- [ ] City/region pages (`/explore/cities/paris`)
- [ ] Renter 2FA
- [ ] Sponsored listing (pay-per-click for marketplace placement)
- [ ] AI-powered vehicle recommendations based on renter history
### Advanced Offers
- [ ] Flash sales (limited time countdown timer)
- [ ] Loyalty program (repeat renter discounts)
- [ ] Bundle offers (car + extras)
### Integrations
- [ ] Google Calendar sync
- [ ] QuickBooks export
- [ ] Zapier webhooks
### Documents — Rental Contracts & Customer Bills
- [x] `ContractSettings` model: per-company legal name, RC/ICE number, tax ID, custom terms, fuel/deposit/damage policies, additional clauses, signature block, footer notes, invoice tax settings, numbering prefixes
- [x] `contractNumber` + `invoiceNumber` fields on Reservation — assigned once via atomic DB transaction, never reset
- [x] `checkInFuelLevel` / `checkOutFuelLevel` fields for contract accuracy
- [x] **Rental Contract PDF** — branded with company logo + name + primary color, contains: customer details, vehicle data + cover photo, rental dates/locations, financial table (rate × days discount + deposit), all policy clauses, full T&Cs, signature lines (company + customer)
- [x] **Customer Invoice PDF** — branded, contains: company legal info + tax ID, bill-to block, line items table (vehicle, deposit, tax if configured), totals (subtotal, discount, tax, grand total), payment details (AmanPay/PayPal transaction ID, paid date), PAID/PENDING stamp
- [x] **Both documents support EN / FR / AR** — all labels, date formats, and currency format per company locale
- [x] **On-demand generation** — PDFs are NEVER stored. Generated fresh from DB data on every request. No Cloudinary PDF upload.
- [x] **Streaming PDF response**`Content-Type: application/pdf`, `Content-Disposition: inline` (view in browser tab) or `attachment` (download)
- [x] **Print from browser** — user opens PDF in new tab and prints via native browser print dialog
- [x] **Email with PDF attachment** — Resend sends email with both documents as attached PDFs
- [x] **Send contract + invoice together** — single API call generates both and attaches both to one email
- [x] **Custom recipient email** — override the customer's email address when sending (for forwarding)
- [x] **Renter self-download** — renter can download their own contract/invoice from `/renter/dashboard`
- [x] **Settings preview** — preview contract/invoice PDF with real company branding + sample data before finalizing settings
- [x] **Automatic triggers**: invoice sent on payment confirmed; contract sent on check-in completed
### Multi-location per company
- [ ] Multiple pickup/return locations
- [ ] Location-aware vehicle assignment
- [ ] Distance filter on marketplace
---
## Out of Scope
- GPS / telematics
- Driver license OCR / ID verification
- Insurance products
- Fuel management
- Peer-to-peer (this is B2B SaaS + B2C marketplace, not P2P)
- Native mobile app (PWA covers push notifications)
+182
View File
@@ -0,0 +1,182 @@
# Staff Management — Integration Guide
## 1. Register the routes in your Express app
```ts
// apps/api/src/index.ts (or server.ts)
import express from 'express'
import teamRouter from './routes/team'
import webhookRouter from './routes/webhooks'
const app = express()
// Webhook MUST use raw body — register BEFORE express.json()
app.use(
'/webhooks',
express.raw({ type: 'application/json' }),
webhookRouter
)
// All other routes use JSON
app.use(express.json())
// Team routes — mounted under /api/v1/team (matches api-routes.md)
app.use('/api/v1/team', teamRouter)
```
---
## 2. Install the Clerk webhook (Clerk Dashboard → Webhooks)
1. Go to **clerk.com** → your app → **Webhooks****Add endpoint**
2. URL: `https://api.RentalDriveGo.com/webhooks/clerk`
3. Events to subscribe: `user.created`
4. Copy the **Signing Secret** and add to `.env`:
```env
CLERK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
```
5. Install `svix` for signature verification:
```bash
npm install svix
```
---
## 3. Environment variables needed
```env
# Clerk
CLERK_SECRET_KEY=sk_live_...
CLERK_WEBHOOK_SECRET=whsec_...
# Frontend
NEXT_PUBLIC_API_URL=https://api.RentalDriveGo.com/api/v1
# Dashboard URL (used in invite redirect)
DASHBOARD_URL=https://app.RentalDriveGo.com
```
---
## 4. Add the page to the Next.js dashboard app
```
apps/dashboard/
├── app/
│ └── dashboard/
│ └── team/
│ └── page.tsx ← copy frontend/pages/team.tsx here
├── components/
│ └── team/
│ ├── InviteModal.tsx ← copy from frontend/components/team/
│ ├── EditMemberModal.tsx
│ └── PermissionsMatrix.tsx
└── hooks/
└── useTeam.ts ← copy from frontend/hooks/
```
Add the route to your dashboard sidebar navigation:
```tsx
// components/Sidebar.tsx — add to nav items
{ href: '/dashboard/team', label: 'Team', icon: UsersIcon }
```
---
## 5. requireRole middleware placement
The `requireRole` middleware is already applied inside the team router.
Only expose it if you need it elsewhere (e.g., `PATCH /pricing-rules``requireRole('MANAGER')`):
```ts
import { requireRole } from './middleware/requireRole'
// Example: license approval endpoint (OWNER or MANAGER only)
router.post(
'/customers/:id/approve-license',
requireRole('MANAGER'),
approveLicenseHandler
)
```
---
## 6. Invite acceptance flow (frontend)
When a user clicks the magic link in their invitation email, Clerk redirects them to:
```
https://app.RentalDriveGo.com/onboarding/accept-invite?__clerk_ticket=...
```
Create this page in your Next.js app:
```tsx
// app/onboarding/accept-invite/page.tsx
'use client'
import { useEffect } from 'react'
import { useClerk } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'
export default function AcceptInvite() {
const { handleRedirectCallback } = useClerk()
const router = useRouter()
useEffect(() => {
handleRedirectCallback({}).then(() => {
// Clerk webhook fires user.created → activates Employee record in DB
// Redirect to dashboard after a short delay
setTimeout(() => router.replace('/dashboard'), 1000)
})
}, [])
return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-sm text-zinc-500">Setting up your account</p>
</div>
)
}
```
---
## 7. Prisma type extensions (optional, for `req.employee`)
To get TypeScript to recognize `req.companyId` and `req.employee` on Express requests,
add a type declaration file:
```ts
// types/express.d.ts
import { Employee, Company } from '@prisma/client'
declare global {
namespace Express {
interface Request {
companyId: string
company: Company
employee: Employee
}
}
}
```
---
## 8. Summary of files delivered
| File | Purpose |
|------|---------|
| `backend/services/teamService.ts` | All business logic — invite, update role, deactivate, remove, Clerk webhook handler |
| `backend/routes/team.ts` | Express router — all `/team` endpoints |
| `backend/routes/webhooks.ts` | Clerk webhook listener — activates employee on invite acceptance |
| `backend/middleware/requireRole.ts` | Role-rank guard middleware — reusable across all routers |
| `frontend/hooks/useTeam.ts` | React data hook — fetch, invite, updateRole, deactivate, reactivate, remove |
| `frontend/pages/team.tsx` | Full `/dashboard/team` Next.js page |
| `frontend/components/team/InviteModal.tsx` | Invite new member modal with role picker |
| `frontend/components/team/EditMemberModal.tsx` | Edit role, deactivate, reactivate, remove modal with confirmation step |
| `frontend/components/team/PermissionsMatrix.tsx` | Static role × feature permissions table |
+316
View File
@@ -0,0 +1,316 @@
# 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
+109
View File
@@ -0,0 +1,109 @@
# 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Marketplace
## Platform Model
**Rental Companies (B2B)** — pay RentalDriveGoa subscription (AmanPay or PayPal), get:
- A fully private dashboard (zero data overlap with any other company)
- Fleet management with vehicle photo upload (photos auto-appear on global marketplace)
- Promotional offers management
- A white-label site at `company.RentalDriveGo.com` where renters book and pay
**Renters (B2C)** — free account, can:
- Browse ALL companies' vehicles and offers on `RentalDriveGo.com/explore`
- Click a vehicle → **redirected to the company's site** to book and pay
- Receive full notifications (Email + SMS + WhatsApp + Push + In-App)
- Track all bookings across companies in one account
---
## ⚠️ Payment Providers — Stripe Has Been Removed
| Provider | Purpose |
|----------|---------|
| **AmanPay** (amanpay.net) | Primary. National/international cards, cash (3000+ points), e-wallet. Moroccan PCI-DSS Level-1 PSP. |
| **PayPal** | Secondary. Global coverage. |
**Two payment contexts:**
1. Company pays RentalDriveGosubscription → RentalDriveGo's own AmanPay/PayPal account
2. Renter pays company for rental → Company's own AmanPay merchant + PayPal account (direct, no intermediary)
---
## 🌐 Marketplace is Discovery Only — No Booking on RentalDriveGo.com/explore
The global marketplace shows all vehicles with photos from all companies. When a renter clicks "Book this vehicle", they are **redirected to the company's branded site** (`{slug}.RentalDriveGo.com/book?vehicleId=X&from=Y&to=Z&ref=marketplace`). The booking form is pre-filled. All payment happens on the company site using the company's own payment accounts.
---
## 📸 Vehicle Photos → Marketplace Visibility
```
Company uploads photos in /dashboard/fleet
→ Stored in Cloudinary
→ Vehicle.photos[] holds the URLs
→ First photo = cover image on marketplace cards
→ All photos shown in marketplace vehicle gallery
→ isPublished=true makes vehicle appear on marketplace
```
---
## 🏗 Five-Layer Architecture
```
Layer 1A: RentalDriveGo.com — B2B marketing site
Layer 1B: RentalDriveGo.com/explore — B2C marketplace (discovery + redirect only)
Layer 2: app.RentalDriveGo.com — Company dashboard (private, subscription-gated)
Layer 3: {slug}.RentalDriveGo.com — Company branded site (booking + payment here)
Layer 4: api.RentalDriveGo.com — REST API
```
---
## 📁 Project Structure
```
rental-car-site/
├── README.md
├── docs/
│ ├── ARCHITECTURE.md ← Payment providers, marketplace redirect model, photo flow
│ ├── DESIGN_SYSTEM.md
│ ├── PAGES.md ← All pages. Marketplace = discovery only. Booking = company site.
│ ├── FEATURES.md
│ └── DEPLOYMENT.md
└── skills/
├── rental-car-website/
├── rental-car-components/
├── rental-car-backend/
│ └── references/
│ ├── schema.md ← AmanPay/PayPal fields, no Stripe
│ ├── api-routes.md ← All routes
│ ├── payment-service.md ← AmanPay + PayPal implementation ← KEY FILE
│ ├── subscription-service.md ← Manual renewal, plan prices in MAD/USD/EUR
│ ├── subdomain-service.md ← Marketplace redirect + company site
│ └── notification-service.md ← All 5 channels
└── rental-car-i18n/
```
---
## 🛠 Tech Stack
| Concern | Technology |
|---------|-----------|
| Subscription payment | AmanPay (primary) + PayPal (secondary) |
| Rental payment | Company's own AmanPay merchant + PayPal |
| PDF generation | `@react-pdf/renderer` — server-side, on-demand, never stored |
| Damage diagrams | SVG top-down car map (22 zones, React interactive + PDF static render) |
| Insurance | Per-company configurable policies with per-day/flat/% charge types |
| Pricing rules | Age/experience-based surcharges & discounts (configurable per company) |
| Email | Resend |
| SMS + WhatsApp | Twilio |
| Push | Firebase FCM |
| Real-time in-app | Socket.io + Redis |
| Images | Cloudinary |
| Company auth | Clerk |
| Renter auth | JWT (custom) |
| API | Node.js + Express + Prisma + PostgreSQL |
| Frontend | Next.js 14 + React 18 + Vite |
| Deployment | Vercel + Railway |
File diff suppressed because it is too large Load Diff
+498
View File
@@ -0,0 +1,498 @@
# 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 | `/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=` |
+980
View File
@@ -0,0 +1,980 @@
# 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")
}
```
@@ -0,0 +1,268 @@
'use client'
import { useState, useEffect } from 'react'
import type { TeamMember } from '../../hooks/useTeam'
interface Props {
member: TeamMember | null
open: boolean
onClose: () => void
onUpdateRole: (memberId: string, role: 'MANAGER' | 'AGENT') => Promise<void>
onDeactivate: (memberId: string) => Promise<void>
onReactivate: (memberId: string) => Promise<void>
onRemove: (memberId: string) => Promise<void>
}
const ROLE_OPTIONS = [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
]
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
export default function EditMemberModal({
member,
open,
onClose,
onUpdateRole,
onDeactivate,
onReactivate,
onRemove,
}: Props) {
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [actionState, setActionState] = useState<ActionState>('idle')
const [confirm, setConfirm] = useState<ConfirmAction>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (member && open) {
setRole(member.role === 'OWNER' ? 'MANAGER' : (member.role as 'MANAGER' | 'AGENT'))
setActionState('idle')
setConfirm(null)
setError(null)
}
}, [member, open])
if (!open || !member) return null
const isPending = member.invitationStatus === 'pending'
const isActive = member.isActive
const handleSave = async () => {
if (role === member.role) { onClose(); return }
setActionState('saving')
setError(null)
try {
await onUpdateRole(member.id, role)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
}
const handleDeactivate = async () => {
setActionState('deactivating')
setError(null)
try {
await onDeactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleReactivate = async () => {
setActionState('reactivating')
setError(null)
try {
await onReactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleRemove = async () => {
setActionState('removing')
setError(null)
try {
await onRemove(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
{/* Member header */}
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
{initials}
</div>
<div>
<p className="font-medium text-zinc-900 dark:text-white text-sm">
{member.firstName} {member.lastName}
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</p>
</div>
<div className="ml-auto">
{isPending && (
<span className="text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border border-amber-100 dark:border-amber-900/50">
Pending invite
</span>
)}
{!isPending && !isActive && (
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
Deactivated
</span>
)}
</div>
</div>
{/* Confirm overlay */}
{confirm && (
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
{confirm === 'deactivate' && 'Deactivate this member?'}
{confirm === 'remove' && 'Permanently remove this member?'}
{confirm === 'reactivate' && 'Reactivate this member?'}
</p>
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
{confirm === 'deactivate' && "They'll immediately lose dashboard access. You can reactivate anytime."}
{confirm === 'remove' && "This will delete the employee record and revoke their Clerk access. This cannot be undone."}
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
</p>
<div className="flex gap-2">
<button
onClick={() => setConfirm(null)}
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
Cancel
</button>
<button
onClick={
confirm === 'deactivate' ? handleDeactivate :
confirm === 'reactivate' ? handleReactivate :
handleRemove
}
disabled={busy}
className={[
'flex-1 text-xs px-3 py-1.5 rounded-lg font-medium disabled:opacity-50',
confirm === 'reactivate'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'bg-red-600 text-white hover:bg-red-700',
].join(' ')}
>
{busy ? 'Working…' : (
confirm === 'deactivate' ? 'Deactivate' :
confirm === 'reactivate' ? 'Reactivate' :
'Remove permanently'
)}
</button>
</div>
</div>
)}
{/* Role selector (hidden for pending invites — role locked until accepted) */}
{!isPending && isActive && (
<div className="mb-4">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
Role
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-2.5 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="text-sm font-medium text-zinc-900 dark:text-white">{option.label}</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{option.description}</div>
</button>
))}
</div>
</div>
)}
{/* Error */}
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Footer actions */}
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
{/* Danger zone */}
<div className="flex gap-2 mr-auto">
{isActive && !isPending && (
<button
onClick={() => setConfirm('deactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Deactivate
</button>
)}
{!isActive && (
<button
onClick={() => setConfirm('reactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
Reactivate
</button>
)}
<button
onClick={() => setConfirm('remove')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Remove
</button>
</div>
<button
onClick={onClose}
disabled={busy}
className="px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50"
>
Cancel
</button>
{isActive && !isPending && (
<button
onClick={handleSave}
disabled={busy}
className="px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50"
>
{actionState === 'saving' ? 'Saving…' : 'Save changes'}
</button>
)}
</div>
</div>
</div>
)
}
+205
View File
@@ -0,0 +1,205 @@
'use client'
import { useState, useEffect } from 'react'
import type { InvitePayload } from '../../hooks/useTeam'
interface Props {
open: boolean
onClose: () => void
onInvite: (payload: InvitePayload) => Promise<void>
}
const ROLE_OPTIONS = [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet ops — no billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
},
]
export default function InviteModal({ open, onClose, onInvite }: Props) {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset form when modal closes
useEffect(() => {
if (!open) {
setTimeout(() => {
setFirstName('')
setLastName('')
setEmail('')
setRole('AGENT')
setError(null)
}, 200)
}
}, [open])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
onClose()
} catch (err: any) {
setError(err.message ?? 'Failed to send invitation')
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div className="mb-5">
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">Invite a team member</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
They'll receive an email invitation to join your dashboard
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
First name
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Youssef"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Last name
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Benali"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
</div>
{/* Email */}
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Email address
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="youssef@yourcompany.com"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
{/* Role */}
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Role
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-3 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-zinc-900 dark:text-white">
{option.label}
</span>
{role === option.value && (
<span className="w-4 h-4 rounded-full bg-zinc-900 dark:bg-white flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white dark:text-zinc-900" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</span>
)}
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
{option.description}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{option.permissions.slice(0, 3).map((p) => (
<span
key={p}
className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400"
>
{p}
</span>
))}
{option.permissions.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
+{option.permissions.length - 3} more
</span>
)}
</div>
</button>
))}
</div>
</div>
{/* Error */}
{error && (
<div className="px-3 py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Footer */}
<div className="flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Sending' : 'Send invite '}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,115 @@
'use client'
type Access = 'full' | 'partial' | 'none'
interface Feature {
label: string
manager: Access
managerNote?: string
agent: Access
agentNote?: string
}
const FEATURES: Feature[] = [
{ label: 'View fleet & vehicles', manager: 'full', agent: 'full' },
{ label: 'Add / edit vehicles', manager: 'full', agent: 'none' },
{ label: 'Publish / unpublish vehicle', manager: 'full', agent: 'none' },
{ label: 'Upload vehicle photos', manager: 'full', agent: 'none' },
{ label: 'View reservations', manager: 'full', agent: 'full' },
{ label: 'Create reservations', manager: 'full', agent: 'full' },
{ label: 'Cancel reservations', manager: 'full', agent: 'partial', agentNote: 'Own only' },
{ label: 'Check-in / check-out', manager: 'full', agent: 'full' },
{ label: 'Damage inspection', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — view', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — edit', manager: 'full', agent: 'partial', agentNote: 'Notes only' },
{ label: 'Approve driver licenses', manager: 'full', agent: 'none' },
{ label: 'Flag / blacklist customer', manager: 'full', agent: 'none' },
{ label: 'Offers & promotions', manager: 'full', agent: 'none' },
{ label: 'Analytics & reports', manager: 'full', agent: 'none' },
{ label: 'Contract & invoice PDF', manager: 'full', agent: 'full' },
{ label: 'Billing & subscription', manager: 'none', agent: 'none' },
{ label: 'Invite / remove staff', manager: 'none', agent: 'none' },
{ label: 'Brand & site settings', manager: 'none', agent: 'none' },
{ label: 'Payment settings', manager: 'none', agent: 'none' },
]
function AccessCell({ access, note }: { access: Access; note?: string }) {
if (access === 'full') {
return (
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-xs text-zinc-700 dark:text-zinc-300">Full</span>
</div>
)
}
if (access === 'partial') {
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2.5 h-0.5 rounded bg-amber-500" />
</div>
<span className="text-xs text-amber-700 dark:text-amber-400">{note ?? 'Limited'}</span>
</div>
)
}
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
</div>
<span className="text-xs text-zinc-400 dark:text-zinc-600">No access</span>
</div>
)
}
export default function PermissionsMatrix() {
return (
<div>
<div className="mb-4">
<h2 className="text-base font-medium text-zinc-900 dark:text-white">Role permissions</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
What each role can do across the dashboard. Owners have full access to everything.
</p>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
{/* Header */}
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
Feature
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Manager
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Agent
</div>
</div>
{/* Rows */}
{FEATURES.map((feature, i) => (
<div
key={feature.label}
className={[
'grid grid-cols-[1fr_120px_120px]',
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
i % 2 === 0 ? '' : 'bg-zinc-50/50 dark:bg-zinc-800/20',
].join(' ')}
>
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
{feature.label}
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.manager} note={feature.managerNote} />
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.agent} note={feature.agentNote} />
</div>
</div>
))}
</div>
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { Request, Response, NextFunction } from 'express'
import { EmployeeRole } from '@prisma/client'
// Role hierarchy: OWNER > MANAGER > AGENT
const ROLE_RANK: Record<EmployeeRole, number> = {
OWNER: 3,
MANAGER: 2,
AGENT: 1,
}
/**
* requireRole(minimumRole)
* Middleware that blocks requests from employees whose role rank
* is below the required minimum.
*
* Usage:
* router.post('/invite', requireRole('OWNER'), handler)
* router.patch('/something', requireRole('MANAGER'), handler)
*/
export function requireRole(minimumRole: EmployeeRole) {
return (req: Request, res: Response, next: NextFunction) => {
const employee = req.employee
if (!employee) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Authentication required',
statusCode: 401,
})
}
const employeeRank = ROLE_RANK[employee.role as EmployeeRole] ?? 0
const requiredRank = ROLE_RANK[minimumRole] ?? 99
if (employeeRank < requiredRank) {
return res.status(403).json({
error: 'forbidden',
message: `This action requires the ${minimumRole} role or higher`,
statusCode: 403,
requiredRole: minimumRole,
yourRole: employee.role,
})
}
next()
}
}
+169
View File
@@ -0,0 +1,169 @@
import { Router, Request, Response, NextFunction } from 'express'
import { z } from 'zod'
import {
listEmployees,
inviteEmployee,
updateEmployeeRole,
deactivateEmployee,
reactivateEmployee,
removeEmployee,
} from '../services/teamService'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { requireRole } from '../middleware/requireRole'
const router = Router()
// All team routes require a valid company employee session + active subscription
router.use(requireCompanyAuth, requireTenant, requireSubscription)
// ─── Validation schemas ───────────────────────────────────────
const inviteSchema = z.object({
firstName: z.string().min(1).max(64),
lastName: z.string().min(1).max(64),
email: z.string().email(),
role: z.enum(['MANAGER', 'AGENT']), // OWNER cannot be invited
})
const updateRoleSchema = z.object({
role: z.enum(['MANAGER', 'AGENT']),
})
// ─── GET /team ────────────────────────────────────────────────
// List all employees for this company.
// All roles can view the team list.
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const members = await listEmployees(req.companyId)
res.json({ data: members })
} catch (err) {
next(err)
}
})
// ─── GET /team/stats ──────────────────────────────────────────
// Quick counts for dashboard stat cards.
router.get('/stats', async (req: Request, res: Response, next: NextFunction) => {
try {
const members = await listEmployees(req.companyId)
const stats = {
total: members.length,
active: members.filter((m) => m.isActive && m.invitationStatus === 'accepted').length,
pending: members.filter((m) => m.invitationStatus === 'pending').length,
inactive: members.filter((m) => !m.isActive && m.invitationStatus === 'accepted').length,
}
res.json({ data: stats })
} catch (err) {
next(err)
}
})
// ─── POST /team/invite ────────────────────────────────────────
// Invite a new employee by email. OWNER only.
router.post(
'/invite',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const body = inviteSchema.parse(req.body)
const result = await inviteEmployee(
req.companyId,
req.employee.id,
body
)
res.status(201).json({ data: result })
} catch (err) {
next(err)
}
}
)
// ─── PATCH /team/:id/role ─────────────────────────────────────
// Change an employee's role. OWNER only.
router.patch(
'/:id/role',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const body = updateRoleSchema.parse(req.body)
const updated = await updateEmployeeRole(
req.companyId,
req.employee.id,
req.employee.role,
req.params.id,
body
)
res.json({ data: updated })
} catch (err) {
next(err)
}
}
)
// ─── POST /team/:id/deactivate ────────────────────────────────
// Suspend access without deleting the record. OWNER only.
router.post(
'/:id/deactivate',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const updated = await deactivateEmployee(
req.companyId,
req.employee.role,
req.params.id
)
res.json({ data: updated })
} catch (err) {
next(err)
}
}
)
// ─── POST /team/:id/reactivate ────────────────────────────────
// Restore a deactivated employee's access. OWNER only.
router.post(
'/:id/reactivate',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const updated = await reactivateEmployee(
req.companyId,
req.employee.role,
req.params.id
)
res.json({ data: updated })
} catch (err) {
next(err)
}
}
)
// ─── DELETE /team/:id ─────────────────────────────────────────
// Permanently remove an employee + revoke their Clerk session/invite. OWNER only.
router.delete(
'/:id',
requireRole('OWNER'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await removeEmployee(
req.companyId,
req.employee.role,
req.params.id
)
res.json({ data: result })
} catch (err) {
next(err)
}
}
)
export default router
+337
View File
@@ -0,0 +1,337 @@
'use client'
import { useState, useMemo } from 'react'
import { useTeam, TeamMember } from '../hooks/useTeam'
import InviteModal from '../components/team/InviteModal'
import EditMemberModal from '../components/team/EditMemberModal'
import PermissionsMatrix from '../components/team/PermissionsMatrix'
import { useEmployee } from '@/hooks/useEmployee' // your existing auth hook
// ─── Helpers ─────────────────────────────────────────────────
function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
}
function timeAgo(dateStr: string | null) {
if (!dateStr) return 'Never'
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 2) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
if (days < 30) return `${days}d ago`
return new Date(dateStr).toLocaleDateString('en', { month: 'short', day: 'numeric' })
}
const AVATAR_BG: string[] = [
'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300',
'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300',
'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300',
'bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300',
]
function avatarColor(id: string) {
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
return AVATAR_BG[hash % AVATAR_BG.length]
}
function RoleBadge({ role }: { role: string }) {
const styles: Record<string, string> = {
OWNER: 'bg-violet-50 dark:bg-violet-950/40 text-violet-700 dark:text-violet-300 border-violet-100 dark:border-violet-900/50',
MANAGER: 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border-blue-100 dark:border-blue-900/50',
AGENT: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-700',
}
return (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${styles[role] ?? styles.AGENT}`}>
{role.charAt(0) + role.slice(1).toLowerCase()}
</span>
)
}
function StatusBadge({ member }: { member: TeamMember }) {
if (member.invitationStatus === 'pending') {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border-amber-100 dark:border-amber-900/50">
Pending invite
</span>
)
}
if (!member.isActive) {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
Deactivated
</span>
)
}
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
Active
</span>
)
}
// ─── Main page ────────────────────────────────────────────────
export default function TeamPage() {
const { employee: currentEmployee } = useEmployee()
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [inviteOpen, setInviteOpen] = useState(false)
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
const [toast, setToast] = useState<string | null>(null)
const isOwner = currentEmployee?.role === 'OWNER'
function showToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const filtered = useMemo(() => {
return members.filter((m) => {
const q = search.toLowerCase()
const matchQ = !q ||
`${m.firstName} ${m.lastName}`.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q)
const matchRole = !roleFilter || m.role === roleFilter
const matchStatus =
!statusFilter ||
(statusFilter === 'active' && m.isActive && m.invitationStatus === 'accepted') ||
(statusFilter === 'pending' && m.invitationStatus === 'pending') ||
(statusFilter === 'inactive' && !m.isActive && m.invitationStatus === 'accepted')
return matchQ && matchRole && matchStatus
})
}, [members, search, roleFilter, statusFilter])
// ── Handlers with toasts ───────────────────────────────────
const handleInvite: typeof invite = async (payload) => {
await invite(payload)
showToast(`Invite sent to ${payload.email}`)
}
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
await updateRole(id, role)
showToast('Role updated')
}
const handleDeactivate = async (id: string) => {
await deactivate(id)
showToast('Member deactivated')
}
const handleReactivate = async (id: string) => {
await reactivate(id)
showToast('Member reactivated')
}
const handleRemove = async (id: string) => {
await remove(id)
showToast('Member removed')
}
// ── Render ─────────────────────────────────────────────────
return (
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
{/* Page header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">Team</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Manage your employees and their access levels
</p>
</div>
{isOwner && (
<button
onClick={() => setInviteOpen(true)}
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Invite member
</button>
)}
</div>
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ label: 'Total members', value: stats.total },
{ label: 'Active', value: stats.active },
{ label: 'Pending invite', value: stats.pending },
{ label: 'Deactivated', value: stats.inactive },
].map((s) => (
<div
key={s.label}
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 flex flex-col gap-1"
>
<span className="text-xs text-zinc-500 dark:text-zinc-400">{s.label}</span>
<span className="text-2xl font-medium text-zinc-900 dark:text-white">{s.value}</span>
</div>
))}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2 mb-4">
<div className="relative flex-1 min-w-48">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 14 14">
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.2" />
<path d="M10 10L13 13" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
<input
type="text"
placeholder="Search by name or email…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">All roles</option>
<option value="OWNER">Owner</option>
<option value="MANAGER">Manager</option>
<option value="AGENT">Agent</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="inactive">Deactivated</option>
</select>
</div>
{/* Table */}
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-10">
{loading ? (
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
<span className="text-sm">Loading team</span>
</div>
) : error ? (
<div className="py-12 text-center text-sm text-red-500">{error}</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">No members match your filters</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Member</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Role</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Status</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">Last active</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{filtered.map((member, i) => (
<tr
key={member.id}
className={[
'border-b border-zinc-100 dark:border-zinc-800 last:border-0',
'hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors',
].join(' ')}
>
{/* Member */}
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${avatarColor(member.id)}`}>
{initials(member)}
</div>
<div>
<div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName}
{member.id === currentEmployee?.id && (
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
</div>
</div>
</td>
{/* Role */}
<td className="px-4 py-3">
<RoleBadge role={member.role} />
</td>
{/* Status */}
<td className="px-4 py-3">
<StatusBadge member={member} />
</td>
{/* Last active */}
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-zinc-400 dark:text-zinc-500">
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)}
</span>
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
{isOwner && member.role !== 'OWNER' && (
<button
onClick={() => setEditTarget(member)}
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Manage
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Permissions matrix */}
<PermissionsMatrix />
{/* Modals */}
<InviteModal
open={inviteOpen}
onClose={() => setInviteOpen(false)}
onInvite={handleInvite}
/>
<EditMemberModal
member={editTarget}
open={!!editTarget}
onClose={() => setEditTarget(null)}
onUpdateRole={handleUpdateRole}
onDeactivate={handleDeactivate}
onReactivate={handleReactivate}
onRemove={handleRemove}
/>
{/* Toast */}
{toast && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 duration-200">
<svg className="w-4 h-4 text-green-400 dark:text-green-600" fill="none" viewBox="0 0 16 16">
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{toast}
</div>
)}
</div>
)
}
+365
View File
@@ -0,0 +1,365 @@
import { PrismaClient, EmployeeRole } from '@prisma/client'
import { clerkClient } from '@clerk/clerk-sdk-node'
const prisma = new PrismaClient()
// ─── Types ────────────────────────────────────────────────────
export interface InvitePayload {
firstName: string
lastName: string
email: string
role: EmployeeRole
}
export interface UpdateRolePayload {
role: EmployeeRole
}
export interface TeamMember {
id: string
clerkUserId: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
createdAt: Date
updatedAt: Date
// hydrated from Clerk
lastActiveAt?: Date | null
invitationStatus?: 'accepted' | 'pending' | 'revoked'
}
// ─── List employees ───────────────────────────────────────────
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
const employees = await prisma.employee.findMany({
where: { companyId },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
})
// Hydrate last-active timestamps from Clerk in parallel
const hydrated = await Promise.allSettled(
employees.map(async (emp) => {
try {
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
return {
...emp,
lastActiveAt: clerkUser.lastActiveAt
? new Date(clerkUser.lastActiveAt)
: null,
invitationStatus: 'accepted' as const,
}
} catch {
// Clerk user not yet accepted invitation
return {
...emp,
lastActiveAt: null,
invitationStatus: 'pending' as const,
}
}
})
)
return hydrated.map((r) =>
r.status === 'fulfilled' ? r.value : (r as any).value
)
}
// ─── Get single employee (scoped to company) ──────────────────
export async function getEmployee(companyId: string, employeeId: string) {
const employee = await prisma.employee.findFirst({
where: { id: employeeId, companyId },
})
if (!employee) {
throw Object.assign(new Error('Employee not found'), { statusCode: 404, code: 'employee_not_found' })
}
return employee
}
// ─── Invite ───────────────────────────────────────────────────
// 1. Send a Clerk invitation (creates the Clerk user + sends email)
// 2. Create the Employee row immediately so the company sees it in the list
// with isActive=false until they accept and log in (Clerk webhook sets isActive=true)
export async function inviteEmployee(
companyId: string,
inviterId: string,
payload: InvitePayload
) {
const { firstName, lastName, email, role } = payload
// Owners cannot be invited — must be the account creator
if (role === 'OWNER') {
throw Object.assign(
new Error('Cannot invite a member with the OWNER role'),
{ statusCode: 400, code: 'invalid_role' }
)
}
// Check for existing active employee with that email in this company
const existing = await prisma.employee.findFirst({
where: { companyId, email },
})
if (existing) {
throw Object.assign(
new Error('An employee with this email already exists in your team'),
{ statusCode: 409, code: 'employee_already_exists' }
)
}
// Get the company's subdomain/name for the invitation email redirect URL
const company = await prisma.company.findUniqueOrThrow({
where: { id: companyId },
include: { brand: { select: { subdomain: true, displayName: true } } },
})
// Send Clerk invitation
// Clerk will create the user and email them a magic link
let clerkInvitation: Awaited<ReturnType<typeof clerkClient.invitations.createInvitation>>
try {
clerkInvitation = await clerkClient.invitations.createInvitation({
emailAddress: email,
redirectUrl: `${process.env.DASHBOARD_URL}/onboarding/accept-invite`,
publicMetadata: {
companyId,
companyName: company.brand?.displayName ?? company.name,
role,
invitedBy: inviterId,
},
})
} catch (err: any) {
// Clerk throws if email already has a Clerk account with a pending invite
if (err?.errors?.[0]?.code === 'duplicate_record') {
throw Object.assign(
new Error('This email already has a pending invitation'),
{ statusCode: 409, code: 'duplicate_invitation' }
)
}
throw err
}
// Create the Employee record with a placeholder clerkUserId
// It will be updated when the user accepts the invite (via Clerk webhook)
const employee = await prisma.employee.create({
data: {
companyId,
clerkUserId: `pending_${clerkInvitation.id}`, // replaced by webhook
firstName,
lastName,
email,
role,
isActive: false, // activated on invite acceptance
},
})
return {
employee,
invitationId: clerkInvitation.id,
}
}
// ─── Update role ──────────────────────────────────────────────
export async function updateEmployeeRole(
companyId: string,
requesterId: string,
requesterRole: EmployeeRole,
employeeId: string,
payload: UpdateRolePayload
) {
const target = await getEmployee(companyId, employeeId)
// Only OWNER can change roles
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can change team member roles'),
{ statusCode: 403, code: 'forbidden' }
)
}
// Cannot change an OWNER's role (there must always be one owner)
if (target.role === 'OWNER') {
throw Object.assign(
new Error('Cannot change the role of the account owner'),
{ statusCode: 400, code: 'cannot_change_owner_role' }
)
}
// Cannot assign OWNER role via this endpoint
if (payload.role === 'OWNER') {
throw Object.assign(
new Error('Cannot assign the OWNER role via this endpoint'),
{ statusCode: 400, code: 'invalid_role' }
)
}
// Prevent self-role-change (edge case)
if (target.id === requesterId) {
throw Object.assign(
new Error('You cannot change your own role'),
{ statusCode: 400, code: 'cannot_change_own_role' }
)
}
const updated = await prisma.employee.update({
where: { id: employeeId },
data: { role: payload.role },
})
return updated
}
// ─── Deactivate ───────────────────────────────────────────────
export async function deactivateEmployee(
companyId: string,
requesterRole: EmployeeRole,
employeeId: string
) {
const target = await getEmployee(companyId, employeeId)
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can deactivate team members'),
{ statusCode: 403, code: 'forbidden' }
)
}
if (target.role === 'OWNER') {
throw Object.assign(
new Error('Cannot deactivate the account owner'),
{ statusCode: 400, code: 'cannot_deactivate_owner' }
)
}
// Revoke Clerk session so they are immediately signed out
if (!target.clerkUserId.startsWith('pending_')) {
try {
await clerkClient.users.banUser(target.clerkUserId)
} catch {
// Non-fatal — still deactivate in DB
}
}
const updated = await prisma.employee.update({
where: { id: employeeId },
data: { isActive: false },
})
return updated
}
// ─── Reactivate ───────────────────────────────────────────────
export async function reactivateEmployee(
companyId: string,
requesterRole: EmployeeRole,
employeeId: string
) {
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can reactivate team members'),
{ statusCode: 403, code: 'forbidden' }
)
}
const target = await getEmployee(companyId, employeeId)
if (!target.clerkUserId.startsWith('pending_')) {
try {
await clerkClient.users.unbanUser(target.clerkUserId)
} catch {
// Non-fatal
}
}
const updated = await prisma.employee.update({
where: { id: employeeId },
data: { isActive: true },
})
return updated
}
// ─── Remove (hard delete) ─────────────────────────────────────
// Only OWNER can remove. Removes employee row + revokes Clerk invite if pending.
export async function removeEmployee(
companyId: string,
requesterRole: EmployeeRole,
employeeId: string
) {
if (requesterRole !== 'OWNER') {
throw Object.assign(
new Error('Only the account owner can remove team members'),
{ statusCode: 403, code: 'forbidden' }
)
}
const target = await getEmployee(companyId, employeeId)
if (target.role === 'OWNER') {
throw Object.assign(
new Error('Cannot remove the account owner'),
{ statusCode: 400, code: 'cannot_remove_owner' }
)
}
// Revoke pending Clerk invitation
if (target.clerkUserId.startsWith('pending_')) {
const inviteId = target.clerkUserId.replace('pending_', '')
try {
await clerkClient.invitations.revokeInvitation(inviteId)
} catch {
// Already accepted or expired — safe to ignore
}
} else {
// Ban the Clerk user so they can't log in
try {
await clerkClient.users.banUser(target.clerkUserId)
} catch {
// Non-fatal
}
}
await prisma.employee.delete({ where: { id: employeeId } })
return { success: true }
}
// ─── Clerk webhook handler ────────────────────────────────────
// Called when a user accepts their invitation. Updates the Employee row
// with the real Clerk user ID and activates the account.
export async function handleInvitationAccepted(
clerkUserId: string,
email: string,
companyId: string,
role: EmployeeRole
) {
const pendingEmployee = await prisma.employee.findFirst({
where: {
companyId,
email,
clerkUserId: { startsWith: 'pending_' },
},
})
if (!pendingEmployee) return null
const activated = await prisma.employee.update({
where: { id: pendingEmployee.id },
data: {
clerkUserId,
isActive: true,
role, // in case it was updated between invite and acceptance
},
})
return activated
}
+172
View File
@@ -0,0 +1,172 @@
import { useState, useEffect, useCallback } from 'react'
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
export type InvitationStatus = 'accepted' | 'pending' | 'revoked'
export interface TeamMember {
id: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
createdAt: string
updatedAt: string
lastActiveAt: string | null
invitationStatus: InvitationStatus
}
export interface TeamStats {
total: number
active: number
pending: number
inactive: number
}
export interface InvitePayload {
firstName: string
lastName: string
email: string
role: 'MANAGER' | 'AGENT'
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://api.RentalDriveGo.com/api/v1'
async function apiFetch<T>(
path: string,
options?: RequestInit
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
...options,
})
const json = await res.json()
if (!res.ok) {
const err = new Error(json.message ?? 'Request failed') as any
err.code = json.error
err.statusCode = res.status
throw err
}
return json.data as T
}
// ─── useTeam ─────────────────────────────────────────────────
export function useTeam() {
const [members, setMembers] = useState<TeamMember[]>([])
const [stats, setStats] = useState<TeamStats>({ total: 0, active: 0, pending: 0, inactive: 0 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchMembers = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [membersData, statsData] = await Promise.all([
apiFetch<TeamMember[]>('/team'),
apiFetch<TeamStats>('/team/stats'),
])
setMembers(membersData)
setStats(statsData)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchMembers() }, [fetchMembers])
// ── Invite ─────────────────────────────────────────────────
const invite = useCallback(async (payload: InvitePayload) => {
const result = await apiFetch<{ employee: TeamMember; invitationId: string }>(
'/team/invite',
{ method: 'POST', body: JSON.stringify(payload) }
)
// Optimistically add the pending member
setMembers((prev) => [...prev, result.employee])
setStats((prev) => ({
...prev,
total: prev.total + 1,
pending: prev.pending + 1,
}))
return result
}, [])
// ── Update role ────────────────────────────────────────────
const updateRole = useCallback(async (memberId: string, role: 'MANAGER' | 'AGENT') => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, role: updated.role } : m))
)
return updated
}, [])
// ── Deactivate ─────────────────────────────────────────────
const deactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/deactivate`, {
method: 'POST',
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, isActive: false } : m))
)
setStats((prev) => ({ ...prev, active: prev.active - 1, inactive: prev.inactive + 1 }))
return updated
}, [])
// ── Reactivate ─────────────────────────────────────────────
const reactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/reactivate`, {
method: 'POST',
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, isActive: true } : m))
)
setStats((prev) => ({ ...prev, active: prev.active + 1, inactive: prev.inactive - 1 }))
return updated
}, [])
// ── Remove ─────────────────────────────────────────────────
const remove = useCallback(async (memberId: string) => {
await apiFetch<{ success: boolean }>(`/team/${memberId}`, {
method: 'DELETE',
})
const target = members.find((m) => m.id === memberId)
setMembers((prev) => prev.filter((m) => m.id !== memberId))
setStats((prev) => ({
...prev,
total: prev.total - 1,
active: target?.isActive ? prev.active - 1 : prev.active,
pending: target?.invitationStatus === 'pending' ? prev.pending - 1 : prev.pending,
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
? prev.inactive - 1
: prev.inactive,
}))
}, [members])
return {
members,
stats,
loading,
error,
refetch: fetchMembers,
invite,
updateRole,
deactivate,
reactivate,
remove,
}
}
+82
View File
@@ -0,0 +1,82 @@
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