project design
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
# Features & Priorities — RentFlow
|
||||
|
||||
## 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 (rentflow.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}.rentflow.com` (claimed during onboarding)
|
||||
- [x] Real-time subdomain availability check
|
||||
- [x] Vercel wildcard domain routing (`*.rentflow.com`)
|
||||
- [x] Company public site: home with offers, vehicle listing, vehicle detail, booking, confirmation
|
||||
- [x] Offers page on public site
|
||||
- [x] "Powered by RentFlow" badge (removable on Pro)
|
||||
- [x] Site language + currency preference
|
||||
|
||||
### Payments — Rental (AmanPay + PayPal)
|
||||
- [x] Company connects their own AmanPay merchant account (Merchant ID + Secret Key)
|
||||
- [x] Company connects their own PayPal business account
|
||||
- [x] Collect payment at booking (marketplace redirect + public site)
|
||||
- [x] AmanPay widget: national card, international card, cash (3000+ points), e-wallet
|
||||
- [x] PayPal buttons component (standard checkout)
|
||||
- [x] If no payment method configured: "Contact to reserve" + pay on pickup flow
|
||||
- [x] Payment status per reservation
|
||||
- [x] Refund on cancellation (via AmanPay or PayPal API)
|
||||
- [x] Guest checkout (no renter account required)
|
||||
|
||||
### Notifications — Full System
|
||||
- [x] **Email** (Resend) — all notification types, rich HTML templates
|
||||
- [x] **SMS** (Twilio) — confirmations, reminders, payment alerts
|
||||
- [x] **WhatsApp** (Twilio WhatsApp API) — confirmations, reminders (key for AR/FR)
|
||||
- [x] **In-App** (Socket.io + Redis) — real-time bell icon in dashboard + renter site
|
||||
- [x] **Push** (Firebase FCM) — renter PWA push notifications
|
||||
- [x] `Notification` table — logs every delivery attempt per channel
|
||||
- [x] `NotificationPreference` table — per-user opt-in/out per type + channel
|
||||
- [x] Preferences UI: matrix toggle (notification type × channel)
|
||||
- [x] All company events: new booking, cancelled, payment received, trial ending, maintenance due, offer expiring, new review
|
||||
- [x] All renter events: confirmed, 24h reminder, 2h reminder, vehicle ready, return reminder, cancelled, refund, new offer from saved company, review request
|
||||
- [x] Scheduled notifications via cron (reminders, trial ending, maintenance due)
|
||||
|
||||
### Reviews
|
||||
- [x] Renter submits review (only after reservation COMPLETED)
|
||||
- [x] 1–5 stars overall + vehicle + service
|
||||
- [x] Text comment
|
||||
- [x] Company can reply to review
|
||||
- [x] Reviews shown on marketplace company profile
|
||||
- [x] Average rating on company card + brand settings
|
||||
|
||||
### Customer CRM
|
||||
- [x] Auto-created from any booking source
|
||||
- [x] Company-scoped view (never cross-company)
|
||||
- [x] Linked to global Renter account (if booked as logged-in renter)
|
||||
- [x] Rental history, notes, flag/blacklist
|
||||
|
||||
### Auth & Onboarding
|
||||
- [x] Company employee auth via Clerk
|
||||
- [x] Renter auth via own JWT (separate)
|
||||
- [x] Onboarding step 2: branding + subdomain
|
||||
- [x] Onboarding step 4: "Your vehicles now appear on the RentFlow 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.rentflow.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
@@ -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.rentflow.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.rentflow.com/api/v1
|
||||
|
||||
# Dashboard URL (used in invite redirect)
|
||||
DASHBOARD_URL=https://app.rentflow.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.rentflow.com/onboarding/accept-invite?__clerk_ticket=...
|
||||
```
|
||||
|
||||
Create this page in your Next.js app:
|
||||
|
||||
```tsx
|
||||
// app/onboarding/accept-invite/page.tsx
|
||||
'use client'
|
||||
import { useEffect } from 'react'
|
||||
import { useClerk } from '@clerk/nextjs'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function AcceptInvite() {
|
||||
const { handleRedirectCallback } = useClerk()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
handleRedirectCallback({}).then(() => {
|
||||
// Clerk webhook fires user.created → activates Employee record in DB
|
||||
// Redirect to dashboard after a short delay
|
||||
setTimeout(() => router.replace('/dashboard'), 1000)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<p className="text-sm text-zinc-500">Setting up your account…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Prisma type extensions (optional, for `req.employee`)
|
||||
|
||||
To get TypeScript to recognize `req.companyId` and `req.employee` on Express requests,
|
||||
add a type declaration file:
|
||||
|
||||
```ts
|
||||
// types/express.d.ts
|
||||
import { Employee, Company } from '@prisma/client'
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
companyId: string
|
||||
company: Company
|
||||
employee: Employee
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary of files delivered
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `backend/services/teamService.ts` | All business logic — invite, update role, deactivate, remove, Clerk webhook handler |
|
||||
| `backend/routes/team.ts` | Express router — all `/team` endpoints |
|
||||
| `backend/routes/webhooks.ts` | Clerk webhook listener — activates employee on invite acceptance |
|
||||
| `backend/middleware/requireRole.ts` | Role-rank guard middleware — reusable across all routers |
|
||||
| `frontend/hooks/useTeam.ts` | React data hook — fetch, invite, updateRole, deactivate, reactivate, remove |
|
||||
| `frontend/pages/team.tsx` | Full `/dashboard/team` Next.js page |
|
||||
| `frontend/components/team/InviteModal.tsx` | Invite new member modal with role picker |
|
||||
| `frontend/components/team/EditMemberModal.tsx` | Edit role, deactivate, reactivate, remove modal with confirmation step |
|
||||
| `frontend/components/team/PermissionsMatrix.tsx` | Static role × feature permissions table |
|
||||
+205
@@ -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,316 @@
|
||||
# Pages Specification — RentFlow
|
||||
|
||||
---
|
||||
|
||||
## LAYER 1A — Marketing Site (rentflow.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 RentFlow 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 (rentflow.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}.rentflow.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 RentFlow marketplace (RentFlow-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}.rentflow.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}.rentflow.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.rentflow.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].rentflow.com" + "Your vehicles are now visible on rentflow.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}.rentflow.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 RentFlow" (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 RentFlow 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.rentflow.com — internal)
|
||||
|
||||
> Separate app from the company dashboard. Own auth (JWT + TOTP 2FA). Only RentFlow 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +1,109 @@
|
||||
# Car_Management_System
|
||||
# 🚗 RentFlow — Multi-Tenant Car Rental SaaS + Marketplace
|
||||
|
||||
## Platform Model
|
||||
|
||||
**Rental Companies (B2B)** — pay RentFlow a 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.rentflow.com` where renters book and pay
|
||||
|
||||
## Getting started
|
||||
**Renters (B2C)** — free account, can:
|
||||
- Browse ALL companies' vehicles and offers on `rentflow.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
|
||||
|
||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
||||
---
|
||||
|
||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
||||
## ⚠️ Payment Providers — Stripe Has Been Removed
|
||||
|
||||
## Add your files
|
||||
| Provider | Purpose |
|
||||
|----------|---------|
|
||||
| **AmanPay** (amanpay.net) | Primary. National/international cards, cash (3000+ points), e-wallet. Moroccan PCI-DSS Level-1 PSP. |
|
||||
| **PayPal** | Secondary. Global coverage. |
|
||||
|
||||
* [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
||||
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
||||
**Two payment contexts:**
|
||||
1. Company pays RentFlow subscription → RentFlow'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 rentflow.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}.rentflow.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
|
||||
|
||||
```
|
||||
cd existing_repo
|
||||
git remote add origin https://gitlab.rentaldrivego.ma/rental_car/car_management_system.git
|
||||
git branch -M main
|
||||
git push -uf origin main
|
||||
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
|
||||
```
|
||||
|
||||
## Integrate with your tools
|
||||
---
|
||||
|
||||
* [Set up project integrations](https://gitlab.rentaldrivego.ma/rental_car/car_management_system/-/settings/integrations)
|
||||
## 🏗 Five-Layer Architecture
|
||||
|
||||
## Collaborate with your team
|
||||
```
|
||||
Layer 1A: rentflow.com — B2B marketing site
|
||||
Layer 1B: rentflow.com/explore — B2C marketplace (discovery + redirect only)
|
||||
Layer 2: app.rentflow.com — Company dashboard (private, subscription-gated)
|
||||
Layer 3: {slug}.rentflow.com — Company branded site (booking + payment here)
|
||||
Layer 4: api.rentflow.com — REST API
|
||||
```
|
||||
|
||||
* [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
||||
* [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
||||
* [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
||||
* [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
||||
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
||||
---
|
||||
|
||||
## Test and Deploy
|
||||
## 📁 Project Structure
|
||||
|
||||
Use the built-in continuous integration in GitLab.
|
||||
```
|
||||
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/
|
||||
```
|
||||
|
||||
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
|
||||
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
||||
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
||||
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
||||
* [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
||||
---
|
||||
|
||||
***
|
||||
## 🛠 Tech Stack
|
||||
|
||||
# Editing this README
|
||||
|
||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
||||
|
||||
## Suggestions for a good README
|
||||
|
||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
||||
|
||||
## Name
|
||||
Choose a self-explaining name for your project.
|
||||
|
||||
## Description
|
||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
||||
|
||||
## Badges
|
||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
||||
|
||||
## Visuals
|
||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
||||
|
||||
## Installation
|
||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
||||
|
||||
## Usage
|
||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
||||
|
||||
## Support
|
||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
||||
|
||||
## Roadmap
|
||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
||||
|
||||
## Contributing
|
||||
State if you are open to contributions and what your requirements are for accepting them.
|
||||
|
||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
||||
|
||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
||||
|
||||
## Authors and acknowledgment
|
||||
Show your appreciation to those who have contributed to the project.
|
||||
|
||||
## License
|
||||
For open source projects, say how it is licensed.
|
||||
|
||||
## Project status
|
||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
||||
| 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
@@ -0,0 +1,498 @@
|
||||
# API Routes Inventory — RentFlow (Complete)
|
||||
|
||||
Base URL: `https://api.rentflow.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` — RentFlow 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 — RentFlow 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}.rentflow.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 (RentFlow 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.rentflow.com → api.rentflow.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=` |
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,980 @@
|
||||
# Prisma Schema — RentFlow (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)
|
||||
|
||||
// RentFlow 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,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
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user