From 65394f6436a5156c5be8bebf9d102893bbbf2cd8 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 30 Apr 2026 18:28:05 +0000 Subject: [PATCH 01/15] project design --- EditMemberModal.tsx | 268 +++++++++ FEATURES.md | 310 ++++++++++ INTEGRATION.md | 182 ++++++ InviteModal.tsx | 205 +++++++ PAGES.md | 316 ++++++++++ PermissionsMatrix.tsx | 115 ++++ README.md | 164 +++--- advanced-features.md | 1308 +++++++++++++++++++++++++++++++++++++++++ api-routes.md | 498 ++++++++++++++++ requireRole.ts | 47 ++ schema.md | 980 ++++++++++++++++++++++++++++++ team.ts | 169 ++++++ team.tsx | 337 +++++++++++ teamService.ts | 365 ++++++++++++ 14 files changed, 5190 insertions(+), 74 deletions(-) create mode 100644 EditMemberModal.tsx create mode 100644 FEATURES.md create mode 100644 INTEGRATION.md create mode 100644 InviteModal.tsx create mode 100644 PAGES.md create mode 100644 PermissionsMatrix.tsx create mode 100644 advanced-features.md create mode 100644 api-routes.md create mode 100644 requireRole.ts create mode 100644 schema.md create mode 100644 team.ts create mode 100644 team.tsx create mode 100644 teamService.ts diff --git a/EditMemberModal.tsx b/EditMemberModal.tsx new file mode 100644 index 0000000..fb2c7c3 --- /dev/null +++ b/EditMemberModal.tsx @@ -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 + onDeactivate: (memberId: string) => Promise + onReactivate: (memberId: string) => Promise + onRemove: (memberId: string) => Promise +} + +const ROLE_OPTIONS = [ + { value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' }, + { value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' }, +] + +type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing' +type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null + +export default function EditMemberModal({ + member, + open, + onClose, + onUpdateRole, + onDeactivate, + onReactivate, + onRemove, +}: Props) { + const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT') + const [actionState, setActionState] = useState('idle') + const [confirm, setConfirm] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + if (member && open) { + setRole(member.role === 'OWNER' ? 'MANAGER' : (member.role as 'MANAGER' | 'AGENT')) + setActionState('idle') + setConfirm(null) + setError(null) + } + }, [member, open]) + + if (!open || !member) return null + + const isPending = member.invitationStatus === 'pending' + const isActive = member.isActive + + const handleSave = async () => { + if (role === member.role) { onClose(); return } + setActionState('saving') + setError(null) + try { + await onUpdateRole(member.id, role) + onClose() + } catch (err: any) { + setError(err.message) + setActionState('idle') + } + } + + const handleDeactivate = async () => { + setActionState('deactivating') + setError(null) + try { + await onDeactivate(member.id) + onClose() + } catch (err: any) { + setError(err.message) + setActionState('idle') + } + setConfirm(null) + } + + const handleReactivate = async () => { + setActionState('reactivating') + setError(null) + try { + await onReactivate(member.id) + onClose() + } catch (err: any) { + setError(err.message) + setActionState('idle') + } + setConfirm(null) + } + + const handleRemove = async () => { + setActionState('removing') + setError(null) + try { + await onRemove(member.id) + onClose() + } catch (err: any) { + setError(err.message) + setActionState('idle') + } + setConfirm(null) + } + + const busy = actionState !== 'idle' + const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '') + + return ( +
{ if (e.target === e.currentTarget && !busy) onClose() }} + > +
+ + {/* Member header */} +
+
+ {initials} +
+
+

+ {member.firstName} {member.lastName} +

+

{member.email}

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

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

+

+ {confirm === 'deactivate' && "They'll immediately lose dashboard access. You can reactivate anytime."} + {confirm === 'remove' && "This will delete the employee record and revoke their Clerk access. This cannot be undone."} + {confirm === 'reactivate' && "They'll regain dashboard access with their current role."} +

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

{error}

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

Setting up your account…

+
+ ) +} +``` + +--- + +## 7. Prisma type extensions (optional, for `req.employee`) + +To get TypeScript to recognize `req.companyId` and `req.employee` on Express requests, +add a type declaration file: + +```ts +// types/express.d.ts +import { Employee, Company } from '@prisma/client' + +declare global { + namespace Express { + interface Request { + companyId: string + company: Company + employee: Employee + } + } +} +``` + +--- + +## 8. Summary of files delivered + +| File | Purpose | +|------|---------| +| `backend/services/teamService.ts` | All business logic — invite, update role, deactivate, remove, Clerk webhook handler | +| `backend/routes/team.ts` | Express router — all `/team` endpoints | +| `backend/routes/webhooks.ts` | Clerk webhook listener — activates employee on invite acceptance | +| `backend/middleware/requireRole.ts` | Role-rank guard middleware — reusable across all routers | +| `frontend/hooks/useTeam.ts` | React data hook — fetch, invite, updateRole, deactivate, reactivate, remove | +| `frontend/pages/team.tsx` | Full `/dashboard/team` Next.js page | +| `frontend/components/team/InviteModal.tsx` | Invite new member modal with role picker | +| `frontend/components/team/EditMemberModal.tsx` | Edit role, deactivate, reactivate, remove modal with confirmation step | +| `frontend/components/team/PermissionsMatrix.tsx` | Static role × feature permissions table | diff --git a/InviteModal.tsx b/InviteModal.tsx new file mode 100644 index 0000000..0870168 --- /dev/null +++ b/InviteModal.tsx @@ -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 +} + +const ROLE_OPTIONS = [ + { + value: 'MANAGER' as const, + label: 'Manager', + description: 'Full fleet ops — no billing or team settings', + permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'], + }, + { + value: 'AGENT' as const, + label: 'Agent', + description: 'Day-to-day operations only', + permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'], + }, +] + +export default function InviteModal({ open, onClose, onInvite }: Props) { + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') + const [email, setEmail] = useState('') + const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Reset form when modal closes + useEffect(() => { + if (!open) { + setTimeout(() => { + setFirstName('') + setLastName('') + setEmail('') + setRole('AGENT') + setError(null) + }, 200) + } + }, [open]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setLoading(true) + + try { + await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role }) + onClose() + } catch (err: any) { + setError(err.message ?? 'Failed to send invitation') + } finally { + setLoading(false) + } + } + + if (!open) return null + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+
+

Invite a team member

+

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

+
+ +
+ {/* Name row */} +
+
+ + setFirstName(e.target.value)} + placeholder="Youssef" + required + className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent" + /> +
+
+ + setLastName(e.target.value)} + placeholder="Benali" + required + className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent" + /> +
+
+ + {/* Email */} +
+ + setEmail(e.target.value)} + placeholder="youssef@yourcompany.com" + required + className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent" + /> +
+ + {/* Role */} +
+ +
+ {ROLE_OPTIONS.map((option) => ( + + ))} +
+
+ + {/* Error */} + {error && ( +
+

{error}

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

Role permissions

+

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

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

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

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

Marked damages ({damages.length})

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

Team

+

+ Manage your employees and their access levels +

+
+ {isOwner && ( + + )} +
+ + {/* Stat cards */} +
+ {[ + { label: 'Total members', value: stats.total }, + { label: 'Active', value: stats.active }, + { label: 'Pending invite', value: stats.pending }, + { label: 'Deactivated', value: stats.inactive }, + ].map((s) => ( +
+ {s.label} + {s.value} +
+ ))} +
+ + {/* Filters */} +
+
+ + + + + setSearch(e.target.value)} + className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent" + /> +
+ + +
+ + {/* Table */} +
+ {loading ? ( +
+
+ Loading team… +
+ ) : error ? ( +
{error}
+ ) : filtered.length === 0 ? ( +
+

No members match your filters

+
+ ) : ( + + + + + + + + + + + {filtered.map((member, i) => ( + + {/* Member */} + + + {/* Role */} + + + {/* Status */} + + + {/* Last active */} + + + {/* Actions */} + + + ))} + +
MemberRoleStatusLast active +
+
+
+ {initials(member)} +
+
+
+ {member.firstName} {member.lastName} + {member.id === currentEmployee?.id && ( + (you) + )} +
+
{member.email}
+
+
+
+ + + + + + {member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)} + + + {isOwner && member.role !== 'OWNER' && ( + + )} +
+ )} +
+ + {/* Permissions matrix */} + + + {/* Modals */} + setInviteOpen(false)} + onInvite={handleInvite} + /> + setEditTarget(null)} + onUpdateRole={handleUpdateRole} + onDeactivate={handleDeactivate} + onReactivate={handleReactivate} + onRemove={handleRemove} + /> + + {/* Toast */} + {toast && ( +
+ + + + {toast} +
+ )} +
+ ) +} diff --git a/teamService.ts b/teamService.ts new file mode 100644 index 0000000..6201100 --- /dev/null +++ b/teamService.ts @@ -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 { + const employees = await prisma.employee.findMany({ + where: { companyId }, + orderBy: [{ role: 'asc' }, { createdAt: 'asc' }], + }) + + // Hydrate last-active timestamps from Clerk in parallel + const hydrated = await Promise.allSettled( + employees.map(async (emp) => { + try { + const clerkUser = await clerkClient.users.getUser(emp.clerkUserId) + return { + ...emp, + lastActiveAt: clerkUser.lastActiveAt + ? new Date(clerkUser.lastActiveAt) + : null, + invitationStatus: 'accepted' as const, + } + } catch { + // Clerk user not yet accepted invitation + return { + ...emp, + lastActiveAt: null, + invitationStatus: 'pending' as const, + } + } + }) + ) + + return hydrated.map((r) => + r.status === 'fulfilled' ? r.value : (r as any).value + ) +} + +// ─── Get single employee (scoped to company) ────────────────── + +export async function getEmployee(companyId: string, employeeId: string) { + const employee = await prisma.employee.findFirst({ + where: { id: employeeId, companyId }, + }) + if (!employee) { + throw Object.assign(new Error('Employee not found'), { statusCode: 404, code: 'employee_not_found' }) + } + return employee +} + +// ─── Invite ─────────────────────────────────────────────────── +// 1. Send a Clerk invitation (creates the Clerk user + sends email) +// 2. Create the Employee row immediately so the company sees it in the list +// with isActive=false until they accept and log in (Clerk webhook sets isActive=true) + +export async function inviteEmployee( + companyId: string, + inviterId: string, + payload: InvitePayload +) { + const { firstName, lastName, email, role } = payload + + // Owners cannot be invited — must be the account creator + if (role === 'OWNER') { + throw Object.assign( + new Error('Cannot invite a member with the OWNER role'), + { statusCode: 400, code: 'invalid_role' } + ) + } + + // Check for existing active employee with that email in this company + const existing = await prisma.employee.findFirst({ + where: { companyId, email }, + }) + if (existing) { + throw Object.assign( + new Error('An employee with this email already exists in your team'), + { statusCode: 409, code: 'employee_already_exists' } + ) + } + + // Get the company's subdomain/name for the invitation email redirect URL + const company = await prisma.company.findUniqueOrThrow({ + where: { id: companyId }, + include: { brand: { select: { subdomain: true, displayName: true } } }, + }) + + // Send Clerk invitation + // Clerk will create the user and email them a magic link + let clerkInvitation: Awaited> + + try { + clerkInvitation = await clerkClient.invitations.createInvitation({ + emailAddress: email, + redirectUrl: `${process.env.DASHBOARD_URL}/onboarding/accept-invite`, + publicMetadata: { + companyId, + companyName: company.brand?.displayName ?? company.name, + role, + invitedBy: inviterId, + }, + }) + } catch (err: any) { + // Clerk throws if email already has a Clerk account with a pending invite + if (err?.errors?.[0]?.code === 'duplicate_record') { + throw Object.assign( + new Error('This email already has a pending invitation'), + { statusCode: 409, code: 'duplicate_invitation' } + ) + } + throw err + } + + // Create the Employee record with a placeholder clerkUserId + // It will be updated when the user accepts the invite (via Clerk webhook) + const employee = await prisma.employee.create({ + data: { + companyId, + clerkUserId: `pending_${clerkInvitation.id}`, // replaced by webhook + firstName, + lastName, + email, + role, + isActive: false, // activated on invite acceptance + }, + }) + + return { + employee, + invitationId: clerkInvitation.id, + } +} + +// ─── Update role ────────────────────────────────────────────── + +export async function updateEmployeeRole( + companyId: string, + requesterId: string, + requesterRole: EmployeeRole, + employeeId: string, + payload: UpdateRolePayload +) { + const target = await getEmployee(companyId, employeeId) + + // Only OWNER can change roles + if (requesterRole !== 'OWNER') { + throw Object.assign( + new Error('Only the account owner can change team member roles'), + { statusCode: 403, code: 'forbidden' } + ) + } + + // Cannot change an OWNER's role (there must always be one owner) + if (target.role === 'OWNER') { + throw Object.assign( + new Error('Cannot change the role of the account owner'), + { statusCode: 400, code: 'cannot_change_owner_role' } + ) + } + + // Cannot assign OWNER role via this endpoint + if (payload.role === 'OWNER') { + throw Object.assign( + new Error('Cannot assign the OWNER role via this endpoint'), + { statusCode: 400, code: 'invalid_role' } + ) + } + + // Prevent self-role-change (edge case) + if (target.id === requesterId) { + throw Object.assign( + new Error('You cannot change your own role'), + { statusCode: 400, code: 'cannot_change_own_role' } + ) + } + + const updated = await prisma.employee.update({ + where: { id: employeeId }, + data: { role: payload.role }, + }) + + return updated +} + +// ─── Deactivate ─────────────────────────────────────────────── + +export async function deactivateEmployee( + companyId: string, + requesterRole: EmployeeRole, + employeeId: string +) { + const target = await getEmployee(companyId, employeeId) + + if (requesterRole !== 'OWNER') { + throw Object.assign( + new Error('Only the account owner can deactivate team members'), + { statusCode: 403, code: 'forbidden' } + ) + } + + if (target.role === 'OWNER') { + throw Object.assign( + new Error('Cannot deactivate the account owner'), + { statusCode: 400, code: 'cannot_deactivate_owner' } + ) + } + + // Revoke Clerk session so they are immediately signed out + if (!target.clerkUserId.startsWith('pending_')) { + try { + await clerkClient.users.banUser(target.clerkUserId) + } catch { + // Non-fatal — still deactivate in DB + } + } + + const updated = await prisma.employee.update({ + where: { id: employeeId }, + data: { isActive: false }, + }) + + return updated +} + +// ─── Reactivate ─────────────────────────────────────────────── + +export async function reactivateEmployee( + companyId: string, + requesterRole: EmployeeRole, + employeeId: string +) { + if (requesterRole !== 'OWNER') { + throw Object.assign( + new Error('Only the account owner can reactivate team members'), + { statusCode: 403, code: 'forbidden' } + ) + } + + const target = await getEmployee(companyId, employeeId) + + if (!target.clerkUserId.startsWith('pending_')) { + try { + await clerkClient.users.unbanUser(target.clerkUserId) + } catch { + // Non-fatal + } + } + + const updated = await prisma.employee.update({ + where: { id: employeeId }, + data: { isActive: true }, + }) + + return updated +} + +// ─── Remove (hard delete) ───────────────────────────────────── +// Only OWNER can remove. Removes employee row + revokes Clerk invite if pending. + +export async function removeEmployee( + companyId: string, + requesterRole: EmployeeRole, + employeeId: string +) { + if (requesterRole !== 'OWNER') { + throw Object.assign( + new Error('Only the account owner can remove team members'), + { statusCode: 403, code: 'forbidden' } + ) + } + + const target = await getEmployee(companyId, employeeId) + + if (target.role === 'OWNER') { + throw Object.assign( + new Error('Cannot remove the account owner'), + { statusCode: 400, code: 'cannot_remove_owner' } + ) + } + + // Revoke pending Clerk invitation + if (target.clerkUserId.startsWith('pending_')) { + const inviteId = target.clerkUserId.replace('pending_', '') + try { + await clerkClient.invitations.revokeInvitation(inviteId) + } catch { + // Already accepted or expired — safe to ignore + } + } else { + // Ban the Clerk user so they can't log in + try { + await clerkClient.users.banUser(target.clerkUserId) + } catch { + // Non-fatal + } + } + + await prisma.employee.delete({ where: { id: employeeId } }) + + return { success: true } +} + +// ─── Clerk webhook handler ──────────────────────────────────── +// Called when a user accepts their invitation. Updates the Employee row +// with the real Clerk user ID and activates the account. + +export async function handleInvitationAccepted( + clerkUserId: string, + email: string, + companyId: string, + role: EmployeeRole +) { + const pendingEmployee = await prisma.employee.findFirst({ + where: { + companyId, + email, + clerkUserId: { startsWith: 'pending_' }, + }, + }) + + if (!pendingEmployee) return null + + const activated = await prisma.employee.update({ + where: { id: pendingEmployee.id }, + data: { + clerkUserId, + isActive: true, + role, // in case it was updated between invite and acceptance + }, + }) + + return activated +} From 2b97c1a04ad5de0c6f47ada8f15da406db3feab6 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 30 Apr 2026 18:29:28 +0000 Subject: [PATCH 02/15] update name of project --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e81a955..ebf4e16 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# 🚗 RentFlow — Multi-Tenant Car Rental SaaS + Marketplace +# 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Marketplace ## Platform Model -**Rental Companies (B2B)** — pay RentFlow a subscription (AmanPay or PayPal), get: +**Rental Companies (B2B)** — pay RentalDriveGoa subscription (AmanPay or PayPal), get: - A fully private dashboard (zero data overlap with any other company) - Fleet management with vehicle photo upload (photos auto-appear on global marketplace) - Promotional offers management @@ -24,7 +24,7 @@ | **PayPal** | Secondary. Global coverage. | **Two payment contexts:** -1. Company pays RentFlow subscription → RentFlow's own AmanPay/PayPal account +1. Company pays RentalDriveGosubscription → RentFlow's own AmanPay/PayPal account 2. Renter pays company for rental → Company's own AmanPay merchant + PayPal account (direct, no intermediary) --- From 695a7f7cc7acdf286418cf1c28d1f706212977df Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Apr 2026 14:59:57 -0400 Subject: [PATCH 03/15] add first files --- .codex | 0 .env.example | 89 ++ .gitignore | 33 + apps/admin/next.config.js | 9 + apps/admin/package.json | 30 + apps/admin/postcss.config.js | 6 + apps/admin/tailwind.config.ts | 16 + apps/admin/tsconfig.json | 23 + apps/api/package.json | 53 ++ apps/api/src/index.ts | 144 +++ apps/api/src/lib/cloudinary.ts | 31 + apps/api/src/lib/prisma.ts | 13 + apps/api/src/lib/redis.ts | 10 + apps/api/src/middleware/requireAdminAuth.ts | 60 ++ apps/api/src/middleware/requireApiKey.ts | 19 + apps/api/src/middleware/requireCompanyAuth.ts | 45 + apps/api/src/middleware/requireRenterAuth.ts | 47 + apps/api/src/middleware/requireRole.ts | 32 + .../api/src/middleware/requireSubscription.ts | 24 + apps/api/src/middleware/requireTenant.ts | 24 + apps/api/src/routes/admin.ts | 219 +++++ apps/api/src/routes/analytics.ts | 52 ++ apps/api/src/routes/auth.renter.ts | 83 ++ apps/api/src/routes/customers.ts | 125 +++ apps/api/src/routes/marketplace.ts | 101 +++ apps/api/src/routes/notifications.ts | 77 ++ apps/api/src/routes/offers.ts | 100 +++ apps/api/src/routes/reservations.ts | 215 +++++ apps/api/src/routes/team.ts | 56 ++ apps/api/src/routes/vehicles.ts | 156 ++++ apps/api/src/routes/webhooks.ts | 39 + .../src/services/financialReportService.ts | 66 ++ apps/api/src/services/insuranceService.ts | 47 + .../src/services/licenseValidationService.ts | 50 ++ apps/api/src/services/notificationService.ts | 135 +++ apps/api/src/services/pricingRuleService.ts | 56 ++ apps/api/src/services/teamService.ts | 143 +++ apps/api/src/types/express.d.ts | 13 + apps/api/tsconfig.json | 10 + apps/dashboard/next.config.js | 9 + apps/dashboard/package.json | 32 + apps/dashboard/postcss.config.js | 6 + .../app/(dashboard)/dashboard/fleet/page.tsx | 336 +++++++ .../src/app/(dashboard)/dashboard/page.tsx | 263 ++++++ apps/dashboard/src/app/(dashboard)/layout.tsx | 16 + apps/dashboard/src/app/globals.css | 72 ++ apps/dashboard/src/app/layout.tsx | 23 + .../src/components/layout/Sidebar.tsx | 99 +++ .../src/components/layout/TopBar.tsx | 71 ++ apps/dashboard/src/components/ui/StatCard.tsx | 49 ++ apps/dashboard/src/lib/api.ts | 80 ++ apps/dashboard/src/middleware.ts | 19 + apps/dashboard/tailwind.config.ts | 26 + apps/dashboard/tsconfig.json | 23 + apps/marketplace/next.config.js | 9 + apps/marketplace/package.json | 29 + apps/marketplace/postcss.config.js | 6 + apps/marketplace/tailwind.config.ts | 16 + apps/marketplace/tsconfig.json | 23 + apps/public-site/next.config.js | 9 + apps/public-site/package.json | 29 + apps/public-site/postcss.config.js | 6 + apps/public-site/tailwind.config.ts | 16 + apps/public-site/tsconfig.json | 23 + package.json | 32 + packages/database/package.json | 27 + packages/database/prisma/schema.prisma | 824 ++++++++++++++++++ packages/database/prisma/seed.ts | 40 + packages/database/tsconfig.json | 8 + packages/types/package.json | 13 + packages/types/src/api.ts | 50 ++ packages/types/src/damage.ts | 53 ++ packages/types/src/fuel.ts | 29 + packages/types/src/index.ts | 3 + .../EditMemberModal.tsx | 0 FEATURES.md => project_design/FEATURES.md | 0 .../INTEGRATION.md | 0 .../InviteModal.tsx | 0 PAGES.md => project_design/PAGES.md | 0 .../PermissionsMatrix.tsx | 0 README.md => project_design/README.md | 0 .../advanced-features.md | 0 api-routes.md => project_design/api-routes.md | 0 .../requireRole.ts | 0 schema.md => project_design/schema.md | 0 team.ts => project_design/team.ts | 0 team.tsx => project_design/team.tsx | 0 .../teamService.ts | 0 useTeam.ts => project_design/useTeam.ts | 0 webhooks.ts => project_design/webhooks.ts | 0 tsconfig.base.json | 21 + turbo.json | 32 + 92 files changed, 4873 insertions(+) create mode 100644 .codex create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 apps/admin/next.config.js create mode 100644 apps/admin/package.json create mode 100644 apps/admin/postcss.config.js create mode 100644 apps/admin/tailwind.config.ts create mode 100644 apps/admin/tsconfig.json create mode 100644 apps/api/package.json create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/lib/cloudinary.ts create mode 100644 apps/api/src/lib/prisma.ts create mode 100644 apps/api/src/lib/redis.ts create mode 100644 apps/api/src/middleware/requireAdminAuth.ts create mode 100644 apps/api/src/middleware/requireApiKey.ts create mode 100644 apps/api/src/middleware/requireCompanyAuth.ts create mode 100644 apps/api/src/middleware/requireRenterAuth.ts create mode 100644 apps/api/src/middleware/requireRole.ts create mode 100644 apps/api/src/middleware/requireSubscription.ts create mode 100644 apps/api/src/middleware/requireTenant.ts create mode 100644 apps/api/src/routes/admin.ts create mode 100644 apps/api/src/routes/analytics.ts create mode 100644 apps/api/src/routes/auth.renter.ts create mode 100644 apps/api/src/routes/customers.ts create mode 100644 apps/api/src/routes/marketplace.ts create mode 100644 apps/api/src/routes/notifications.ts create mode 100644 apps/api/src/routes/offers.ts create mode 100644 apps/api/src/routes/reservations.ts create mode 100644 apps/api/src/routes/team.ts create mode 100644 apps/api/src/routes/vehicles.ts create mode 100644 apps/api/src/routes/webhooks.ts create mode 100644 apps/api/src/services/financialReportService.ts create mode 100644 apps/api/src/services/insuranceService.ts create mode 100644 apps/api/src/services/licenseValidationService.ts create mode 100644 apps/api/src/services/notificationService.ts create mode 100644 apps/api/src/services/pricingRuleService.ts create mode 100644 apps/api/src/services/teamService.ts create mode 100644 apps/api/src/types/express.d.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/dashboard/next.config.js create mode 100644 apps/dashboard/package.json create mode 100644 apps/dashboard/postcss.config.js create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/layout.tsx create mode 100644 apps/dashboard/src/app/globals.css create mode 100644 apps/dashboard/src/app/layout.tsx create mode 100644 apps/dashboard/src/components/layout/Sidebar.tsx create mode 100644 apps/dashboard/src/components/layout/TopBar.tsx create mode 100644 apps/dashboard/src/components/ui/StatCard.tsx create mode 100644 apps/dashboard/src/lib/api.ts create mode 100644 apps/dashboard/src/middleware.ts create mode 100644 apps/dashboard/tailwind.config.ts create mode 100644 apps/dashboard/tsconfig.json create mode 100644 apps/marketplace/next.config.js create mode 100644 apps/marketplace/package.json create mode 100644 apps/marketplace/postcss.config.js create mode 100644 apps/marketplace/tailwind.config.ts create mode 100644 apps/marketplace/tsconfig.json create mode 100644 apps/public-site/next.config.js create mode 100644 apps/public-site/package.json create mode 100644 apps/public-site/postcss.config.js create mode 100644 apps/public-site/tailwind.config.ts create mode 100644 apps/public-site/tsconfig.json create mode 100644 package.json create mode 100644 packages/database/package.json create mode 100644 packages/database/prisma/schema.prisma create mode 100644 packages/database/prisma/seed.ts create mode 100644 packages/database/tsconfig.json create mode 100644 packages/types/package.json create mode 100644 packages/types/src/api.ts create mode 100644 packages/types/src/damage.ts create mode 100644 packages/types/src/fuel.ts create mode 100644 packages/types/src/index.ts rename EditMemberModal.tsx => project_design/EditMemberModal.tsx (100%) rename FEATURES.md => project_design/FEATURES.md (100%) rename INTEGRATION.md => project_design/INTEGRATION.md (100%) rename InviteModal.tsx => project_design/InviteModal.tsx (100%) rename PAGES.md => project_design/PAGES.md (100%) rename PermissionsMatrix.tsx => project_design/PermissionsMatrix.tsx (100%) rename README.md => project_design/README.md (100%) rename advanced-features.md => project_design/advanced-features.md (100%) rename api-routes.md => project_design/api-routes.md (100%) rename requireRole.ts => project_design/requireRole.ts (100%) rename schema.md => project_design/schema.md (100%) rename team.ts => project_design/team.ts (100%) rename team.tsx => project_design/team.tsx (100%) rename teamService.ts => project_design/teamService.ts (100%) rename useTeam.ts => project_design/useTeam.ts (100%) rename webhooks.ts => project_design/webhooks.ts (100%) create mode 100644 tsconfig.base.json create mode 100644 turbo.json diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f2d3764 --- /dev/null +++ b/.env.example @@ -0,0 +1,89 @@ +# ═══════════════════════════════════════════════════════════════ +# RentalDriveGo — Environment Variables +# Copy to .env.local and fill in your values. +# ═══════════════════════════════════════════════════════════════ + +# ─── Database ────────────────────────────────────────────────── +DATABASE_URL="postgresql://postgres:password@localhost:5432/rentaldrivego" + +# ─── API ─────────────────────────────────────────────────────── +API_PORT=4000 +API_URL=http://localhost:4000 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 + +# ─── JWT (Renter auth + Admin auth) ─────────────────────────── +JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d + +# ─── Clerk (Company employee auth) ──────────────────────────── +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... +CLERK_SECRET_KEY=sk_test_... +CLERK_WEBHOOK_SECRET=whsec_... +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard +NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding + +# ─── AmanPay (Primary payment provider) ─────────────────────── +# RentFlow's own AmanPay account (for collecting subscription fees) +AMANPAY_MERCHANT_ID=your-amanpay-merchant-id +AMANPAY_SECRET_KEY=your-amanpay-secret-key +AMANPAY_BASE_URL=https://api.amanpay.net +AMANPAY_WEBHOOK_SECRET=your-amanpay-webhook-secret + +# ─── PayPal (Secondary payment provider) ────────────────────── +# RentFlow's own PayPal account (for collecting subscription fees) +PAYPAL_CLIENT_ID=your-paypal-client-id +PAYPAL_CLIENT_SECRET=your-paypal-client-secret +PAYPAL_BASE_URL=https://api-m.paypal.com +# Use https://api-m.sandbox.paypal.com for sandbox +NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id + +# ─── Cloudinary (Vehicle + brand photos) ────────────────────── +CLOUDINARY_CLOUD_NAME=your-cloud-name +CLOUDINARY_API_KEY=your-api-key +CLOUDINARY_API_SECRET=your-api-secret +NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name + +# ─── Resend (Email) ──────────────────────────────────────────── +RESEND_API_KEY=re_... +EMAIL_FROM=noreply@rentaldrivego.com +EMAIL_FROM_NAME=RentalDriveGo + +# ─── Twilio (SMS + WhatsApp) ─────────────────────────────────── +TWILIO_ACCOUNT_SID=AC... +TWILIO_AUTH_TOKEN=your-twilio-auth-token +TWILIO_PHONE_NUMBER=+1234567890 +TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886 + +# ─── Firebase (Push notifications) ─────────────────────────── +FIREBASE_PROJECT_ID=your-project-id +FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" +FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com +NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789 +NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123 + +# ─── Redis (Real-time / Socket.io) ──────────────────────────── +REDIS_URL=redis://localhost:6379 + +# ─── App URLs ────────────────────────────────────────────────── +NEXT_PUBLIC_MARKETING_URL=http://localhost:3000 +NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001 +NEXT_PUBLIC_ADMIN_URL=http://localhost:3002 +NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore +# Public site is subdomain-based; use this for local dev: +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 +DASHBOARD_URL=http://localhost:3001 + +# ─── Admin seed (first SUPER_ADMIN created on db:seed) ──────── +ADMIN_SEED_EMAIL=admin@rentaldrivego.com +ADMIN_SEED_PASSWORD=changeme123 +ADMIN_SEED_FIRST_NAME=Super +ADMIN_SEED_LAST_NAME=Admin + +# ─── Misc ────────────────────────────────────────────────────── +NODE_ENV=development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15a7330 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build outputs +dist/ +.next/ +out/ + +# Environment variables +.env +.env.local +.env.*.local + +# Turbo +.turbo + +# Prisma +packages/database/generated/ + +# Misc +.DS_Store +*.pem +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js new file mode 100644 index 0000000..4f89111 --- /dev/null +++ b/apps/admin/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + domains: ['res.cloudinary.com'], + }, + transpilePackages: ['@rentaldrivego/types'], +} + +module.exports = nextConfig diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..c125d36 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rentaldrivego/admin", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3002", + "build": "next build", + "start": "next start -p 3002", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@rentaldrivego/types": "*", + "next": "14.2.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^3.4.3", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "zod": "^3.23.0", + "dayjs": "^1.11.11", + "recharts": "^2.12.7", + "lucide-react": "^0.376.0" + }, + "devDependencies": { + "@types/node": "^20.12.0", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "typescript": "^5.4.0" + } +} diff --git a/apps/admin/postcss.config.js b/apps/admin/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/apps/admin/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/admin/tailwind.config.ts b/apps/admin/tailwind.config.ts new file mode 100644 index 0000000..8055fd6 --- /dev/null +++ b/apps/admin/tailwind.config.ts @@ -0,0 +1,16 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: ['./src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + fontFamily: { + sans: ['Inter', 'ui-sans-serif', 'system-ui'], + }, + }, + }, + plugins: [], +} + +export default config diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..b8cdcc7 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..d231fe7 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,53 @@ +{ + "name": "@rentaldrivego/api", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@rentaldrivego/database": "*", + "@rentaldrivego/types": "*", + "@clerk/clerk-sdk-node": "^5.0.0", + "express": "^4.19.2", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "morgan": "^1.10.0", + "zod": "^3.23.0", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "multer": "^1.4.5-lts.1", + "cloudinary": "^2.2.0", + "resend": "^3.2.0", + "twilio": "^5.1.0", + "firebase-admin": "^12.1.0", + "ioredis": "^5.3.2", + "socket.io": "^4.7.5", + "svix": "^1.20.0", + "otplib": "^12.0.1", + "qrcode": "^1.5.3", + "@react-pdf/renderer": "^3.4.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "dayjs": "^1.11.11", + "node-cron": "^3.0.3" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/morgan": "^1.9.9", + "@types/jsonwebtoken": "^9.0.6", + "@types/bcryptjs": "^2.4.6", + "@types/multer": "^1.4.11", + "@types/qrcode": "^1.5.5", + "@types/node-cron": "^3.0.11", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "typescript": "^5.4.0", + "@types/node": "^20.12.0", + "ts-node-dev": "^2.0.0" + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..d974266 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,144 @@ +import express from 'express' +import cors from 'cors' +import helmet from 'helmet' +import morgan from 'morgan' +import http from 'http' +import { Server as SocketIOServer } from 'socket.io' +import cron from 'node-cron' +import { redis } from './lib/redis' +import { prisma } from './lib/prisma' + +// ─── Routes ─────────────────────────────────────────────────── +import webhookRouter from './routes/webhooks' +import renterAuthRouter from './routes/auth.renter' +import vehiclesRouter from './routes/vehicles' +import reservationsRouter from './routes/reservations' +import teamRouter from './routes/team' +import customersRouter from './routes/customers' +import offersRouter from './routes/offers' +import analyticsRouter from './routes/analytics' +import notificationsRouter from './routes/notifications' +import marketplaceRouter from './routes/marketplace' +import adminRouter from './routes/admin' + +const app = express() +const server = http.createServer(app) + +// ─── Socket.io ──────────────────────────────────────────────── +const io = new SocketIOServer(server, { + cors: { origin: '*', methods: ['GET', 'POST'] }, +}) + +io.on('connection', (socket) => { + const userId = socket.handshake.auth?.userId as string | undefined + if (userId) { + socket.join(`user:${userId}`) + } +}) + +// Redis pub/sub → broadcast to connected clients +const subscriber = redis.duplicate() +subscriber.psubscribe('notifications:*', (err) => { + if (err) console.error('[Redis] Subscribe error:', err) +}) +subscriber.on('pmessage', (_pattern, channel, message) => { + const userId = channel.replace('notifications:', '') + io.to(`user:${userId}`).emit('notification', JSON.parse(message)) +}) + +// ─── Middleware ──────────────────────────────────────────────── +// Webhook must use raw body BEFORE express.json() +app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter) + +app.use(helmet()) +app.use(cors({ origin: process.env.CORS_ORIGINS?.split(',') ?? '*', credentials: true })) +app.use(morgan('combined')) +app.use(express.json({ limit: '10mb' })) + +// ─── API Routes ─────────────────────────────────────────────── +const v1 = '/api/v1' + +app.use(`${v1}/auth/renter`, renterAuthRouter) +app.use(`${v1}/vehicles`, vehiclesRouter) +app.use(`${v1}/reservations`, reservationsRouter) +app.use(`${v1}/team`, teamRouter) +app.use(`${v1}/customers`, customersRouter) +app.use(`${v1}/offers`, offersRouter) +app.use(`${v1}/analytics`, analyticsRouter) +app.use(`${v1}/notifications`, notificationsRouter) +app.use(`${v1}/marketplace`, marketplaceRouter) +app.use(`${v1}/admin`, adminRouter) + +// ─── Health check ───────────────────────────────────────────── +app.get('/health', (_req, res) => { + res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() }) +}) + +// ─── Error handler ──────────────────────────────────────────── +app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + const statusCode = err.statusCode ?? 500 + const message = err.message ?? 'Internal server error' + const code = err.code ?? 'internal_error' + + if (statusCode >= 500) { + console.error('[API Error]', err) + } + + // Zod validation error + if (err.name === 'ZodError') { + return res.status(400).json({ error: 'validation_error', message: 'Invalid request body', issues: err.issues, statusCode: 400 }) + } + + // Prisma not found + if (err.code === 'P2025') { + return res.status(404).json({ error: 'not_found', message: 'Resource not found', statusCode: 404 }) + } + + // Prisma unique constraint + if (err.code === 'P2002') { + return res.status(409).json({ error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 }) + } + + res.status(statusCode).json({ error: code, message, statusCode }) +}) + +// ─── Scheduled jobs ─────────────────────────────────────────── + +// Daily: flag expiring/expired licenses +cron.schedule('0 8 * * *', async () => { + const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } }) + for (const c of customers) { + if (!c.licenseExpiry) continue + const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + const expired = c.licenseExpiry <= new Date() + const expiring = !expired && daysLeft < 90 + if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) { + await prisma.customer.update({ where: { id: c.id }, data: { licenseExpired: expired, licenseExpiringSoon: expiring, licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID' } }) + } + } +}) + +// Daily: send trial-ending reminders (3 days before trial end) +cron.schedule('0 9 * * *', async () => { + const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) + const subscriptions = await prisma.subscription.findMany({ + where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } }, + include: { company: { include: { employees: { where: { role: 'OWNER' } } } } }, + }) + for (const sub of subscriptions) { + const owner = sub.company.employees[0] + if (owner) { + await prisma.notification.create({ + data: { type: 'SUBSCRIPTION_TRIAL_ENDING', title: 'Your trial ends soon', body: 'Your 14-day free trial ends in 3 days. Add a payment method to keep access.', companyId: sub.companyId, employeeId: owner.id, channel: 'IN_APP', status: 'DELIVERED' }, + }) + } + } +}) + +// ─── Start ──────────────────────────────────────────────────── +const PORT = process.env.API_PORT ?? 4000 +server.listen(PORT, () => { + console.log(`[API] Server running on port ${PORT}`) +}) + +export { app, io } diff --git a/apps/api/src/lib/cloudinary.ts b/apps/api/src/lib/cloudinary.ts new file mode 100644 index 0000000..391eea8 --- /dev/null +++ b/apps/api/src/lib/cloudinary.ts @@ -0,0 +1,31 @@ +import { v2 as cloudinary } from 'cloudinary' + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, + secure: true, +}) + +export { cloudinary } + +export async function uploadImage( + buffer: Buffer, + folder: string, + publicId?: string +): Promise { + return new Promise((resolve, reject) => { + const uploadStream = cloudinary.uploader.upload_stream( + { folder, public_id: publicId, resource_type: 'image', quality: 'auto', fetch_format: 'auto' }, + (error, result) => { + if (error) return reject(error) + resolve(result!.secure_url) + } + ) + uploadStream.end(buffer) + }) +} + +export async function deleteImage(publicId: string): Promise { + await cloudinary.uploader.destroy(publicId) +} diff --git a/apps/api/src/lib/prisma.ts b/apps/api/src/lib/prisma.ts new file mode 100644 index 0000000..33b1be2 --- /dev/null +++ b/apps/api/src/lib/prisma.ts @@ -0,0 +1,13 @@ +import { PrismaClient } from '@rentaldrivego/database' + +const globalForPrisma = global as unknown as { prisma: PrismaClient } + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + }) + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma +} diff --git a/apps/api/src/lib/redis.ts b/apps/api/src/lib/redis.ts new file mode 100644 index 0000000..ac69fc6 --- /dev/null +++ b/apps/api/src/lib/redis.ts @@ -0,0 +1,10 @@ +import Redis from 'ioredis' + +export const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', { + maxRetriesPerRequest: 3, + retryStrategy: (times) => Math.min(times * 50, 2000), +}) + +redis.on('error', (err) => { + console.error('[Redis] Connection error:', err.message) +}) diff --git a/apps/api/src/middleware/requireAdminAuth.ts b/apps/api/src/middleware/requireAdminAuth.ts new file mode 100644 index 0000000..b932c69 --- /dev/null +++ b/apps/api/src/middleware/requireAdminAuth.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from 'express' +import jwt from 'jsonwebtoken' +import { prisma } from '../lib/prisma' +import { AdminRole } from '@rentaldrivego/database' + +const ROLE_RANK: Record = { + SUPER_ADMIN: 5, + ADMIN: 4, + SUPPORT: 3, + FINANCE: 2, + VIEWER: 1, +} + +export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null + + if (!token) { + return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 }) + } + + try { + const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string } + if (payload.type !== 'admin') { + return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 }) + } + + const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } }) + if (!admin || !admin.isActive) { + return res.status(401).json({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 }) + } + + req.admin = admin + next() + } catch { + return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 }) + } +} + +export function requireAdminRole(minimumRole: AdminRole) { + return (req: Request, res: Response, next: NextFunction) => { + const admin = req.admin + if (!admin) { + return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 }) + } + + const rank = ROLE_RANK[admin.role] ?? 0 + const required = ROLE_RANK[minimumRole] ?? 99 + + if (rank < required) { + return res.status(403).json({ + error: 'forbidden', + message: `This action requires the ${minimumRole} role or higher`, + statusCode: 403, + }) + } + + next() + } +} diff --git a/apps/api/src/middleware/requireApiKey.ts b/apps/api/src/middleware/requireApiKey.ts new file mode 100644 index 0000000..d8037d3 --- /dev/null +++ b/apps/api/src/middleware/requireApiKey.ts @@ -0,0 +1,19 @@ +import { Request, Response, NextFunction } from 'express' +import { prisma } from '../lib/prisma' + +export async function requireApiKey(req: Request, res: Response, next: NextFunction) { + const apiKey = req.headers['x-api-key'] as string | undefined + + if (!apiKey) { + return res.status(401).json({ error: 'missing_api_key', message: 'API key required in x-api-key header', statusCode: 401 }) + } + + const company = await prisma.company.findUnique({ where: { apiKey } }) + if (!company) { + return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 }) + } + + req.company = company + req.companyId = company.id + next() +} diff --git a/apps/api/src/middleware/requireCompanyAuth.ts b/apps/api/src/middleware/requireCompanyAuth.ts new file mode 100644 index 0000000..aa5ffb3 --- /dev/null +++ b/apps/api/src/middleware/requireCompanyAuth.ts @@ -0,0 +1,45 @@ +import { Request, Response, NextFunction } from 'express' +import { clerkClient } from '@clerk/clerk-sdk-node' +import { prisma } from '../lib/prisma' + +export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization + const sessionToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null + + if (!sessionToken) { + return res.status(401).json({ + error: 'unauthenticated', + message: 'Authentication required', + statusCode: 401, + }) + } + + try { + const session = await clerkClient.sessions.verifySession(sessionToken, sessionToken) + const clerkUserId = session.userId + + const employee = await prisma.employee.findUnique({ + where: { clerkUserId }, + include: { company: true }, + }) + + if (!employee || !employee.isActive) { + return res.status(401).json({ + error: 'unauthenticated', + message: 'Employee account not found or inactive', + statusCode: 401, + }) + } + + req.employee = employee + req.company = employee.company + req.companyId = employee.companyId + next() + } catch { + return res.status(401).json({ + error: 'invalid_token', + message: 'Invalid or expired session token', + statusCode: 401, + }) + } +} diff --git a/apps/api/src/middleware/requireRenterAuth.ts b/apps/api/src/middleware/requireRenterAuth.ts new file mode 100644 index 0000000..a5f8f61 --- /dev/null +++ b/apps/api/src/middleware/requireRenterAuth.ts @@ -0,0 +1,47 @@ +import { Request, Response, NextFunction } from 'express' +import jwt from 'jsonwebtoken' +import { prisma } from '../lib/prisma' + +export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null + + if (!token) { + return res.status(401).json({ error: 'unauthenticated', message: 'Renter authentication required', statusCode: 401 }) + } + + try { + const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string } + if (payload.type !== 'renter') { + return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 }) + } + + const renter = await prisma.renter.findUnique({ where: { id: payload.sub } }) + if (!renter || !renter.isActive) { + return res.status(401).json({ error: 'unauthenticated', message: 'Renter account not found or inactive', statusCode: 401 }) + } + + req.renterId = renter.id + next() + } catch { + return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 }) + } +} + +export async function optionalRenterAuth(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null + + if (!token) return next() + + try { + const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string } + if (payload.type === 'renter') { + req.renterId = payload.sub + } + } catch { + // Optional — ignore invalid tokens + } + + next() +} diff --git a/apps/api/src/middleware/requireRole.ts b/apps/api/src/middleware/requireRole.ts new file mode 100644 index 0000000..38defb4 --- /dev/null +++ b/apps/api/src/middleware/requireRole.ts @@ -0,0 +1,32 @@ +import { Request, Response, NextFunction } from 'express' +import { EmployeeRole } from '@rentaldrivego/database' + +const ROLE_RANK: Record = { + OWNER: 3, + MANAGER: 2, + AGENT: 1, +} + +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] ?? 0 + const requiredRank = ROLE_RANK[minimumRole] ?? 99 + + if (employeeRank < requiredRank) { + return res.status(403).json({ + error: 'forbidden', + message: `This action requires the ${minimumRole} role or higher`, + statusCode: 403, + requiredRole: minimumRole, + yourRole: employee.role, + }) + } + + next() + } +} diff --git a/apps/api/src/middleware/requireSubscription.ts b/apps/api/src/middleware/requireSubscription.ts new file mode 100644 index 0000000..7d380ee --- /dev/null +++ b/apps/api/src/middleware/requireSubscription.ts @@ -0,0 +1,24 @@ +import { Request, Response, NextFunction } from 'express' + +const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING'] + +export function requireSubscription(req: Request, res: Response, next: NextFunction) { + const company = req.company + if (!company) { + return res.status(401).json({ error: 'unauthenticated', message: 'No company context', statusCode: 401 }) + } + + if (BLOCKED_STATUSES.includes(company.status)) { + return res.status(402).json({ + error: 'subscription_' + company.status.toLowerCase(), + message: + company.status === 'SUSPENDED' + ? 'Your account has been suspended. Please contact support or renew your subscription.' + : 'Your account is pending activation. Please complete your subscription setup.', + statusCode: 402, + billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing`, + }) + } + + next() +} diff --git a/apps/api/src/middleware/requireTenant.ts b/apps/api/src/middleware/requireTenant.ts new file mode 100644 index 0000000..14482a2 --- /dev/null +++ b/apps/api/src/middleware/requireTenant.ts @@ -0,0 +1,24 @@ +import { Request, Response, NextFunction } from 'express' +import { prisma } from '../lib/prisma' + +export async function requireTenant(req: Request, res: Response, next: NextFunction) { + if (!req.companyId) { + return res.status(401).json({ + error: 'unauthenticated', + message: 'Tenant context missing — requireCompanyAuth must run first', + statusCode: 401, + }) + } + + const company = await prisma.company.findUnique({ where: { id: req.companyId } }) + if (!company) { + return res.status(401).json({ + error: 'company_not_found', + message: 'Company not found', + statusCode: 401, + }) + } + + req.company = company + next() +} diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts new file mode 100644 index 0000000..a46808c --- /dev/null +++ b/apps/api/src/routes/admin.ts @@ -0,0 +1,219 @@ +import { Router } from 'express' +import { z } from 'zod' +import bcrypt from 'bcryptjs' +import jwt from 'jsonwebtoken' +import { authenticator } from 'otplib' +import qrcode from 'qrcode' +import { prisma } from '../lib/prisma' +import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAuth' + +const router = Router() + +function signAdminToken(adminId: string) { + return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' }) +} + +// ─── Auth ───────────────────────────────────────────────────── + +router.post('/auth/login', async (req, res, next) => { + try { + const { email, password, totpCode } = z.object({ email: z.string().email(), password: z.string(), totpCode: z.string().optional() }).parse(req.body) + const admin = await prisma.adminUser.findUnique({ where: { email } }) + if (!admin || !admin.isActive) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) + + const valid = await bcrypt.compare(password, admin.passwordHash) + if (!valid) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) + + if (admin.totpEnabled) { + if (!totpCode) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 }) + const valid2fa = authenticator.verify({ token: totpCode, secret: admin.totpSecret! }) + if (!valid2fa) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 }) + } + + await prisma.adminUser.update({ where: { id: admin.id }, data: { lastLoginAt: new Date(), lastLoginIp: req.ip } }) + const token = signAdminToken(admin.id) + + await prisma.auditLog.create({ data: { adminUserId: admin.id, action: 'ADMIN_LOGIN', resource: 'AdminUser', resourceId: admin.id, ipAddress: req.ip, userAgent: req.headers['user-agent'] } }) + + res.json({ data: { token, admin: { id: admin.id, email: admin.email, firstName: admin.firstName, lastName: admin.lastName, role: admin.role, totpEnabled: admin.totpEnabled } } }) + } catch (err) { next(err) } +}) + +router.get('/auth/me', requireAdminAuth, (req, res) => { + const { passwordHash, totpSecret, ...safe } = req.admin as any + res.json({ data: safe }) +}) + +router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => { + try { + const secret = authenticator.generateSecret() + await prisma.adminUser.update({ where: { id: req.admin.id }, data: { totpSecret: secret } }) + const otpauth = authenticator.keyuri(req.admin.email, 'RentalDriveGo Admin', secret) + const qr = await qrcode.toDataURL(otpauth) + res.json({ data: { secret, qrCode: qr } }) + } catch (err) { next(err) } +}) + +router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => { + try { + const { code } = z.object({ code: z.string().length(6) }).parse(req.body) + const admin = await prisma.adminUser.findUniqueOrThrow({ where: { id: req.admin.id } }) + if (!admin.totpSecret) return res.status(400).json({ error: 'no_totp_secret', message: '2FA setup not initiated', statusCode: 400 }) + const valid = authenticator.verify({ token: code, secret: admin.totpSecret }) + if (!valid) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 }) + await prisma.adminUser.update({ where: { id: admin.id }, data: { totpEnabled: true } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +// ─── Companies ──────────────────────────────────────────────── + +router.get('/companies', requireAdminAuth, async (req, res, next) => { + try { + const { q, status, plan, page = '1', pageSize = '20' } = req.query as Record + const where: any = {} + if (status) where.status = status + if (q) where.OR = [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }, { slug: { contains: q, mode: 'insensitive' } }] + if (plan) where.subscription = { plan } + + const [companies, total] = await Promise.all([ + prisma.company.findMany({ + where, + include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true } }, subscription: { select: { plan: true, status: true } }, _count: { select: { employees: true, vehicles: true } } }, + skip: (parseInt(page) - 1) * parseInt(pageSize), + take: parseInt(pageSize), + orderBy: { createdAt: 'desc' }, + }), + prisma.company.count({ where }), + ]) + res.json({ data: companies, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) }) + } catch (err) { next(err) } +}) + +router.get('/companies/:id', requireAdminAuth, async (req, res, next) => { + try { + const company = await prisma.company.findUniqueOrThrow({ where: { id: req.params.id }, include: { brand: true, subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } }, employees: true } }) + res.json({ data: company }) + } catch (err) { next(err) } +}) + +router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + const { status, reason } = z.object({ status: z.enum(['ACTIVE','SUSPENDED','CANCELLED']), reason: z.string().optional() }).parse(req.body) + const before = await prisma.company.findUniqueOrThrow({ where: { id: req.params.id } }) + const updated = await prisma.company.update({ where: { id: req.params.id }, data: { status } }) + await prisma.auditLog.create({ data: { adminUserId: req.admin.id, action: `SET_COMPANY_STATUS_${status}`, resource: 'Company', resourceId: req.params.id, companyId: req.params.id, before: { status: before.status }, after: { status }, note: reason, ipAddress: req.ip } }) + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { + try { + await prisma.company.delete({ where: { id: req.params.id } }) + await prisma.auditLog.create({ data: { adminUserId: req.admin.id, action: 'DELETE_COMPANY', resource: 'Company', resourceId: req.params.id, ipAddress: req.ip } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +// ─── Impersonation ──────────────────────────────────────────── + +router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { + try { + const company = await prisma.company.findUniqueOrThrow({ where: { id: req.params.id }, include: { employees: { where: { role: 'OWNER' } } } }) + const token = jwt.sign({ companyId: company.id, employeeId: company.employees[0]?.id, isImpersonation: true }, process.env.JWT_SECRET!, { expiresIn: '30m' }) + await prisma.auditLog.create({ data: { adminUserId: req.admin.id, action: 'IMPERSONATE_COMPANY', resource: 'Company', resourceId: company.id, companyId: company.id, ipAddress: req.ip } }) + res.json({ data: { token, expiresIn: 1800 } }) + } catch (err) { next(err) } +}) + +// ─── Renters ────────────────────────────────────────────────── + +router.get('/renters', requireAdminAuth, async (req, res, next) => { + try { + const { q, blocked, page = '1', pageSize = '20' } = req.query as Record + const where: any = {} + if (blocked !== undefined) where.isActive = blocked === 'false' + if (q) where.OR = [{ firstName: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] + const [renters, total] = await Promise.all([ + prisma.renter.findMany({ where, select: { id: true, firstName: true, lastName: true, email: true, phone: true, isActive: true, createdAt: true, _count: { select: { reservations: true } } }, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }), + prisma.renter.count({ where }), + ]) + res.json({ data: renters, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) }) + } catch (err) { next(err) } +}) + +router.post('/renters/:id/block', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + await prisma.renter.update({ where: { id: req.params.id }, data: { isActive: false } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.post('/renters/:id/unblock', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + await prisma.renter.update({ where: { id: req.params.id }, data: { isActive: true } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +// ─── Metrics ────────────────────────────────────────────────── + +router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const [totalCompanies, activeCompanies, trialingCompanies, suspendedCompanies, totalRenters, totalReservations] = await Promise.all([ + prisma.company.count(), + prisma.company.count({ where: { status: 'ACTIVE' } }), + prisma.company.count({ where: { status: 'TRIALING' } }), + prisma.company.count({ where: { status: 'SUSPENDED' } }), + prisma.renter.count(), + prisma.reservation.count(), + ]) + res.json({ data: { totalCompanies, activeCompanies, trialingCompanies, suspendedCompanies, totalRenters, totalReservations } }) + } catch (err) { next(err) } +}) + +// ─── Audit Log ──────────────────────────────────────────────── + +router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { + try { + const { adminId, action, companyId, page = '1', pageSize = '50' } = req.query as Record + const where: any = {} + if (adminId) where.adminUserId = adminId + if (action) where.action = { contains: action } + if (companyId) where.companyId = companyId + const [logs, total] = await Promise.all([ + prisma.auditLog.findMany({ where, include: { adminUser: { select: { firstName: true, lastName: true, email: true } } }, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }), + prisma.auditLog.count({ where }), + ]) + res.json({ data: logs, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) }) + } catch (err) { next(err) } +}) + +// ─── Admin Users (SUPER_ADMIN only) ─────────────────────────── + +router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => { + try { + const admins = await prisma.adminUser.findMany({ select: { id: true, email: true, firstName: true, lastName: true, role: true, isActive: true, totpEnabled: true, lastLoginAt: true, createdAt: true } }) + res.json({ data: admins }) + } catch (err) { next(err) } +}) + +router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => { + try { + const body = z.object({ email: z.string().email(), firstName: z.string(), lastName: z.string(), role: z.enum(['ADMIN','SUPPORT','FINANCE','VIEWER']), password: z.string().min(8) }).parse(req.body) + const passwordHash = await bcrypt.hash(body.password, 12) + const admin = await prisma.adminUser.create({ data: { ...body, passwordHash, password: undefined } as any }) + const { passwordHash: _, ...safe } = admin + res.status(201).json({ data: safe }) + } catch (err) { next(err) } +}) + +router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => { + try { + const { role } = z.object({ role: z.enum(['ADMIN','SUPPORT','FINANCE','VIEWER']) }).parse(req.body) + await prisma.adminUser.update({ where: { id: req.params.id }, data: { role } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts new file mode 100644 index 0000000..b67d86a --- /dev/null +++ b/apps/api/src/routes/analytics.ts @@ -0,0 +1,52 @@ +import { Router } from 'express' +import { prisma } from '../lib/prisma' +import { requireCompanyAuth } from '../middleware/requireCompanyAuth' +import { requireTenant } from '../middleware/requireTenant' +import { requireSubscription } from '../middleware/requireSubscription' +import { generateFinancialReport, toCsv } from '../services/financialReportService' + +const router = Router() +router.use(requireCompanyAuth, requireTenant, requireSubscription) + +router.get('/summary', async (req, res, next) => { + try { + const { period = '30d' } = req.query as Record + const days = parseInt(period.replace('d', '')) || 30 + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + + const [totalReservations, activeVehicles, totalRevenue, totalCustomers] = await Promise.all([ + prisma.reservation.count({ where: { companyId: req.companyId, createdAt: { gte: since } } }), + prisma.vehicle.count({ where: { companyId: req.companyId, status: 'AVAILABLE' } }), + prisma.reservation.aggregate({ where: { companyId: req.companyId, status: { in: ['COMPLETED'] }, createdAt: { gte: since } }, _sum: { totalAmount: true } }), + prisma.customer.count({ where: { companyId: req.companyId } }), + ]) + + res.json({ data: { totalReservations, activeVehicles, totalRevenue: totalRevenue._sum.totalAmount ?? 0, totalCustomers, period } }) + } catch (err) { next(err) } +}) + +router.get('/sources', async (req, res, next) => { + try { + const sources = await prisma.reservation.groupBy({ by: ['source'], where: { companyId: req.companyId }, _count: { id: true } }) + res.json({ data: sources }) + } catch (err) { next(err) } +}) + +router.get('/report', async (req, res, next) => { + try { + const { from, to, format = 'JSON' } = req.query as Record + if (!from || !to) return res.status(400).json({ error: 'missing_params', message: 'from and to date params required', statusCode: 400 }) + + const report = await generateFinancialReport(req.companyId, new Date(from), new Date(to)) + + if (format === 'CSV') { + res.setHeader('Content-Type', 'text/csv') + res.setHeader('Content-Disposition', `attachment; filename="report-${from}-${to}.csv"`) + return res.send(toCsv(report.rows)) + } + + res.json({ data: report }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/auth.renter.ts b/apps/api/src/routes/auth.renter.ts new file mode 100644 index 0000000..c5cf024 --- /dev/null +++ b/apps/api/src/routes/auth.renter.ts @@ -0,0 +1,83 @@ +import { Router } from 'express' +import { z } from 'zod' +import bcrypt from 'bcryptjs' +import jwt from 'jsonwebtoken' +import { prisma } from '../lib/prisma' +import { requireRenterAuth } from '../middleware/requireRenterAuth' + +const router = Router() + +function signRenterToken(renterId: string) { + return jwt.sign({ sub: renterId, type: 'renter' }, process.env.JWT_SECRET!, { + expiresIn: process.env.RENTER_JWT_EXPIRY ?? '7d', + }) +} + +router.post('/signup', async (req, res, next) => { + try { + const body = z.object({ + firstName: z.string().min(1), + lastName: z.string().min(1), + email: z.string().email(), + password: z.string().min(8), + phone: z.string().optional(), + }).parse(req.body) + + const existing = await prisma.renter.findUnique({ where: { email: body.email } }) + if (existing) return res.status(409).json({ error: 'email_taken', message: 'Email already in use', statusCode: 409 }) + + const passwordHash = await bcrypt.hash(body.password, 12) + const renter = await prisma.renter.create({ + data: { firstName: body.firstName, lastName: body.lastName, email: body.email, passwordHash, phone: body.phone ?? null }, + }) + + const token = signRenterToken(renter.id) + res.status(201).json({ data: { token, renter: { id: renter.id, email: renter.email, firstName: renter.firstName, lastName: renter.lastName } } }) + } catch (err) { next(err) } +}) + +router.post('/login', async (req, res, next) => { + try { + const body = z.object({ email: z.string().email(), password: z.string() }).parse(req.body) + const renter = await prisma.renter.findUnique({ where: { email: body.email } }) + if (!renter || !renter.isActive) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) + + const valid = await bcrypt.compare(body.password, renter.passwordHash) + if (!valid) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) + + const token = signRenterToken(renter.id) + res.json({ data: { token, renter: { id: renter.id, email: renter.email, firstName: renter.firstName, lastName: renter.lastName } } }) + } catch (err) { next(err) } +}) + +router.get('/me', requireRenterAuth, async (req, res, next) => { + try { + const renter = await prisma.renter.findUniqueOrThrow({ where: { id: req.renterId }, select: { id: true, firstName: true, lastName: true, email: true, phone: true, preferredLocale: true, preferredCurrency: true, emailVerified: true } }) + res.json({ data: renter }) + } catch (err) { next(err) } +}) + +router.patch('/me', requireRenterAuth, async (req, res, next) => { + try { + const body = z.object({ + firstName: z.string().min(1).optional(), + lastName: z.string().min(1).optional(), + phone: z.string().optional(), + preferredLocale: z.enum(['en', 'fr', 'ar']).optional(), + preferredCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(), + }).parse(req.body) + + const renter = await prisma.renter.update({ where: { id: req.renterId }, data: body }) + res.json({ data: renter }) + } catch (err) { next(err) } +}) + +router.post('/me/fcm-token', requireRenterAuth, async (req, res, next) => { + try { + const { fcmToken } = z.object({ fcmToken: z.string() }).parse(req.body) + await prisma.renter.update({ where: { id: req.renterId }, data: { fcmToken } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/customers.ts b/apps/api/src/routes/customers.ts new file mode 100644 index 0000000..756628b --- /dev/null +++ b/apps/api/src/routes/customers.ts @@ -0,0 +1,125 @@ +import { Router } from 'express' +import { z } from 'zod' +import { prisma } from '../lib/prisma' +import { requireCompanyAuth } from '../middleware/requireCompanyAuth' +import { requireTenant } from '../middleware/requireTenant' +import { requireSubscription } from '../middleware/requireSubscription' +import { requireRole } from '../middleware/requireRole' +import { validateAndFlagLicense } from '../services/licenseValidationService' + +const router = Router() +router.use(requireCompanyAuth, requireTenant, requireSubscription) + +const customerSchema = z.object({ + firstName: z.string().min(1), + lastName: z.string().min(1), + email: z.string().email(), + phone: z.string().optional(), + driverLicense: z.string().optional(), + dateOfBirth: z.string().datetime().optional(), + nationality: z.string().optional(), + address: z.record(z.unknown()).optional(), + notes: z.string().optional(), + licenseExpiry: z.string().datetime().optional(), + licenseIssuedAt: z.string().datetime().optional(), + licenseCountry: z.string().optional(), + licenseNumber: z.string().optional(), + licenseCategory: z.string().optional(), +}) + +router.get('/', async (req, res, next) => { + try { + const { q, flagged, page = '1', pageSize = '20' } = req.query as Record + const where: any = { companyId: req.companyId } + if (flagged !== undefined) where.flagged = flagged === 'true' + if (q) where.OR = [{ firstName: { contains: q, mode: 'insensitive' } }, { lastName: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] + + const [customers, total] = await Promise.all([ + prisma.customer.findMany({ where, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }), + prisma.customer.count({ where }), + ]) + res.json({ data: customers, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) }) + } catch (err) { next(err) } +}) + +router.post('/', async (req, res, next) => { + try { + const body = customerSchema.parse(req.body) + const customer = await prisma.customer.create({ + data: { + ...body, + companyId: req.companyId, + dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null, + licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null, + licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null, + }, + }) + if (body.licenseExpiry) { + await validateAndFlagLicense(customer.id).catch(() => null) + } + res.status(201).json({ data: customer }) + } catch (err) { next(err) } +}) + +router.get('/:id', async (req, res, next) => { + try { + const customer = await prisma.customer.findFirstOrThrow({ + where: { id: req.params.id, companyId: req.companyId }, + include: { reservations: { include: { vehicle: true }, orderBy: { createdAt: 'desc' }, take: 20 } }, + }) + res.json({ data: customer }) + } catch (err) { next(err) } +}) + +router.patch('/:id', async (req, res, next) => { + try { + const body = customerSchema.partial().parse(req.body) + const updated = await prisma.customer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { ...body, dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : undefined, licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : undefined, licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : undefined } }) + if (updated.count === 0) return res.status(404).json({ error: 'not_found', message: 'Customer not found', statusCode: 404 }) + if (body.licenseExpiry) await validateAndFlagLicense(req.params.id).catch(() => null) + const customer = await prisma.customer.findUniqueOrThrow({ where: { id: req.params.id } }) + res.json({ data: customer }) + } catch (err) { next(err) } +}) + +router.post('/:id/flag', async (req, res, next) => { + try { + const { reason } = z.object({ reason: z.string().optional() }).parse(req.body) + await prisma.customer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { flagged: true, flagReason: reason ?? null } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.delete('/:id/flag', async (req, res, next) => { + try { + await prisma.customer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { flagged: false, flagReason: null } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.post('/:id/validate-license', async (req, res, next) => { + try { + const customer = await prisma.customer.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + const result = await validateAndFlagLicense(customer.id) + res.json({ data: result }) + } catch (err) { next(err) } +}) + +router.post('/:id/approve-license', requireRole('MANAGER'), async (req, res, next) => { + try { + const { decision, note } = z.object({ decision: z.enum(['APPROVE', 'DENY']), note: z.string().optional() }).parse(req.body) + const customer = await prisma.customer.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + const updated = await prisma.customer.update({ + where: { id: customer.id }, + data: { + licenseValidationStatus: decision === 'APPROVE' ? 'APPROVED' : 'DENIED', + licenseApprovedBy: `${req.employee.firstName} ${req.employee.lastName}`, + licenseApprovedAt: new Date(), + licenseApprovalNote: note ?? null, + }, + }) + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/marketplace.ts b/apps/api/src/routes/marketplace.ts new file mode 100644 index 0000000..719962b --- /dev/null +++ b/apps/api/src/routes/marketplace.ts @@ -0,0 +1,101 @@ +import { Router } from 'express' +import { prisma } from '../lib/prisma' +import { optionalRenterAuth } from '../middleware/requireRenterAuth' + +const router = Router() +router.use(optionalRenterAuth) + +router.get('/offers', async (req, res, next) => { + try { + const offers = await prisma.offer.findMany({ + where: { isPublic: true, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } }, + include: { company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, primaryColor: true } } } } }, + orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }], + take: 50, + }) + res.json({ data: offers }) + } catch (err) { next(err) } +}) + +router.get('/companies', async (req, res, next) => { + try { + const { city, hasOffer, page = '1', pageSize = '20' } = req.query as Record + const where: any = { status: 'ACTIVE', brand: { isListedOnMarketplace: true } } + if (city) where.brand = { ...where.brand, publicCity: { contains: city, mode: 'insensitive' } } + + const companies = await prisma.company.findMany({ + where, + include: { + brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } }, + _count: { select: { vehicles: { where: { isPublished: true } } } }, + }, + skip: (parseInt(page) - 1) * parseInt(pageSize), + take: parseInt(pageSize), + }) + res.json({ data: companies }) + } catch (err) { next(err) } +}) + +router.get('/search', async (req, res, next) => { + try { + const { city, startDate, endDate, category, maxPrice, page = '1', pageSize = '20' } = req.query as Record + const where: any = { isPublished: true, company: { status: 'ACTIVE', brand: { isListedOnMarketplace: true } } } + if (category) where.category = category + if (maxPrice) where.dailyRate = { lte: parseInt(maxPrice) } + + const vehicles = await prisma.vehicle.findMany({ + where, + include: { + company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } }, + }, + skip: (parseInt(page) - 1) * parseInt(pageSize), + take: parseInt(pageSize), + orderBy: { dailyRate: 'asc' }, + }) + + res.json({ data: vehicles }) + } catch (err) { next(err) } +}) + +router.get('/:slug', async (req, res, next) => { + try { + const company = await prisma.company.findFirst({ + where: { slug: req.params.slug, status: 'ACTIVE' }, + include: { + brand: true, + vehicles: { where: { isPublished: true }, orderBy: { createdAt: 'desc' } }, + offers: { where: { isPublic: true, isActive: true, validUntil: { gte: new Date() } }, orderBy: { isFeatured: 'desc' } }, + }, + }) + if (!company) return res.status(404).json({ error: 'not_found', message: 'Company not found', statusCode: 404 }) + res.json({ data: company }) + } catch (err) { next(err) } +}) + +router.get('/:slug/reviews', async (req, res, next) => { + try { + const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug } }) + const reviews = await prisma.review.findMany({ + where: { companyId: company.id, isPublished: true }, + include: { renter: { select: { firstName: true, lastName: true } } }, + orderBy: { createdAt: 'desc' }, + take: 50, + }) + res.json({ data: reviews }) + } catch (err) { next(err) } +}) + +router.post('/offers/:code/validate', async (req, res, next) => { + try { + const offer = await prisma.offer.findFirst({ + where: { promoCode: req.params.code, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } }, + }) + if (!offer) return res.status(404).json({ error: 'invalid_code', message: 'Promo code not found or expired', statusCode: 404 }) + if (offer.maxRedemptions && offer.redemptionCount >= offer.maxRedemptions) { + return res.status(409).json({ error: 'code_exhausted', message: 'Promo code has reached its maximum redemptions', statusCode: 409 }) + } + res.json({ data: offer }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/notifications.ts b/apps/api/src/routes/notifications.ts new file mode 100644 index 0000000..c76a23f --- /dev/null +++ b/apps/api/src/routes/notifications.ts @@ -0,0 +1,77 @@ +import { Router } from 'express' +import { z } from 'zod' +import { prisma } from '../lib/prisma' +import { requireCompanyAuth } from '../middleware/requireCompanyAuth' +import { requireTenant } from '../middleware/requireTenant' +import { requireSubscription } from '../middleware/requireSubscription' +import { requireRenterAuth } from '../middleware/requireRenterAuth' +import { NotificationType, NotificationChannel } from '@rentaldrivego/database' + +const router = Router() + +// ─── Company notifications ──────────────────────────────────── + +const companyAuth = [requireCompanyAuth, requireTenant, requireSubscription] + +router.get('/company', ...companyAuth, async (req: any, res: any, next: any) => { + try { + const { unread } = req.query as Record + const where: any = { companyId: req.companyId, channel: 'IN_APP' } + if (unread === 'true') where.readAt = null + const notifications = await prisma.notification.findMany({ where, orderBy: { createdAt: 'desc' }, take: 50 }) + res.json({ data: notifications }) + } catch (err) { next(err) } +}) + +router.post('/company/:id/read', ...companyAuth, async (req: any, res: any, next: any) => { + try { + await prisma.notification.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { readAt: new Date(), status: 'READ' } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.post('/company/read-all', ...companyAuth, async (req: any, res: any, next: any) => { + try { + await prisma.notification.updateMany({ where: { companyId: req.companyId, readAt: null }, data: { readAt: new Date(), status: 'READ' } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.get('/company/preferences', ...companyAuth, async (req: any, res: any, next: any) => { + try { + const prefs = await prisma.notificationPreference.findMany({ where: { employeeId: req.employee.id } }) + res.json({ data: prefs }) + } catch (err) { next(err) } +}) + +router.patch('/company/preferences', ...companyAuth, async (req: any, res: any, next: any) => { + try { + const body = z.array(z.object({ notificationType: z.string(), channel: z.string(), enabled: z.boolean() })).parse(req.body) + for (const pref of body) { + await prisma.notificationPreference.upsert({ + where: { employeeId_notificationType_channel: { employeeId: req.employee.id, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel } }, + create: { employeeId: req.employee.id, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel, enabled: pref.enabled }, + update: { enabled: pref.enabled }, + }) + } + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +// ─── Renter notifications ───────────────────────────────────── + +router.get('/renter', requireRenterAuth, async (req: any, res: any, next: any) => { + try { + const notifications = await prisma.notification.findMany({ where: { renterId: req.renterId, channel: 'IN_APP' }, orderBy: { createdAt: 'desc' }, take: 50 }) + res.json({ data: notifications }) + } catch (err) { next(err) } +}) + +router.post('/renter/:id/read', requireRenterAuth, async (req: any, res: any, next: any) => { + try { + await prisma.notification.updateMany({ where: { id: req.params.id, renterId: req.renterId }, data: { readAt: new Date(), status: 'READ' } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/offers.ts b/apps/api/src/routes/offers.ts new file mode 100644 index 0000000..19c0df1 --- /dev/null +++ b/apps/api/src/routes/offers.ts @@ -0,0 +1,100 @@ +import { Router } from 'express' +import { z } from 'zod' +import { prisma } from '../lib/prisma' +import { requireCompanyAuth } from '../middleware/requireCompanyAuth' +import { requireTenant } from '../middleware/requireTenant' +import { requireSubscription } from '../middleware/requireSubscription' + +const router = Router() +router.use(requireCompanyAuth, requireTenant, requireSubscription) + +const offerSchema = z.object({ + title: z.string().min(1), + description: z.string().optional(), + termsAndConds: z.string().optional(), + type: z.enum(['PERCENTAGE','FIXED_AMOUNT','FREE_DAY','SPECIAL_RATE']), + discountValue: z.number().int().min(0), + specialRate: z.number().int().optional(), + appliesToAll: z.boolean().default(true), + categories: z.array(z.enum(['ECONOMY','COMPACT','MIDSIZE','FULLSIZE','SUV','LUXURY','VAN','TRUCK'])).default([]), + minRentalDays: z.number().int().optional(), + maxRentalDays: z.number().int().optional(), + promoCode: z.string().optional(), + maxRedemptions: z.number().int().optional(), + validFrom: z.string().datetime(), + validUntil: z.string().datetime(), + isActive: z.boolean().default(true), + isPublic: z.boolean().default(true), + isFeatured: z.boolean().default(false), + vehicleIds: z.array(z.string()).default([]), +}) + +router.get('/', async (req, res, next) => { + try { + const { active, public: pub } = req.query as Record + const where: any = { companyId: req.companyId } + if (active !== undefined) where.isActive = active === 'true' + if (pub !== undefined) where.isPublic = pub === 'true' + const offers = await prisma.offer.findMany({ where, orderBy: { createdAt: 'desc' } }) + res.json({ data: offers }) + } catch (err) { next(err) } +}) + +router.post('/', async (req, res, next) => { + try { + const { vehicleIds, ...body } = offerSchema.parse(req.body) + const offer = await prisma.offer.create({ + data: { ...body, companyId: req.companyId, validFrom: new Date(body.validFrom), validUntil: new Date(body.validUntil), vehicles: vehicleIds.length > 0 ? { create: vehicleIds.map((id) => ({ vehicleId: id })) } : undefined }, + include: { vehicles: true }, + }) + res.status(201).json({ data: offer }) + } catch (err) { next(err) } +}) + +router.get('/:id', async (req, res, next) => { + try { + const offer = await prisma.offer.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, include: { vehicles: true } }) + res.json({ data: offer }) + } catch (err) { next(err) } +}) + +router.patch('/:id', async (req, res, next) => { + try { + const { vehicleIds, ...body } = offerSchema.partial().parse(req.body) + const offer = await prisma.offer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { ...body, validFrom: body.validFrom ? new Date(body.validFrom) : undefined, validUntil: body.validUntil ? new Date(body.validUntil) : undefined } }) + if (offer.count === 0) return res.status(404).json({ error: 'not_found', message: 'Offer not found', statusCode: 404 }) + const updated = await prisma.offer.findUniqueOrThrow({ where: { id: req.params.id }, include: { vehicles: true } }) + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.delete('/:id', async (req, res, next) => { + try { + await prisma.offer.deleteMany({ where: { id: req.params.id, companyId: req.companyId } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.post('/:id/activate', async (req, res, next) => { + try { + await prisma.offer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { isActive: true } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.post('/:id/deactivate', async (req, res, next) => { + try { + await prisma.offer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { isActive: false } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.get('/:id/stats', async (req, res, next) => { + try { + const offer = await prisma.offer.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + const reservations = await prisma.reservation.count({ where: { offerId: offer.id } }) + res.json({ data: { redemptions: offer.redemptionCount, reservations } }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/reservations.ts b/apps/api/src/routes/reservations.ts new file mode 100644 index 0000000..23df1ff --- /dev/null +++ b/apps/api/src/routes/reservations.ts @@ -0,0 +1,215 @@ +import { Router } from 'express' +import { z } from 'zod' +import { prisma } from '../lib/prisma' +import { requireCompanyAuth } from '../middleware/requireCompanyAuth' +import { requireTenant } from '../middleware/requireTenant' +import { requireSubscription } from '../middleware/requireSubscription' +import { applyInsurancesToReservation } from '../services/insuranceService' +import { applyPricingRules } from '../services/pricingRuleService' +import { validateAndFlagLicense } from '../services/licenseValidationService' +import { sendNotification } from '../services/notificationService' + +const router = Router() +router.use(requireCompanyAuth, requireTenant, requireSubscription) + +const createSchema = z.object({ + vehicleId: z.string().cuid(), + customerId: z.string().cuid(), + startDate: z.string().datetime(), + endDate: z.string().datetime(), + pickupLocation: z.string().optional(), + returnLocation: z.string().optional(), + offerId: z.string().cuid().optional(), + promoCodeUsed: z.string().optional(), + depositAmount: z.number().int().min(0).default(0), + notes: z.string().optional(), + selectedInsurancePolicyIds: z.array(z.string()).default([]), +}) + +router.get('/', async (req, res, next) => { + try { + const { status, vehicleId, source, startDate, endDate, page = '1', pageSize = '20' } = req.query as Record + const where: any = { companyId: req.companyId } + if (status) where.status = status + if (vehicleId) where.vehicleId = vehicleId + if (source) where.source = source + if (startDate) where.startDate = { gte: new Date(startDate) } + if (endDate) where.endDate = { lte: new Date(endDate) } + + const [reservations, total] = await Promise.all([ + prisma.reservation.findMany({ + where, + include: { vehicle: true, customer: true }, + skip: (parseInt(page) - 1) * parseInt(pageSize), + take: parseInt(pageSize), + orderBy: { createdAt: 'desc' }, + }), + prisma.reservation.count({ where }), + ]) + + res.json({ data: reservations, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) }) + } catch (err) { next(err) } +}) + +router.post('/', async (req, res, next) => { + try { + const body = createSchema.parse(req.body) + + // Validate vehicle belongs to this company + const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: body.vehicleId, companyId: req.companyId } }) + const customer = await prisma.customer.findFirstOrThrow({ where: { id: body.customerId, companyId: req.companyId } }) + + const start = new Date(body.startDate) + const end = new Date(body.endDate) + const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + + // Check availability + const conflict = await prisma.reservation.findFirst({ + where: { vehicleId: body.vehicleId, status: { in: ['CONFIRMED', 'ACTIVE'] }, startDate: { lt: end }, endDate: { gt: start } }, + }) + if (conflict) return res.status(409).json({ error: 'vehicle_unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 }) + + let discountAmount = 0 + let offerId: string | null = body.offerId ?? null + + if (body.promoCodeUsed) { + const offer = await prisma.offer.findFirst({ + where: { companyId: req.companyId, promoCode: body.promoCodeUsed, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } }, + }) + if (offer) { + offerId = offer.id + const base = vehicle.dailyRate * totalDays + if (offer.type === 'PERCENTAGE') discountAmount = Math.round(base * offer.discountValue / 100) + else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue + else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue + await prisma.offer.update({ where: { id: offer.id }, data: { redemptionCount: { increment: 1 } } }) + } + } + + const baseAmount = vehicle.dailyRate * totalDays + const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, [], vehicle.dailyRate, totalDays) + const totalAmount = baseAmount - discountAmount + pricingTotal + body.depositAmount + + const reservation = await prisma.reservation.create({ + data: { + companyId: req.companyId, + vehicleId: body.vehicleId, + customerId: body.customerId, + startDate: start, + endDate: end, + pickupLocation: body.pickupLocation ?? null, + returnLocation: body.returnLocation ?? null, + offerId, + promoCodeUsed: body.promoCodeUsed ?? null, + source: 'DASHBOARD', + dailyRate: vehicle.dailyRate, + discountAmount, + totalDays, + totalAmount, + depositAmount: body.depositAmount, + notes: body.notes ?? null, + pricingRulesApplied: applied, + pricingRulesTotal: pricingTotal, + }, + include: { vehicle: true, customer: true }, + }) + + if (body.selectedInsurancePolicyIds.length > 0) { + await applyInsurancesToReservation(reservation.id, req.companyId, body.selectedInsurancePolicyIds, totalDays, baseAmount) + } + + // Validate customer license + await validateAndFlagLicense(body.customerId).catch(() => null) + + res.status(201).json({ data: reservation }) + } catch (err) { next(err) } +}) + +router.get('/:id', async (req, res, next) => { + try { + const reservation = await prisma.reservation.findFirstOrThrow({ + where: { id: req.params.id, companyId: req.companyId }, + include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true }, + }) + res.json({ data: reservation }) + } catch (err) { next(err) } +}) + +router.post('/:id/confirm', async (req, res, next) => { + try { + const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + if (reservation.status !== 'DRAFT') return res.status(400).json({ error: 'invalid_status', message: 'Only DRAFT reservations can be confirmed', statusCode: 400 }) + + const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' } }) + + // Notify + const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } }) + await sendNotification({ type: 'BOOKING_CONFIRMED', title: 'Booking Confirmed', body: `Your booking has been confirmed.`, companyId: req.companyId, email: customer.email, channels: ['EMAIL', 'IN_APP'] }).catch(() => null) + + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.post('/:id/checkin', async (req, res, next) => { + try { + const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body) + const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + if (reservation.status !== 'CONFIRMED') return res.status(400).json({ error: 'invalid_status', message: 'Only CONFIRMED reservations can be checked in', statusCode: 400 }) + const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'ACTIVE', checkedInAt: new Date(), checkInMileage: mileage ?? null } }) + await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'RENTED' } }) + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.post('/:id/checkout', async (req, res, next) => { + try { + const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body) + const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + if (reservation.status !== 'ACTIVE') return res.status(400).json({ error: 'invalid_status', message: 'Only ACTIVE reservations can be checked out', statusCode: 400 }) + const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null } }) + await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE', mileage: mileage ?? undefined } }) + + const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } }) + await sendNotification({ type: 'REVIEW_REQUEST', title: 'How was your rental?', body: 'Please leave a review for your recent rental.', companyId: req.companyId, email: customer.email, channels: ['EMAIL'] }).catch(() => null) + + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.post('/:id/cancel', async (req, res, next) => { + try { + const { reason } = z.object({ reason: z.string().optional() }).parse(req.body) + const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + if (['COMPLETED', 'CANCELLED'].includes(reservation.status)) { + return res.status(400).json({ error: 'invalid_status', message: 'Reservation cannot be cancelled', statusCode: 400 }) + } + const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CANCELLED', cancelReason: reason ?? null, cancelledBy: 'COMPANY' } }) + if (['CONFIRMED', 'ACTIVE'].includes(reservation.status)) { + await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE' } }) + } + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.get('/:id/billing', async (req, res, next) => { + try { + const reservation = await prisma.reservation.findFirstOrThrow({ + where: { id: req.params.id, companyId: req.companyId }, + include: { vehicle: true, insurances: true, additionalDrivers: true }, + }) + + const baseAmount = reservation.dailyRate * reservation.totalDays + const lineItems = [ + { description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model} — ${reservation.totalDays} day(s)`, qty: reservation.totalDays, unitPrice: reservation.dailyRate, total: baseAmount, category: 'RENTAL' }, + ...reservation.insurances.map((ins) => ({ description: ins.policyName, qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, total: ins.totalCharge, category: 'INSURANCE' })), + ...reservation.additionalDrivers.filter((d) => d.totalCharge > 0).map((d) => ({ description: `Additional Driver — ${d.firstName} ${d.lastName}`, qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER' })), + ...(reservation.depositAmount > 0 ? [{ description: 'Security Deposit (refundable)', qty: 1, unitPrice: reservation.depositAmount, total: reservation.depositAmount, category: 'DEPOSIT' }] : []), + ] + + const grandTotal = lineItems.reduce((s, i) => s + i.total, 0) - reservation.discountAmount + + res.json({ data: { lineItems, discountAmount: reservation.discountAmount, pricingRulesApplied: reservation.pricingRulesApplied, pricingRulesTotal: reservation.pricingRulesTotal, grandTotal } }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/team.ts b/apps/api/src/routes/team.ts new file mode 100644 index 0000000..da9a743 --- /dev/null +++ b/apps/api/src/routes/team.ts @@ -0,0 +1,56 @@ +import { Router } 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() +router.use(requireCompanyAuth, requireTenant, requireSubscription) + +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']), +}) + +router.get('/', async (req, res, next) => { + try { res.json({ data: await listEmployees(req.companyId) }) } catch (err) { next(err) } +}) + +router.get('/stats', async (req, res, next) => { + try { + const members = await listEmployees(req.companyId) + res.json({ data: { 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 } }) + } catch (err) { next(err) } +}) + +router.post('/invite', requireRole('OWNER'), async (req, res, next) => { + try { + const body = inviteSchema.parse(req.body) + res.status(201).json({ data: await inviteEmployee(req.companyId, req.employee.id, body) }) + } catch (err) { next(err) } +}) + +router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => { + try { + const { role } = z.object({ role: z.enum(['MANAGER', 'AGENT']) }).parse(req.body) + res.json({ data: await updateEmployeeRole(req.companyId, req.employee.id, req.employee.role, req.params.id, { role }) }) + } catch (err) { next(err) } +}) + +router.post('/:id/deactivate', requireRole('OWNER'), async (req, res, next) => { + try { res.json({ data: await deactivateEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) } +}) + +router.post('/:id/reactivate', requireRole('OWNER'), async (req, res, next) => { + try { res.json({ data: await reactivateEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) } +}) + +router.delete('/:id', requireRole('OWNER'), async (req, res, next) => { + try { res.json({ data: await removeEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/vehicles.ts b/apps/api/src/routes/vehicles.ts new file mode 100644 index 0000000..2b5ac2c --- /dev/null +++ b/apps/api/src/routes/vehicles.ts @@ -0,0 +1,156 @@ +import { Router } from 'express' +import { z } from 'zod' +import multer from 'multer' +import { prisma } from '../lib/prisma' +import { uploadImage } from '../lib/cloudinary' +import { requireCompanyAuth } from '../middleware/requireCompanyAuth' +import { requireTenant } from '../middleware/requireTenant' +import { requireSubscription } from '../middleware/requireSubscription' + +const router = Router() +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }) + +router.use(requireCompanyAuth, requireTenant, requireSubscription) + +const vehicleSchema = z.object({ + make: z.string().min(1), + model: z.string().min(1), + year: z.number().int().min(1990).max(new Date().getFullYear() + 1), + color: z.string().min(1), + licensePlate: z.string().min(1), + vin: z.string().optional(), + category: z.enum(['ECONOMY','COMPACT','MIDSIZE','FULLSIZE','SUV','LUXURY','VAN','TRUCK']), + seats: z.number().int().min(1).max(20).default(5), + transmission: z.enum(['AUTOMATIC','MANUAL']).default('AUTOMATIC'), + fuelType: z.enum(['GASOLINE','DIESEL','ELECTRIC','HYBRID']).default('GASOLINE'), + features: z.array(z.string()).default([]), + dailyRate: z.number().int().min(0), + mileage: z.number().int().optional(), + notes: z.string().optional(), + isPublished: z.boolean().default(true), +}) + +router.get('/', async (req, res, next) => { + try { + const { status, category, published, page = '1', pageSize = '20' } = req.query as Record + const where: any = { companyId: req.companyId } + if (status) where.status = status + if (category) where.category = category + if (published !== undefined) where.isPublished = published === 'true' + + const [vehicles, total] = await Promise.all([ + prisma.vehicle.findMany({ where, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }), + prisma.vehicle.count({ where }), + ]) + + res.json({ data: vehicles, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) }) + } catch (err) { next(err) } +}) + +router.post('/', async (req, res, next) => { + try { + const body = vehicleSchema.parse(req.body) + const vehicle = await prisma.vehicle.create({ data: { ...body, companyId: req.companyId } }) + res.status(201).json({ data: vehicle }) + } catch (err) { next(err) } +}) + +router.get('/:id', async (req, res, next) => { + try { + const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + res.json({ data: vehicle }) + } catch (err) { next(err) } +}) + +router.patch('/:id', async (req, res, next) => { + try { + const body = vehicleSchema.partial().parse(req.body) + const vehicle = await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: body }) + if (vehicle.count === 0) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 }) + const updated = await prisma.vehicle.findUniqueOrThrow({ where: { id: req.params.id } }) + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.delete('/:id', async (req, res, next) => { + try { + await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { status: 'OUT_OF_SERVICE', isPublished: false } }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.post('/:id/photos', upload.array('photos', 10), async (req, res, next) => { + try { + const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + const files = req.files as Express.Multer.File[] + const urls = await Promise.all(files.map((f) => uploadImage(f.buffer, `vehicles/${req.companyId}`))) + const updated = await prisma.vehicle.update({ where: { id: vehicle.id }, data: { photos: [...vehicle.photos, ...urls] } }) + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.delete('/:id/photos/:idx', async (req, res, next) => { + try { + const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + const idx = parseInt(req.params.idx) + const photos = vehicle.photos.filter((_, i) => i !== idx) + const updated = await prisma.vehicle.update({ where: { id: vehicle.id }, data: { photos } }) + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.patch('/:id/publish', async (req, res, next) => { + try { + const { isPublished } = z.object({ isPublished: z.boolean() }).parse(req.body) + await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { isPublished } }) + res.json({ data: { success: true, isPublished } }) + } catch (err) { next(err) } +}) + +router.get('/:id/availability', async (req, res, next) => { + try { + const { startDate, endDate } = req.query as Record + if (!startDate || !endDate) return res.status(400).json({ error: 'missing_params', message: 'startDate and endDate required', statusCode: 400 }) + + const conflicts = await prisma.reservation.findMany({ + where: { + vehicleId: req.params.id, + companyId: req.companyId, + status: { in: ['CONFIRMED', 'ACTIVE'] }, + startDate: { lt: new Date(endDate) }, + endDate: { gt: new Date(startDate) }, + }, + select: { id: true, startDate: true, endDate: true, status: true }, + }) + + res.json({ data: { available: conflicts.length === 0, conflicts } }) + } catch (err) { next(err) } +}) + +router.get('/:id/maintenance', async (req, res, next) => { + try { + const logs = await prisma.maintenanceLog.findMany({ where: { vehicleId: req.params.id }, orderBy: { performedAt: 'desc' } }) + res.json({ data: logs }) + } catch (err) { next(err) } +}) + +router.post('/:id/maintenance', async (req, res, next) => { + try { + const body = z.object({ + type: z.string().min(1), + description: z.string().optional(), + cost: z.number().int().optional(), + mileage: z.number().int().optional(), + performedAt: z.string().datetime(), + nextDueAt: z.string().datetime().optional(), + nextDueMileage: z.number().int().optional(), + }).parse(req.body) + + // Validate vehicle belongs to company + await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + const log = await prisma.maintenanceLog.create({ data: { ...body, vehicleId: req.params.id, performedAt: new Date(body.performedAt), nextDueAt: body.nextDueAt ? new Date(body.nextDueAt) : undefined } }) + res.status(201).json({ data: log }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts new file mode 100644 index 0000000..64d63b0 --- /dev/null +++ b/apps/api/src/routes/webhooks.ts @@ -0,0 +1,39 @@ +import { Router } from 'express' +import { Webhook } from 'svix' +import { EmployeeRole } from '@rentaldrivego/database' +import { handleInvitationAccepted } from '../services/teamService' + +const router = Router() + +router.post('/clerk', async (req, res) => { + const webhookSecret = process.env.CLERK_WEBHOOK_SECRET + if (!webhookSecret) return res.status(500).json({ error: 'Webhook secret not configured' }) + + const wh = new Webhook(webhookSecret) + let event: any + + try { + event = wh.verify(req.body, { + 'svix-id': req.headers['svix-id'] as string, + 'svix-timestamp': req.headers['svix-timestamp'] as string, + 'svix-signature': req.headers['svix-signature'] as string, + }) + } catch { + return res.status(400).json({ error: 'Invalid webhook signature' }) + } + + if (event.type === 'user.created') { + const clerkUserId: string = event.data.id + const email: string = event.data.email_addresses?.[0]?.email_address ?? '' + const meta = event.data.public_metadata ?? {} + const { companyId, role } = meta as { companyId?: string; role?: EmployeeRole } + + if (companyId && role && email) { + await handleInvitationAccepted(clerkUserId, email, companyId, role).catch(console.error) + } + } + + res.json({ received: true }) +}) + +export default router diff --git a/apps/api/src/services/financialReportService.ts b/apps/api/src/services/financialReportService.ts new file mode 100644 index 0000000..258863d --- /dev/null +++ b/apps/api/src/services/financialReportService.ts @@ -0,0 +1,66 @@ +import { prisma } from '../lib/prisma' + +export async function generateFinancialReport(companyId: string, startDate: Date, endDate: Date) { + const reservations = await prisma.reservation.findMany({ + where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, startDate: { gte: startDate }, endDate: { lte: endDate } }, + include: { + vehicle: { select: { make: true, model: true, year: true, licensePlate: true } }, + customer: { select: { firstName: true, lastName: true, email: true } }, + rentalPayments: { where: { status: 'SUCCEEDED' } }, + insurances: true, + additionalDrivers: true, + }, + orderBy: { startDate: 'asc' }, + }) + + const summary = { + totalReservations: reservations.length, + totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0), + totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0), + totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0), + totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0), + totalPricingRulesAdj: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0), + totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0), + totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0), + averageRentalDays: reservations.length > 0 ? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0, + } + + const rows = reservations.map((r) => ({ + reservationId: r.id, + contractNumber: r.contractNumber ?? '—', + invoiceNumber: r.invoiceNumber ?? '—', + customerName: `${r.customer.firstName} ${r.customer.lastName}`, + vehicle: `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`, + plate: r.vehicle.licensePlate, + startDate: r.startDate.toISOString().split('T')[0], + endDate: r.endDate.toISOString().split('T')[0], + days: r.totalDays, + dailyRate: r.dailyRate, + baseAmount: r.dailyRate * r.totalDays, + discount: r.discountAmount, + insurance: r.insuranceTotal, + additionalDriver: r.additionalDriverTotal, + pricingAdj: r.pricingRulesTotal, + deposit: r.depositAmount, + totalAmount: r.totalAmount, + paymentStatus: r.rentalPayments[0]?.status ?? 'UNPAID', + paymentMethod: r.rentalPayments[0]?.paymentMethod ?? '—', + source: r.source, + })) + + return { summary, rows, period: { startDate, endDate } } +} + +export function toCsv(rows: Record[]): string { + if (rows.length === 0) return '' + const headers = Object.keys(rows[0]!) + return [ + headers.join(','), + ...rows.map((row) => + headers.map((h) => { + const val = row[h] ?? '' + return typeof val === 'string' && val.includes(',') ? `"${val}"` : String(val) + }).join(',') + ), + ].join('\n') +} diff --git a/apps/api/src/services/insuranceService.ts b/apps/api/src/services/insuranceService.ts new file mode 100644 index 0000000..068cb25 --- /dev/null +++ b/apps/api/src/services/insuranceService.ts @@ -0,0 +1,47 @@ +import { InsurancePolicy } from '@rentaldrivego/database' +import { prisma } from '../lib/prisma' + +export function calculateInsuranceCharge( + policy: InsurancePolicy, + totalDays: number, + baseRentalAmount: number +): number { + switch (policy.chargeType) { + case 'PER_DAY': return policy.chargeValue * totalDays + case 'PER_RENTAL': return policy.chargeValue + case 'PERCENTAGE_OF_RENTAL': return Math.round(baseRentalAmount * policy.chargeValue / 100) + default: return 0 + } +} + +export async function applyInsurancesToReservation( + reservationId: string, + companyId: string, + selectedPolicyIds: string[], + totalDays: number, + baseRentalAmount: number +) { + const allPolicies = await prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } }) + const required = allPolicies.filter((p) => p.isRequired) + const selected = allPolicies.filter((p) => selectedPolicyIds.includes(p.id) && !p.isRequired) + const toApply = [...required, ...selected] + + const records = toApply.map((policy) => ({ + reservationId, + insurancePolicyId: policy.id, + policyName: policy.name, + policyType: policy.type, + chargeType: policy.chargeType, + chargeValue: policy.chargeValue, + totalCharge: calculateInsuranceCharge(policy, totalDays, baseRentalAmount), + })) + + const insuranceTotal = records.reduce((s, r) => s + r.totalCharge, 0) + + await prisma.$transaction([ + prisma.reservationInsurance.createMany({ data: records }), + prisma.reservation.update({ where: { id: reservationId }, data: { insuranceTotal } }), + ]) + + return { records, insuranceTotal } +} diff --git a/apps/api/src/services/licenseValidationService.ts b/apps/api/src/services/licenseValidationService.ts new file mode 100644 index 0000000..1c50706 --- /dev/null +++ b/apps/api/src/services/licenseValidationService.ts @@ -0,0 +1,50 @@ +import { prisma } from '../lib/prisma' +import { LicenseStatus } from '@rentaldrivego/database' + +const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000 + +export interface LicenseValidationResult { + status: 'VALID' | 'EXPIRING' | 'EXPIRED' + daysUntilExpiry: number | null + requiresApproval: boolean + message: string +} + +export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult { + if (!licenseExpiry) { + return { status: 'VALID', daysUntilExpiry: null, requiresApproval: false, message: 'No expiry date on record' } + } + + const now = new Date() + const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + + if (licenseExpiry <= now) { + return { status: 'EXPIRED', daysUntilExpiry, requiresApproval: true, message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` } + } + + if (licenseExpiry.getTime() - now.getTime() < THREE_MONTHS_MS) { + return { status: 'EXPIRING', daysUntilExpiry, requiresApproval: true, message: `License expires in ${daysUntilExpiry} day(s) — approval required` } + } + + return { status: 'VALID', daysUntilExpiry, requiresApproval: false, message: `Valid — expires in ${daysUntilExpiry} day(s)` } +} + +export async function validateAndFlagLicense(customerId: string) { + const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } }) + const result = validateLicense(customer.licenseExpiry) + + const status: LicenseStatus = + result.status === 'EXPIRED' ? 'EXPIRED' : + result.status === 'EXPIRING' ? 'EXPIRING' : 'VALID' + + await prisma.customer.update({ + where: { id: customerId }, + data: { + licenseExpired: result.status === 'EXPIRED', + licenseExpiringSoon: result.status === 'EXPIRING', + licenseValidationStatus: status, + }, + }) + + return result +} diff --git a/apps/api/src/services/notificationService.ts b/apps/api/src/services/notificationService.ts new file mode 100644 index 0000000..e0db42b --- /dev/null +++ b/apps/api/src/services/notificationService.ts @@ -0,0 +1,135 @@ +import { Resend } from 'resend' +import twilio from 'twilio' +import admin from 'firebase-admin' +import { prisma } from '../lib/prisma' +import { redis } from '../lib/redis' +import { NotificationType, NotificationChannel } from '@rentaldrivego/database' + +const resend = new Resend(process.env.RESEND_API_KEY) + +const twilioClient = twilio( + process.env.TWILIO_ACCOUNT_SID, + process.env.TWILIO_AUTH_TOKEN +) + +if (!admin.apps.length) { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: process.env.FIREBASE_PROJECT_ID, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'), + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + }), + }) +} + +interface SendNotificationOptions { + type: NotificationType + title: string + body: string + data?: Record + companyId?: string + employeeId?: string + renterId?: string + email?: string + phone?: string + fcmToken?: string + channels: NotificationChannel[] + locale?: string +} + +export async function sendNotification(opts: SendNotificationOptions) { + const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = [] + + for (const channel of opts.channels) { + try { + const notification = await prisma.notification.create({ + data: { + type: opts.type, + title: opts.title, + body: opts.body, + data: opts.data ?? {}, + channel, + status: 'PENDING', + companyId: opts.companyId ?? null, + employeeId: opts.employeeId ?? null, + renterId: opts.renterId ?? null, + }, + }) + + let providerMessageId: string | null = null + let success = false + + if (channel === 'EMAIL' && opts.email) { + const { data, error } = await resend.emails.send({ + from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM}>`, + to: opts.email, + subject: opts.title, + html: `

${opts.body}

`, + }) + if (!error) { + providerMessageId = data?.id ?? null + success = true + } + } + + if (channel === 'SMS' && opts.phone) { + const msg = await twilioClient.messages.create({ + body: opts.body, + from: process.env.TWILIO_PHONE_NUMBER!, + to: opts.phone, + }) + providerMessageId = msg.sid + success = true + } + + if (channel === 'WHATSAPP' && opts.phone) { + const msg = await twilioClient.messages.create({ + body: opts.body, + from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`, + to: `whatsapp:${opts.phone}`, + }) + providerMessageId = msg.sid + success = true + } + + if (channel === 'PUSH' && opts.fcmToken) { + const response = await admin.messaging().send({ + token: opts.fcmToken, + notification: { title: opts.title, body: opts.body }, + data: Object.fromEntries( + Object.entries(opts.data ?? {}).map(([k, v]) => [k, String(v)]) + ), + }) + providerMessageId = response + success = true + } + + if (channel === 'IN_APP') { + // Emit via Socket.io through Redis pub/sub + const targetId = opts.employeeId ?? opts.renterId + if (targetId) { + await redis.publish( + `notifications:${targetId}`, + JSON.stringify({ ...notification, status: 'DELIVERED' }) + ) + } + success = true + } + + await prisma.notification.update({ + where: { id: notification.id }, + data: { + status: success ? 'SENT' : 'FAILED', + sentAt: success ? new Date() : null, + providerMessageId, + }, + }) + + results.push({ channel, success }) + } catch (err: any) { + results.push({ channel, success: false, error: err.message }) + } + } + + return results +} diff --git a/apps/api/src/services/pricingRuleService.ts b/apps/api/src/services/pricingRuleService.ts new file mode 100644 index 0000000..a7fbe2b --- /dev/null +++ b/apps/api/src/services/pricingRuleService.ts @@ -0,0 +1,56 @@ +import { prisma } from '../lib/prisma' + +interface DriverInfo { + dateOfBirth?: Date | null + licenseIssuedAt?: Date | null +} + +export async function applyPricingRules( + companyId: string, + customerId: string, + additionalDrivers: DriverInfo[], + dailyRate: number, + totalDays: number +): Promise<{ applied: any[]; total: number }> { + const rules = await prisma.pricingRule.findMany({ where: { companyId, isActive: true } }) + const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } }) + const allDrivers: DriverInfo[] = [customer, ...additionalDrivers] + const applied: any[] = [] + const baseAmount = dailyRate * totalDays + + for (const driver of allDrivers) { + for (const rule of rules) { + const driverAge = driver.dateOfBirth + ? Math.floor((Date.now() - driver.dateOfBirth.getTime()) / (365.25 * 24 * 60 * 60 * 1000)) + : null + const licenseYears = driver.licenseIssuedAt + ? Math.floor((Date.now() - driver.licenseIssuedAt.getTime()) / (365.25 * 24 * 60 * 60 * 1000)) + : null + + let conditionMet = false + if (rule.condition === 'AGE_LESS_THAN' && driverAge !== null) conditionMet = driverAge < rule.conditionValue + else if (rule.condition === 'AGE_GREATER_THAN' && driverAge !== null) conditionMet = driverAge > rule.conditionValue + else if (rule.condition === 'LICENSE_YEARS_LESS_THAN' && licenseYears !== null) conditionMet = licenseYears < rule.conditionValue + else if (rule.condition === 'LICENSE_YEARS_GREATER_THAN' && licenseYears !== null) conditionMet = licenseYears > rule.conditionValue + + if (!conditionMet) continue + + let amount = 0 + if (rule.adjustmentType === 'PERCENTAGE') amount = Math.round(baseAmount * rule.adjustmentValue / 100) + else if (rule.adjustmentType === 'FLAT_PER_DAY') amount = rule.adjustmentValue * totalDays + else amount = rule.adjustmentValue + + if (rule.type === 'DISCOUNT') amount = -amount + applied.push({ ruleId: rule.id, name: rule.name, type: rule.type, amount }) + } + } + + // Deduplicate: each rule applied once even if multiple drivers qualify + const deduped = applied.reduce((acc: any[], item) => { + if (!acc.find((a) => a.ruleId === item.ruleId)) acc.push(item) + return acc + }, []) + + const total = deduped.reduce((s: number, r: any) => s + r.amount, 0) + return { applied: deduped, total } +} diff --git a/apps/api/src/services/teamService.ts b/apps/api/src/services/teamService.ts new file mode 100644 index 0000000..3fef18e --- /dev/null +++ b/apps/api/src/services/teamService.ts @@ -0,0 +1,143 @@ +import { EmployeeRole } from '@rentaldrivego/database' +import { clerkClient } from '@clerk/clerk-sdk-node' +import { prisma } from '../lib/prisma' + +export interface InvitePayload { + firstName: string + lastName: string + email: string + 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 + lastActiveAt?: Date | null + invitationStatus?: 'accepted' | 'pending' | 'revoked' +} + +export async function listEmployees(companyId: string): Promise { + const employees = await prisma.employee.findMany({ + where: { companyId }, + orderBy: [{ role: 'asc' }, { createdAt: 'asc' }], + }) + + 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 { + return { ...emp, lastActiveAt: null, invitationStatus: 'pending' as const } + } + }) + ) + + return hydrated.map((r) => (r.status === 'fulfilled' ? r.value : (r as any).value)) +} + +export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) { + if (payload.role === 'OWNER') { + throw Object.assign(new Error('Cannot invite a member with the OWNER role'), { statusCode: 400, code: 'invalid_role' }) + } + + const existing = await prisma.employee.findFirst({ where: { companyId, email: payload.email } }) + if (existing) { + throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' }) + } + + const company = await prisma.company.findUniqueOrThrow({ + where: { id: companyId }, + include: { brand: { select: { subdomain: true, displayName: true } } }, + }) + + let clerkInvitation: any + try { + clerkInvitation = await clerkClient.invitations.createInvitation({ + emailAddress: payload.email, + redirectUrl: `${process.env.DASHBOARD_URL}/onboarding/accept-invite`, + publicMetadata: { companyId, companyName: company.brand?.displayName ?? company.name, role: payload.role, invitedBy: inviterId }, + }) + } catch (err: any) { + 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 + } + + const employee = await prisma.employee.create({ + data: { companyId, clerkUserId: `pending_${clerkInvitation.id}`, firstName: payload.firstName, lastName: payload.lastName, email: payload.email, role: payload.role, isActive: false }, + }) + + return { employee, invitationId: clerkInvitation.id } +} + +export async function updateEmployeeRole(companyId: string, requesterId: string, requesterRole: EmployeeRole, employeeId: string, payload: { role: EmployeeRole }) { + const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } }) + + if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can change team member roles'), { statusCode: 403, code: 'forbidden' }) + if (target.role === 'OWNER') throw Object.assign(new Error("Cannot change the role of the account owner"), { statusCode: 400 }) + if (payload.role === 'OWNER') throw Object.assign(new Error("Cannot assign the OWNER role via this endpoint"), { statusCode: 400 }) + if (target.id === requesterId) throw Object.assign(new Error("You cannot change your own role"), { statusCode: 400 }) + + return prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } }) +} + +export async function deactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) { + if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can deactivate team members'), { statusCode: 403 }) + const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } }) + if (target.role === 'OWNER') throw Object.assign(new Error('Cannot deactivate the account owner'), { statusCode: 400 }) + + if (!target.clerkUserId.startsWith('pending_')) { + try { await clerkClient.users.banUser(target.clerkUserId) } catch { /* non-fatal */ } + } + + return prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } }) +} + +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 }) + const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } }) + + if (!target.clerkUserId.startsWith('pending_')) { + try { await clerkClient.users.unbanUser(target.clerkUserId) } catch { /* non-fatal */ } + } + + return prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } }) +} + +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 }) + const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } }) + if (target.role === 'OWNER') throw Object.assign(new Error('Cannot remove the account owner'), { statusCode: 400 }) + + if (target.clerkUserId.startsWith('pending_')) { + try { await clerkClient.invitations.revokeInvitation(target.clerkUserId.replace('pending_', '')) } catch { /* expired */ } + } else { + try { await clerkClient.users.banUser(target.clerkUserId) } catch { /* non-fatal */ } + } + + await prisma.employee.delete({ where: { id: employeeId } }) + return { success: true } +} + +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 + + return prisma.employee.update({ + where: { id: pendingEmployee.id }, + data: { clerkUserId, isActive: true, role }, + }) +} diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts new file mode 100644 index 0000000..73e255e --- /dev/null +++ b/apps/api/src/types/express.d.ts @@ -0,0 +1,13 @@ +import { Employee, Company, AdminUser } from '@rentaldrivego/database' + +declare global { + namespace Express { + interface Request { + companyId: string + company: Company + employee: Employee + admin: AdminUser + renterId: string + } + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..8518aa9 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "lib": ["ES2022"], + "jsx": "react" + }, + "include": ["src/**/*"] +} diff --git a/apps/dashboard/next.config.js b/apps/dashboard/next.config.js new file mode 100644 index 0000000..4f89111 --- /dev/null +++ b/apps/dashboard/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + domains: ['res.cloudinary.com'], + }, + transpilePackages: ['@rentaldrivego/types'], +} + +module.exports = nextConfig diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json new file mode 100644 index 0000000..9a77270 --- /dev/null +++ b/apps/dashboard/package.json @@ -0,0 +1,32 @@ +{ + "name": "@rentaldrivego/dashboard", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3001", + "build": "next build", + "start": "next start -p 3001", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@rentaldrivego/types": "*", + "@clerk/nextjs": "^5.0.0", + "next": "14.2.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^3.4.3", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "zod": "^3.23.0", + "dayjs": "^1.11.11", + "recharts": "^2.12.7", + "socket.io-client": "^4.7.5", + "lucide-react": "^0.376.0" + }, + "devDependencies": { + "@types/node": "^20.12.0", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "typescript": "^5.4.0" + } +} diff --git a/apps/dashboard/postcss.config.js b/apps/dashboard/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/apps/dashboard/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx new file mode 100644 index 0000000..5a2ba66 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx @@ -0,0 +1,336 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Plus, Edit, Eye, ChevronDown, Search } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import { apiFetch } from '@/lib/api' +import { formatCurrency } from '@rentaldrivego/types' +import dayjs from 'dayjs' + +interface Vehicle { + id: string + make: string + model: string + year: number + category: string + status: 'AVAILABLE' | 'RENTED' | 'MAINTENANCE' | 'OUT_OF_SERVICE' + dailyRate: number + isPublished: boolean + primaryPhoto: string | null + licensePlate: string +} + +interface AddVehicleModalProps { + open: boolean + onClose: () => void + onSaved: () => void +} + +const STATUS_BADGE: Record = { + AVAILABLE: 'badge-green', + RENTED: 'badge-blue', + MAINTENANCE: 'badge-amber', + OUT_OF_SERVICE: 'badge-red', +} + +function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) { + const [form, setForm] = useState({ + make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY', + dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL', fuelType: 'GASOLINE', + }) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + try { + await apiFetch('/vehicles', { + method: 'POST', + body: JSON.stringify({ + ...form, + dailyRate: Math.round(parseFloat(form.dailyRate) * 100), + year: Number(form.year), + seats: Number(form.seats), + }), + }) + onSaved() + onClose() + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + if (!open) return null + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+

Add New Vehicle

+
+
+
+ + setForm({ ...form, make: e.target.value })} /> +
+
+ + setForm({ ...form, model: e.target.value })} /> +
+
+
+
+ + setForm({ ...form, year: Number(e.target.value) })} /> +
+
+ + +
+
+
+
+ + setForm({ ...form, dailyRate: e.target.value })} /> +
+
+ + setForm({ ...form, licensePlate: e.target.value })} /> +
+
+
+
+ + setForm({ ...form, color: e.target.value })} /> +
+
+ + setForm({ ...form, seats: Number(e.target.value) })} /> +
+
+ + +
+
+
+ + +
+ {error && ( +
{error}
+ )} +
+ + +
+
+
+
+ ) +} + +export default function FleetPage() { + const [vehicles, setVehicles] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showAddModal, setShowAddModal] = useState(false) + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [categoryFilter, setCategoryFilter] = useState('') + const [publishedFilter, setPublishedFilter] = useState('') + + const fetchVehicles = () => { + setLoading(true) + apiFetch('/vehicles') + .then(setVehicles) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + } + + useEffect(() => { fetchVehicles() }, []) + + const togglePublished = async (id: string, current: boolean) => { + try { + await apiFetch(`/vehicles/${id}`, { method: 'PATCH', body: JSON.stringify({ isPublished: !current }) }) + setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, isPublished: !current } : v)) + } catch (err: any) { + alert(err.message) + } + } + + const filtered = vehicles.filter((v) => { + if (search && !`${v.make} ${v.model} ${v.licensePlate}`.toLowerCase().includes(search.toLowerCase())) return false + if (statusFilter && v.status !== statusFilter) return false + if (categoryFilter && v.category !== categoryFilter) return false + if (publishedFilter === 'published' && !v.isPublished) return false + if (publishedFilter === 'unpublished' && v.isPublished) return false + return true + }) + + return ( +
+ {/* Header */} +
+
+

Fleet

+

{vehicles.length} vehicle{vehicles.length !== 1 ? 's' : ''} total

+
+ +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + /> +
+ + + +
+ + {/* Table */} +
+ {loading ? ( +
+
+
+ ) : error ? ( +
+

{error}

+
+ ) : ( +
+ + + + + + + + + + + + + {filtered.map((vehicle) => ( + + + + + + + + + ))} + {filtered.length === 0 && ( + + + + )} + +
VehicleCategoryStatusDaily RatePublishedActions
+
+
+ {vehicle.primaryPhoto ? ( + {`${vehicle.make} + ) : ( +
No photo
+ )} +
+
+

{vehicle.make} {vehicle.model}

+

{vehicle.year} · {vehicle.licensePlate}

+
+
+
+ {vehicle.category} + + {vehicle.status} + + {formatCurrency(vehicle.dailyRate, 'MAD')}/day + + + +
+ + + + + + +
+
+ {vehicles.length === 0 ? 'No vehicles yet. Add your first vehicle to get started.' : 'No vehicles match your filters.'} +
+
+ )} +
+ + setShowAddModal(false)} onSaved={fetchVehicles} /> +
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..a47fdcd --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,263 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from 'lucide-react' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts' +import StatCard from '@/components/ui/StatCard' +import { apiFetch } from '@/lib/api' +import dayjs from 'dayjs' +import { formatCurrency } from '@rentaldrivego/types' + +interface DashboardData { + kpis: { + totalBookings: number + activeVehicles: number + monthlyRevenue: number + totalCustomers: number + bookingsChange: number + vehiclesChange: number + revenueChange: number + customersChange: number + } + recentReservations: { + id: string + bookingRef: string + customerName: string + vehicleName: string + startDate: string + endDate: string + status: string + totalAmount: number + }[] + sourceBreakdown: { source: string; count: number; revenue: number }[] + subscription: { + status: string + planName: string + trialEndsAt: string | null + } +} + +const STATUS_COLORS: Record = { + CONFIRMED: 'badge-blue', + PENDING: 'badge-amber', + ACTIVE: 'badge-green', + COMPLETED: 'badge-gray', + CANCELLED: 'badge-red', +} + +function SubscriptionBanner({ status, planName, trialEndsAt }: { status: string; planName: string; trialEndsAt: string | null }) { + if (status === 'ACTIVE') return null + + const configs: Record = { + TRIALING: { + bg: 'bg-blue-50 border-blue-200 text-blue-800', + icon: , + message: trialEndsAt + ? `You're on a free trial. Trial ends ${dayjs(trialEndsAt).format('MMM D, YYYY')}.` + : "You're on a free trial.", + }, + PAST_DUE: { + bg: 'bg-amber-50 border-amber-200 text-amber-800', + icon: , + message: 'Your payment is past due. Please update your billing information.', + }, + SUSPENDED: { + bg: 'bg-red-50 border-red-200 text-red-800', + icon: , + message: 'Your account has been suspended. Please contact support or renew your subscription.', + }, + } + + const config = configs[status] + if (!config) return null + + return ( +
+ {config.icon} +

{config.message}

+ + Manage billing → + +
+ ) +} + +export default function DashboardPage() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + apiFetch('/analytics/dashboard') + .then(setData) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return ( +
+
+
+ ) + } + + if (error) { + return ( +
+

Failed to load dashboard

+

{error}

+
+ ) + } + + const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 } + + return ( +
+ {data?.subscription && ( + + )} + + {/* KPI Cards */} +
+ + + + +
+ +
+ {/* Booking Sources Chart */} +
+

Booking Sources

+ {data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? ( + + + + + + + + + + + + ) : ( +
+ No booking data available yet +
+ )} +
+ + {/* Quick stats */} +
+

Quick Stats

+
+ {(data?.sourceBreakdown ?? []).map((item) => ( +
+ {item.source.toLowerCase()} +
+ {item.count} +

{formatCurrency(item.revenue, 'MAD')}

+
+
+ ))} + {(!data?.sourceBreakdown || data.sourceBreakdown.length === 0) && ( +

No data yet

+ )} +
+
+
+ + {/* Recent Reservations */} +
+
+

Recent Reservations

+ + View all → + +
+
+ + + + + + + + + + + + + {(data?.recentReservations ?? []).map((res) => ( + + + + + + + + + ))} + {(!data?.recentReservations || data.recentReservations.length === 0) && ( + + + + )} + +
Booking #CustomerVehicleDatesStatusTotal
+ + #{res.bookingRef} + + {res.customerName}{res.vehicleName} + {dayjs(res.startDate).format('MMM D')} – {dayjs(res.endDate).format('MMM D, YYYY')} + + + {res.status} + + + {formatCurrency(res.totalAmount, 'MAD')} +
+ No reservations yet. Once customers book vehicles, they'll appear here. +
+
+
+
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/layout.tsx b/apps/dashboard/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..be67129 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/layout.tsx @@ -0,0 +1,16 @@ +import Sidebar from '@/components/layout/Sidebar' +import TopBar from '@/components/layout/TopBar' + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ +
+ {children} +
+
+
+ ) +} diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css new file mode 100644 index 0000000..1fc975d --- /dev/null +++ b/apps/dashboard/src/app/globals.css @@ -0,0 +1,72 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --primary: #2563eb; + --primary-hover: #1d4ed8; + --accent: #f59e0b; + --sidebar-bg: #0f172a; + --sidebar-text: #94a3b8; + --sidebar-active: #ffffff; +} + +@layer base { + * { + box-sizing: border-box; + } + + body { + @apply bg-slate-50 text-slate-900 antialiased; + } + + h1, h2, h3, h4, h5, h6 { + @apply font-semibold; + } +} + +@layer components { + .btn-primary { + @apply inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed; + } + + .btn-secondary { + @apply inline-flex items-center gap-2 px-4 py-2 bg-white hover:bg-slate-50 text-slate-700 text-sm font-medium rounded-lg border border-slate-200 transition-colors; + } + + .btn-danger { + @apply inline-flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50; + } + + .input-field { + @apply w-full px-3 py-2 text-sm border border-slate-200 rounded-lg bg-white text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors; + } + + .card { + @apply bg-white border border-slate-200 rounded-xl shadow-sm; + } + + .badge-green { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700; + } + + .badge-blue { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700; + } + + .badge-amber { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700; + } + + .badge-red { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700; + } + + .badge-gray { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600; + } + + .badge-purple { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700; + } +} diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx new file mode 100644 index 0000000..da10384 --- /dev/null +++ b/apps/dashboard/src/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import { ClerkProvider } from '@clerk/nextjs' +import './globals.css' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'RentalDriveGo Dashboard', + description: 'Manage your rental car business', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ) +} diff --git a/apps/dashboard/src/components/layout/Sidebar.tsx b/apps/dashboard/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..0a8c553 --- /dev/null +++ b/apps/dashboard/src/components/layout/Sidebar.tsx @@ -0,0 +1,99 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { + LayoutDashboard, + Car, + Calendar, + Users, + Tag, + UserPlus, + BarChart2, + CreditCard, + Settings, + LogOut, +} from 'lucide-react' +import { useClerk, useUser } from '@clerk/nextjs' + +const NAV_ITEMS = [ + { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: true }, + { href: '/dashboard/fleet', label: 'Fleet', icon: Car }, + { href: '/dashboard/reservations', label: 'Reservations', icon: Calendar }, + { href: '/dashboard/customers', label: 'Customers', icon: Users }, + { href: '/dashboard/offers', label: 'Offers', icon: Tag }, + { href: '/dashboard/team', label: 'Team', icon: UserPlus }, + { href: '/dashboard/reports', label: 'Reports', icon: BarChart2 }, + { href: '/dashboard/billing', label: 'Billing', icon: CreditCard }, + { href: '/dashboard/settings', label: 'Settings', icon: Settings }, +] + +export default function Sidebar() { + const pathname = usePathname() + const { signOut } = useClerk() + const { user } = useUser() + + const isActive = (item: typeof NAV_ITEMS[0]) => { + if (item.exact) return pathname === item.href + return pathname.startsWith(item.href) + } + + return ( + + ) +} diff --git a/apps/dashboard/src/components/layout/TopBar.tsx b/apps/dashboard/src/components/layout/TopBar.tsx new file mode 100644 index 0000000..7cc70f9 --- /dev/null +++ b/apps/dashboard/src/components/layout/TopBar.tsx @@ -0,0 +1,71 @@ +'use client' + +import { Bell } from 'lucide-react' +import { usePathname } from 'next/navigation' +import { useState, useEffect } from 'react' +import { apiFetch } from '@/lib/api' +import { useUser } from '@clerk/nextjs' + +const PAGE_TITLES: Record = { + '/dashboard': 'Dashboard', + '/dashboard/fleet': 'Fleet Management', + '/dashboard/reservations': 'Reservations', + '/dashboard/customers': 'Customers', + '/dashboard/offers': 'Offers', + '/dashboard/team': 'Team', + '/dashboard/reports': 'Reports', + '/dashboard/billing': 'Billing', + '/dashboard/settings': 'Settings', +} + +export default function TopBar() { + const pathname = usePathname() + const { user } = useUser() + const [unreadCount, setUnreadCount] = useState(0) + const [showNotifs, setShowNotifs] = useState(false) + + const title = PAGE_TITLES[pathname] ?? 'Dashboard' + + useEffect(() => { + apiFetch<{ unread: number }>('/notifications/unread-count') + .then((data) => setUnreadCount(data.unread)) + .catch(() => {}) + }, [pathname]) + + return ( +
+

{title}

+ +
+ {/* Notification bell */} +
+ + + {showNotifs && ( +
+

Notifications

+

+ {unreadCount === 0 ? 'No new notifications' : `${unreadCount} unread notification${unreadCount > 1 ? 's' : ''}`} +

+
+ )} +
+ + {/* User avatar */} +
+ {user?.firstName?.[0]?.toUpperCase() ?? 'U'} +
+
+
+ ) +} diff --git a/apps/dashboard/src/components/ui/StatCard.tsx b/apps/dashboard/src/components/ui/StatCard.tsx new file mode 100644 index 0000000..7b96d09 --- /dev/null +++ b/apps/dashboard/src/components/ui/StatCard.tsx @@ -0,0 +1,49 @@ +import { LucideIcon } from 'lucide-react' + +interface StatCardProps { + title: string + value: string | number + change?: number + icon: LucideIcon + iconColor?: string + iconBg?: string +} + +export default function StatCard({ + title, + value, + change, + icon: Icon, + iconColor = 'text-blue-600', + iconBg = 'bg-blue-50', +}: StatCardProps) { + const isPositive = change !== undefined && change >= 0 + const isNegative = change !== undefined && change < 0 + + return ( +
+
+
+

{title}

+

{value}

+ {change !== undefined && ( +
+ + {isPositive ? '+' : ''}{change.toFixed(1)}% + + vs last month +
+ )} +
+
+ +
+
+
+ ) +} diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts new file mode 100644 index 0000000..350ad80 --- /dev/null +++ b/apps/dashboard/src/lib/api.ts @@ -0,0 +1,80 @@ +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1' + +async function getClerkToken(): Promise { + try { + // In Next.js App Router, the Clerk session token is available via the Clerk SDK + // For client-side calls, we read it from a cookie set by @clerk/nextjs + if (typeof window !== 'undefined') { + // Client-side: use window.__clerk if available + const clerk = (window as any).__clerk + if (clerk?.session) { + return await clerk.session.getToken() + } + } + return null + } catch { + return null + } +} + +export async function apiFetch(path: string, options?: RequestInit): Promise { + const token = await getClerkToken() + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options?.headers as Record ?? {}), + } + + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + credentials: 'include', + }) + + let json: any + try { + json = await res.json() + } catch { + throw new Error(`HTTP ${res.status}: ${res.statusText}`) + } + + if (!res.ok) { + const err = new Error(json?.message ?? json?.error ?? `Request failed with status ${res.status}`) as any + err.code = json?.error + err.statusCode = res.status + throw err + } + + return (json?.data ?? json) as T +} + +export async function apiFetchServer(path: string, token: string, options?: RequestInit): Promise { + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + ...(options?.headers as Record ?? {}), + }, + cache: 'no-store', + }) + + let json: any + try { + json = await res.json() + } catch { + throw new Error(`HTTP ${res.status}: ${res.statusText}`) + } + + if (!res.ok) { + const err = new Error(json?.message ?? `Request failed with status ${res.status}`) as any + err.statusCode = res.status + throw err + } + + return (json?.data ?? json) as T +} diff --git a/apps/dashboard/src/middleware.ts b/apps/dashboard/src/middleware.ts new file mode 100644 index 0000000..19df502 --- /dev/null +++ b/apps/dashboard/src/middleware.ts @@ -0,0 +1,19 @@ +import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' + +const isProtectedRoute = createRouteMatcher([ + '/dashboard(.*)', + '/onboarding(.*)', +]) + +export default clerkMiddleware((auth, req) => { + if (isProtectedRoute(req)) { + auth().protect() + } +}) + +export const config = { + matcher: [ + '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', + '/(api|trpc)(.*)', + ], +} diff --git a/apps/dashboard/tailwind.config.ts b/apps/dashboard/tailwind.config.ts new file mode 100644 index 0000000..ee11eb2 --- /dev/null +++ b/apps/dashboard/tailwind.config.ts @@ -0,0 +1,26 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: ['./src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + colors: { + primary: { + 50: '#eff6ff', + 100: '#dbeafe', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 900: '#1e3a8a', + }, + }, + fontFamily: { + sans: ['Inter', 'ui-sans-serif', 'system-ui'], + }, + }, + }, + plugins: [], +} + +export default config diff --git a/apps/dashboard/tsconfig.json b/apps/dashboard/tsconfig.json new file mode 100644 index 0000000..b8cdcc7 --- /dev/null +++ b/apps/dashboard/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/marketplace/next.config.js b/apps/marketplace/next.config.js new file mode 100644 index 0000000..4f89111 --- /dev/null +++ b/apps/marketplace/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + domains: ['res.cloudinary.com'], + }, + transpilePackages: ['@rentaldrivego/types'], +} + +module.exports = nextConfig diff --git a/apps/marketplace/package.json b/apps/marketplace/package.json new file mode 100644 index 0000000..b3ad3e5 --- /dev/null +++ b/apps/marketplace/package.json @@ -0,0 +1,29 @@ +{ + "name": "@rentaldrivego/marketplace", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3000", + "build": "next build", + "start": "next start -p 3000", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@rentaldrivego/types": "*", + "next": "14.2.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^3.4.3", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "zod": "^3.23.0", + "dayjs": "^1.11.11", + "lucide-react": "^0.376.0" + }, + "devDependencies": { + "@types/node": "^20.12.0", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "typescript": "^5.4.0" + } +} diff --git a/apps/marketplace/postcss.config.js b/apps/marketplace/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/apps/marketplace/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/marketplace/tailwind.config.ts b/apps/marketplace/tailwind.config.ts new file mode 100644 index 0000000..8055fd6 --- /dev/null +++ b/apps/marketplace/tailwind.config.ts @@ -0,0 +1,16 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: ['./src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + fontFamily: { + sans: ['Inter', 'ui-sans-serif', 'system-ui'], + }, + }, + }, + plugins: [], +} + +export default config diff --git a/apps/marketplace/tsconfig.json b/apps/marketplace/tsconfig.json new file mode 100644 index 0000000..b8cdcc7 --- /dev/null +++ b/apps/marketplace/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/public-site/next.config.js b/apps/public-site/next.config.js new file mode 100644 index 0000000..4f89111 --- /dev/null +++ b/apps/public-site/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + domains: ['res.cloudinary.com'], + }, + transpilePackages: ['@rentaldrivego/types'], +} + +module.exports = nextConfig diff --git a/apps/public-site/package.json b/apps/public-site/package.json new file mode 100644 index 0000000..b14d317 --- /dev/null +++ b/apps/public-site/package.json @@ -0,0 +1,29 @@ +{ + "name": "@rentaldrivego/public-site", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3003", + "build": "next build", + "start": "next start -p 3003", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@rentaldrivego/types": "*", + "next": "14.2.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^3.4.3", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "zod": "^3.23.0", + "dayjs": "^1.11.11", + "lucide-react": "^0.376.0" + }, + "devDependencies": { + "@types/node": "^20.12.0", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "typescript": "^5.4.0" + } +} diff --git a/apps/public-site/postcss.config.js b/apps/public-site/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/apps/public-site/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/public-site/tailwind.config.ts b/apps/public-site/tailwind.config.ts new file mode 100644 index 0000000..8055fd6 --- /dev/null +++ b/apps/public-site/tailwind.config.ts @@ -0,0 +1,16 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: ['./src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + fontFamily: { + sans: ['Inter', 'ui-sans-serif', 'system-ui'], + }, + }, + }, + plugins: [], +} + +export default config diff --git a/apps/public-site/tsconfig.json b/apps/public-site/tsconfig.json new file mode 100644 index 0000000..b8cdcc7 --- /dev/null +++ b/apps/public-site/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fa2a9c9 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "rentaldrivego", + "version": "1.0.0", + "private": true, + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "build": "turbo build", + "dev": "turbo dev", + "lint": "turbo lint", + "type-check": "turbo type-check", + "db:generate": "turbo db:generate", + "db:push": "turbo db:push", + "db:migrate": "turbo db:migrate", + "db:seed": "turbo db:seed", + "db:studio": "cd packages/database && npx prisma studio" + }, + "devDependencies": { + "turbo": "^1.13.0", + "typescript": "^5.4.0", + "@types/node": "^20.12.0", + "prettier": "^3.2.5", + "eslint": "^8.57.0" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" + }, + "packageManager": "npm@10.5.0" +} diff --git a/packages/database/package.json b/packages/database/package.json new file mode 100644 index 0000000..e128963 --- /dev/null +++ b/packages/database/package.json @@ -0,0 +1,27 @@ +{ + "name": "@rentaldrivego/database", + "version": "1.0.0", + "private": true, + "scripts": { + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:migrate:deploy": "prisma migrate deploy", + "db:seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts", + "db:studio": "prisma studio" + }, + "dependencies": { + "@prisma/client": "^5.13.0" + }, + "devDependencies": { + "prisma": "^5.13.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.0", + "@types/node": "^20.12.0", + "bcryptjs": "^2.4.3", + "@types/bcryptjs": "^2.4.6" + }, + "exports": { + ".": "./generated/index.js" + } +} diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma new file mode 100644 index 0000000..7c263ef --- /dev/null +++ b/packages/database/prisma/schema.prisma @@ -0,0 +1,824 @@ +generator client { + provider = "prisma-client-js" + output = "../generated" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ═══════════════════════════════════════════════════════════════ +// ENUMS +// ═══════════════════════════════════════════════════════════════ + +enum CompanyStatus { PENDING TRIALING ACTIVE PAST_DUE SUSPENDED CANCELLED } +enum Plan { STARTER GROWTH PRO } +enum BillingPeriod { MONTHLY ANNUAL } +enum SubscriptionStatus { TRIALING ACTIVE PAST_DUE CANCELLED UNPAID } +enum InvoiceStatus { PENDING PAID FAILED REFUNDED } +enum PaymentProvider { AMANPAY PAYPAL } +enum PaymentStatus { PENDING SUCCEEDED FAILED REFUNDED PARTIALLY_REFUNDED } +enum PaymentType { CHARGE DEPOSIT REFUND } +enum EmployeeRole { OWNER MANAGER AGENT } +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 } +enum OfferType { PERCENTAGE FIXED_AMOUNT FREE_DAY SPECIAL_RATE } +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 FuelPolicyType { FULL_TO_FULL FULL_TO_EMPTY SAME_TO_SAME PREPAID FREE } +enum InspectionType { CHECKIN CHECKOUT } +enum FuelLevel { FULL SEVEN_EIGHTHS THREE_QUARTERS FIVE_EIGHTHS HALF THREE_EIGHTHS QUARTER ONE_EIGHTH EMPTY } +enum DiagramView { TOP FRONT REAR LEFT RIGHT } +enum DamageType { SCRATCH DENT CRACK CHIP MISSING STAIN OTHER } +enum DamageSeverity { MINOR MODERATE MAJOR } +enum ReportingPeriod { WEEKLY MONTHLY QUARTERLY ANNUAL } +enum ReportFormat { PDF CSV BOTH } +enum AdminRole { SUPER_ADMIN ADMIN SUPPORT FINANCE VIEWER } +enum AdminResource { COMPANIES COMPANY_EMPLOYEES SUBSCRIPTIONS PAYMENTS OFFERS RENTERS ADMIN_USERS AUDIT_LOGS PLATFORM_METRICS } +enum AdminAction { READ CREATE UPDATE DELETE SUSPEND IMPERSONATE } + +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 } + +// ═══════════════════════════════════════════════════════════════ +// 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) + subscriptionPaymentRef String? + 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? + insurancePolicies InsurancePolicy[] + pricingRules PricingRule[] + notifications Notification[] @relation("CompanyNotifications") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("companies") +} + +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") + 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") +} + +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 + currency String @default("MAD") + status InvoiceStatus + amanpayTransactionId String? @unique + paypalCaptureId String? @unique + paymentProvider PaymentProvider @default(AMANPAY) + paidAt DateTime? + + createdAt DateTime @default(now()) + + @@index([companyId]) + @@map("subscription_invoices") +} + +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? + publicEmail String? + publicPhone String? + publicAddress String? + publicCity String? + publicCountry String? + websiteUrl String? + whatsappNumber String? + facebookUrl String? + instagramUrl String? + defaultLocale String @default("en") + defaultCurrency String @default("MAD") + amanpayMerchantId String? + amanpaySecretKey String? + paypalEmail String? + paypalMerchantId String? + paymentMethodsEnabled PaymentProvider[] + isListedOnMarketplace Boolean @default(true) + marketplaceRating Float? + + updatedAt DateTime @updatedAt + + @@map("brand_settings") +} + +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") +} + +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[] + dailyRate Int + status VehicleStatus @default(AVAILABLE) + photos String[] + mileage Int? + notes String? + isPublished Boolean @default(true) + + reservations Reservation[] + maintenance MaintenanceLog[] + offerVehicles OfferVehicle[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([companyId]) + @@index([companyId, status]) + @@index([companyId, isPublished]) + @@map("vehicles") +} + +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? + 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) + isFeatured Boolean @default(false) + + vehicles OfferVehicle[] + reservations Reservation[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([companyId]) + @@index([isPublic, isActive, validUntil]) + @@map("offers") +} + +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") +} + +model Customer { + id String @id @default(cuid()) + companyId String + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + renterId String? + firstName String + lastName String + email String + phone String? + driverLicense String? + dateOfBirth DateTime? + nationality String? + address Json? + notes String? + flagged Boolean @default(false) + flagReason String? + + licenseExpiry DateTime? + licenseIssuedAt DateTime? + licenseCountry String? + licenseNumber String? + licenseCategory String? + licenseExpired Boolean @default(false) + licenseExpiringSoon Boolean @default(false) + licenseValidationStatus LicenseStatus @default(PENDING) + licenseApprovedBy String? + licenseApprovedAt DateTime? + licenseApprovalNote String? + + reservations Reservation[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([companyId, email]) + @@index([companyId]) + @@map("customers") +} + +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? + renter Renter? @relation("RenterReservations", fields: [renterId], references: [id]) + offerId String? + offer Offer? @relation(fields: [offerId], references: [id]) + promoCodeUsed String? + source BookingSource @default(DASHBOARD) + marketplaceRef String? + 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? + contractNumber String? @unique + invoiceNumber String? @unique + checkInFuelLevel String? + checkOutFuelLevel String? + + insurances ReservationInsurance[] + insuranceTotal Int @default(0) + additionalDrivers AdditionalDriver[] + additionalDriverTotal Int @default(0) + pricingRulesApplied Json? + pricingRulesTotal Int @default(0) + damageReports DamageReport[] + rentalPayments RentalPayment[] + inspections DamageInspection[] + review Review? + damageChargeAmount Int? + damageChargeNote String? + extras Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([companyId]) + @@index([companyId, status]) + @@index([vehicleId, startDate, endDate]) + @@index([renterId]) + @@map("reservations") +} + +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 + currency String @default("MAD") + status PaymentStatus @default(PENDING) + type PaymentType @default(CHARGE) + paymentProvider PaymentProvider + amanpayTransactionId String? @unique + paypalCaptureId String? @unique + paymentMethod String? + paidAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([companyId]) + @@index([reservationId]) + @@map("rental_payments") +} + +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") +} + +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 +// ═══════════════════════════════════════════════════════════════ + +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 — CONTRACT SETTINGS +// ═══════════════════════════════════════════════════════════════ + +model ContractSettings { + id String @id @default(cuid()) + companyId String @unique + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + legalName String? + registrationNumber String? + taxId String? + 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([]) + signatureRequired Boolean @default(true) + contractFooterNote String? + invoiceFooterNote String? + showTax Boolean @default(false) + taxRate Float? + taxLabel String? + contractPrefix String @default("CNT") + invoicePrefix String @default("INV") + fuelPolicyType FuelPolicyType @default(FULL_TO_FULL) + fuelPolicyNote String? + fuelChargePerLiter Int? + fuelShortfallFee Int? + lateFeePerHour Int? + taxLines Json? + lastContractSeq Int @default(0) + lastInvoiceSeq Int @default(0) + additionalDriverCharge AdditionalDriverCharge @default(FREE) + additionalDriverDailyRate Int @default(0) + additionalDriverFlatRate Int @default(0) + + updatedAt DateTime @updatedAt + + @@map("contract_settings") +} + +// ═══════════════════════════════════════════════════════════════ +// INSURANCE +// ═══════════════════════════════════════════════════════════════ + +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 + 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]) + policyName String + policyType InsuranceType + chargeType InsuranceChargeType + chargeValue Int + totalCharge Int + + @@unique([reservationId, insurancePolicyId]) + @@map("reservation_insurances") +} + +// ═══════════════════════════════════════════════════════════════ +// ADDITIONAL DRIVERS +// ═══════════════════════════════════════════════════════════════ + +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 REPORTS +// ═══════════════════════════════════════════════════════════════ + +model DamageReport { + id String @id @default(cuid()) + reservationId String + reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade) + companyId String + type DamageReportType + damages Json + photos String[] + fuelLevel FuelLevel @default(FULL) + mileage Int? + inspectedAt DateTime @default(now()) + inspectedBy String? + customerSignedAt DateTime? + customerName String? + + @@unique([reservationId, type]) + @@index([companyId]) + @@map("damage_reports") +} + +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? + generalCondition String? + employeeNotes String? + customerAgreed Boolean @default(false) + inspectedAt DateTime @default(now()) + inspectedBy String? + damagePoints DamagePoint[] + + createdAt DateTime @default(now()) + + @@unique([reservationId, type]) + @@index([reservationId]) + @@index([companyId]) + @@map("damage_inspections") +} + +model DamagePoint { + id String @id @default(cuid()) + inspectionId String + inspection DamageInspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade) + viewType DiagramView + x Float + y Float + damageType DamageType + severity DamageSeverity @default(MINOR) + description String? + isPreExisting Boolean @default(false) + + @@index([inspectionId]) + @@map("damage_points") +} + +// ═══════════════════════════════════════════════════════════════ +// PRICING RULES +// ═══════════════════════════════════════════════════════════════ + +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 + adjustmentType PriceAdjustmentType + adjustmentValue Int + isActive Boolean @default(true) + description String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([companyId]) + @@map("pricing_rules") +} + +// ═══════════════════════════════════════════════════════════════ +// ACCOUNTING +// ═══════════════════════════════════════════════════════════════ + +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) + currency String @default("MAD") + accountantEmail String? + accountantName String? + autoSendReport Boolean @default(false) + reportFormat ReportFormat @default(PDF) + + updatedAt DateTime @updatedAt + + @@map("accounting_settings") +} + +// ═══════════════════════════════════════════════════════════════ +// 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") +} + +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") +} + +model AuditLog { + id String @id @default(cuid()) + adminUserId String + adminUser AdminUser @relation(fields: [adminUserId], references: [id]) + action String + resource String + resourceId String? + companyId String? + before Json? + after Json? + ipAddress String? + userAgent String? + note String? + + createdAt DateTime @default(now()) + + @@index([adminUserId]) + @@index([companyId]) + @@index([action]) + @@map("audit_logs") +} diff --git a/packages/database/prisma/seed.ts b/packages/database/prisma/seed.ts new file mode 100644 index 0000000..408384b --- /dev/null +++ b/packages/database/prisma/seed.ts @@ -0,0 +1,40 @@ +import { PrismaClient } from '../generated' +import bcrypt from 'bcryptjs' + +const prisma = new PrismaClient() + +async function main() { + const email = process.env.ADMIN_SEED_EMAIL ?? 'admin@rentaldrivego.com' + const password = process.env.ADMIN_SEED_PASSWORD ?? 'changeme123' + const firstName = process.env.ADMIN_SEED_FIRST_NAME ?? 'Super' + const lastName = process.env.ADMIN_SEED_LAST_NAME ?? 'Admin' + + const existing = await prisma.adminUser.findUnique({ where: { email } }) + if (existing) { + console.log(`Super admin already exists: ${email}`) + return + } + + const passwordHash = await bcrypt.hash(password, 12) + + const admin = await prisma.adminUser.create({ + data: { + email, + firstName, + lastName, + passwordHash, + role: 'SUPER_ADMIN', + isActive: true, + }, + }) + + console.log(`Created SUPER_ADMIN: ${admin.email} (id: ${admin.id})`) + console.log('Login with the password you set in ADMIN_SEED_PASSWORD') +} + +main() + .catch((err) => { + console.error(err) + process.exit(1) + }) + .finally(() => prisma.$disconnect()) diff --git a/packages/database/tsconfig.json b/packages/database/tsconfig.json new file mode 100644 index 0000000..aada7ec --- /dev/null +++ b/packages/database/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + }, + "include": ["prisma/**/*"] +} diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..8131fdb --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,13 @@ +{ + "name": "@rentaldrivego/types", + "version": "1.0.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.4.0" + } +} diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts new file mode 100644 index 0000000..5c1977f --- /dev/null +++ b/packages/types/src/api.ts @@ -0,0 +1,50 @@ +// Standard API response shapes + +export interface ApiSuccess { + data: T +} + +export interface ApiPaginated { + data: T[] + total: number + page: number + pageSize: number + totalPages: number +} + +export interface ApiError { + error: string + message: string + statusCode: number + [key: string]: unknown +} + +// Plan prices in smallest currency unit +export const PLAN_PRICES: Record>> = { + STARTER: { + MONTHLY: { MAD: 29900, USD: 2900, EUR: 2700 }, + ANNUAL: { MAD: 299000, USD: 29000, EUR: 27000 }, + }, + GROWTH: { + MONTHLY: { MAD: 59900, USD: 5900, EUR: 5400 }, + ANNUAL: { MAD: 599000, USD: 59000, EUR: 54000 }, + }, + PRO: { + MONTHLY: { MAD: 99900, USD: 9900, EUR: 9000 }, + ANNUAL: { MAD: 999000, USD: 99000, EUR: 90000 }, + }, +} + +export type Locale = 'en' | 'fr' | 'ar' +export type SupportedCurrency = 'MAD' | 'USD' | 'EUR' + +export function formatCurrency(amount: number, currency: SupportedCurrency, locale: Locale = 'en'): string { + const divisor = 100 + const value = amount / divisor + const localeMap: Record = { en: 'en-US', fr: 'fr-FR', ar: 'ar-MA' } + return new Intl.NumberFormat(localeMap[locale], { + style: 'currency', + currency, + minimumFractionDigits: 2, + }).format(value) +} diff --git a/packages/types/src/damage.ts b/packages/types/src/damage.ts new file mode 100644 index 0000000..ebc4a60 --- /dev/null +++ b/packages/types/src/damage.ts @@ -0,0 +1,53 @@ +export interface DamageZone { + zone: VehicleZone + severity: 'SCRATCH' | 'DENT' | 'CRACK' | 'MISSING' | 'OTHER' + note?: string +} + +export type VehicleZone = + | 'hood' | 'roof' | 'trunk' + | 'front-left-door' | 'front-right-door' + | 'rear-left-door' | 'rear-right-door' + | 'front-left-fender' | 'front-right-fender' + | 'rear-left-fender' | 'rear-right-fender' + | 'front-bumper' | 'rear-bumper' + | 'windshield' | 'rear-window' + | 'left-mirror' | 'right-mirror' + | 'front-left-wheel' | 'front-right-wheel' + | 'rear-left-wheel' | 'rear-right-wheel' + | 'interior' | 'under-hood' | 'undercarriage' + +export const DAMAGE_ZONE_COORDS: Record = { + 'hood': { x: 48, y: 8, w: 64, h: 28 }, + 'roof': { x: 48, y: 52, w: 64, h: 56 }, + 'trunk': { x: 48, y: 124, w: 64, h: 28 }, + 'front-bumper': { x: 48, y: 0, w: 64, h: 10 }, + 'rear-bumper': { x: 48, y: 150, w: 64, h: 10 }, + 'front-left-fender': { x: 20, y: 10, w: 30, h: 35 }, + 'front-right-fender': { x: 110, y: 10, w: 30, h: 35 }, + 'front-left-door': { x: 20, y: 52, w: 30, h: 30 }, + 'front-right-door': { x: 110, y: 52, w: 30, h: 30 }, + 'rear-left-door': { x: 20, y: 82, w: 30, h: 30 }, + 'rear-right-door': { x: 110, y: 82, w: 30, h: 30 }, + 'rear-left-fender': { x: 20, y: 112, w: 30, h: 35 }, + 'rear-right-fender': { x: 110, y: 112, w: 30, h: 35 }, + 'windshield': { x: 52, y: 38, w: 56, h: 18 }, + 'rear-window': { x: 52, y: 104, w: 56, h: 18 }, + 'left-mirror': { x: 8, y: 50, w: 14, h: 10 }, + 'right-mirror': { x: 138, y: 50, w: 14, h: 10 }, + 'front-left-wheel': { x: 10, y: 24, w: 22, h: 26 }, + 'front-right-wheel': { x: 128, y: 24, w: 22, h: 26 }, + 'rear-left-wheel': { x: 10, y: 110, w: 22, h: 26 }, + 'rear-right-wheel': { x: 128, y: 110, w: 22, h: 26 }, + 'interior': { x: 55, y: 58, w: 50, h: 44 }, + 'under-hood': { x: 52, y: 10, w: 56, h: 20 }, + 'undercarriage': { x: 52, y: 130, w: 56, h: 20 }, +} + +export const SEVERITY_COLORS: Record = { + SCRATCH: '#F59E0B', + DENT: '#EF4444', + CRACK: '#8B5CF6', + MISSING: '#374151', + OTHER: '#6B7280', +} diff --git a/packages/types/src/fuel.ts b/packages/types/src/fuel.ts new file mode 100644 index 0000000..244cb3b --- /dev/null +++ b/packages/types/src/fuel.ts @@ -0,0 +1,29 @@ +export type FuelPolicyType = 'FULL_TO_FULL' | 'FULL_TO_EMPTY' | 'SAME_TO_SAME' | 'PREPAID' | 'FREE' + +export const FUEL_POLICY_LABELS: Record> = { + FULL_TO_FULL: { + en: 'Full-to-Full: Return vehicle with a full tank. A refueling fee applies if returned with less fuel.', + fr: "Plein-à-Plein: Retournez le véhicule avec le plein. Des frais de carburant s'appliquent sinon.", + ar: 'ممتلئ إلى ممتلئ: يجب إعادة السيارة بخزان ممتلئ. رسوم إضافية في حال الإعادة بمستوى أقل.', + }, + FULL_TO_EMPTY: { + en: 'Full-to-Empty: Vehicle is provided with a full tank. No refund for unused fuel upon return.', + fr: 'Plein-à-Vide: Le véhicule est fourni avec un plein. Aucun remboursement pour carburant non utilisé.', + ar: 'ممتلئ إلى فارغ: تُسلَّم السيارة بخزان ممتلئ. لا استرداد للوقود غير المستخدم.', + }, + SAME_TO_SAME: { + en: 'Same-to-Same: Return vehicle with the same fuel level as at pickup.', + fr: "Même niveau: Retournez avec le même niveau de carburant qu'au départ.", + ar: 'نفس المستوى: أعد السيارة بنفس مستوى الوقود عند الاستلام.', + }, + PREPAID: { + en: 'Prepaid Fuel: You pre-purchase a full tank at a fixed rate. Return at any fuel level.', + fr: "Carburant prépayé: Vous prépayez un plein à tarif fixe. Retour à n'importe quel niveau.", + ar: 'الوقود المدفوع مسبقاً: تدفع مقدماً لخزان كامل بسعر ثابت. العودة بأي مستوى.', + }, + FREE: { + en: 'Fuel Included: Fuel cost is included in the rental price.', + fr: 'Carburant inclus: Le coût du carburant est inclus dans le tarif de location.', + ar: 'الوقود مشمول: تكلفة الوقود مشمولة في سعر الإيجار.', + }, +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000..75c5265 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,3 @@ +export * from './damage' +export * from './fuel' +export * from './api' diff --git a/EditMemberModal.tsx b/project_design/EditMemberModal.tsx similarity index 100% rename from EditMemberModal.tsx rename to project_design/EditMemberModal.tsx diff --git a/FEATURES.md b/project_design/FEATURES.md similarity index 100% rename from FEATURES.md rename to project_design/FEATURES.md diff --git a/INTEGRATION.md b/project_design/INTEGRATION.md similarity index 100% rename from INTEGRATION.md rename to project_design/INTEGRATION.md diff --git a/InviteModal.tsx b/project_design/InviteModal.tsx similarity index 100% rename from InviteModal.tsx rename to project_design/InviteModal.tsx diff --git a/PAGES.md b/project_design/PAGES.md similarity index 100% rename from PAGES.md rename to project_design/PAGES.md diff --git a/PermissionsMatrix.tsx b/project_design/PermissionsMatrix.tsx similarity index 100% rename from PermissionsMatrix.tsx rename to project_design/PermissionsMatrix.tsx diff --git a/README.md b/project_design/README.md similarity index 100% rename from README.md rename to project_design/README.md diff --git a/advanced-features.md b/project_design/advanced-features.md similarity index 100% rename from advanced-features.md rename to project_design/advanced-features.md diff --git a/api-routes.md b/project_design/api-routes.md similarity index 100% rename from api-routes.md rename to project_design/api-routes.md diff --git a/requireRole.ts b/project_design/requireRole.ts similarity index 100% rename from requireRole.ts rename to project_design/requireRole.ts diff --git a/schema.md b/project_design/schema.md similarity index 100% rename from schema.md rename to project_design/schema.md diff --git a/team.ts b/project_design/team.ts similarity index 100% rename from team.ts rename to project_design/team.ts diff --git a/team.tsx b/project_design/team.tsx similarity index 100% rename from team.tsx rename to project_design/team.tsx diff --git a/teamService.ts b/project_design/teamService.ts similarity index 100% rename from teamService.ts rename to project_design/teamService.ts diff --git a/useTeam.ts b/project_design/useTeam.ts similarity index 100% rename from useTeam.ts rename to project_design/useTeam.ts diff --git a/webhooks.ts b/project_design/webhooks.ts similarity index 100% rename from webhooks.ts rename to project_design/webhooks.ts diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..7386f24 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "exclude": ["node_modules", "dist"] +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..484eae4 --- /dev/null +++ b/turbo.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": ["**/.env.*local"], + "pipeline": { + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "!.next/cache/**", "dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "lint": { + "outputs": [] + }, + "type-check": { + "outputs": [] + }, + "db:generate": { + "cache": false + }, + "db:push": { + "cache": false + }, + "db:migrate": { + "cache": false + }, + "db:seed": { + "cache": false + } + } +} From 750ae56a29aa0cc565a5ad7282ed00f5936a0220 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 22:58:23 -0400 Subject: [PATCH 04/15] fixing platform admin --- .dockerignore | 16 + .env.docker.dev | 23 + .env.docker.production.example | 17 + .env.docker.test | 10 + .env.example | 7 +- DOCKER.md | 131 + Dockerfile.dev | 15 + Dockerfile.production | 30 + Dockerfile.test | 15 + apps/admin/next-env.d.ts | 5 + apps/admin/next.config.js | 10 + .../src/app/dashboard/admin-users/page.tsx | 211 + .../src/app/dashboard/audit-logs/page.tsx | 100 + .../src/app/dashboard/companies/[id]/page.tsx | 204 + .../src/app/dashboard/companies/page.tsx | 207 + apps/admin/src/app/dashboard/layout.tsx | 90 + apps/admin/src/app/dashboard/page.tsx | 111 + apps/admin/src/app/dashboard/renters/page.tsx | 188 + apps/admin/src/app/favicon.ico/route.ts | 6 + apps/admin/src/app/globals.css | 15 + apps/admin/src/app/icon.tsx | 32 + apps/admin/src/app/layout.tsx | 16 + apps/admin/src/app/login/page.tsx | 212 + apps/admin/src/app/page.tsx | 5 + apps/admin/src/components/I18nProvider.tsx | 129 + apps/admin/src/components/PublicShell.tsx | 54 + apps/admin/src/lib/api.ts | 14 + apps/api/package.json | 49 +- apps/api/src/index.ts | 165 +- apps/api/src/middleware/rateLimiter.ts | 53 + apps/api/src/routes/admin.ts | 72 +- apps/api/src/routes/analytics.ts | 136 +- apps/api/src/routes/auth.company.ts | 540 + apps/api/src/routes/auth.renter.ts | 105 +- apps/api/src/routes/companies.ts | 410 + apps/api/src/routes/customers.ts | 35 +- apps/api/src/routes/marketplace.ts | 157 +- apps/api/src/routes/notifications.ts | 51 + apps/api/src/routes/payments.ts | 252 + apps/api/src/routes/reservations.ts | 216 +- apps/api/src/routes/site.ts | 499 + apps/api/src/routes/subscriptions.ts | 288 + apps/api/src/routes/team.ts | 20 +- apps/api/src/routes/vehicles.ts | 2 +- .../src/services/additionalDriverService.ts | 95 + apps/api/src/services/amanpayService.ts | 97 + .../src/services/financialReportService.ts | 18 +- apps/api/src/services/insuranceService.ts | 12 +- .../src/services/licenseValidationService.ts | 2 + apps/api/src/services/notificationService.ts | 159 +- apps/api/src/services/paypalService.ts | 127 + apps/api/src/services/teamService.ts | 4 +- apps/dashboard/next-env.d.ts | 5 + .../(dashboard)/dashboard/billing/page.tsx | 350 + .../(dashboard)/dashboard/customers/page.tsx | 71 + .../(dashboard)/dashboard/fleet/[id]/page.tsx | 73 + .../app/(dashboard)/dashboard/fleet/page.tsx | 11 +- .../dashboard/notifications/page.tsx | 208 + .../app/(dashboard)/dashboard/offers/page.tsx | 63 + .../(dashboard)/dashboard/reports/page.tsx | 152 + .../dashboard/reservations/[id]/page.tsx | 272 + .../dashboard/reservations/page.tsx | 81 + .../(dashboard)/dashboard/settings/page.tsx | 591 ++ .../app/(dashboard)/dashboard/team/page.tsx | 317 + apps/dashboard/src/app/(dashboard)/layout.tsx | 19 +- apps/dashboard/src/app/icon.tsx | 32 + apps/dashboard/src/app/layout.tsx | 21 +- .../src/app/onboarding/accept-invite/page.tsx | 25 + apps/dashboard/src/app/onboarding/page.tsx | 269 + apps/dashboard/src/app/page.tsx | 5 + .../src/app/sign-in/[[...sign-in]]/page.tsx | 153 + .../src/app/sign-up/[[...sign-up]]/page.tsx | 632 ++ .../dashboard/src/components/I18nProvider.tsx | 192 + .../src/components/layout/PublicShell.tsx | 61 + .../src/components/layout/Sidebar.tsx | 95 +- .../src/components/layout/TopBar.tsx | 77 +- .../reservations/DamageInspectionCard.tsx | 249 + .../src/components/team/EditMemberModal.tsx | 261 + .../src/components/team/InviteModal.tsx | 199 + .../src/components/team/PermissionsMatrix.tsx | 113 + apps/dashboard/src/hooks/useTeam.ts | 138 + apps/dashboard/src/lib/api.ts | 14 +- apps/dashboard/src/lib/clerk.ts | 5 + apps/dashboard/src/lib/urls.ts | 1 + apps/dashboard/src/middleware.ts | 14 +- apps/marketplace/next-env.d.ts | 5 + apps/marketplace/public/favicon.ico | Bin 0 -> 1172463 bytes apps/marketplace/public/rentalcardrive.png | Bin 0 -> 1172463 bytes apps/marketplace/src/app/HomeContent.tsx | 365 + .../src/app/branded-website/page.tsx | 5 + .../src/app/company-workspace/page.tsx | 5 + .../src/app/explore/[slug]/page.tsx | 148 + .../app/explore/[slug]/vehicles/[id]/page.tsx | 118 + apps/marketplace/src/app/explore/page.tsx | 286 + apps/marketplace/src/app/features/page.tsx | 71 + apps/marketplace/src/app/globals.css | 15 + apps/marketplace/src/app/layout.tsx | 23 + apps/marketplace/src/app/page.tsx | 5 + .../src/app/platform-operations/page.tsx | 5 + .../src/app/pricing/PricingClient.tsx | 269 + .../src/app/pricing/PricingPageContent.tsx | 74 + apps/marketplace/src/app/pricing/page.tsx | 11 + .../src/app/renter/dashboard/page.tsx | 331 + .../src/app/renter/notifications/page.tsx | 164 + .../src/app/renter/profile/page.tsx | 351 + .../src/app/renter/saved-companies/page.tsx | 113 + .../src/app/renter/sign-in/page.tsx | 6 + .../src/app/renter/sign-up/page.tsx | 6 + .../src/components/MarketplaceShell.tsx | 264 + .../src/components/WorkspaceFrame.tsx | 71 + .../src/components/WorkspaceTabs.tsx | 233 + apps/marketplace/src/lib/api.ts | 31 + apps/marketplace/src/lib/i18n.server.ts | 7 + apps/marketplace/src/lib/i18n.ts | 7 + apps/marketplace/src/lib/renter.ts | 115 + apps/public-site/next-env.d.ts | 5 + apps/public-site/public/rentalcardrive.png | Bin 0 -> 1172463 bytes apps/public-site/src/app/about/page.tsx | 90 + apps/public-site/src/app/blog/page.tsx | 31 + apps/public-site/src/app/book/BookClient.tsx | 754 ++ .../src/app/book/confirmation/page.tsx | 124 + apps/public-site/src/app/book/page.tsx | 21 + apps/public-site/src/app/contact/page.tsx | 17 + apps/public-site/src/app/globals.css | 15 + apps/public-site/src/app/layout.tsx | 101 + apps/public-site/src/app/offers/page.tsx | 35 + apps/public-site/src/app/page.tsx | 105 + apps/public-site/src/app/pricing/page.tsx | 126 + .../src/app/vehicles/VehicleFilters.tsx | 127 + .../src/app/vehicles/[id]/page.tsx | 70 + apps/public-site/src/app/vehicles/page.tsx | 141 + .../src/components/PublicLanguageSwitcher.tsx | 57 + apps/public-site/src/lib/api.ts | 32 + apps/public-site/src/lib/i18n.server.ts | 7 + apps/public-site/src/lib/i18n.ts | 987 ++ apps/public-site/src/middleware.ts | 63 + command_to_run_dev.txt | 24 + docker-compose.dev.yml | 154 + docker-compose.production.yml | 107 + docker-compose.test.yml | 40 + docker/pgadmin/servers.json | 13 + docker/scripts/dev-bootstrap.sh | 22 + docs/project-design/AUDIT.md | 57 + docs/project-design/FEATURES.md | 310 + docs/project-design/INTEGRATION.md | 182 + docs/project-design/PAGES.md | 316 + docs/project-design/README.md | 109 + docs/project-design/advanced-features.md | 1308 +++ docs/project-design/api-routes.md | 498 + docs/project-design/schema.md | 980 ++ docs/project-design/stubs/EditMemberModal.tsx | 268 + docs/project-design/stubs/InviteModal.tsx | 205 + .../stubs/PermissionsMatrix.tsx | 115 + docs/project-design/stubs/requireRole.ts | 47 + docs/project-design/stubs/team.ts | 169 + docs/project-design/stubs/team.tsx | 337 + docs/project-design/stubs/teamService.ts | 365 + docs/project-design/stubs/useTeam.ts | 172 + docs/project-design/stubs/webhooks.ts | 82 + package-lock.json | 8922 +++++++++++++++++ package.json | 1 + packages/database/package.json | 7 +- packages/database/prisma/schema.prisma | 348 +- packages/database/src/index.js | 5 + packages/database/src/index.ts | 73 + packages/types/tsconfig.json | 17 + project_design/FEATURES.md | 14 +- project_design/INTEGRATION.md | 8 +- project_design/PAGES.md | 30 +- project_design/README.md | 20 +- project_design/advanced-features.md | 14 +- project_design/api-routes.md | 14 +- project_design/rentalcardrive.png | Bin 0 -> 1172463 bytes project_design/schema.md | 4 +- project_design/useTeam.ts | 2 +- 175 files changed, 31249 insertions(+), 328 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.docker.dev create mode 100644 .env.docker.production.example create mode 100644 .env.docker.test create mode 100644 DOCKER.md create mode 100644 Dockerfile.dev create mode 100644 Dockerfile.production create mode 100644 Dockerfile.test create mode 100644 apps/admin/next-env.d.ts create mode 100644 apps/admin/src/app/dashboard/admin-users/page.tsx create mode 100644 apps/admin/src/app/dashboard/audit-logs/page.tsx create mode 100644 apps/admin/src/app/dashboard/companies/[id]/page.tsx create mode 100644 apps/admin/src/app/dashboard/companies/page.tsx create mode 100644 apps/admin/src/app/dashboard/layout.tsx create mode 100644 apps/admin/src/app/dashboard/page.tsx create mode 100644 apps/admin/src/app/dashboard/renters/page.tsx create mode 100644 apps/admin/src/app/favicon.ico/route.ts create mode 100644 apps/admin/src/app/globals.css create mode 100644 apps/admin/src/app/icon.tsx create mode 100644 apps/admin/src/app/layout.tsx create mode 100644 apps/admin/src/app/login/page.tsx create mode 100644 apps/admin/src/app/page.tsx create mode 100644 apps/admin/src/components/I18nProvider.tsx create mode 100644 apps/admin/src/components/PublicShell.tsx create mode 100644 apps/admin/src/lib/api.ts create mode 100644 apps/api/src/middleware/rateLimiter.ts create mode 100644 apps/api/src/routes/auth.company.ts create mode 100644 apps/api/src/routes/companies.ts create mode 100644 apps/api/src/routes/payments.ts create mode 100644 apps/api/src/routes/site.ts create mode 100644 apps/api/src/routes/subscriptions.ts create mode 100644 apps/api/src/services/additionalDriverService.ts create mode 100644 apps/api/src/services/amanpayService.ts create mode 100644 apps/api/src/services/paypalService.ts create mode 100644 apps/dashboard/next-env.d.ts create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/notifications/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/reservations/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/settings/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/team/page.tsx create mode 100644 apps/dashboard/src/app/icon.tsx create mode 100644 apps/dashboard/src/app/onboarding/accept-invite/page.tsx create mode 100644 apps/dashboard/src/app/onboarding/page.tsx create mode 100644 apps/dashboard/src/app/page.tsx create mode 100644 apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx create mode 100644 apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx create mode 100644 apps/dashboard/src/components/I18nProvider.tsx create mode 100644 apps/dashboard/src/components/layout/PublicShell.tsx create mode 100644 apps/dashboard/src/components/reservations/DamageInspectionCard.tsx create mode 100644 apps/dashboard/src/components/team/EditMemberModal.tsx create mode 100644 apps/dashboard/src/components/team/InviteModal.tsx create mode 100644 apps/dashboard/src/components/team/PermissionsMatrix.tsx create mode 100644 apps/dashboard/src/hooks/useTeam.ts create mode 100644 apps/dashboard/src/lib/clerk.ts create mode 100644 apps/dashboard/src/lib/urls.ts create mode 100644 apps/marketplace/next-env.d.ts create mode 100644 apps/marketplace/public/favicon.ico create mode 100644 apps/marketplace/public/rentalcardrive.png create mode 100644 apps/marketplace/src/app/HomeContent.tsx create mode 100644 apps/marketplace/src/app/branded-website/page.tsx create mode 100644 apps/marketplace/src/app/company-workspace/page.tsx create mode 100644 apps/marketplace/src/app/explore/[slug]/page.tsx create mode 100644 apps/marketplace/src/app/explore/[slug]/vehicles/[id]/page.tsx create mode 100644 apps/marketplace/src/app/explore/page.tsx create mode 100644 apps/marketplace/src/app/features/page.tsx create mode 100644 apps/marketplace/src/app/globals.css create mode 100644 apps/marketplace/src/app/layout.tsx create mode 100644 apps/marketplace/src/app/page.tsx create mode 100644 apps/marketplace/src/app/platform-operations/page.tsx create mode 100644 apps/marketplace/src/app/pricing/PricingClient.tsx create mode 100644 apps/marketplace/src/app/pricing/PricingPageContent.tsx create mode 100644 apps/marketplace/src/app/pricing/page.tsx create mode 100644 apps/marketplace/src/app/renter/dashboard/page.tsx create mode 100644 apps/marketplace/src/app/renter/notifications/page.tsx create mode 100644 apps/marketplace/src/app/renter/profile/page.tsx create mode 100644 apps/marketplace/src/app/renter/saved-companies/page.tsx create mode 100644 apps/marketplace/src/app/renter/sign-in/page.tsx create mode 100644 apps/marketplace/src/app/renter/sign-up/page.tsx create mode 100644 apps/marketplace/src/components/MarketplaceShell.tsx create mode 100644 apps/marketplace/src/components/WorkspaceFrame.tsx create mode 100644 apps/marketplace/src/components/WorkspaceTabs.tsx create mode 100644 apps/marketplace/src/lib/api.ts create mode 100644 apps/marketplace/src/lib/i18n.server.ts create mode 100644 apps/marketplace/src/lib/i18n.ts create mode 100644 apps/marketplace/src/lib/renter.ts create mode 100644 apps/public-site/next-env.d.ts create mode 100644 apps/public-site/public/rentalcardrive.png create mode 100644 apps/public-site/src/app/about/page.tsx create mode 100644 apps/public-site/src/app/blog/page.tsx create mode 100644 apps/public-site/src/app/book/BookClient.tsx create mode 100644 apps/public-site/src/app/book/confirmation/page.tsx create mode 100644 apps/public-site/src/app/book/page.tsx create mode 100644 apps/public-site/src/app/contact/page.tsx create mode 100644 apps/public-site/src/app/globals.css create mode 100644 apps/public-site/src/app/layout.tsx create mode 100644 apps/public-site/src/app/offers/page.tsx create mode 100644 apps/public-site/src/app/page.tsx create mode 100644 apps/public-site/src/app/pricing/page.tsx create mode 100644 apps/public-site/src/app/vehicles/VehicleFilters.tsx create mode 100644 apps/public-site/src/app/vehicles/[id]/page.tsx create mode 100644 apps/public-site/src/app/vehicles/page.tsx create mode 100644 apps/public-site/src/components/PublicLanguageSwitcher.tsx create mode 100644 apps/public-site/src/lib/api.ts create mode 100644 apps/public-site/src/lib/i18n.server.ts create mode 100644 apps/public-site/src/lib/i18n.ts create mode 100644 apps/public-site/src/middleware.ts create mode 100644 command_to_run_dev.txt create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.production.yml create mode 100644 docker-compose.test.yml create mode 100644 docker/pgadmin/servers.json create mode 100644 docker/scripts/dev-bootstrap.sh create mode 100644 docs/project-design/AUDIT.md create mode 100644 docs/project-design/FEATURES.md create mode 100644 docs/project-design/INTEGRATION.md create mode 100644 docs/project-design/PAGES.md create mode 100644 docs/project-design/README.md create mode 100644 docs/project-design/advanced-features.md create mode 100644 docs/project-design/api-routes.md create mode 100644 docs/project-design/schema.md create mode 100644 docs/project-design/stubs/EditMemberModal.tsx create mode 100644 docs/project-design/stubs/InviteModal.tsx create mode 100644 docs/project-design/stubs/PermissionsMatrix.tsx create mode 100644 docs/project-design/stubs/requireRole.ts create mode 100644 docs/project-design/stubs/team.ts create mode 100644 docs/project-design/stubs/team.tsx create mode 100644 docs/project-design/stubs/teamService.ts create mode 100644 docs/project-design/stubs/useTeam.ts create mode 100644 docs/project-design/stubs/webhooks.ts create mode 100644 package-lock.json create mode 100644 packages/database/src/index.js create mode 100644 packages/database/src/index.ts create mode 100644 packages/types/tsconfig.json create mode 100644 project_design/rentalcardrive.png diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ba0d71e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +.turbo +.git +.gitignore +.codex +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.next +dist +coverage +tmp +.env.local +.env.*.local + diff --git a/.env.docker.dev b/.env.docker.dev new file mode 100644 index 0000000..a730102 --- /dev/null +++ b/.env.docker.dev @@ -0,0 +1,23 @@ +DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego +REDIS_URL=redis://redis:6379 +API_PORT=4000 +API_URL=http://localhost:4000 +API_INTERNAL_URL=http://api:4000/api/v1 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_MARKETING_URL=http://localhost:3000 +NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore +NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001 +NEXT_PUBLIC_ADMIN_URL=http://localhost:3002 +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 +DASHBOARD_URL=http://localhost:3001 +JWT_SECRET=dev-secret +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +ADMIN_SEED_EMAIL=admin@rentaldrivego.com +ADMIN_SEED_PASSWORD=changeme123 +ADMIN_SEED_FIRST_NAME=Platform +ADMIN_SEED_LAST_NAME=Admin +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= +NODE_ENV=development +CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003 diff --git a/.env.docker.production.example b/.env.docker.production.example new file mode 100644 index 0000000..f8d01e1 --- /dev/null +++ b/.env.docker.production.example @@ -0,0 +1,17 @@ +DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego +REDIS_URL=redis://redis:6379 +API_PORT=4000 +API_URL=http://localhost:4000 +API_INTERNAL_URL=http://api:4000/api/v1 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_MARKETING_URL=http://localhost:3000 +NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore +NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001 +NEXT_PUBLIC_ADMIN_URL=http://localhost:3002 +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 +DASHBOARD_URL=http://localhost:3001 +JWT_SECRET=replace-with-a-long-random-secret +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +POSTGRES_PASSWORD=replace-with-a-real-password +NODE_ENV=production diff --git a/.env.docker.test b/.env.docker.test new file mode 100644 index 0000000..5a91bd4 --- /dev/null +++ b/.env.docker.test @@ -0,0 +1,10 @@ +DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test +REDIS_URL=redis://redis:6379 +API_PORT=4000 +API_URL=http://localhost:4000 +API_INTERNAL_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +JWT_SECRET=test-secret +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +NODE_ENV=test diff --git a/.env.example b/.env.example index f2d3764..704be9d 100644 --- a/.env.example +++ b/.env.example @@ -26,14 +26,14 @@ NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding # ─── AmanPay (Primary payment provider) ─────────────────────── -# RentFlow's own AmanPay account (for collecting subscription fees) +# RentalDriveGo's own AmanPay account (for collecting subscription fees) AMANPAY_MERCHANT_ID=your-amanpay-merchant-id AMANPAY_SECRET_KEY=your-amanpay-secret-key AMANPAY_BASE_URL=https://api.amanpay.net AMANPAY_WEBHOOK_SECRET=your-amanpay-webhook-secret # ─── PayPal (Secondary payment provider) ────────────────────── -# RentFlow's own PayPal account (for collecting subscription fees) +# RentalDriveGo's own PayPal account (for collecting subscription fees) PAYPAL_CLIENT_ID=your-paypal-client-id PAYPAL_CLIENT_SECRET=your-paypal-client-secret PAYPAL_BASE_URL=https://api-m.paypal.com @@ -67,6 +67,9 @@ NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789 NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123 +# The Node API uses Resend for email delivery. +# MAIL_* SMTP variables are intentionally omitted here. + # ─── Redis (Real-time / Socket.io) ──────────────────────────── REDIS_URL=redis://localhost:6379 diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..aead9ca --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,131 @@ +## Docker Environments + +Three Docker environments are available: + +- `Dockerfile.dev` with `docker-compose.dev.yml` +- `Dockerfile.test` with `docker-compose.test.yml` +- `Dockerfile.production` with `docker-compose.production.yml` + +### Development + +Use the full dev stack for local work with hot reload and bundled Postgres and Redis: + +```bash +docker compose -f docker-compose.dev.yml --profile full up --build +``` + +Services: + +- marketplace: `http://localhost:3000` +- dashboard: `http://localhost:3001` +- admin: `http://localhost:3002` +- public-site: `http://localhost:3003` +- api: `http://localhost:4000` +- pgAdmin: `http://localhost:5050` + +Each dev app now runs in its own container and can be started independently with a profile tag: + +```bash +docker compose -f docker-compose.dev.yml --profile api up --build +docker compose -f docker-compose.dev.yml --profile marketplace up --build +docker compose -f docker-compose.dev.yml --profile dashboard up --build +docker compose -f docker-compose.dev.yml --profile admin up --build +docker compose -f docker-compose.dev.yml --profile public-site up --build +docker compose -f docker-compose.dev.yml --profile tools up --build +``` + +Notes: + +- `api` starts `postgres`, `redis`, and `migrate` automatically through dependencies. +- frontend profiles also start `api` and its dependencies automatically. +- `tools` starts only `pgadmin` plus its required `postgres` dependency. + +On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts. + +Default dev platform administrator: + +- email: `admin@rentaldrivego.com` +- password: `changeme123` + +If you intentionally want a fresh dev bootstrap: + +```bash +docker compose -f docker-compose.dev.yml down -v +``` + +If you want to keep the database and only apply new schema changes manually: + +```bash +docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy" +``` + +pgAdmin dev login: + +- email: `admin@rentaldrivego.local` +- email: `admin@rentaldrivego.dev` +- password: `admin` + +pgAdmin opens with the dev Postgres server pre-registered as `RentalDriveGo Dev DB`. + +pgAdmin Postgres connection: + +- host: `postgres` +- port: `5432` +- database: `rentaldrivego` +- username: `postgres` +- password: `password` + +### Test + +Use the test stack to run repeatable containerized verification: + +```bash +docker compose -f docker-compose.test.yml up --build --abort-on-container-exit +``` + +The test container runs: + +- `npm run db:deploy` +- `npm run db:generate` +- `npm run type-check` +- `npm run build` + +### Production + +1. Copy `.env.docker.production.example` to `.env.docker.production` +2. Fill in real secrets and domain values +3. Start the stack: + +```bash +docker compose -f docker-compose.production.yml up --build -d +``` + +Production compose starts separate containers for: + +- postgres +- redis +- api +- marketplace +- dashboard +- admin +- public-site + +### Notes + +- The production image builds the whole monorepo once, then each service overrides its runtime command. +- The dev compose file bind-mounts the repo and keeps `node_modules` in a named volume. +- `API_INTERNAL_URL` is used for server-side container-to-container calls, while `NEXT_PUBLIC_API_URL` is used by the browser. +- The Dockerfiles activate the repo's pinned `npm@10.5.0` with `corepack` before install so container builds do not depend on the npm version bundled with the base image. +- The dev compose stack stores Postgres data in `postgres_dev_data` and the bootstrap marker in `postgres_bootstrap_state`, so `up --build` does not reseed an existing local database. +- If you need database schema updates inside Docker, run: + +```bash +docker compose -f docker-compose.dev.yml run --rm migrate +``` + +If a cached base image still fails during `npm ci`, refresh it and rebuild without cache: + +```bash +docker pull node:20-bookworm +docker compose -f docker-compose.dev.yml build --no-cache dashboard +``` diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..287ad82 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM node:20-bookworm + +WORKDIR /app + +RUN npm install -g npm@10.5.0 --no-fund --no-audit + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm ci --no-fund --no-audit + +EXPOSE 3000 3001 3002 3003 4000 + +CMD ["sh", "-c", "npm run db:generate && npm run dev"] diff --git a/Dockerfile.production b/Dockerfile.production new file mode 100644 index 0000000..4b1bee3 --- /dev/null +++ b/Dockerfile.production @@ -0,0 +1,30 @@ +FROM node:20-bookworm AS builder + +WORKDIR /app + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm install +RUN npm run db:generate +RUN npm run build + +FROM node:20-bookworm AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY --from=builder /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/apps ./apps +COPY --from=builder /app/packages ./packages + +EXPOSE 3000 3001 3002 3003 4000 + +CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"] diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..2b313a6 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,15 @@ +FROM node:20-bookworm + +WORKDIR /app + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm install + +ENV NODE_ENV=test + +CMD ["sh", "-c", "npm run db:generate && npm run type-check && npm run build"] diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index 4f89111..ff15bca 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -1,9 +1,19 @@ /** @type {import('next').NextConfig} */ +const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') + const nextConfig = { images: { domains: ['res.cloudinary.com'], }, transpilePackages: ['@rentaldrivego/types'], + async rewrites() { + return [ + { + source: '/api/:path*', + destination: `${apiOrigin}/api/:path*`, + }, + ] + }, } module.exports = nextConfig diff --git a/apps/admin/src/app/dashboard/admin-users/page.tsx b/apps/admin/src/app/dashboard/admin-users/page.tsx new file mode 100644 index 0000000..389cc54 --- /dev/null +++ b/apps/admin/src/app/dashboard/admin-users/page.tsx @@ -0,0 +1,211 @@ +'use client' + +import { useEffect, useState } from 'react' + +const API_BASE = '/api/v1' + +interface AdminUser { + id: string + firstName: string + lastName: string + email: string + role: string + isActive: boolean + createdAt: string + permissions?: { id: string; resource: string; actions: string[] }[] +} + +const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'] + +export default function AdminUsersPage() { + const [admins, setAdmins] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showModal, setShowModal] = useState(false) + const [form, setForm] = useState({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' }) + const [creating, setCreating] = useState(false) + + function getToken() { return localStorage.getItem('admin_token') ?? '' } + + async function fetchAdmins() { + try { + const res = await fetch(`${API_BASE}/admin/admins`, { + headers: { Authorization: `Bearer ${getToken()}` }, + cache: 'no-store', + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Failed') + setAdmins(json.data ?? []) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchAdmins() }, []) + + async function createAdmin(e: React.FormEvent) { + e.preventDefault() + setCreating(true) + setError(null) + try { + const res = await fetch(`${API_BASE}/admin/admins`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify(form), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Failed to create') + setShowModal(false) + setForm({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' }) + await fetchAdmins() + } catch (err: any) { + setError(err.message) + } finally { + setCreating(false) + } + } + + const ROLE_COLORS: Record = { + SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40', + ADMIN: 'text-sky-400 bg-sky-950/40', + SUPPORT: 'text-amber-400 bg-amber-950/40', + FINANCE: 'text-violet-400 bg-violet-950/40', + VIEWER: 'text-zinc-400 bg-zinc-800', + } + + return ( +
+
+
+

Platform

+

Admin Users

+
+ +
+ + {error &&
{error}
} + +
+
+ + + + + + + + + + + + + {loading ? ( + + ) : admins.length === 0 ? ( + + ) : admins.map((a) => ( + + + + + + + + + ))} + +
NameEmailRolePermissionsStatusJoined
Loading…
No admin users found
{a.firstName} {a.lastName}{a.email} + + {a.role} + + + {a.permissions && a.permissions.length > 0 + ? a.permissions.map((permission) => permission.resource).join(', ') + : 'Role-based only'} + + + {a.isActive ? 'Active' : 'Inactive'} + + {new Date(a.createdAt).toLocaleDateString()}
+
+
+ + {showModal && ( +
+
+
+

New admin user

+ +
+
+
+
+ + setForm({ ...form, firstName: e.target.value })} + /> +
+
+ + setForm({ ...form, lastName: e.target.value })} + /> +
+
+
+ + setForm({ ...form, email: e.target.value })} + /> +
+
+ + setForm({ ...form, password: e.target.value })} + /> +
+
+ + +
+
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/apps/admin/src/app/dashboard/audit-logs/page.tsx b/apps/admin/src/app/dashboard/audit-logs/page.tsx new file mode 100644 index 0000000..c6cff74 --- /dev/null +++ b/apps/admin/src/app/dashboard/audit-logs/page.tsx @@ -0,0 +1,100 @@ +'use client' + +import { useEffect, useState } from 'react' + +const API_BASE = '/api/v1' + +interface AuditLog { + id: string + action: string + resource: string + resourceId: string | null + createdAt: string + adminUser: { email: string; firstName: string; lastName: string } | null +} + +const ACTION_COLORS: Record = { + CREATE: 'text-emerald-400 bg-emerald-950/40', + UPDATE: 'text-sky-400 bg-sky-950/40', + DELETE: 'text-red-400 bg-red-950/40', + SUSPEND: 'text-amber-400 bg-amber-950/40', + ACTIVATE: 'text-emerald-400 bg-emerald-950/40', + LOGIN: 'text-zinc-400 bg-zinc-800', +} + +export default function AdminAuditLogsPage() { + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [filter, setFilter] = useState('') + + useEffect(() => { + const token = localStorage.getItem('admin_token') ?? '' + fetch(`${API_BASE}/admin/audit-logs`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }) + .then((r) => r.json()) + .then((json) => setLogs(json.data ?? [])) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + }, []) + + const filtered = filter + ? logs.filter((l) => l.action.toLowerCase().includes(filter.toLowerCase()) || l.resource.toLowerCase().includes(filter.toLowerCase())) + : logs + + return ( +
+
+
+

Platform

+

Audit Logs

+
+ setFilter(e.target.value)} + /> +
+ + {error &&
{error}
} + +
+
+ + + + + + + + + + + + {loading ? ( + + ) : filtered.length === 0 ? ( + + ) : filtered.map((log) => ( + + + + + + + + ))} + +
ActionEntityEntity IDAdminTime
Loading…
No logs found
+ + {log.action} + + {log.resource}{log.resourceId ? `${log.resourceId.slice(0, 12)}…` : '—'}{log.adminUser ? `${log.adminUser.firstName} ${log.adminUser.lastName}` : '—'}{new Date(log.createdAt).toLocaleString()}
+
+
+
+ ) +} diff --git a/apps/admin/src/app/dashboard/companies/[id]/page.tsx b/apps/admin/src/app/dashboard/companies/[id]/page.tsx new file mode 100644 index 0000000..5513332 --- /dev/null +++ b/apps/admin/src/app/dashboard/companies/[id]/page.tsx @@ -0,0 +1,204 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' + +const API_BASE = '/api/v1' + +interface CompanyDetail { + id: string + name: string + slug: string + email: string + phone: string | null + status: string + createdAt: string + subscription: { plan: string; status: string; trialEndAt: string | null } | null + _count: { employees: number; vehicles: number; customers: number; reservations: number } +} + +interface AuditLog { + id: string + action: string + entityType: string + entityId: string + createdAt: string + admin: { email: string } | null +} + +const STATUS_COLORS: Record = { + ACTIVE: 'text-emerald-400', + TRIALING: 'text-sky-400', + SUSPENDED: 'text-red-400', + PENDING: 'text-amber-400', + CANCELLED: 'text-zinc-400', +} + +export default function AdminCompanyDetailPage() { + const { id } = useParams<{ id: string }>() + const router = useRouter() + const [company, setCompany] = useState(null) + const [auditLogs, setAuditLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [actioning, setActioning] = useState(false) + const [deleteConfirm, setDeleteConfirm] = useState(false) + + function getToken() { return localStorage.getItem('admin_token') ?? '' } + + async function fetchData() { + const token = getToken() + const headers = { Authorization: `Bearer ${token}` } + try { + const [cRes, aRes] = await Promise.all([ + fetch(`${API_BASE}/admin/companies/${id}`, { headers, cache: 'no-store' }), + fetch(`${API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { headers, cache: 'no-store' }), + ]) + const cJson = await cRes.json() + const aJson = await aRes.json() + if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found') + setCompany(cJson.data) + setAuditLogs(aJson.data ?? []) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchData() }, [id]) + + async function changeStatus(status: string) { + setActioning(true) + try { + const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify({ status }), + }) + if (!res.ok) throw new Error('Action failed') + await fetchData() + } catch (err: any) { + setError(err.message) + } finally { + setActioning(false) + } + } + + async function deleteCompany() { + setActioning(true) + try { + const res = await fetch(`${API_BASE}/admin/companies/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${getToken()}` }, + }) + if (!res.ok) throw new Error('Delete failed') + router.push('/dashboard/companies') + } catch (err: any) { + setError(err.message) + setActioning(false) + } + } + + if (loading) return
Loading…
+ if (error && !company) return
{error}
+ if (!company) return null + + return ( +
+
+ ← Companies +
+ +
+
+

{company.name}

+

{company.slug}

+
+ {company.status} +
+ + {error &&
{error}
} + +
+ {[ + { label: 'Employees', value: company._count.employees }, + { label: 'Vehicles', value: company._count.vehicles }, + { label: 'Customers', value: company._count.customers }, + { label: 'Reservations', value: company._count.reservations }, + ].map((kpi) => ( +
+

{kpi.label}

+

{kpi.value}

+
+ ))} +
+ +
+
+

Details

+
+
Email
{company.email}
+
Phone
{company.phone ?? '—'}
+
Joined
{new Date(company.createdAt).toLocaleDateString()}
+ {company.subscription && <> +
Plan
{company.subscription.plan}
+
Sub status
{company.subscription.status}
+ {company.subscription.trialEndAt && ( +
Trial ends
{new Date(company.subscription.trialEndAt).toLocaleDateString()}
+ )} + } +
+
+ +
+

Actions

+
+ {company.status === 'SUSPENDED' ? ( + + ) : ( + + )} + {!deleteConfirm ? ( + + ) : ( +
+

This will permanently delete all company data. Are you sure?

+
+ + +
+
+ )} +
+
+
+ + {auditLogs.length > 0 && ( +
+
+

Recent audit events

+
+
+ {auditLogs.map((log) => ( +
+
+ {log.action} + {log.entityType} +
+ {new Date(log.createdAt).toLocaleString()} +
+ ))} +
+
+ )} +
+ ) +} diff --git a/apps/admin/src/app/dashboard/companies/page.tsx b/apps/admin/src/app/dashboard/companies/page.tsx new file mode 100644 index 0000000..9f36bbe --- /dev/null +++ b/apps/admin/src/app/dashboard/companies/page.tsx @@ -0,0 +1,207 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { useAdminI18n } from '@/components/I18nProvider' + +const API_BASE = '/api/v1' + +interface Company { + id: string + name: string + slug: string + status: string + email: string + subscription: { plan: string; status: string } | null + _count: { employees: number; vehicles: number } +} + +const STATUS_COLORS: Record = { + ACTIVE: 'text-emerald-400 bg-emerald-900/30', + TRIALING: 'text-sky-400 bg-sky-900/30', + SUSPENDED: 'text-red-400 bg-red-900/30', + PENDING: 'text-amber-400 bg-amber-900/30', + CANCELLED: 'text-zinc-400 bg-zinc-800', +} + +export default function AdminCompaniesPage() { + const { language, dict } = useAdminI18n() + const copy = { + en: { + title: 'Companies', + search: 'Search by name or slug…', + loading: 'Loading…', + empty: 'No companies found', + company: 'Company', + slug: 'Slug', + status: 'Status', + plan: 'Plan', + fleet: 'Fleet', + vehicles: 'vehicles', + view: 'View', + suspend: 'Suspend', + reactivate: 'Reactivate', + }, + fr: { + title: 'Entreprises', + search: 'Rechercher par nom ou slug…', + loading: 'Chargement…', + empty: 'Aucune entreprise trouvée', + company: 'Entreprise', + slug: 'Slug', + status: 'Statut', + plan: 'Plan', + fleet: 'Flotte', + vehicles: 'véhicules', + view: 'Voir', + suspend: 'Suspendre', + reactivate: 'Réactiver', + }, + ar: { + title: 'الشركات', + search: 'ابحث بالاسم أو الـ slug…', + loading: 'جارٍ التحميل…', + empty: 'لم يتم العثور على شركات', + company: 'الشركة', + slug: 'Slug', + status: 'الحالة', + plan: 'الخطة', + fleet: 'الأسطول', + vehicles: 'سيارات', + view: 'عرض', + suspend: 'تعليق', + reactivate: 'إعادة التفعيل', + }, + }[language] + const [companies, setCompanies] = useState([]) + const [filtered, setFiltered] = useState([]) + const [search, setSearch] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [actioning, setActioning] = useState(null) + + async function fetchCompanies() { + const token = localStorage.getItem('admin_token') + try { + const res = await fetch(`${API_BASE}/admin/companies`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + cache: 'no-store', + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch') + setCompanies(json.data ?? []) + setFiltered(json.data ?? []) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchCompanies() }, []) + + useEffect(() => { + const q = search.toLowerCase() + setFiltered(companies.filter((c) => c.name.toLowerCase().includes(q) || c.slug.includes(q) || c.email.toLowerCase().includes(q))) + }, [search, companies]) + + async function changeStatus(id: string, status: string) { + setActioning(id) + const token = localStorage.getItem('admin_token') + try { + const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ status }), + }) + if (!res.ok) throw new Error('Action failed') + await fetchCompanies() + } catch (err: any) { + setError(err.message) + } finally { + setActioning(null) + } + } + + return ( +
+
+
+

{dict.platform}

+

{copy.title}

+
+ setSearch(e.target.value)} + /> +
+ + {error &&
{error}
} + +
+
+ + + + + + + + + + + + {loading ? ( + + ) : filtered.length === 0 ? ( + + ) : filtered.map((c) => ( + + + + + + + + + ))} + +
{copy.company}{copy.slug}{copy.status}{copy.plan}{copy.fleet} +
{copy.loading}
{copy.empty}
+

{c.name}

+

{c.email}

+
{c.slug} + + {c.status} + + {c.subscription?.plan ?? '—'}{c._count?.vehicles ?? 0} {copy.vehicles} +
+ + {copy.view} + + {c.status === 'ACTIVE' || c.status === 'TRIALING' ? ( + + ) : c.status === 'SUSPENDED' ? ( + + ) : null} +
+
+
+
+
+ ) +} diff --git a/apps/admin/src/app/dashboard/layout.tsx b/apps/admin/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..9b5ec67 --- /dev/null +++ b/apps/admin/src/app/dashboard/layout.tsx @@ -0,0 +1,90 @@ +'use client' + +import Link from 'next/link' +import { usePathname, useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider' + +const navLinks = [ + { href: '/dashboard', key: 'overview', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' }, + { href: '/dashboard/companies', key: 'companies', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' }, + { href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' }, + { href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' }, + { href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' }, +] + +export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) { + const { dict } = useAdminI18n() + const pathname = usePathname() + const router = useRouter() + const [ready, setReady] = useState(false) + + useEffect(() => { + const token = localStorage.getItem('admin_token') + if (!token) { + router.replace('/login') + } else { + setReady(true) + } + }, [router]) + + function handleLogout() { + localStorage.removeItem('admin_token') + router.push('/login') + } + + if (!ready) { + return ( +
+
+
+ ) + } + + return ( +
+ +
{children}
+
+ ) +} diff --git a/apps/admin/src/app/dashboard/page.tsx b/apps/admin/src/app/dashboard/page.tsx new file mode 100644 index 0000000..a7ba6ca --- /dev/null +++ b/apps/admin/src/app/dashboard/page.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { useAdminI18n } from '@/components/I18nProvider' + +const API_BASE = '/api/v1' + +interface Metrics { + totalCompanies: number + activeCompanies: number + totalRenters: number + totalReservations: number + mrr?: number +} + +export default function AdminDashboardPage() { + const { language, dict } = useAdminI18n() + const copy = { + en: { + kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'], + platformOverview: 'Platform overview', + cards: [ + ['Companies', 'List, search, suspend, reactivate, and review subscription state.', 'View all →'], + ['Renters', 'Support flows for blocking and unblocking marketplace renters.', 'View all →'], + ['Audit logs', 'Full trace of every admin action taken on the platform.', 'View logs →'], + ], + }, + fr: { + kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'], + platformOverview: 'Vue plateforme', + cards: [ + ['Entreprises', 'Lister, rechercher, suspendre, réactiver et revoir l’état des abonnements.', 'Voir tout →'], + ['Locataires', 'Flux de support pour bloquer et débloquer les locataires marketplace.', 'Voir tout →'], + ['Journaux d’audit', 'Trace complète de chaque action admin sur la plateforme.', 'Voir les journaux →'], + ], + }, + ar: { + kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'], + platformOverview: 'نظرة عامة على المنصة', + cards: [ + ['الشركات', 'اعرض وابحث وعلّق وأعد التفعيل وراجع حالة الاشتراك.', 'عرض الكل ←'], + ['المستأجرون', 'مسارات دعم لحظر وفك حظر مستأجري السوق.', 'عرض الكل ←'], + ['سجلات التدقيق', 'تتبع كامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'], + ], + }, + }[language] + const [metrics, setMetrics] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const token = localStorage.getItem('admin_token') ?? '' + fetch(`${API_BASE}/admin/metrics`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }) + .then((r) => r.json()) + .then((json) => setMetrics(json.data)) + .catch(() => null) + .finally(() => setLoading(false)) + }, []) + + const kpis = metrics + ? [ + { label: 'Total companies', value: metrics.totalCompanies }, + { label: copy.kpis[1], value: metrics.activeCompanies }, + { label: copy.kpis[2], value: metrics.totalRenters }, + { label: copy.kpis[3], value: metrics.totalReservations }, + ] + : [] + if (kpis.length > 0) kpis[0].label = copy.kpis[0] + + return ( +
+
+

{dict.admin}

+

{copy.platformOverview}

+
+ +
+ {loading + ? Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ )) + : kpis.map((kpi) => ( +
+

{kpi.label}

+

{kpi.value?.toLocaleString() ?? '—'}

+
+ ))} +
+ +
+ {[ + ['/dashboard/companies', ...copy.cards[0]], + ['/dashboard/renters', ...copy.cards[1]], + ['/dashboard/audit-logs', ...copy.cards[2]], + ].map(([href, title, body, cta]) => ( + +

{title}

+

{body}

+

{cta}

+ + ))} +
+
+ ) +} diff --git a/apps/admin/src/app/dashboard/renters/page.tsx b/apps/admin/src/app/dashboard/renters/page.tsx new file mode 100644 index 0000000..4b4f2e1 --- /dev/null +++ b/apps/admin/src/app/dashboard/renters/page.tsx @@ -0,0 +1,188 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useAdminI18n } from '@/components/I18nProvider' + +const API_BASE = '/api/v1' + +interface Renter { + id: string + firstName: string + lastName: string + email: string + phone: string | null + isBlocked: boolean + createdAt: string +} + +export default function AdminRentersPage() { + const { language, dict } = useAdminI18n() + const copy = { + en: { + title: 'Renters', + search: 'Search renters…', + name: 'Name', + email: 'Email', + phone: 'Phone', + status: 'Status', + joined: 'Joined', + loading: 'Loading…', + empty: 'No renters found', + blocked: 'Blocked', + active: 'Active', + unblock: 'Unblock', + block: 'Block', + }, + fr: { + title: 'Locataires', + search: 'Rechercher des locataires…', + name: 'Nom', + email: 'Email', + phone: 'Téléphone', + status: 'Statut', + joined: 'Inscrit', + loading: 'Chargement…', + empty: 'Aucun locataire trouvé', + blocked: 'Bloqué', + active: 'Actif', + unblock: 'Débloquer', + block: 'Bloquer', + }, + ar: { + title: 'المستأجرون', + search: 'ابحث عن مستأجرين…', + name: 'الاسم', + email: 'البريد الإلكتروني', + phone: 'الهاتف', + status: 'الحالة', + joined: 'تاريخ الانضمام', + loading: 'جارٍ التحميل…', + empty: 'لم يتم العثور على مستأجرين', + blocked: 'محظور', + active: 'نشط', + unblock: 'فك الحظر', + block: 'حظر', + }, + }[language] + const [renters, setRenters] = useState([]) + const [filtered, setFiltered] = useState([]) + const [search, setSearch] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [actioning, setActioning] = useState(null) + + function getToken() { return localStorage.getItem('admin_token') ?? '' } + + async function fetchRenters() { + try { + const res = await fetch(`${API_BASE}/admin/renters`, { + headers: { Authorization: `Bearer ${getToken()}` }, + cache: 'no-store', + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Failed') + setRenters(json.data ?? []) + setFiltered(json.data ?? []) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchRenters() }, []) + + useEffect(() => { + const q = search.toLowerCase() + setFiltered(renters.filter((r) => + `${r.firstName} ${r.lastName}`.toLowerCase().includes(q) || r.email.toLowerCase().includes(q) + )) + }, [search, renters]) + + async function toggleBlock(id: string, isBlocked: boolean) { + setActioning(id) + try { + const endpoint = isBlocked ? 'unblock' : 'block' + const res = await fetch(`${API_BASE}/admin/renters/${id}/${endpoint}`, { + method: 'POST', + headers: { Authorization: `Bearer ${getToken()}` }, + }) + if (!res.ok) throw new Error('Action failed') + await fetchRenters() + } catch (err: any) { + setError(err.message) + } finally { + setActioning(null) + } + } + + return ( +
+
+
+

{dict.platform}

+

{copy.title}

+
+ setSearch(e.target.value)} + /> +
+ + {error &&
{error}
} + +
+
+ + + + + + + + + + + + {loading ? ( + + ) : filtered.length === 0 ? ( + + ) : filtered.map((r) => ( + + + + + + + + + ))} + +
{copy.name}{copy.email}{copy.phone}{copy.status}{copy.joined} +
{copy.loading}
{copy.empty}
{r.firstName} {r.lastName}{r.email}{r.phone ?? '—'} + + {r.isBlocked ? copy.blocked : copy.active} + + {new Date(r.createdAt).toLocaleDateString()} + +
+
+
+
+ ) +} diff --git a/apps/admin/src/app/favicon.ico/route.ts b/apps/admin/src/app/favicon.ico/route.ts new file mode 100644 index 0000000..c678dff --- /dev/null +++ b/apps/admin/src/app/favicon.ico/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from 'next/server' + +export function GET(request: Request) { + const url = new URL('/icon', request.url) + return NextResponse.redirect(url) +} diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css new file mode 100644 index 0000000..270c43b --- /dev/null +++ b/apps/admin/src/app/globals.css @@ -0,0 +1,15 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-zinc-950 text-zinc-50 antialiased; +} + +.shell { + @apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8; +} + +.panel { + @apply rounded-2xl border border-zinc-800 bg-zinc-900 shadow-sm; +} diff --git a/apps/admin/src/app/icon.tsx b/apps/admin/src/app/icon.tsx new file mode 100644 index 0000000..7403251 --- /dev/null +++ b/apps/admin/src/app/icon.tsx @@ -0,0 +1,32 @@ +import { ImageResponse } from 'next/og' + +export const size = { + width: 32, + height: 32, +} + +export const contentType = 'image/png' + +export default function Icon() { + return new ImageResponse( + ( +
+ A +
+ ), + size, + ) +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx new file mode 100644 index 0000000..ab8bea4 --- /dev/null +++ b/apps/admin/src/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next' +import { AdminI18nProvider } from '@/components/I18nProvider' +import './globals.css' + +export const metadata: Metadata = { + title: 'RentalDriveGo Admin', + description: 'Platform administration for RentalDriveGo.', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx new file mode 100644 index 0000000..e5b8b14 --- /dev/null +++ b/apps/admin/src/app/login/page.tsx @@ -0,0 +1,212 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { useAdminI18n } from '@/components/I18nProvider' +import PublicShell from '@/components/PublicShell' + +const API_BASE = '/api/v1' + +export default function AdminLoginPage() { + const { language } = useAdminI18n() + const dict = { + en: { + loginFailed: 'Login failed', + verifyFailed: '2FA verification failed', + brand: 'Admin console', + access: 'Platform operations access', + email: 'Email', + password: 'Password', + signIn: 'Sign in', + signingIn: 'Signing in…', + enterCode: 'Enter the 6-digit code from your authenticator app', + authCode: 'Authentication code', + verify: 'Verify', + verifying: 'Verifying…', + back: 'Back to login', + }, + fr: { + loginFailed: 'Échec de connexion', + verifyFailed: 'Échec de la vérification 2FA', + brand: 'Console admin', + access: 'Accès opérations plateforme', + email: 'Email', + password: 'Mot de passe', + signIn: 'Connexion', + signingIn: 'Connexion…', + enterCode: 'Entrez le code à 6 chiffres de votre application d’authentification', + authCode: 'Code d’authentification', + verify: 'Vérifier', + verifying: 'Vérification…', + back: 'Retour à la connexion', + }, + ar: { + loginFailed: 'فشل تسجيل الدخول', + verifyFailed: 'فشل التحقق الثنائي', + brand: 'لوحة الإدارة', + access: 'وصول عمليات المنصة', + email: 'البريد الإلكتروني', + password: 'كلمة المرور', + signIn: 'تسجيل الدخول', + signingIn: 'جارٍ تسجيل الدخول…', + enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة', + authCode: 'رمز المصادقة', + verify: 'تحقق', + verifying: 'جارٍ التحقق…', + back: 'العودة إلى تسجيل الدخول', + }, + }[language] + const router = useRouter() + const [step, setStep] = useState<'credentials' | 'totp'>('credentials') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [totp, setTotp] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + async function handleCredentials(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError(null) + try { + const res = await fetch(`${API_BASE}/admin/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + const json = await res.json() + if (res.status === 401 && json?.error === 'totp_required') { + setStep('totp') + return + } + if (!res.ok) throw new Error(json?.message ?? dict.loginFailed) + if (json.data?.token) { + localStorage.setItem('admin_token', json.data.token) + router.push('/dashboard') + } + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + async function handleTotp(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError(null) + try { + const res = await fetch(`${API_BASE}/admin/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, totpCode: totp }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? dict.verifyFailed) + if (json.data?.token) { + localStorage.setItem('admin_token', json.data.token) + router.push('/dashboard') + } + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( + +
+
+
+ RentalDriveGo +

{dict.brand}

+

{dict.access}

+
+ +
+ {error && ( +
+ {error} +
+ )} + + {step === 'credentials' ? ( +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent" + placeholder="admin@rentaldrivego.com" + /> +
+
+ + setPassword(e.target.value)} + className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent" + placeholder="••••••••" + /> +
+ +
+ ) : ( +
+
+
+ + + +
+

{dict.enterCode}

+
+
+ + setTotp(e.target.value.replace(/\D/g, ''))} + className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-center text-2xl tracking-[0.5em] placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent" + placeholder="000000" + /> +
+ + +
+ )} +
+
+
+
+ ) +} diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx new file mode 100644 index 0000000..4083365 --- /dev/null +++ b/apps/admin/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function AdminRootPage() { + redirect('/login') +} diff --git a/apps/admin/src/components/I18nProvider.tsx b/apps/admin/src/components/I18nProvider.tsx new file mode 100644 index 0000000..5704f08 --- /dev/null +++ b/apps/admin/src/components/I18nProvider.tsx @@ -0,0 +1,129 @@ +'use client' + +import { createContext, useContext, useEffect, useMemo, useState } from 'react' + +export type AdminLanguage = 'en' | 'fr' | 'ar' + +type AdminDictionary = { + nav: Record + logout: string + language: string + overview: string + admin: string + platform: string + loading: string +} + +const dictionaries: Record = { + en: { + nav: { + overview: 'Overview', + companies: 'Companies', + renters: 'Renters', + auditLogs: 'Audit Logs', + adminUsers: 'Admin Users', + }, + logout: 'Logout', + language: 'Language', + overview: 'Platform overview', + admin: 'Admin', + platform: 'Platform', + loading: 'Loading', + }, + fr: { + nav: { + overview: 'Vue d’ensemble', + companies: 'Entreprises', + renters: 'Locataires', + auditLogs: 'Journaux d’audit', + adminUsers: 'Utilisateurs admin', + }, + logout: 'Déconnexion', + language: 'Langue', + overview: 'Vue plateforme', + admin: 'Admin', + platform: 'Plateforme', + loading: 'Chargement', + }, + ar: { + nav: { + overview: 'نظرة عامة', + companies: 'الشركات', + renters: 'المستأجرون', + auditLogs: 'سجلات التدقيق', + adminUsers: 'مستخدمو الإدارة', + }, + logout: 'تسجيل الخروج', + language: 'اللغة', + overview: 'نظرة عامة على المنصة', + admin: 'الإدارة', + platform: 'المنصة', + loading: 'جارٍ التحميل', + }, +} + +type AdminI18nContext = { + language: AdminLanguage + setLanguage: (value: AdminLanguage) => void + dict: AdminDictionary +} + +const Context = createContext(null) + +export function AdminI18nProvider({ children }: { children: React.ReactNode }) { + const [language, setLanguage] = useState('en') + + useEffect(() => { + const stored = window.localStorage.getItem('admin-language') + if (stored === 'en' || stored === 'fr' || stored === 'ar') { + setLanguage(stored) + } + }, []) + + useEffect(() => { + document.documentElement.lang = language + document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr' + window.localStorage.setItem('admin-language', language) + }, [language]) + + const value = useMemo(() => ({ language, setLanguage, dict: dictionaries[language] }), [language]) + return {children} +} + +export function useAdminI18n() { + const context = useContext(Context) + if (!context) throw new Error('useAdminI18n must be used within AdminI18nProvider') + return context +} + +export function AdminLanguageSwitcher() { + const { language, setLanguage, dict } = useAdminI18n() + const [embedded, setEmbedded] = useState(false) + + useEffect(() => { + setEmbedded(window.self !== window.top) + }, []) + + if (embedded) return null + + return ( +
+ {dict.language} + {(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => { + const active = value === language + return ( + + ) + })} +
+ ) +} diff --git a/apps/admin/src/components/PublicShell.tsx b/apps/admin/src/components/PublicShell.tsx new file mode 100644 index 0000000..81971c7 --- /dev/null +++ b/apps/admin/src/components/PublicShell.tsx @@ -0,0 +1,54 @@ +'use client' + +import Link from 'next/link' +import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider' + +export default function PublicShell({ children }: { children: React.ReactNode }) { + const { language } = useAdminI18n() + const dict = { + en: { + admin: 'Admin Console', + signIn: 'Sign in', + preferences: 'Admin preferences', + }, + fr: { + admin: 'Console admin', + signIn: 'Connexion', + preferences: 'Preferences admin', + }, + ar: { + admin: 'لوحة الإدارة', + signIn: 'تسجيل الدخول', + preferences: 'تفضيلات الإدارة', + }, + }[language] + + return ( +
+
+
+ + RentalDriveGo + {dict.admin} + + +
+
+
{children}
+
+
+

+ {dict.preferences} +

+
+ +
+
+
+
+ ) +} diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts new file mode 100644 index 0000000..ed40d98 --- /dev/null +++ b/apps/admin/src/lib/api.ts @@ -0,0 +1,14 @@ +const API_BASE = + (typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1') + ?? process.env.NEXT_PUBLIC_API_URL + ?? 'http://localhost:4000/api/v1' + +export async function adminFetch(path: string, token?: string): Promise { + const res = await fetch(`${API_BASE}${path}`, { + cache: 'no-store', + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Request failed') + return json.data as T +} diff --git a/apps/api/package.json b/apps/api/package.json index d231fe7..04ce451 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,45 +9,48 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@clerk/clerk-sdk-node": "^5.0.0", + "@react-pdf/renderer": "^3.4.3", "@rentaldrivego/database": "*", "@rentaldrivego/types": "*", - "@clerk/clerk-sdk-node": "^5.0.0", - "express": "^4.19.2", - "cors": "^2.8.5", - "helmet": "^7.1.0", - "morgan": "^1.10.0", - "zod": "^3.23.0", - "jsonwebtoken": "^9.0.2", "bcryptjs": "^2.4.3", - "multer": "^1.4.5-lts.1", "cloudinary": "^2.2.0", - "resend": "^3.2.0", - "twilio": "^5.1.0", + "cors": "^2.8.5", + "dayjs": "^1.11.11", + "express": "^4.19.2", + "express-rate-limit": "^8.5.1", "firebase-admin": "^12.1.0", + "helmet": "^7.1.0", "ioredis": "^5.3.2", - "socket.io": "^4.7.5", - "svix": "^1.20.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "node-cron": "^3.0.3", + "nodemailer": "^6.9.16", "otplib": "^12.0.1", "qrcode": "^1.5.3", - "@react-pdf/renderer": "^3.4.3", "react": "^18.3.1", "react-dom": "^18.3.1", - "dayjs": "^1.11.11", - "node-cron": "^3.0.3" + "resend": "^3.2.0", + "socket.io": "^4.7.5", + "svix": "^1.20.0", + "twilio": "^5.1.0", + "zod": "^3.23.0" }, "devDependencies": { - "@types/express": "^4.17.21", - "@types/cors": "^2.8.17", - "@types/morgan": "^1.9.9", - "@types/jsonwebtoken": "^9.0.6", "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", + "@types/morgan": "^1.9.9", "@types/multer": "^1.4.11", - "@types/qrcode": "^1.5.5", + "@types/node": "^20.12.0", "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^6.4.17", + "@types/qrcode": "^1.5.5", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.0", - "typescript": "^5.4.0", - "@types/node": "^20.12.0", - "ts-node-dev": "^2.0.0" + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.0" } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d974266..3917dd2 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -5,11 +5,15 @@ import morgan from 'morgan' import http from 'http' import { Server as SocketIOServer } from 'socket.io' import cron from 'node-cron' +import jwt from 'jsonwebtoken' +import { z } from 'zod' import { redis } from './lib/redis' import { prisma } from './lib/prisma' +import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter' // ─── Routes ─────────────────────────────────────────────────── import webhookRouter from './routes/webhooks' +import companyAuthRouter from './routes/auth.company' import renterAuthRouter from './routes/auth.renter' import vehiclesRouter from './routes/vehicles' import reservationsRouter from './routes/reservations' @@ -20,17 +24,74 @@ import analyticsRouter from './routes/analytics' import notificationsRouter from './routes/notifications' import marketplaceRouter from './routes/marketplace' import adminRouter from './routes/admin' +import companiesRouter from './routes/companies' +import subscriptionsRouter from './routes/subscriptions' +import siteRouter from './routes/site' +import paymentsRouter from './routes/payments' const app = express() const server = http.createServer(app) +const v1 = '/api/v1' +const defaultCorsOrigins = [ + 'http://localhost:3000', + 'http://localhost:3001', + 'http://localhost:3002', + 'http://localhost:3003', + 'http://127.0.0.1:3000', + 'http://127.0.0.1:3001', + 'http://127.0.0.1:3002', + 'http://127.0.0.1:3003', +] +const corsOrigins = process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean) + : defaultCorsOrigins + +const routeDocs = [ + { method: 'GET', path: '/health', description: 'Health check' }, + { method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' }, + { method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' }, + { method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' }, + { method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' }, + { method: 'GET', path: `${v1}/reservations`, description: 'List reservations' }, + { method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' }, + { method: 'GET', path: `${v1}/customers`, description: 'List customers' }, + { method: 'POST', path: `${v1}/customers`, description: 'Create customer' }, + { method: 'GET', path: `${v1}/offers`, description: 'List offers' }, + { method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' }, + { method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' }, + { method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' }, + { method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' }, + { method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' }, + { method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' }, + { method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' }, + { method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' }, +] // ─── Socket.io ──────────────────────────────────────────────── const io = new SocketIOServer(server, { - cors: { origin: '*', methods: ['GET', 'POST'] }, + cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] }, +}) + +const redisMessageSchema = z.object({ + type: z.string(), + payload: z.unknown(), +}) + +// Authenticate socket connections via JWT before joining user rooms +io.use((socket, next) => { + const token = socket.handshake.auth?.token as string | undefined + if (!token) return next() // unauthenticated connections allowed; they just don't join rooms + try { + const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string } + ;(socket as any).authenticatedUserId = payload.sub + next() + } catch { + next(new Error('invalid_token')) + } }) io.on('connection', (socket) => { - const userId = socket.handshake.auth?.userId as string | undefined + const userId = (socket as any).authenticatedUserId as string | undefined if (userId) { socket.join(`user:${userId}`) } @@ -42,8 +103,14 @@ subscriber.psubscribe('notifications:*', (err) => { if (err) console.error('[Redis] Subscribe error:', err) }) subscriber.on('pmessage', (_pattern, channel, message) => { - const userId = channel.replace('notifications:', '') - io.to(`user:${userId}`).emit('notification', JSON.parse(message)) + try { + const parsed = JSON.parse(message) + const validated = redisMessageSchema.parse(parsed) + const userId = channel.replace('notifications:', '') + io.to(`user:${userId}`).emit('notification', validated) + } catch (err) { + console.error('[Redis] Invalid notification message:', err) + } }) // ─── Middleware ──────────────────────────────────────────────── @@ -51,29 +118,95 @@ subscriber.on('pmessage', (_pattern, channel, message) => { app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter) app.use(helmet()) -app.use(cors({ origin: process.env.CORS_ORIGINS?.split(',') ?? '*', credentials: true })) +app.use(cors({ origin: corsOrigins, credentials: true })) app.use(morgan('combined')) app.use(express.json({ limit: '10mb' })) // ─── API Routes ─────────────────────────────────────────────── -const v1 = '/api/v1' +// Auth routes: strict brute-force protection +app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter) +app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter) -app.use(`${v1}/auth/renter`, renterAuthRouter) -app.use(`${v1}/vehicles`, vehiclesRouter) -app.use(`${v1}/reservations`, reservationsRouter) -app.use(`${v1}/team`, teamRouter) -app.use(`${v1}/customers`, customersRouter) -app.use(`${v1}/offers`, offersRouter) -app.use(`${v1}/analytics`, analyticsRouter) -app.use(`${v1}/notifications`, notificationsRouter) -app.use(`${v1}/marketplace`, marketplaceRouter) -app.use(`${v1}/admin`, adminRouter) +// Admin routes: dedicated limit +app.use(`${v1}/admin`, adminLimiter, adminRouter) + +// Public unauthenticated routes +app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter) +app.use(`${v1}/site`, publicLimiter, siteRouter) + +// Authenticated company/renter routes +app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter) +app.use(`${v1}/reservations`, apiLimiter, reservationsRouter) +app.use(`${v1}/team`, apiLimiter, teamRouter) +app.use(`${v1}/customers`, apiLimiter, customersRouter) +app.use(`${v1}/offers`, apiLimiter, offersRouter) +app.use(`${v1}/analytics`, apiLimiter, analyticsRouter) +app.use(`${v1}/notifications`, apiLimiter, notificationsRouter) +app.use(`${v1}/companies`, apiLimiter, companiesRouter) +app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter) +app.use(`${v1}/payments`, apiLimiter, paymentsRouter) // ─── Health check ───────────────────────────────────────────── app.get('/health', (_req, res) => { res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() }) }) +app.get(`${v1}/docs`, (_req, res) => { + res.json({ + name: 'rentaldrivego-api', + version: '1.0.0', + baseUrl: v1, + docsUrl: '/docs', + routes: routeDocs, + }) +}) + +app.get('/docs', (_req, res) => { + const rows = routeDocs + .map( + (route) => ` + + ${route.method} + ${route.path} + ${route.description} + `, + ) + .join('') + + res.type('html').send(` + + + + + RentalDriveGo API Docs + + + +

RentalDriveGo API

+

Base URL: ${v1} · JSON index: ${v1}/docs

+ + + + + + + + + ${rows} +
MethodPathDescription
+ +`) +}) + // ─── Error handler ──────────────────────────────────────────── app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { const statusCode = err.statusCode ?? 500 diff --git a/apps/api/src/middleware/rateLimiter.ts b/apps/api/src/middleware/rateLimiter.ts new file mode 100644 index 0000000..4228741 --- /dev/null +++ b/apps/api/src/middleware/rateLimiter.ts @@ -0,0 +1,53 @@ +import rateLimit, { ipKeyGenerator } from 'express-rate-limit' + +const trustProxy = process.env.NODE_ENV === 'production' +const getClientIpKey = (req: Parameters[0]) => + trustProxy + ? ipKeyGenerator((req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? '') + : ipKeyGenerator(req.ip ?? '') + +// Strict limiter for auth endpoints — prevents brute-force and credential stuffing +export const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, + standardHeaders: 'draft-7', + legacyHeaders: false, + skipSuccessfulRequests: false, + keyGenerator: (req) => getClientIpKey(req), + message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 }, +}) + +// Standard limiter for general authenticated API endpoints +export const apiLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 120, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: (req) => { + const ip = getClientIpKey(req) + const companyId = (req as any).companyId ?? '' + const renterId = (req as any).renterId ?? '' + return `${ip}:${companyId || renterId}` + }, + message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 }, +}) + +// Tight limiter for public marketplace and site endpoints (no auth) +export const publicLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: (req) => getClientIpKey(req), + message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 }, +}) + +// Tight limiter for admin endpoints +export const adminLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 30, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: (req) => getClientIpKey(req), + message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 }, +}) diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index a46808c..920117b 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -9,6 +9,11 @@ import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAu const router = Router() +const permissionSchema = z.object({ + resource: z.enum(['COMPANIES', 'COMPANY_EMPLOYEES', 'SUBSCRIPTIONS', 'PAYMENTS', 'OFFERS', 'RENTERS', 'ADMIN_USERS', 'AUDIT_LOGS', 'PLATFORM_METRICS']), + actions: z.array(z.enum(['READ', 'CREATE', 'UPDATE', 'DELETE', 'SUSPEND', 'IMPERSONATE'])).min(1), +}) + function signAdminToken(adminId: string) { return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' }) } @@ -17,7 +22,7 @@ function signAdminToken(adminId: string) { router.post('/auth/login', async (req, res, next) => { try { - const { email, password, totpCode } = z.object({ email: z.string().email(), password: z.string(), totpCode: z.string().optional() }).parse(req.body) + const { email, password, totpCode } = z.object({ email: z.string().email().max(255).trim().toLowerCase(), password: z.string().max(128), totpCode: z.string().length(6).optional() }).parse(req.body) const admin = await prisma.adminUser.findUnique({ where: { email } }) if (!admin || !admin.isActive) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) @@ -176,11 +181,12 @@ router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { try { - const { adminId, action, companyId, page = '1', pageSize = '50' } = req.query as Record + const { adminId, action, companyId, entityId, page = '1', pageSize = '50' } = req.query as Record const where: any = {} if (adminId) where.adminUserId = adminId if (action) where.action = { contains: action } if (companyId) where.companyId = companyId + if (entityId) where.resourceId = entityId const [logs, total] = await Promise.all([ prisma.auditLog.findMany({ where, include: { adminUser: { select: { firstName: true, lastName: true, email: true } } }, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }), prisma.auditLog.count({ where }), @@ -193,16 +199,48 @@ router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (re router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => { try { - const admins = await prisma.adminUser.findMany({ select: { id: true, email: true, firstName: true, lastName: true, role: true, isActive: true, totpEnabled: true, lastLoginAt: true, createdAt: true } }) + const admins = await prisma.adminUser.findMany({ + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + isActive: true, + totpEnabled: true, + lastLoginAt: true, + createdAt: true, + permissions: true, + }, + }) res.json({ data: admins }) } catch (err) { next(err) } }) router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => { try { - const body = z.object({ email: z.string().email(), firstName: z.string(), lastName: z.string(), role: z.enum(['ADMIN','SUPPORT','FINANCE','VIEWER']), password: z.string().min(8) }).parse(req.body) + const body = z.object({ + email: z.string().email(), + firstName: z.string(), + lastName: z.string(), + role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']), + password: z.string().min(8), + permissions: z.array(permissionSchema).optional(), + }).parse(req.body) const passwordHash = await bcrypt.hash(body.password, 12) - const admin = await prisma.adminUser.create({ data: { ...body, passwordHash, password: undefined } as any }) + const admin = await prisma.adminUser.create({ + data: { + email: body.email, + firstName: body.firstName, + lastName: body.lastName, + role: body.role, + passwordHash, + permissions: body.permissions ? { + create: body.permissions, + } : undefined, + }, + include: { permissions: true }, + }) const { passwordHash: _, ...safe } = admin res.status(201).json({ data: safe }) } catch (err) { next(err) } @@ -210,10 +248,32 @@ router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => { try { - const { role } = z.object({ role: z.enum(['ADMIN','SUPPORT','FINANCE','VIEWER']) }).parse(req.body) + const { role } = z.object({ role: z.enum(['SUPER_ADMIN', 'ADMIN','SUPPORT','FINANCE','VIEWER']) }).parse(req.body) await prisma.adminUser.update({ where: { id: req.params.id }, data: { role } }) res.json({ data: { success: true } }) } catch (err) { next(err) } }) +router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => { + try { + const { permissions } = z.object({ permissions: z.array(permissionSchema) }).parse(req.body) + await prisma.adminUser.findUniqueOrThrow({ where: { id: req.params.id } }) + await prisma.$transaction([ + prisma.adminPermission.deleteMany({ where: { adminUserId: req.params.id } }), + prisma.adminPermission.createMany({ + data: permissions.map((permission) => ({ + adminUserId: req.params.id, + resource: permission.resource, + actions: permission.actions, + })), + }), + ]) + const admin = await prisma.adminUser.findUniqueOrThrow({ + where: { id: req.params.id }, + include: { permissions: true }, + }) + res.json({ data: admin }) + } catch (err) { next(err) } +}) + export default router diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index b67d86a..3e2dc3b 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -8,6 +8,29 @@ import { generateFinancialReport, toCsv } from '../services/financialReportServi const router = Router() router.use(requireCompanyAuth, requireTenant, requireSubscription) +function getRangeFromPeriod(period: string) { + const now = new Date() + + switch (period) { + case 'WEEKLY': { + const start = new Date(now) + start.setDate(now.getDate() - 7) + return { from: start, to: now } + } + case 'QUARTERLY': { + const start = new Date(now.getFullYear(), now.getMonth() - 2, 1) + return { from: start, to: now } + } + case 'ANNUAL': { + const start = new Date(now.getFullYear(), 0, 1) + return { from: start, to: now } + } + case 'MONTHLY': + default: + return { from: new Date(now.getFullYear(), now.getMonth(), 1), to: now } + } +} + router.get('/summary', async (req, res, next) => { try { const { period = '30d' } = req.query as Record @@ -25,6 +48,108 @@ router.get('/summary', async (req, res, next) => { } catch (err) { next(err) } }) +router.get('/dashboard', async (req, res, next) => { + try { + const now = new Date() + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) + const prevMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1) + const prevMonthEnd = new Date(monthStart.getTime() - 1) + + const [ + totalBookings, + previousBookings, + activeVehicles, + previousActiveVehicles, + totalCustomers, + previousCustomers, + monthlyRevenueAgg, + previousRevenueAgg, + recentReservations, + sourceGroups, + subscription, + ] = await Promise.all([ + prisma.reservation.count({ where: { companyId: req.companyId, createdAt: { gte: monthStart } } }), + prisma.reservation.count({ where: { companyId: req.companyId, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } } }), + prisma.vehicle.count({ where: { companyId: req.companyId, status: { in: ['AVAILABLE', 'RENTED'] } } }), + prisma.vehicle.count({ where: { companyId: req.companyId, createdAt: { lte: prevMonthEnd }, status: { in: ['AVAILABLE', 'RENTED'] } } }), + prisma.customer.count({ where: { companyId: req.companyId } }), + prisma.customer.count({ where: { companyId: req.companyId, createdAt: { lte: prevMonthEnd } } }), + prisma.reservation.aggregate({ where: { companyId: req.companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: monthStart } }, _sum: { totalAmount: true } }), + prisma.reservation.aggregate({ where: { companyId: req.companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } }, _sum: { totalAmount: true } }), + prisma.reservation.findMany({ + where: { companyId: req.companyId }, + include: { customer: true, vehicle: true }, + orderBy: { createdAt: 'desc' }, + take: 8, + }), + prisma.reservation.groupBy({ + by: ['source'], + where: { companyId: req.companyId }, + _count: { id: true }, + _sum: { totalAmount: true }, + }), + prisma.subscription.findUnique({ where: { companyId: req.companyId } }), + ]) + + const pct = (current: number, previous: number) => { + if (previous === 0) return current > 0 ? 100 : 0 + return Math.round(((current - previous) / previous) * 100) + } + + const typedRecentReservations = recentReservations as Array<{ + id: string + contractNumber: string | null + startDate: Date + endDate: Date + status: string + totalAmount: number + customer: { firstName: string; lastName: string } + vehicle: { make: string; model: string } + }> + + const typedSourceGroups = sourceGroups as Array<{ + source: string + _count: { id: number } + _sum: { totalAmount: number | null } + }> + + res.json({ + data: { + kpis: { + totalBookings, + activeVehicles, + monthlyRevenue: monthlyRevenueAgg._sum.totalAmount ?? 0, + totalCustomers, + bookingsChange: pct(totalBookings, previousBookings), + vehiclesChange: pct(activeVehicles, previousActiveVehicles), + revenueChange: pct(monthlyRevenueAgg._sum.totalAmount ?? 0, previousRevenueAgg._sum.totalAmount ?? 0), + customersChange: pct(totalCustomers, previousCustomers), + }, + recentReservations: typedRecentReservations.map((reservation) => ({ + id: reservation.id, + bookingRef: reservation.contractNumber ?? reservation.id.slice(-8).toUpperCase(), + customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`, + vehicleName: `${reservation.vehicle.make} ${reservation.vehicle.model}`, + startDate: reservation.startDate, + endDate: reservation.endDate, + status: reservation.status, + totalAmount: reservation.totalAmount, + })), + sourceBreakdown: typedSourceGroups.map((group) => ({ + source: group.source, + count: group._count.id, + revenue: group._sum.totalAmount ?? 0, + })), + subscription: subscription ? { + status: subscription.status, + planName: subscription.plan, + trialEndsAt: subscription.trialEndAt, + } : null, + }, + }) + } catch (err) { next(err) } +}) + router.get('/sources', async (req, res, next) => { try { const sources = await prisma.reservation.groupBy({ by: ['source'], where: { companyId: req.companyId }, _count: { id: true } }) @@ -34,14 +159,17 @@ router.get('/sources', async (req, res, next) => { router.get('/report', async (req, res, next) => { try { - const { from, to, format = 'JSON' } = req.query as Record - if (!from || !to) return res.status(400).json({ error: 'missing_params', message: 'from and to date params required', statusCode: 400 }) + const { from, to, format = 'JSON', period } = req.query as Record + const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } }) + const derivedRange = getRangeFromPeriod(period || accountingSettings?.reportingPeriod || 'MONTHLY') + const startDate = from ? new Date(from) : derivedRange.from + const endDate = to ? new Date(to) : derivedRange.to - const report = await generateFinancialReport(req.companyId, new Date(from), new Date(to)) + const report = await generateFinancialReport(req.companyId, startDate, endDate) if (format === 'CSV') { res.setHeader('Content-Type', 'text/csv') - res.setHeader('Content-Disposition', `attachment; filename="report-${from}-${to}.csv"`) + res.setHeader('Content-Disposition', `attachment; filename="report-${startDate.toISOString().slice(0, 10)}-${endDate.toISOString().slice(0, 10)}.csv"`) return res.send(toCsv(report.rows)) } diff --git a/apps/api/src/routes/auth.company.ts b/apps/api/src/routes/auth.company.ts new file mode 100644 index 0000000..1faed98 --- /dev/null +++ b/apps/api/src/routes/auth.company.ts @@ -0,0 +1,540 @@ +import { Router } from 'express' +import { z } from 'zod' +import { clerkClient } from '@clerk/clerk-sdk-node' +import { prisma } from '../lib/prisma' +import { sendNotification } from '../services/notificationService' + +const router = Router() + +const signupSchema = z.object({ + firstName: z.string().min(1).max(80), + lastName: z.string().min(1).max(80), + email: z.string().email(), + companyName: z.string().min(2).max(120), + companyPhone: z.string().optional(), + country: z.string().optional(), + city: z.string().optional(), + plan: z.enum(['STARTER', 'GROWTH', 'PRO']), + billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), + currency: z.enum(['MAD', 'USD', 'EUR']), + paymentProvider: z.enum(['AMANPAY', 'PAYPAL']), +}) + +const ownerWorkspaceSchema = z.object({ + companyName: z.string().min(2).max(120), + companyPhone: z.string().nullish(), + country: z.string().nullish(), + city: z.string().nullish(), + plan: z.enum(['STARTER', 'GROWTH', 'PRO']), + billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), + currency: z.enum(['MAD', 'USD', 'EUR']), + paymentProvider: z.enum(['AMANPAY', 'PAYPAL']), +}) + +const completeSignupMetadataSchema = ownerWorkspaceSchema.extend({ + signupFlow: z.literal('company-owner'), +}) + +const verifySchema = z.object({ + email: z.string().email(), +}) + +function slugifyCompanyName(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50) || 'company' +} + +async function generateUniqueSlug(baseName: string) { + const base = slugifyCompanyName(baseName) + + for (let attempt = 0; attempt < 25; attempt += 1) { + const slug = attempt === 0 ? base : `${base}-${attempt + 1}` + const existing = await prisma.company.findUnique({ where: { slug } }) + if (!existing) return slug + } + + return `${base}-${Date.now().toString(36)}` +} + +function addDays(date: Date, days: number) { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000) +} + +function buildSignupConfirmationEmail(opts: { + firstName: string + companyName: string + plan: 'STARTER' | 'GROWTH' | 'PRO' + billingPeriod: 'MONTHLY' | 'ANNUAL' + currency: 'MAD' | 'USD' | 'EUR' + paymentProvider?: 'AMANPAY' | 'PAYPAL' + trialEndAt?: Date + isResend?: boolean + usesInvitation?: boolean +}) { + const greetingName = opts.firstName.trim() || 'there' + const lines = [ + `Hi ${greetingName},`, + '', + opts.isResend + ? `We sent a fresh owner invitation for ${opts.companyName}.` + : `Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`, + `Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`, + `Currency: ${opts.currency}`, + ] + + if (opts.paymentProvider) { + lines.push(`Primary payment provider: ${opts.paymentProvider}`) + } + + if (opts.trialEndAt) { + lines.push( + `Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })}.` + ) + } + + lines.push( + '', + opts.usesInvitation === false + ? 'Your workspace is ready and the owner record has been created for this environment.' + : 'Open the invitation email from Clerk to confirm your account, finish onboarding, and enter the dashboard.', + '', + 'RentalDriveGo' + ) + + return lines.join('\n') +} + +async function createOwnerInvitation(email: string, companyId: string, companyName: string) { + const dashboardUrl = process.env.DASHBOARD_URL + if (!process.env.CLERK_SECRET_KEY || !dashboardUrl) { + throw Object.assign(new Error('Clerk signup is not configured on this environment'), { + statusCode: 503, + code: 'clerk_not_configured', + }) + } + + return clerkClient.invitations.createInvitation({ + emailAddress: email, + redirectUrl: `${dashboardUrl}/onboarding/accept-invite`, + publicMetadata: { + companyId, + companyName, + role: 'OWNER', + signupFlow: 'company-owner', + }, + }) +} + +async function verifyClerkSessionToken(sessionToken: string) { + return clerkClient.sessions.verifySession(sessionToken, sessionToken) +} + +function getPrimaryEmail(user: Awaited>) { + const primary = user.emailAddresses.find((email) => email.id === user.primaryEmailAddressId) + return primary ?? user.emailAddresses[0] ?? null +} + +async function createCompanyWorkspaceForOwner(opts: { + clerkUserId: string + firstName: string + lastName: string + email: string + companyName: string + companyPhone?: string | null + country?: string | null + city?: string | null + plan: 'STARTER' | 'GROWTH' | 'PRO' + billingPeriod: 'MONTHLY' | 'ANNUAL' + currency: 'MAD' | 'USD' | 'EUR' + paymentProvider: 'AMANPAY' | 'PAYPAL' +}) { + const existingEmployeeByClerkUser = await prisma.employee.findUnique({ + where: { clerkUserId: opts.clerkUserId }, + include: { company: true }, + }) + if (existingEmployeeByClerkUser) { + return { + company: existingEmployeeByClerkUser.company, + employeeId: existingEmployeeByClerkUser.id, + invitationId: null, + trialEndAt: null, + created: false, + } + } + + const existingCompany = await prisma.company.findUnique({ where: { email: opts.email } }) + if (existingCompany) { + throw Object.assign(new Error('A company account with this email already exists'), { + statusCode: 409, + code: 'email_taken', + }) + } + + const existingEmployeeByEmail = await prisma.employee.findFirst({ where: { email: opts.email } }) + if (existingEmployeeByEmail) { + throw Object.assign(new Error('An employee account with this email already exists'), { + statusCode: 409, + code: 'email_taken', + }) + } + + const slug = await generateUniqueSlug(opts.companyName) + const now = new Date() + const trialEndAt = addDays(now, 14) + + const result = await prisma.$transaction(async (tx) => { + const company = await tx.company.create({ + data: { + name: opts.companyName, + slug, + email: opts.email, + phone: opts.companyPhone || null, + status: 'TRIALING', + }, + }) + + await tx.brandSettings.create({ + data: { + companyId: company.id, + displayName: opts.companyName, + subdomain: slug, + publicEmail: opts.email, + publicPhone: opts.companyPhone || null, + publicCountry: opts.country || null, + publicCity: opts.city || null, + defaultCurrency: opts.currency, + }, + }) + + await tx.subscription.create({ + data: { + companyId: company.id, + plan: opts.plan, + billingPeriod: opts.billingPeriod, + currency: opts.currency, + status: 'TRIALING', + trialStartAt: now, + trialEndAt, + currentPeriodStart: now, + currentPeriodEnd: trialEndAt, + }, + }) + + const employee = await tx.employee.create({ + data: { + companyId: company.id, + clerkUserId: opts.clerkUserId, + firstName: opts.firstName, + lastName: opts.lastName, + email: opts.email, + phone: opts.companyPhone || null, + role: 'OWNER', + isActive: true, + }, + }) + + return { + company, + employeeId: employee.id, + trialEndAt, + created: true, + } + }) + + await sendNotification({ + type: 'SUBSCRIPTION_TRIAL_ENDING', + title: 'Your workspace is ready', + body: buildSignupConfirmationEmail({ + firstName: opts.firstName, + companyName: opts.companyName, + plan: opts.plan, + billingPeriod: opts.billingPeriod, + currency: opts.currency, + paymentProvider: opts.paymentProvider, + trialEndAt: result.trialEndAt, + usesInvitation: false, + }), + companyId: result.company.id, + employeeId: result.employeeId, + email: opts.email, + channels: ['EMAIL', 'IN_APP'], + }).catch(() => null) + + return { + ...result, + invitationId: null, + } +} + +router.post('/signup', async (req, res, next) => { + try { + const body = signupSchema.parse(req.body) + + const existingCompany = await prisma.company.findUnique({ where: { email: body.email } }) + if (existingCompany) { + return res.status(409).json({ + error: 'email_taken', + message: 'A company account with this email already exists', + statusCode: 409, + }) + } + + const existingEmployee = await prisma.employee.findFirst({ where: { email: body.email } }) + if (existingEmployee) { + return res.status(409).json({ + error: 'email_taken', + message: 'An employee account with this email already exists', + statusCode: 409, + }) + } + + const slug = await generateUniqueSlug(body.companyName) + const now = new Date() + const trialEndAt = addDays(now, 14) + const usesInvitation = Boolean(process.env.CLERK_SECRET_KEY && process.env.DASHBOARD_URL) + + const result = await prisma.$transaction(async (tx) => { + const company = await tx.company.create({ + data: { + name: body.companyName, + slug, + email: body.email, + phone: body.companyPhone || null, + status: 'TRIALING', + }, + }) + + await tx.brandSettings.create({ + data: { + companyId: company.id, + displayName: body.companyName, + subdomain: slug, + publicEmail: body.email, + publicPhone: body.companyPhone || null, + publicCountry: body.country || null, + publicCity: body.city || null, + defaultCurrency: body.currency, + }, + }) + + const subscription = await tx.subscription.create({ + data: { + companyId: company.id, + plan: body.plan, + billingPeriod: body.billingPeriod, + currency: body.currency, + status: 'TRIALING', + trialStartAt: now, + trialEndAt, + currentPeriodStart: now, + currentPeriodEnd: trialEndAt, + }, + }) + + const invitation = usesInvitation + ? await createOwnerInvitation(body.email, company.id, body.companyName) + : null + + const employee = await tx.employee.create({ + data: { + companyId: company.id, + clerkUserId: invitation ? `pending_${invitation.id}` : `local_owner_${company.id}`, + firstName: body.firstName, + lastName: body.lastName, + email: body.email, + phone: body.companyPhone || null, + role: 'OWNER', + isActive: !invitation, + }, + }) + + return { + company, + subscription, + employeeId: employee.id, + invitationId: invitation?.id ?? null, + } + }) + + await sendNotification({ + type: 'SUBSCRIPTION_TRIAL_ENDING', + title: 'Your workspace is ready', + body: buildSignupConfirmationEmail({ + firstName: body.firstName, + companyName: body.companyName, + plan: body.plan, + billingPeriod: body.billingPeriod, + currency: body.currency, + paymentProvider: body.paymentProvider, + trialEndAt, + usesInvitation, + }), + companyId: result.company.id, + employeeId: result.employeeId, + email: body.email, + channels: ['EMAIL', 'IN_APP'], + }).catch(() => null) + + res.status(201).json({ + data: { + companyId: result.company.id, + companyName: result.company.name, + slug: result.company.slug, + invitationId: result.invitationId, + trialEndsAt: trialEndAt.toISOString(), + nextStep: usesInvitation ? 'check_email_for_invitation' : 'workspace_created', + }, + }) + } catch (err) { + next(err) + } +}) + +router.post('/complete-signup', async (req, res, next) => { + try { + const authHeader = req.headers.authorization + const sessionToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null + + if (!sessionToken) { + return res.status(401).json({ + error: 'unauthenticated', + message: 'Authentication required', + statusCode: 401, + }) + } + + const session = await verifyClerkSessionToken(sessionToken) + const user = await clerkClient.users.getUser(session.userId) + const primaryEmail = getPrimaryEmail(user) + + if (!primaryEmail?.emailAddress) { + return res.status(400).json({ + error: 'email_missing', + message: 'The signed-in Clerk user does not have a primary email address', + statusCode: 400, + }) + } + + if (primaryEmail.verification?.status !== 'verified') { + return res.status(400).json({ + error: 'email_not_verified', + message: 'Verify the owner email address before creating the workspace', + statusCode: 400, + }) + } + + const metadata = completeSignupMetadataSchema.parse(user.unsafeMetadata ?? {}) + const existingEmployee = await prisma.employee.findUnique({ + where: { clerkUserId: user.id }, + include: { company: true }, + }) + + if (existingEmployee) { + return res.json({ + data: { + companyId: existingEmployee.companyId, + companyName: existingEmployee.company.name, + slug: existingEmployee.company.slug, + trialEndsAt: null, + nextStep: 'enter_dashboard', + }, + }) + } + + const result = await createCompanyWorkspaceForOwner({ + clerkUserId: user.id, + firstName: user.firstName?.trim() || 'Owner', + lastName: user.lastName?.trim() || 'Account', + email: primaryEmail.emailAddress, + companyName: metadata.companyName, + companyPhone: metadata.companyPhone ?? null, + country: metadata.country ?? null, + city: metadata.city ?? null, + plan: metadata.plan, + billingPeriod: metadata.billingPeriod, + currency: metadata.currency, + paymentProvider: metadata.paymentProvider, + }) + + res.status(result.created ? 201 : 200).json({ + data: { + companyId: result.company.id, + companyName: result.company.name, + slug: result.company.slug, + trialEndsAt: result.trialEndAt?.toISOString() ?? null, + nextStep: 'enter_dashboard', + }, + }) + } catch (err) { + next(err) + } +}) + +router.post('/verify-email', async (req, res, next) => { + try { + const { email } = verifySchema.parse(req.body) + const pendingEmployee = await prisma.employee.findFirst({ + where: { + email, + role: 'OWNER', + clerkUserId: { startsWith: 'pending_' }, + }, + include: { company: true }, + }) + + if (!pendingEmployee) { + return res.status(404).json({ + error: 'pending_signup_not_found', + message: 'No pending company signup was found for this email', + statusCode: 404, + }) + } + + const newInvitation = await createOwnerInvitation(email, pendingEmployee.companyId, pendingEmployee.company.name) + await prisma.employee.update({ + where: { id: pendingEmployee.id }, + data: { clerkUserId: `pending_${newInvitation.id}` }, + }) + + const subscription = await prisma.subscription.findUnique({ + where: { companyId: pendingEmployee.companyId }, + }) + + await sendNotification({ + type: 'SUBSCRIPTION_TRIAL_ENDING', + title: 'Your owner invitation has been resent', + body: buildSignupConfirmationEmail({ + firstName: pendingEmployee.firstName, + companyName: pendingEmployee.company.name, + plan: subscription?.plan ?? 'STARTER', + billingPeriod: subscription?.billingPeriod ?? 'MONTHLY', + currency: subscription?.currency === 'USD' || subscription?.currency === 'EUR' ? subscription.currency : 'MAD', + trialEndAt: subscription?.trialEndAt ?? undefined, + isResend: true, + }), + companyId: pendingEmployee.companyId, + employeeId: pendingEmployee.id, + email, + channels: ['EMAIL', 'IN_APP'], + }).catch(() => null) + + res.json({ + data: { + success: true, + invitationId: newInvitation.id, + message: 'A fresh invitation link has been sent to your email address', + }, + }) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/apps/api/src/routes/auth.renter.ts b/apps/api/src/routes/auth.renter.ts index c5cf024..42433a5 100644 --- a/apps/api/src/routes/auth.renter.ts +++ b/apps/api/src/routes/auth.renter.ts @@ -1,68 +1,83 @@ import { Router } from 'express' import { z } from 'zod' -import bcrypt from 'bcryptjs' -import jwt from 'jsonwebtoken' import { prisma } from '../lib/prisma' import { requireRenterAuth } from '../middleware/requireRenterAuth' const router = Router() -function signRenterToken(renterId: string) { - return jwt.sign({ sub: renterId, type: 'renter' }, process.env.JWT_SECRET!, { - expiresIn: process.env.RENTER_JWT_EXPIRY ?? '7d', +router.post('/signup', async (_req, res) => { + res.status(403).json({ + error: 'renter_signup_disabled', + message: 'Renter account creation is disabled. Only company owners can sign in to the platform.', + statusCode: 403, }) -} - -router.post('/signup', async (req, res, next) => { - try { - const body = z.object({ - firstName: z.string().min(1), - lastName: z.string().min(1), - email: z.string().email(), - password: z.string().min(8), - phone: z.string().optional(), - }).parse(req.body) - - const existing = await prisma.renter.findUnique({ where: { email: body.email } }) - if (existing) return res.status(409).json({ error: 'email_taken', message: 'Email already in use', statusCode: 409 }) - - const passwordHash = await bcrypt.hash(body.password, 12) - const renter = await prisma.renter.create({ - data: { firstName: body.firstName, lastName: body.lastName, email: body.email, passwordHash, phone: body.phone ?? null }, - }) - - const token = signRenterToken(renter.id) - res.status(201).json({ data: { token, renter: { id: renter.id, email: renter.email, firstName: renter.firstName, lastName: renter.lastName } } }) - } catch (err) { next(err) } }) -router.post('/login', async (req, res, next) => { - try { - const body = z.object({ email: z.string().email(), password: z.string() }).parse(req.body) - const renter = await prisma.renter.findUnique({ where: { email: body.email } }) - if (!renter || !renter.isActive) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) - - const valid = await bcrypt.compare(body.password, renter.passwordHash) - if (!valid) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) - - const token = signRenterToken(renter.id) - res.json({ data: { token, renter: { id: renter.id, email: renter.email, firstName: renter.firstName, lastName: renter.lastName } } }) - } catch (err) { next(err) } +router.post('/login', async (_req, res) => { + res.status(403).json({ + error: 'renter_login_disabled', + message: 'Renter sign-in is disabled. Only company owners can sign in to the platform.', + statusCode: 403, + }) }) router.get('/me', requireRenterAuth, async (req, res, next) => { try { - const renter = await prisma.renter.findUniqueOrThrow({ where: { id: req.renterId }, select: { id: true, firstName: true, lastName: true, email: true, phone: true, preferredLocale: true, preferredCurrency: true, emailVerified: true } }) - res.json({ data: renter }) + const renter = await prisma.renter.findUniqueOrThrow({ + where: { id: req.renterId }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + preferredLocale: true, + preferredCurrency: true, + emailVerified: true, + savedCompanies: { + select: { + companyId: true, + }, + }, + }, + }) + + const savedCompanyIds = renter.savedCompanies.map((saved) => saved.companyId) + const companies = savedCompanyIds.length === 0 + ? [] + : await prisma.company.findMany({ + where: { id: { in: savedCompanyIds } }, + select: { + id: true, + brand: { + select: { + displayName: true, + subdomain: true, + logoUrl: true, + }, + }, + }, + }) + + const companyMap = new Map(companies.map((company) => [company.id, company])) + + const data = { + ...renter, + savedCompanies: renter.savedCompanies.map((saved) => ({ + id: saved.companyId, + brand: companyMap.get(saved.companyId)?.brand ?? null, + })), + } + res.json({ data }) } catch (err) { next(err) } }) router.patch('/me', requireRenterAuth, async (req, res, next) => { try { const body = z.object({ - firstName: z.string().min(1).optional(), - lastName: z.string().min(1).optional(), - phone: z.string().optional(), + firstName: z.string().min(1).max(100).trim().optional(), + lastName: z.string().min(1).max(100).trim().optional(), + phone: z.string().max(30).trim().optional(), preferredLocale: z.enum(['en', 'fr', 'ar']).optional(), preferredCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(), }).parse(req.body) diff --git a/apps/api/src/routes/companies.ts b/apps/api/src/routes/companies.ts new file mode 100644 index 0000000..e48d69a --- /dev/null +++ b/apps/api/src/routes/companies.ts @@ -0,0 +1,410 @@ +import { Router } from 'express' +import { z } from 'zod' +import multer from 'multer' +import { prisma } from '../lib/prisma' +import { uploadImage } from '../lib/cloudinary' +import { requireCompanyAuth } from '../middleware/requireCompanyAuth' +import { requireTenant } from '../middleware/requireTenant' +import { requireSubscription } from '../middleware/requireSubscription' + +const router = Router() +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }) +router.use(requireCompanyAuth, requireTenant, requireSubscription) + +const companySchema = z.object({ + name: z.string().min(1).optional(), + email: z.string().email().optional(), + phone: z.string().optional(), + address: z.record(z.unknown()).optional(), +}) + +const brandSchema = z.object({ + displayName: z.string().min(1).optional(), + tagline: z.string().optional(), + primaryColor: z.string().optional(), + accentColor: z.string().optional(), + publicEmail: z.string().email().optional(), + publicPhone: z.string().optional(), + publicAddress: z.string().optional(), + publicCity: z.string().optional(), + publicCountry: z.string().optional(), + websiteUrl: z.string().url().optional(), + whatsappNumber: z.string().optional(), + defaultLocale: z.string().optional(), + defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(), + amanpayMerchantId: z.string().optional(), + amanpaySecretKey: z.string().optional(), + paypalEmail: z.string().email().optional(), + paypalMerchantId: z.string().optional(), + isListedOnMarketplace: z.boolean().optional(), +}) + +const contractSettingsSchema = z.object({ + legalName: z.string().optional(), + registrationNumber: z.string().optional(), + taxId: z.string().optional(), + terms: z.string().optional(), + fuelPolicy: z.string().optional(), + depositPolicy: z.string().optional(), + lateFeePolicy: z.string().optional(), + damagePolicy: z.string().optional(), + contractFooterNote: z.string().optional(), + invoiceFooterNote: z.string().optional(), + signatureRequired: z.boolean().optional(), + showTax: z.boolean().optional(), + taxRate: z.number().optional(), + taxLabel: z.string().optional(), + fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(), + fuelPolicyNote: z.string().optional(), + fuelChargePerLiter: z.number().int().optional(), + fuelShortfallFee: z.number().int().optional(), + lateFeePerHour: z.number().int().optional(), + additionalDriverCharge: z.enum(['FREE', 'PER_DAY', 'FLAT']).optional(), + additionalDriverDailyRate: z.number().int().optional(), + additionalDriverFlatRate: z.number().int().optional(), +}) + +const insurancePolicySchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + type: z.enum(['CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'FULL', 'BASIC', 'ROADSIDE', 'PERSONAL', 'CUSTOM']), + chargeType: z.enum(['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL']), + chargeValue: z.number().int().min(0), + isRequired: z.boolean().default(false), + isActive: z.boolean().default(true), + sortOrder: z.number().int().default(0), +}) + +const pricingRuleSchema = z.object({ + name: z.string().min(1), + type: z.enum(['SURCHARGE', 'DISCOUNT']), + condition: z.enum(['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN']), + conditionValue: z.number().int(), + adjustmentType: z.enum(['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL']), + adjustmentValue: z.number().int(), + isActive: z.boolean().default(true), + description: z.string().optional(), +}) + +const accountingSettingsSchema = z.object({ + reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(), + fiscalYearStart: z.number().int().min(1).max(12).optional(), + currency: z.enum(['MAD', 'USD', 'EUR']).optional(), + accountantEmail: z.string().email().optional(), + accountantName: z.string().optional(), + autoSendReport: z.boolean().optional(), + reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(), +}) + +function buildPaymentMethodsEnabled(input: { + amanpayMerchantId?: string | null + amanpaySecretKey?: string | null + paypalEmail?: string | null +}) { + const methods: Array<'AMANPAY' | 'PAYPAL'> = [] + if (input.amanpayMerchantId && input.amanpaySecretKey) methods.push('AMANPAY') + if (input.paypalEmail) methods.push('PAYPAL') + return methods +} + +router.get('/me', async (req, res, next) => { + try { + const company = await prisma.company.findUniqueOrThrow({ + where: { id: req.companyId }, + include: { + brand: true, + subscription: true, + _count: { select: { vehicles: true, customers: true, reservations: true } }, + }, + }) + res.json({ data: company }) + } catch (err) { next(err) } +}) + +router.patch('/me', async (req, res, next) => { + try { + const body = companySchema.parse(req.body) + const company = await prisma.company.update({ + where: { id: req.companyId }, + data: body, + }) + res.json({ data: company }) + } catch (err) { next(err) } +}) + +router.get('/me/brand', async (req, res, next) => { + try { + const brand = await prisma.brandSettings.findUnique({ + where: { companyId: req.companyId }, + }) + res.json({ data: brand }) + } catch (err) { next(err) } +}) + +router.patch('/me/brand', async (req, res, next) => { + try { + const body = brandSchema.parse(req.body) + const current = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId } }) + const paymentMethodsEnabled = buildPaymentMethodsEnabled({ + amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId, + amanpaySecretKey: body.amanpaySecretKey ?? current?.amanpaySecretKey, + paypalEmail: body.paypalEmail ?? current?.paypalEmail, + }) + const brand = await prisma.brandSettings.upsert({ + where: { companyId: req.companyId }, + update: { ...body, paymentMethodsEnabled }, + create: { + companyId: req.companyId, + displayName: body.displayName ?? req.company.name, + subdomain: req.company.slug, + paymentMethodsEnabled, + ...body, + }, + }) + res.json({ data: brand }) + } catch (err) { next(err) } +}) + +router.post('/me/brand/logo', upload.single('file'), async (req, res, next) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'missing_file', message: 'A logo file is required', statusCode: 400 }) + } + const url = await uploadImage(req.file.buffer, `brands/${req.companyId}`, 'logo') + const brand = await prisma.brandSettings.upsert({ + where: { companyId: req.companyId }, + update: { logoUrl: url }, + create: { + companyId: req.companyId, + displayName: req.company.name, + subdomain: req.company.slug, + logoUrl: url, + }, + }) + res.json({ data: brand }) + } catch (err) { next(err) } +}) + +router.post('/me/brand/hero', upload.single('file'), async (req, res, next) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'missing_file', message: 'A hero image file is required', statusCode: 400 }) + } + const url = await uploadImage(req.file.buffer, `brands/${req.companyId}`, 'hero') + const brand = await prisma.brandSettings.upsert({ + where: { companyId: req.companyId }, + update: { heroImageUrl: url }, + create: { + companyId: req.companyId, + displayName: req.company.name, + subdomain: req.company.slug, + heroImageUrl: url, + }, + }) + res.json({ data: brand }) + } catch (err) { next(err) } +}) + +router.post('/me/brand/subdomain/check', async (req, res, next) => { + try { + const { subdomain } = z.object({ subdomain: z.string().min(3) }).parse(req.body) + const existing = await prisma.brandSettings.findFirst({ + where: { subdomain, companyId: { not: req.companyId } }, + }) + res.json({ data: { available: !existing } }) + } catch (err) { next(err) } +}) + +router.post('/me/brand/custom-domain', async (req, res, next) => { + try { + const { customDomain } = z.object({ customDomain: z.string().min(3) }).parse(req.body) + const normalized = customDomain.toLowerCase().trim() + const existing = await prisma.brandSettings.findFirst({ + where: { customDomain: normalized, companyId: { not: req.companyId } }, + }) + if (existing) { + return res.status(409).json({ error: 'domain_taken', message: 'This custom domain is already in use', statusCode: 409 }) + } + const brand = await prisma.brandSettings.upsert({ + where: { companyId: req.companyId }, + update: { + customDomain: normalized, + customDomainVerified: false, + customDomainAddedAt: new Date(), + }, + create: { + companyId: req.companyId, + displayName: req.company.name, + subdomain: req.company.slug, + customDomain: normalized, + customDomainVerified: false, + customDomainAddedAt: new Date(), + }, + }) + res.json({ data: brand }) + } catch (err) { next(err) } +}) + +router.get('/me/brand/custom-domain/status', async (req, res, next) => { + try { + const brand = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId } }) + const customDomain = brand?.customDomain ?? null + res.json({ + data: { + customDomain, + verified: brand?.customDomainVerified ?? false, + status: customDomain ? (brand?.customDomainVerified ? 'verified' : 'pending_dns') : 'not_configured', + dnsTarget: process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET ?? 'cname.vercel-dns.com', + }, + }) + } catch (err) { next(err) } +}) + +router.delete('/me/brand/custom-domain', async (req, res, next) => { + try { + const brand = await prisma.brandSettings.updateMany({ + where: { companyId: req.companyId }, + data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null }, + }) + res.json({ data: { success: brand.count > 0 } }) + } catch (err) { next(err) } +}) + +router.get('/me/contract-settings', async (req, res, next) => { + try { + const settings = await prisma.contractSettings.findUnique({ where: { companyId: req.companyId } }) + res.json({ data: settings }) + } catch (err) { next(err) } +}) + +router.patch('/me/contract-settings', async (req, res, next) => { + try { + const body = contractSettingsSchema.parse(req.body) + const settings = await prisma.contractSettings.upsert({ + where: { companyId: req.companyId }, + update: body, + create: { companyId: req.companyId, ...body }, + }) + res.json({ data: settings }) + } catch (err) { next(err) } +}) + +router.get('/me/insurance-policies', async (req, res, next) => { + try { + const policies = await prisma.insurancePolicy.findMany({ + where: { companyId: req.companyId }, + orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }], + }) + res.json({ data: policies }) + } catch (err) { next(err) } +}) + +router.post('/me/insurance-policies', async (req, res, next) => { + try { + const body = insurancePolicySchema.parse(req.body) + const policy = await prisma.insurancePolicy.create({ data: { companyId: req.companyId, ...body } }) + res.status(201).json({ data: policy }) + } catch (err) { next(err) } +}) + +router.patch('/me/insurance-policies/:id', async (req, res, next) => { + try { + const body = insurancePolicySchema.partial().parse(req.body) + const policy = await prisma.insurancePolicy.updateMany({ + where: { id: req.params.id, companyId: req.companyId }, + data: body, + }) + if (policy.count === 0) return res.status(404).json({ error: 'not_found', message: 'Insurance policy not found', statusCode: 404 }) + const updated = await prisma.insurancePolicy.findUniqueOrThrow({ where: { id: req.params.id } }) + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.delete('/me/insurance-policies/:id', async (req, res, next) => { + try { + const deleted = await prisma.insurancePolicy.deleteMany({ where: { id: req.params.id, companyId: req.companyId } }) + if (deleted.count === 0) return res.status(404).json({ error: 'not_found', message: 'Insurance policy not found', statusCode: 404 }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.get('/me/pricing-rules', async (req, res, next) => { + try { + const rules = await prisma.pricingRule.findMany({ + where: { companyId: req.companyId }, + orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }], + }) + res.json({ data: rules }) + } catch (err) { next(err) } +}) + +router.post('/me/pricing-rules', async (req, res, next) => { + try { + const body = pricingRuleSchema.parse(req.body) + const rule = await prisma.pricingRule.create({ data: { companyId: req.companyId, ...body } }) + res.status(201).json({ data: rule }) + } catch (err) { next(err) } +}) + +router.patch('/me/pricing-rules/:id', async (req, res, next) => { + try { + const body = pricingRuleSchema.partial().parse(req.body) + const updated = await prisma.pricingRule.updateMany({ + where: { id: req.params.id, companyId: req.companyId }, + data: body, + }) + if (updated.count === 0) return res.status(404).json({ error: 'not_found', message: 'Pricing rule not found', statusCode: 404 }) + const rule = await prisma.pricingRule.findUniqueOrThrow({ where: { id: req.params.id } }) + res.json({ data: rule }) + } catch (err) { next(err) } +}) + +router.delete('/me/pricing-rules/:id', async (req, res, next) => { + try { + const deleted = await prisma.pricingRule.deleteMany({ where: { id: req.params.id, companyId: req.companyId } }) + if (deleted.count === 0) return res.status(404).json({ error: 'not_found', message: 'Pricing rule not found', statusCode: 404 }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.get('/me/accounting-settings', async (req, res, next) => { + try { + const settings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } }) + res.json({ data: settings }) + } catch (err) { next(err) } +}) + +router.patch('/me/accounting-settings', async (req, res, next) => { + try { + const body = accountingSettingsSchema.parse(req.body) + const settings = await prisma.accountingSettings.upsert({ + where: { companyId: req.companyId }, + update: body, + create: { companyId: req.companyId, ...body }, + }) + res.json({ data: settings }) + } catch (err) { next(err) } +}) + +router.get('/me/api-key', async (req, res, next) => { + try { + const company = await prisma.company.findUniqueOrThrow({ + where: { id: req.companyId }, + select: { apiKey: true }, + }) + res.json({ data: company }) + } catch (err) { next(err) } +}) + +router.post('/me/api-key/regenerate', async (req, res, next) => { + try { + const company = await prisma.company.update({ + where: { id: req.companyId }, + data: { apiKey: `api_${Math.random().toString(36).slice(2)}${Date.now()}` }, + select: { apiKey: true }, + }) + res.json({ data: company }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/customers.ts b/apps/api/src/routes/customers.ts index 756628b..834e510 100644 --- a/apps/api/src/routes/customers.ts +++ b/apps/api/src/routes/customers.ts @@ -10,35 +10,42 @@ import { validateAndFlagLicense } from '../services/licenseValidationService' const router = Router() router.use(requireCompanyAuth, requireTenant, requireSubscription) +const paginationSchema = z.object({ + page: z.coerce.number().int().min(1).max(10000).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), +}) + const customerSchema = z.object({ - firstName: z.string().min(1), - lastName: z.string().min(1), - email: z.string().email(), - phone: z.string().optional(), - driverLicense: z.string().optional(), + firstName: z.string().min(1).max(100).trim(), + lastName: z.string().min(1).max(100).trim(), + email: z.string().email().max(255).trim().toLowerCase(), + phone: z.string().max(30).trim().optional(), + driverLicense: z.string().max(50).trim().optional(), dateOfBirth: z.string().datetime().optional(), - nationality: z.string().optional(), + nationality: z.string().max(100).trim().optional(), address: z.record(z.unknown()).optional(), - notes: z.string().optional(), + notes: z.string().max(2000).trim().optional(), licenseExpiry: z.string().datetime().optional(), licenseIssuedAt: z.string().datetime().optional(), - licenseCountry: z.string().optional(), - licenseNumber: z.string().optional(), - licenseCategory: z.string().optional(), + licenseCountry: z.string().max(100).trim().optional(), + licenseNumber: z.string().max(50).trim().optional(), + licenseCategory: z.string().max(20).trim().optional(), }) router.get('/', async (req, res, next) => { try { - const { q, flagged, page = '1', pageSize = '20' } = req.query as Record + const { q, flagged } = req.query as Record + const { page, pageSize } = paginationSchema.parse(req.query) + const safeQ = q ? q.trim().slice(0, 100) : undefined const where: any = { companyId: req.companyId } if (flagged !== undefined) where.flagged = flagged === 'true' - if (q) where.OR = [{ firstName: { contains: q, mode: 'insensitive' } }, { lastName: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] + if (safeQ) where.OR = [{ firstName: { contains: safeQ, mode: 'insensitive' } }, { lastName: { contains: safeQ, mode: 'insensitive' } }, { email: { contains: safeQ, mode: 'insensitive' } }] const [customers, total] = await Promise.all([ - prisma.customer.findMany({ where, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }), + prisma.customer.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: 'desc' } }), prisma.customer.count({ where }), ]) - res.json({ data: customers, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) }) + res.json({ data: customers, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }) } catch (err) { next(err) } }) diff --git a/apps/api/src/routes/marketplace.ts b/apps/api/src/routes/marketplace.ts index 719962b..070fbc8 100644 --- a/apps/api/src/routes/marketplace.ts +++ b/apps/api/src/routes/marketplace.ts @@ -1,10 +1,23 @@ import { Router } from 'express' +import { z } from 'zod' import { prisma } from '../lib/prisma' import { optionalRenterAuth } from '../middleware/requireRenterAuth' const router = Router() router.use(optionalRenterAuth) +const paginationSchema = z.object({ + page: z.coerce.number().int().min(1).max(10000).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), +}) + +function isDatabaseUnavailableError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + + const candidate = error as { code?: string; message?: string } + return candidate.code === 'P1001' || candidate.message?.includes("Can't reach database server") === true +} + router.get('/offers', async (req, res, next) => { try { const offers = await prisma.offer.findMany({ @@ -14,14 +27,22 @@ router.get('/offers', async (req, res, next) => { take: 50, }) res.json({ data: offers }) - } catch (err) { next(err) } + } catch (err) { + if (isDatabaseUnavailableError(err)) return res.json({ data: [] }) + next(err) + } }) router.get('/companies', async (req, res, next) => { try { - const { city, hasOffer, page = '1', pageSize = '20' } = req.query as Record + const { city, hasOffer } = req.query as Record + const { page, pageSize } = paginationSchema.parse(req.query) + const safeCity = city ? city.trim().slice(0, 100) : undefined const where: any = { status: 'ACTIVE', brand: { isListedOnMarketplace: true } } - if (city) where.brand = { ...where.brand, publicCity: { contains: city, mode: 'insensitive' } } + if (safeCity) where.brand = { ...where.brand, publicCity: { contains: safeCity, mode: 'insensitive' } } + if (hasOffer === 'true') { + where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } } + } const companies = await prisma.company.findMany({ where, @@ -29,32 +50,69 @@ router.get('/companies', async (req, res, next) => { brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } }, _count: { select: { vehicles: { where: { isPublished: true } } } }, }, - skip: (parseInt(page) - 1) * parseInt(pageSize), - take: parseInt(pageSize), + skip: (page - 1) * pageSize, + take: pageSize, }) res.json({ data: companies }) - } catch (err) { next(err) } + } catch (err) { + if (isDatabaseUnavailableError(err)) return res.json({ data: [] }) + next(err) + } }) router.get('/search', async (req, res, next) => { try { - const { city, startDate, endDate, category, maxPrice, page = '1', pageSize = '20' } = req.query as Record + const searchSchema = z.object({ + city: z.string().trim().max(100).optional(), + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + category: z.string().trim().max(50).optional(), + maxPrice: z.coerce.number().int().min(0).max(1_000_000).optional(), + transmission: z.string().trim().max(20).optional(), + }) + const { city, startDate, endDate, category, maxPrice, transmission } = searchSchema.parse(req.query) + const { page, pageSize } = paginationSchema.parse(req.query) + const where: any = { isPublished: true, company: { status: 'ACTIVE', brand: { isListedOnMarketplace: true } } } if (category) where.category = category - if (maxPrice) where.dailyRate = { lte: parseInt(maxPrice) } + if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice } + if (transmission) where.transmission = transmission + if (city) where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: city, mode: 'insensitive' } } } const vehicles = await prisma.vehicle.findMany({ where, include: { company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } }, }, - skip: (parseInt(page) - 1) * parseInt(pageSize), - take: parseInt(pageSize), + skip: (page - 1) * pageSize, + take: pageSize, orderBy: { dailyRate: 'asc' }, }) - res.json({ data: vehicles }) - } catch (err) { next(err) } + const typedVehicles = vehicles as Array<(typeof vehicles)[number]> + + const unavailableVehicleIds = startDate && endDate + ? new Set((await prisma.reservation.findMany({ + where: { + status: { in: ['CONFIRMED', 'ACTIVE'] }, + vehicleId: { in: typedVehicles.map((vehicle) => vehicle.id) }, + startDate: { lt: new Date(endDate) }, + endDate: { gt: new Date(startDate) }, + }, + select: { vehicleId: true }, + })).map((reservation: { vehicleId: string }) => reservation.vehicleId)) + : null + + res.json({ + data: typedVehicles.map((vehicle) => ({ + ...vehicle, + availability: unavailableVehicleIds ? !unavailableVehicleIds.has(vehicle.id) : null, + })), + }) + } catch (err) { + if (isDatabaseUnavailableError(err)) return res.json({ data: [] }) + next(err) + } }) router.get('/:slug', async (req, res, next) => { @@ -69,7 +127,12 @@ router.get('/:slug', async (req, res, next) => { }) if (!company) return res.status(404).json({ error: 'not_found', message: 'Company not found', statusCode: 404 }) res.json({ data: company }) - } catch (err) { next(err) } + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 }) + } + next(err) + } }) router.get('/:slug/reviews', async (req, res, next) => { @@ -82,7 +145,66 @@ router.get('/:slug/reviews', async (req, res, next) => { take: 50, }) res.json({ data: reviews }) - } catch (err) { next(err) } + } catch (err) { + if (isDatabaseUnavailableError(err)) return res.json({ data: [] }) + next(err) + } +}) + +router.get('/:slug/vehicles', async (req, res, next) => { + try { + const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } }) + const vehicles = await prisma.vehicle.findMany({ + where: { companyId: company.id, isPublished: true }, + orderBy: { createdAt: 'desc' }, + }) + res.json({ data: vehicles }) + } catch (err) { + if (isDatabaseUnavailableError(err)) return res.json({ data: [] }) + next(err) + } +}) + +router.get('/:slug/vehicles/:id', async (req, res, next) => { + try { + const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } }) + const vehicle = await prisma.vehicle.findFirstOrThrow({ + where: { id: req.params.id, companyId: company.id, isPublished: true }, + include: { + company: { include: { brand: true } }, + reservations: { + where: { status: { in: ['CONFIRMED', 'ACTIVE'] } }, + select: { startDate: true, endDate: true }, + }, + }, + }) + res.json({ data: vehicle }) + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 }) + } + next(err) + } +}) + +router.get('/:slug/offers', async (req, res, next) => { + try { + const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } }) + const offers = await prisma.offer.findMany({ + where: { + companyId: company.id, + isPublic: true, + isActive: true, + validFrom: { lte: new Date() }, + validUntil: { gte: new Date() }, + }, + orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }], + }) + res.json({ data: offers }) + } catch (err) { + if (isDatabaseUnavailableError(err)) return res.json({ data: [] }) + next(err) + } }) router.post('/offers/:code/validate', async (req, res, next) => { @@ -95,7 +217,12 @@ router.post('/offers/:code/validate', async (req, res, next) => { return res.status(409).json({ error: 'code_exhausted', message: 'Promo code has reached its maximum redemptions', statusCode: 409 }) } res.json({ data: offer }) - } catch (err) { next(err) } + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.status(503).json({ error: 'database_unavailable', message: 'Offer validation is temporarily unavailable', statusCode: 503 }) + } + next(err) + } }) export default router diff --git a/apps/api/src/routes/notifications.ts b/apps/api/src/routes/notifications.ts index c76a23f..28c742f 100644 --- a/apps/api/src/routes/notifications.ts +++ b/apps/api/src/routes/notifications.ts @@ -23,6 +23,15 @@ router.get('/company', ...companyAuth, async (req: any, res: any, next: any) => } catch (err) { next(err) } }) +router.get('/unread-count', ...companyAuth, async (req: any, res: any, next: any) => { + try { + const unread = await prisma.notification.count({ + where: { companyId: req.companyId, channel: 'IN_APP', readAt: null }, + }) + res.json({ data: { unread } }) + } catch (err) { next(err) } +}) + router.post('/company/:id/read', ...companyAuth, async (req: any, res: any, next: any) => { try { await prisma.notification.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { readAt: new Date(), status: 'READ' } }) @@ -74,4 +83,46 @@ router.post('/renter/:id/read', requireRenterAuth, async (req: any, res: any, ne } catch (err) { next(err) } }) +router.post('/renter/read-all', requireRenterAuth, async (req: any, res: any, next: any) => { + try { + await prisma.notification.updateMany({ + where: { renterId: req.renterId, channel: 'IN_APP', readAt: null }, + data: { readAt: new Date(), status: 'READ' }, + }) + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.get('/renter/preferences', requireRenterAuth, async (req: any, res: any, next: any) => { + try { + const prefs = await prisma.notificationPreference.findMany({ where: { renterId: req.renterId } }) + res.json({ data: prefs }) + } catch (err) { next(err) } +}) + +router.patch('/renter/preferences', requireRenterAuth, async (req: any, res: any, next: any) => { + try { + const body = z.array(z.object({ notificationType: z.string(), channel: z.string(), enabled: z.boolean() })).parse(req.body) + for (const pref of body) { + await prisma.notificationPreference.upsert({ + where: { + renterId_notificationType_channel: { + renterId: req.renterId, + notificationType: pref.notificationType as NotificationType, + channel: pref.channel as NotificationChannel, + }, + }, + create: { + renterId: req.renterId, + notificationType: pref.notificationType as NotificationType, + channel: pref.channel as NotificationChannel, + enabled: pref.enabled, + }, + update: { enabled: pref.enabled }, + }) + } + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + export default router diff --git a/apps/api/src/routes/payments.ts b/apps/api/src/routes/payments.ts new file mode 100644 index 0000000..dc62be5 --- /dev/null +++ b/apps/api/src/routes/payments.ts @@ -0,0 +1,252 @@ +import { Router } from 'express' +import { z } from 'zod' +import { prisma } from '../lib/prisma' +import { requireCompanyAuth } from '../middleware/requireCompanyAuth' +import { requireTenant } from '../middleware/requireTenant' +import { requireSubscription } from '../middleware/requireSubscription' +import * as amanpay from '../services/amanpayService' +import * as paypal from '../services/paypalService' + +const router = Router() + +// ─── Webhook endpoints (no auth — must come before middleware) ────────────── + +router.post('/webhooks/amanpay', async (req, res, next) => { + try { + const rawBody = JSON.stringify(req.body) + const signature = req.headers['x-amanpay-signature'] as string ?? '' + + if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) { + return res.status(401).json({ error: 'invalid_signature' }) + } + + const event = req.body + const transactionId = event.transaction_id ?? event.id + const orderId = event.order_id ?? event.metadata?.order_id + const status = event.status?.toUpperCase() + + if (status === 'PAID' || status === 'SUCCEEDED') { + const payment = await prisma.rentalPayment.findFirst({ where: { amanpayTransactionId: transactionId } }) + if (payment) { + await prisma.rentalPayment.update({ + where: { id: payment.id }, + data: { status: 'SUCCEEDED', paidAt: new Date() }, + }) + await prisma.reservation.update({ + where: { id: payment.reservationId }, + data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } }, + }) + } + } else if (status === 'FAILED') { + await prisma.rentalPayment.updateMany({ + where: { amanpayTransactionId: transactionId }, + data: { status: 'FAILED' }, + }) + } + + res.json({ received: true }) + } catch (err) { next(err) } +}) + +router.post('/webhooks/paypal', async (req, res, next) => { + try { + const rawBody = JSON.stringify(req.body) + const isValid = await paypal.verifyWebhookEvent( + req.headers as Record, + rawBody, + ) + if (paypal.isConfigured() && !isValid) { + return res.status(401).json({ error: 'invalid_signature' }) + } + + const event = req.body + const eventType = event.event_type as string + + if (eventType === 'PAYMENT.CAPTURE.COMPLETED') { + const captureId = event.resource?.id as string + const payment = await prisma.rentalPayment.findFirst({ where: { paypalCaptureId: captureId } }) + if (payment) { + await prisma.rentalPayment.update({ + where: { id: payment.id }, + data: { status: 'SUCCEEDED', paidAt: new Date() }, + }) + await prisma.reservation.update({ + where: { id: payment.reservationId }, + data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } }, + }) + } + } else if (eventType === 'PAYMENT.CAPTURE.DENIED') { + const captureId = event.resource?.id as string + await prisma.rentalPayment.updateMany({ + where: { paypalCaptureId: captureId }, + data: { status: 'FAILED' }, + }) + } + + res.json({ received: true }) + } catch (err) { next(err) } +}) + +// ─── Authenticated routes ──────────────────────────────────────────────────── + +router.use(requireCompanyAuth, requireTenant, requireSubscription) + +const chargeSchema = z.object({ + provider: z.enum(['AMANPAY', 'PAYPAL']), + type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'), + currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'), + successUrl: z.string().url(), + failureUrl: z.string().url(), +}) + +router.get('/reservations/:id', async (req, res, next) => { + try { + const payments = await prisma.rentalPayment.findMany({ + where: { reservationId: req.params.id, companyId: req.companyId }, + orderBy: { createdAt: 'desc' }, + }) + res.json({ data: payments }) + } catch (err) { next(err) } +}) + +router.post('/reservations/:id/charge', async (req, res, next) => { + try { + const { provider, type, currency, successUrl, failureUrl } = chargeSchema.parse(req.body) + + const reservation = await prisma.reservation.findFirstOrThrow({ + where: { id: req.params.id, companyId: req.companyId }, + include: { vehicle: true, customer: true }, + }) + + if (reservation.paymentStatus === 'PAID') { + return res.status(409).json({ error: 'already_paid', message: 'Reservation is already fully paid', statusCode: 409 }) + } + + const amount = type === 'DEPOSIT' ? reservation.depositAmount : reservation.totalAmount + const description = `${type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}` + const orderId = `${reservation.id}-${type}-${Date.now()}` + const webhookBase = process.env.API_URL ?? 'http://localhost:4000' + + let checkoutUrl: string + let amanpayTransactionId: string | null = null + let paypalCaptureId: string | null = null + + if (provider === 'AMANPAY') { + if (!amanpay.isConfigured()) { + return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured', statusCode: 503 }) + } + const result = await amanpay.createCheckout({ + amount, + currency, + orderId, + description, + customerEmail: reservation.customer.email, + customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`, + successUrl, + failureUrl, + webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`, + }) + checkoutUrl = result.checkoutUrl + amanpayTransactionId = result.transactionId + } else { + if (!paypal.isConfigured()) { + return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not configured', statusCode: 503 }) + } + const result = await paypal.createOrder({ + amount, + currency, + orderId, + description, + returnUrl: successUrl, + cancelUrl: failureUrl, + }) + checkoutUrl = result.approveUrl + paypalCaptureId = result.orderId + } + + const payment = await prisma.rentalPayment.create({ + data: { + companyId: req.companyId, + reservationId: reservation.id, + amount, + currency, + status: 'PENDING', + type, + paymentProvider: provider, + amanpayTransactionId, + paypalCaptureId, + }, + }) + + res.json({ data: { payment, checkoutUrl } }) + } catch (err) { next(err) } +}) + +router.post('/reservations/:id/capture-paypal', async (req, res, next) => { + try { + const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body) + + const payment = await prisma.rentalPayment.findFirstOrThrow({ + where: { paypalCaptureId: paypalOrderId, companyId: req.companyId }, + }) + + const capture = await paypal.captureOrder(paypalOrderId) + const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId + + const updated = await prisma.rentalPayment.update({ + where: { id: payment.id }, + data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId }, + }) + + await prisma.reservation.update({ + where: { id: payment.reservationId }, + data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } }, + }) + + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.post('/reservations/:reservationId/payments/:paymentId/refund', async (req, res, next) => { + try { + const { amount, reason } = z.object({ + amount: z.number().int().positive().optional(), + reason: z.string().optional(), + }).parse(req.body) + + const payment = await prisma.rentalPayment.findFirstOrThrow({ + where: { id: req.params.paymentId, companyId: req.companyId, reservationId: req.params.reservationId }, + }) + + if (payment.status !== 'SUCCEEDED') { + return res.status(400).json({ error: 'not_capturable', message: 'Only succeeded payments can be refunded', statusCode: 400 }) + } + + const refundAmount = amount ?? payment.amount + + if (payment.paymentProvider === 'AMANPAY') { + if (!payment.amanpayTransactionId) throw new Error('No AmanPay transaction ID') + await amanpay.refundTransaction(payment.amanpayTransactionId, refundAmount, reason) + } else { + if (!payment.paypalCaptureId) throw new Error('No PayPal capture ID') + await paypal.refundCapture(payment.paypalCaptureId, refundAmount, payment.currency, reason) + } + + const isPartial = refundAmount < payment.amount + const updated = await prisma.rentalPayment.update({ + where: { id: payment.id }, + data: { status: isPartial ? 'PARTIALLY_REFUNDED' : 'REFUNDED' }, + }) + + if (!isPartial) { + await prisma.reservation.update({ + where: { id: payment.reservationId }, + data: { paymentStatus: 'REFUNDED' }, + }) + } + + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/reservations.ts b/apps/api/src/routes/reservations.ts index 23df1ff..7c90920 100644 --- a/apps/api/src/routes/reservations.ts +++ b/apps/api/src/routes/reservations.ts @@ -4,14 +4,47 @@ import { prisma } from '../lib/prisma' import { requireCompanyAuth } from '../middleware/requireCompanyAuth' import { requireTenant } from '../middleware/requireTenant' import { requireSubscription } from '../middleware/requireSubscription' +import { applyAdditionalDriversToReservation } from '../services/additionalDriverService' import { applyInsurancesToReservation } from '../services/insuranceService' import { applyPricingRules } from '../services/pricingRuleService' -import { validateAndFlagLicense } from '../services/licenseValidationService' +import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService' import { sendNotification } from '../services/notificationService' const router = Router() router.use(requireCompanyAuth, requireTenant, requireSubscription) +const additionalDriverSchema = z.object({ + firstName: z.string().min(1), + lastName: z.string().min(1), + email: z.string().email().optional(), + phone: z.string().optional(), + driverLicense: z.string().min(1), + licenseExpiry: z.string().datetime().optional(), + licenseIssuedAt: z.string().datetime().optional(), + dateOfBirth: z.string().datetime().optional(), + nationality: z.string().optional(), +}) + +const inspectionSchema = z.object({ + mileage: z.number().int().optional(), + fuelLevel: z.enum(['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']), + fuelCharge: z.number().int().min(0).optional(), + generalCondition: z.string().optional(), + employeeNotes: z.string().optional(), + customerAgreed: z.boolean().default(false), + damagePoints: z.array( + z.object({ + viewType: z.enum(['TOP', 'FRONT', 'REAR', 'LEFT', 'RIGHT']), + x: z.number(), + y: z.number(), + damageType: z.enum(['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']), + severity: z.enum(['MINOR', 'MODERATE', 'MAJOR']).default('MINOR'), + description: z.string().optional(), + isPreExisting: z.boolean().default(false), + }), + ).default([]), +}) + const createSchema = z.object({ vehicleId: z.string().cuid(), customerId: z.string().cuid(), @@ -24,8 +57,44 @@ const createSchema = z.object({ depositAmount: z.number().int().min(0).default(0), notes: z.string().optional(), selectedInsurancePolicyIds: z.array(z.string()).default([]), + additionalDrivers: z.array(additionalDriverSchema).default([]), }) +async function assertReservationLicenseCompliance(reservationId: string, companyId: string) { + const reservation = await prisma.reservation.findFirstOrThrow({ + where: { id: reservationId, companyId }, + include: { customer: true, additionalDrivers: true }, + }) + + const customerLicense = validateLicense(reservation.customer.licenseExpiry) + const customerDenied = ['DENIED', 'EXPIRED'].includes(reservation.customer.licenseValidationStatus) + if (customerDenied || customerLicense.status === 'EXPIRED') { + throw Object.assign(new Error('Primary driver license is not valid for this reservation'), { + statusCode: 400, + code: 'invalid_primary_license', + }) + } + + if (customerLicense.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') { + throw Object.assign(new Error('Primary driver license requires manager approval before this reservation can proceed'), { + statusCode: 400, + code: 'primary_license_requires_approval', + }) + } + + const blockedDriver = reservation.additionalDrivers.find((driver) => { + if (driver.licenseExpired) return true + return driver.requiresApproval && !driver.approvedAt + }) + + if (blockedDriver) { + throw Object.assign(new Error(`Additional driver ${blockedDriver.firstName} ${blockedDriver.lastName} requires approval before this reservation can proceed`), { + statusCode: 400, + code: 'additional_driver_requires_approval', + }) + } +} + router.get('/', async (req, res, next) => { try { const { status, vehicleId, source, startDate, endDate, page = '1', pageSize = '20' } = req.query as Record @@ -87,7 +156,7 @@ router.post('/', async (req, res, next) => { } const baseAmount = vehicle.dailyRate * totalDays - const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, [], vehicle.dailyRate, totalDays) + const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, body.additionalDrivers, vehicle.dailyRate, totalDays) const totalAmount = baseAmount - discountAmount + pricingTotal + body.depositAmount const reservation = await prisma.reservation.create({ @@ -118,6 +187,10 @@ router.post('/', async (req, res, next) => { await applyInsurancesToReservation(reservation.id, req.companyId, body.selectedInsurancePolicyIds, totalDays, baseAmount) } + if (body.additionalDrivers.length > 0) { + await applyAdditionalDriversToReservation(reservation.id, req.companyId, body.additionalDrivers, totalDays) + } + // Validate customer license await validateAndFlagLicense(body.customerId).catch(() => null) @@ -139,6 +212,7 @@ router.post('/:id/confirm', async (req, res, next) => { try { const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) if (reservation.status !== 'DRAFT') return res.status(400).json({ error: 'invalid_status', message: 'Only DRAFT reservations can be confirmed', statusCode: 400 }) + await assertReservationLicenseCompliance(reservation.id, req.companyId) const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' } }) @@ -155,6 +229,7 @@ router.post('/:id/checkin', async (req, res, next) => { const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body) const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) if (reservation.status !== 'CONFIRMED') return res.status(400).json({ error: 'invalid_status', message: 'Only CONFIRMED reservations can be checked in', statusCode: 400 }) + await assertReservationLicenseCompliance(reservation.id, req.companyId) const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'ACTIVE', checkedInAt: new Date(), checkInMileage: mileage ?? null } }) await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'RENTED' } }) res.json({ data: updated }) @@ -201,8 +276,8 @@ router.get('/:id/billing', async (req, res, next) => { const baseAmount = reservation.dailyRate * reservation.totalDays const lineItems = [ { description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model} — ${reservation.totalDays} day(s)`, qty: reservation.totalDays, unitPrice: reservation.dailyRate, total: baseAmount, category: 'RENTAL' }, - ...reservation.insurances.map((ins) => ({ description: ins.policyName, qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, total: ins.totalCharge, category: 'INSURANCE' })), - ...reservation.additionalDrivers.filter((d) => d.totalCharge > 0).map((d) => ({ description: `Additional Driver — ${d.firstName} ${d.lastName}`, qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER' })), + ...reservation.insurances.map((ins: (typeof reservation.insurances)[number]) => ({ description: ins.policyName, qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, total: ins.totalCharge, category: 'INSURANCE' })), + ...reservation.additionalDrivers.filter((d: (typeof reservation.additionalDrivers)[number]) => d.totalCharge > 0).map((d: (typeof reservation.additionalDrivers)[number]) => ({ description: `Additional Driver — ${d.firstName} ${d.lastName}`, qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER' })), ...(reservation.depositAmount > 0 ? [{ description: 'Security Deposit (refundable)', qty: 1, unitPrice: reservation.depositAmount, total: reservation.depositAmount, category: 'DEPOSIT' }] : []), ] @@ -212,4 +287,137 @@ router.get('/:id/billing', async (req, res, next) => { } catch (err) { next(err) } }) +router.get('/:id/inspections', async (req, res, next) => { + try { + const inspections = await prisma.damageInspection.findMany({ + where: { reservationId: req.params.id, companyId: req.companyId }, + include: { damagePoints: true }, + orderBy: { inspectedAt: 'asc' }, + }) + res.json({ data: inspections }) + } catch (err) { next(err) } +}) + +router.put('/:id/inspections/:type', async (req, res, next) => { + try { + const type = z.enum(['CHECKIN', 'CHECKOUT']).parse(req.params.type.toUpperCase()) + const body = inspectionSchema.parse(req.body) + const reservation = await prisma.reservation.findFirstOrThrow({ + where: { id: req.params.id, companyId: req.companyId }, + include: { vehicle: true, customer: true }, + }) + + const inspection = await prisma.damageInspection.upsert({ + where: { reservationId_type: { reservationId: reservation.id, type } }, + update: { + mileage: body.mileage ?? null, + fuelLevel: body.fuelLevel, + fuelCharge: body.fuelCharge ?? null, + generalCondition: body.generalCondition ?? null, + employeeNotes: body.employeeNotes ?? null, + customerAgreed: body.customerAgreed, + inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`, + inspectedAt: new Date(), + damagePoints: { + deleteMany: {}, + create: body.damagePoints, + }, + }, + create: { + reservationId: reservation.id, + companyId: req.companyId, + type, + mileage: body.mileage ?? null, + fuelLevel: body.fuelLevel, + fuelCharge: body.fuelCharge ?? null, + generalCondition: body.generalCondition ?? null, + employeeNotes: body.employeeNotes ?? null, + customerAgreed: body.customerAgreed, + inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`, + damagePoints: { + create: body.damagePoints, + }, + }, + include: { damagePoints: true }, + }) + + await prisma.damageReport.upsert({ + where: { reservationId_type: { reservationId: reservation.id, type } }, + update: { + damages: body.damagePoints.map((point) => ({ + viewType: point.viewType, + x: point.x, + y: point.y, + damageType: point.damageType, + severity: point.severity, + note: point.description ?? '', + isPreExisting: point.isPreExisting, + })), + fuelLevel: body.fuelLevel, + mileage: body.mileage ?? null, + inspectedAt: new Date(), + inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`, + customerSignedAt: body.customerAgreed ? new Date() : null, + customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`, + }, + create: { + reservationId: reservation.id, + companyId: req.companyId, + type, + damages: body.damagePoints.map((point) => ({ + viewType: point.viewType, + x: point.x, + y: point.y, + damageType: point.damageType, + severity: point.severity, + note: point.description ?? '', + isPreExisting: point.isPreExisting, + })), + photos: [], + fuelLevel: body.fuelLevel, + mileage: body.mileage ?? null, + inspectedAt: new Date(), + inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`, + customerSignedAt: body.customerAgreed ? new Date() : null, + customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`, + }, + }) + + await prisma.reservation.update({ + where: { id: reservation.id }, + data: type === 'CHECKIN' + ? { + checkInMileage: body.mileage ?? null, + checkInFuelLevel: body.fuelLevel, + } + : { + checkOutMileage: body.mileage ?? null, + checkOutFuelLevel: body.fuelLevel, + }, + }) + + res.json({ data: inspection }) + } catch (err) { next(err) } +}) + +router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next) => { + try { + const { approved, note } = z.object({ approved: z.boolean(), note: z.string().optional() }).parse(req.body) + const driver = await prisma.additionalDriver.findFirstOrThrow({ + where: { id: req.params.driverId, reservationId: req.params.id, companyId: req.companyId }, + }) + + const updated = await prisma.additionalDriver.update({ + where: { id: driver.id }, + data: { + approvedAt: approved ? new Date() : null, + approvedBy: approved ? `${req.employee.firstName} ${req.employee.lastName}` : null, + approvalNote: note ?? driver.approvalNote, + }, + }) + + res.json({ data: updated }) + } catch (err) { next(err) } +}) + export default router diff --git a/apps/api/src/routes/site.ts b/apps/api/src/routes/site.ts new file mode 100644 index 0000000..245d012 --- /dev/null +++ b/apps/api/src/routes/site.ts @@ -0,0 +1,499 @@ +import { Router } from 'express' +import { z } from 'zod' +import { prisma } from '../lib/prisma' +import { applyAdditionalDriversToReservation } from '../services/additionalDriverService' +import * as amanpay from '../services/amanpayService' +import { applyInsurancesToReservation } from '../services/insuranceService' +import { applyPricingRules } from '../services/pricingRuleService' +import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService' +import * as paypal from '../services/paypalService' + +const router = Router() + +function isDatabaseUnavailableError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + + const candidate = error as { code?: string; message?: string } + return candidate.code === 'P1001' || candidate.message?.includes("Can't reach database server") === true +} + +async function findCompanyBySlug(slug: string) { + return prisma.company.findFirstOrThrow({ + where: { slug, status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE', 'SUSPENDED'] } }, + include: { brand: true, contractSettings: true }, + }) +} + +router.get('/:slug/brand', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + res.json({ + data: { + company: { + id: company.id, + slug: company.slug, + name: company.name, + phone: company.phone, + }, + brand: company.brand, + }, + }) + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.json({ + data: { + company: { id: 'demo', slug: req.params.slug, name: 'Demo Company', phone: null }, + brand: null, + }, + }) + } + next(err) + } +}) + +router.get('/:slug/vehicles', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + const vehicles = await prisma.vehicle.findMany({ + where: { companyId: company.id, isPublished: true }, + orderBy: { createdAt: 'desc' }, + }) + res.json({ data: vehicles }) + } catch (err) { + if (isDatabaseUnavailableError(err)) return res.json({ data: [] }) + next(err) + } +}) + +router.get('/:slug/vehicles/:id', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + const vehicle = await prisma.vehicle.findFirstOrThrow({ + where: { id: req.params.id, companyId: company.id, isPublished: true }, + include: { + reservations: { + where: { status: { in: ['CONFIRMED', 'ACTIVE'] } }, + select: { startDate: true, endDate: true, status: true }, + }, + }, + }) + res.json({ data: vehicle }) + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 }) + } + next(err) + } +}) + +router.get('/:slug/offers', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + const offers = await prisma.offer.findMany({ + where: { + companyId: company.id, + isActive: true, + validFrom: { lte: new Date() }, + validUntil: { gte: new Date() }, + }, + orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }], + }) + res.json({ data: offers }) + } catch (err) { + if (isDatabaseUnavailableError(err)) return res.json({ data: [] }) + next(err) + } +}) + +router.get('/:slug/booking-options', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + const insurancePolicies = await prisma.insurancePolicy.findMany({ + where: { companyId: company.id, isActive: true }, + orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }], + }) + + res.json({ + data: { + insurancePolicies, + contractSettings: company.contractSettings, + }, + }) + } catch (err) { + if (isDatabaseUnavailableError(err)) return res.json({ data: { insurancePolicies: [], contractSettings: null } }) + next(err) + } +}) + +router.post('/:slug/availability', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + const { vehicleId, startDate, endDate } = z.object({ + vehicleId: z.string().cuid(), + startDate: z.string().datetime(), + endDate: z.string().datetime(), + }).parse(req.body) + + const conflicts = await prisma.reservation.findMany({ + where: { + companyId: company.id, + vehicleId, + status: { in: ['CONFIRMED', 'ACTIVE'] }, + startDate: { lt: new Date(endDate) }, + endDate: { gt: new Date(startDate) }, + }, + select: { startDate: true, endDate: true }, + }) + + res.json({ data: { available: conflicts.length === 0, conflicts } }) + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 }) + } + next(err) + } +}) + +router.post('/:slug/book/validate-code', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + const { code } = z.object({ code: z.string().min(1) }).parse(req.body) + const offer = await prisma.offer.findFirst({ + where: { + companyId: company.id, + promoCode: code, + isActive: true, + validFrom: { lte: new Date() }, + validUntil: { gte: new Date() }, + }, + }) + if (!offer) { + return res.status(404).json({ error: 'invalid_code', message: 'Promo code not found or expired', statusCode: 404 }) + } + res.json({ data: offer }) + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.status(503).json({ error: 'database_unavailable', message: 'Promo code validation is temporarily unavailable', statusCode: 503 }) + } + next(err) + } +}) + +router.post('/:slug/book', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + const body = z.object({ + vehicleId: z.string().cuid(), + startDate: z.string().datetime(), + endDate: z.string().datetime(), + firstName: z.string().min(1), + lastName: z.string().min(1), + email: z.string().email(), + phone: z.string().optional(), + driverLicense: z.string().optional(), + dateOfBirth: z.string().datetime().optional(), + licenseExpiry: z.string().datetime().optional(), + licenseIssuedAt: z.string().datetime().optional(), + nationality: z.string().optional(), + offerId: z.string().cuid().optional(), + promoCodeUsed: z.string().optional(), + notes: z.string().optional(), + source: z.enum(['PUBLIC_SITE', 'MARKETPLACE']).default('PUBLIC_SITE'), + selectedInsurancePolicyIds: z.array(z.string()).default([]), + additionalDrivers: z.array(z.object({ + firstName: z.string().min(1), + lastName: z.string().min(1), + email: z.string().email().optional(), + phone: z.string().optional(), + driverLicense: z.string().min(1), + licenseExpiry: z.string().datetime().optional(), + licenseIssuedAt: z.string().datetime().optional(), + dateOfBirth: z.string().datetime().optional(), + nationality: z.string().optional(), + })).default([]), + }).parse(req.body) + + const vehicle = await prisma.vehicle.findFirstOrThrow({ + where: { id: body.vehicleId, companyId: company.id, isPublished: true }, + }) + + const customer = await prisma.customer.upsert({ + where: { companyId_email: { companyId: company.id, email: body.email } }, + update: { + firstName: body.firstName, + lastName: body.lastName, + phone: body.phone ?? null, + driverLicense: body.driverLicense ?? null, + dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null, + licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null, + licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null, + nationality: body.nationality ?? null, + }, + create: { + companyId: company.id, + firstName: body.firstName, + lastName: body.lastName, + email: body.email, + phone: body.phone ?? null, + driverLicense: body.driverLicense ?? null, + dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null, + licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null, + licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null, + nationality: body.nationality ?? null, + }, + }) + + const start = new Date(body.startDate) + const end = new Date(body.endDate) + const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000)) + const baseAmount = vehicle.dailyRate * totalDays + + let discountAmount = 0 + if (body.promoCodeUsed) { + const offer = await prisma.offer.findFirst({ + where: { + companyId: company.id, + promoCode: body.promoCodeUsed, + isActive: true, + validFrom: { lte: new Date() }, + validUntil: { gte: new Date() }, + }, + }) + if (offer) { + if (offer.type === 'PERCENTAGE') discountAmount = Math.round(baseAmount * offer.discountValue / 100) + else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue + else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue + } + } + + const { applied, total: pricingRulesTotal } = await applyPricingRules( + company.id, + customer.id, + body.additionalDrivers, + vehicle.dailyRate, + totalDays, + ) + + const primaryLicenseResult = validateLicense(body.licenseExpiry ? new Date(body.licenseExpiry) : null) + if (primaryLicenseResult.status === 'EXPIRED') { + return res.status(400).json({ error: 'license_expired', message: 'The primary driver license is expired', statusCode: 400 }) + } + + const reservation = await prisma.reservation.create({ + data: { + companyId: company.id, + vehicleId: vehicle.id, + customerId: customer.id, + offerId: body.offerId ?? null, + promoCodeUsed: body.promoCodeUsed ?? null, + source: body.source, + startDate: start, + endDate: end, + dailyRate: vehicle.dailyRate, + totalDays, + totalAmount: baseAmount - discountAmount + pricingRulesTotal, + discountAmount, + pricingRulesApplied: applied, + pricingRulesTotal, + notes: body.notes ?? null, + status: 'DRAFT', + }, + }) + + if (body.selectedInsurancePolicyIds.length > 0) { + await applyInsurancesToReservation(reservation.id, company.id, body.selectedInsurancePolicyIds, totalDays, baseAmount) + } + + if (body.additionalDrivers.length > 0) { + await applyAdditionalDriversToReservation(reservation.id, company.id, body.additionalDrivers, totalDays) + } + + if (body.licenseExpiry) { + await validateAndFlagLicense(customer.id).catch(() => null) + } + + const refreshedReservation = await prisma.reservation.findUniqueOrThrow({ + where: { id: reservation.id }, + include: { insurances: true, additionalDrivers: true }, + }) + + res.status(201).json({ + data: { + ...refreshedReservation, + requiresManualApproval: + primaryLicenseResult.requiresApproval || + refreshedReservation.additionalDrivers.some((driver) => driver.requiresApproval), + }, + }) + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.status(503).json({ error: 'database_unavailable', message: 'Booking is temporarily unavailable', statusCode: 503 }) + } + next(err) + } +}) + +router.get('/:slug/booking/:id', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + const reservation = await prisma.reservation.findFirstOrThrow({ + where: { id: req.params.id, companyId: company.id }, + include: { vehicle: true, customer: true }, + }) + res.json({ data: reservation }) + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.status(503).json({ error: 'database_unavailable', message: 'Booking details are temporarily unavailable', statusCode: 503 }) + } + next(err) + } +}) + +router.post('/:slug/booking/:id/pay', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + const reservation = await prisma.reservation.findFirstOrThrow({ + where: { id: req.params.id, companyId: company.id }, + include: { vehicle: true, customer: true, additionalDrivers: true }, + }) + + if (reservation.paymentStatus === 'PAID') { + return res.status(409).json({ error: 'already_paid', message: 'This reservation is already paid', statusCode: 409 }) + } + + const customerLicenseResult = validateLicense(reservation.customer.licenseExpiry) + if ( + reservation.customer.licenseValidationStatus === 'DENIED' || + customerLicenseResult.status === 'EXPIRED' || + (customerLicenseResult.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') || + reservation.additionalDrivers.some((driver) => driver.licenseExpired || (driver.requiresApproval && !driver.approvedAt)) + ) { + return res.status(409).json({ + error: 'license_review_required', + message: 'This reservation requires license review before payment can be processed', + statusCode: 409, + }) + } + + const { provider, currency, successUrl, failureUrl } = z.object({ + provider: z.enum(['AMANPAY', 'PAYPAL']), + currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'), + successUrl: z.string().url(), + failureUrl: z.string().url(), + }).parse(req.body) + + const amount = reservation.totalAmount + const description = `Rental: ${reservation.vehicle.make} ${reservation.vehicle.model}` + const orderId = `res-${reservation.id}-${Date.now()}` + const webhookBase = process.env.API_URL ?? 'http://localhost:4000' + + let checkoutUrl: string + let amanpayTransactionId: string | null = null + let paypalCaptureId: string | null = null + + if (provider === 'AMANPAY') { + if (!amanpay.isConfigured()) { + return res.status(503).json({ error: 'provider_not_configured', message: 'Online payment is not available for this company', statusCode: 503 }) + } + const brand = company.brand as any + const merchantId = brand?.amanpayMerchantId ?? process.env.AMANPAY_MERCHANT_ID ?? '' + const secretKey = brand?.amanpaySecretKey ?? process.env.AMANPAY_SECRET_KEY ?? '' + if (!merchantId || !secretKey) { + return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured for this company', statusCode: 503 }) + } + const result = await amanpay.createCheckout({ + amount, + currency, + orderId, + description, + customerEmail: reservation.customer.email, + customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`, + successUrl, + failureUrl, + webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`, + }) + checkoutUrl = result.checkoutUrl + amanpayTransactionId = result.transactionId + } else { + if (!paypal.isConfigured()) { + return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not available for this company', statusCode: 503 }) + } + const result = await paypal.createOrder({ + amount, + currency, + orderId, + description, + returnUrl: successUrl, + cancelUrl: failureUrl, + }) + checkoutUrl = result.approveUrl + paypalCaptureId = result.orderId + } + + await prisma.rentalPayment.create({ + data: { + companyId: company.id, + reservationId: reservation.id, + amount, + currency, + status: 'PENDING', + type: 'CHARGE', + paymentProvider: provider, + amanpayTransactionId, + paypalCaptureId, + }, + }) + + res.json({ data: { checkoutUrl } }) + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.status(503).json({ error: 'database_unavailable', message: 'Payment initiation is temporarily unavailable', statusCode: 503 }) + } + next(err) + } +}) + +router.post('/:slug/booking/:id/capture-paypal', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body) + + const payment = await prisma.rentalPayment.findFirstOrThrow({ + where: { paypalCaptureId: paypalOrderId, companyId: company.id }, + }) + + const capture = await paypal.captureOrder(paypalOrderId) + const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId + + await prisma.rentalPayment.update({ + where: { id: payment.id }, + data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId }, + }) + await prisma.reservation.update({ + where: { id: payment.reservationId }, + data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } }, + }) + + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +router.post('/:slug/contact', async (req, res, next) => { + try { + const company = await findCompanyBySlug(req.params.slug) + const body = z.object({ + name: z.string().min(1), + email: z.string().email(), + message: z.string().min(1), + }).parse(req.body) + res.json({ + data: { + success: true, + deliveredTo: company.brand?.publicEmail ?? company.email, + preview: body, + }, + }) + } catch (err) { next(err) } +}) + +export default router diff --git a/apps/api/src/routes/subscriptions.ts b/apps/api/src/routes/subscriptions.ts new file mode 100644 index 0000000..5a52c2f --- /dev/null +++ b/apps/api/src/routes/subscriptions.ts @@ -0,0 +1,288 @@ +import { Router } from 'express' +import { z } from 'zod' +import { PLAN_PRICES } from '@rentaldrivego/types' +import { prisma } from '../lib/prisma' +import { requireCompanyAuth } from '../middleware/requireCompanyAuth' +import { requireTenant } from '../middleware/requireTenant' +import * as amanpay from '../services/amanpayService' +import * as paypal from '../services/paypalService' + +const router = Router() + +router.get('/plans', (_req, res) => { + res.json({ data: PLAN_PRICES }) +}) + +// ─── AmanPay subscription webhook (no auth) ────────────────────────────────── + +router.post('/webhooks/amanpay', async (req, res, next) => { + try { + const rawBody = JSON.stringify(req.body) + const signature = req.headers['x-amanpay-signature'] as string ?? '' + + if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) { + return res.status(401).json({ error: 'invalid_signature' }) + } + + const event = req.body + const transactionId = event.transaction_id ?? event.id + const status = event.status?.toUpperCase() + + if (status === 'PAID' || status === 'SUCCEEDED') { + const invoice = await prisma.subscriptionInvoice.findFirst({ + where: { amanpayTransactionId: transactionId }, + include: { subscription: true }, + }) + if (invoice) { + await prisma.subscriptionInvoice.update({ + where: { id: invoice.id }, + data: { status: 'PAID', paidAt: new Date() }, + }) + await prisma.subscription.update({ + where: { id: invoice.subscriptionId }, + data: { + status: 'ACTIVE', + currentPeriodStart: new Date(), + currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod), + }, + }) + } + } + + res.json({ received: true }) + } catch (err) { next(err) } +}) + +// ─── PayPal subscription webhook (no auth) ─────────────────────────────────── + +router.post('/webhooks/paypal', async (req, res, next) => { + try { + const rawBody = JSON.stringify(req.body) + const isValid = await paypal.verifyWebhookEvent(req.headers as Record, rawBody) + if (paypal.isConfigured() && !isValid) { + return res.status(401).json({ error: 'invalid_signature' }) + } + + const event = req.body + if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') { + const captureId = event.resource?.id as string + const invoice = await prisma.subscriptionInvoice.findFirst({ + where: { paypalCaptureId: captureId }, + include: { subscription: true }, + }) + if (invoice) { + await prisma.subscriptionInvoice.update({ + where: { id: invoice.id }, + data: { status: 'PAID', paidAt: new Date() }, + }) + await prisma.subscription.update({ + where: { id: invoice.subscriptionId }, + data: { + status: 'ACTIVE', + currentPeriodStart: new Date(), + currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod), + }, + }) + } + } + + res.json({ received: true }) + } catch (err) { next(err) } +}) + +// ─── PayPal capture redirect (no full auth needed — just valid session) ─────── + +router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, res, next) => { + try { + const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body) + + const invoice = await prisma.subscriptionInvoice.findFirstOrThrow({ + where: { paypalCaptureId: paypalOrderId, companyId: req.companyId }, + include: { subscription: true }, + }) + + const capture = await paypal.captureOrder(paypalOrderId) + const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId + + await prisma.subscriptionInvoice.update({ + where: { id: invoice.id }, + data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId }, + }) + await prisma.subscription.update({ + where: { id: invoice.subscriptionId }, + data: { + status: 'ACTIVE', + currentPeriodStart: new Date(), + currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod), + }, + }) + + res.json({ data: { success: true } }) + } catch (err) { next(err) } +}) + +// ─── Authenticated subscription routes ─────────────────────────────────────── + +router.use(requireCompanyAuth, requireTenant) + +router.get('/me', async (req, res, next) => { + try { + const subscription = await prisma.subscription.findUnique({ + where: { companyId: req.companyId }, + include: { invoices: { orderBy: { createdAt: 'desc' }, take: 12 } }, + }) + res.json({ data: subscription }) + } catch (err) { next(err) } +}) + +router.get('/invoices', async (req, res, next) => { + try { + const invoices = await prisma.subscriptionInvoice.findMany({ + where: { companyId: req.companyId }, + orderBy: { createdAt: 'desc' }, + take: 50, + }) + res.json({ data: invoices }) + } catch (err) { next(err) } +}) + +const checkoutSchema = z.object({ + plan: z.enum(['STARTER', 'GROWTH', 'PRO']), + billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), + currency: z.enum(['MAD', 'USD', 'EUR']), + provider: z.enum(['AMANPAY', 'PAYPAL']), + successUrl: z.string().url(), + failureUrl: z.string().url(), +}) + +router.post('/checkout', async (req, res, next) => { + try { + const body = checkoutSchema.parse(req.body) + const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod] + if (!prices) return res.status(400).json({ error: 'invalid_plan', message: 'Invalid plan or billing period', statusCode: 400 }) + const amount = prices[body.currency] + if (!amount) return res.status(400).json({ error: 'invalid_currency', message: 'Currency not supported for this plan', statusCode: 400 }) + + const company = await prisma.company.findUniqueOrThrow({ where: { id: req.companyId } }) + + let subscription = await prisma.subscription.findUnique({ where: { companyId: req.companyId } }) + if (!subscription) { + subscription = await prisma.subscription.create({ + data: { + companyId: req.companyId, + plan: body.plan, + billingPeriod: body.billingPeriod, + currency: body.currency, + status: 'PENDING' as any, + }, + }) + } + + const orderId = `sub-${req.companyId}-${Date.now()}` + const description = `${body.plan} plan — ${body.billingPeriod}` + const webhookBase = process.env.API_URL ?? 'http://localhost:4000' + + let checkoutUrl: string + let amanpayTransactionId: string | null = null + let paypalCaptureId: string | null = null + + if (body.provider === 'AMANPAY') { + if (!amanpay.isConfigured()) { + return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured on this platform', statusCode: 503 }) + } + const result = await amanpay.createCheckout({ + amount, + currency: body.currency, + orderId, + description, + customerEmail: company.email, + customerName: company.name, + successUrl: body.successUrl, + failureUrl: body.failureUrl, + webhookUrl: `${webhookBase}/api/v1/subscriptions/webhooks/amanpay`, + }) + checkoutUrl = result.checkoutUrl + amanpayTransactionId = result.transactionId + } else { + if (!paypal.isConfigured()) { + return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not configured on this platform', statusCode: 503 }) + } + const result = await paypal.createOrder({ + amount, + currency: body.currency, + orderId, + description, + returnUrl: body.successUrl, + cancelUrl: body.failureUrl, + }) + checkoutUrl = result.approveUrl + paypalCaptureId = result.orderId + } + + const invoice = await prisma.subscriptionInvoice.create({ + data: { + companyId: req.companyId, + subscriptionId: subscription.id, + amount, + currency: body.currency, + status: 'PENDING', + paymentProvider: body.provider, + amanpayTransactionId, + paypalCaptureId, + }, + }) + + res.json({ data: { invoice, checkoutUrl } }) + } catch (err) { next(err) } +}) + +router.post('/change-plan', async (req, res, next) => { + try { + const { plan, billingPeriod, currency } = z.object({ + plan: z.enum(['STARTER', 'GROWTH', 'PRO']), + billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), + currency: z.enum(['MAD', 'USD', 'EUR']), + }).parse(req.body) + + const updated = await prisma.subscription.update({ + where: { companyId: req.companyId }, + data: { plan, billingPeriod, currency }, + }) + + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.post('/cancel', async (req, res, next) => { + try { + const updated = await prisma.subscription.update({ + where: { companyId: req.companyId }, + data: { cancelAtPeriodEnd: true }, + }) + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +router.post('/resume', async (req, res, next) => { + try { + const updated = await prisma.subscription.update({ + where: { companyId: req.companyId }, + data: { cancelAtPeriodEnd: false }, + }) + res.json({ data: updated }) + } catch (err) { next(err) } +}) + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function addPeriod(date: Date, period: string): Date { + const d = new Date(date) + if (period === 'ANNUAL') { + d.setFullYear(d.getFullYear() + 1) + } else { + d.setMonth(d.getMonth() + 1) + } + return d +} + +export default router diff --git a/apps/api/src/routes/team.ts b/apps/api/src/routes/team.ts index da9a743..1dd4622 100644 --- a/apps/api/src/routes/team.ts +++ b/apps/api/src/routes/team.ts @@ -30,27 +30,37 @@ router.get('/stats', async (req, res, next) => { router.post('/invite', requireRole('OWNER'), async (req, res, next) => { try { const body = inviteSchema.parse(req.body) - res.status(201).json({ data: await inviteEmployee(req.companyId, req.employee.id, body) }) + res.status(201).json({ data: await inviteEmployee(req.companyId!, req.employee!.id, body) }) } catch (err) { next(err) } }) router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => { try { const { role } = z.object({ role: z.enum(['MANAGER', 'AGENT']) }).parse(req.body) - res.json({ data: await updateEmployeeRole(req.companyId, req.employee.id, req.employee.role, req.params.id, { role }) }) + const memberId = req.params.id! + res.json({ data: await updateEmployeeRole(req.companyId!, req.employee!.id, req.employee!.role, memberId, { role }) }) } catch (err) { next(err) } }) router.post('/:id/deactivate', requireRole('OWNER'), async (req, res, next) => { - try { res.json({ data: await deactivateEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) } + try { + const memberId = req.params.id! + res.json({ data: await deactivateEmployee(req.companyId!, req.employee!.role, memberId) }) + } catch (err) { next(err) } }) router.post('/:id/reactivate', requireRole('OWNER'), async (req, res, next) => { - try { res.json({ data: await reactivateEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) } + try { + const memberId = req.params.id! + res.json({ data: await reactivateEmployee(req.companyId!, req.employee!.role, memberId) }) + } catch (err) { next(err) } }) router.delete('/:id', requireRole('OWNER'), async (req, res, next) => { - try { res.json({ data: await removeEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) } + try { + const memberId = req.params.id! + res.json({ data: await removeEmployee(req.companyId!, req.employee!.role, memberId) }) + } catch (err) { next(err) } }) export default router diff --git a/apps/api/src/routes/vehicles.ts b/apps/api/src/routes/vehicles.ts index 2b5ac2c..8fe0b8e 100644 --- a/apps/api/src/routes/vehicles.ts +++ b/apps/api/src/routes/vehicles.ts @@ -93,7 +93,7 @@ router.delete('/:id/photos/:idx', async (req, res, next) => { try { const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) const idx = parseInt(req.params.idx) - const photos = vehicle.photos.filter((_, i) => i !== idx) + const photos = vehicle.photos.filter((_: string, i: number) => i !== idx) const updated = await prisma.vehicle.update({ where: { id: vehicle.id }, data: { photos } }) res.json({ data: updated }) } catch (err) { next(err) } diff --git a/apps/api/src/services/additionalDriverService.ts b/apps/api/src/services/additionalDriverService.ts new file mode 100644 index 0000000..7e9b5a3 --- /dev/null +++ b/apps/api/src/services/additionalDriverService.ts @@ -0,0 +1,95 @@ +import { AdditionalDriverCharge } from '@rentaldrivego/database' +import { prisma } from '../lib/prisma' +import { validateLicense } from './licenseValidationService' + +export interface AdditionalDriverInput { + firstName: string + lastName: string + email?: string + phone?: string + driverLicense: string + licenseExpiry?: string | null + licenseIssuedAt?: string | null + dateOfBirth?: string | null + nationality?: string +} + +export function calculateAdditionalDriverCharge( + chargeType: AdditionalDriverCharge, + chargeValue: number, + totalDays: number, +) { + switch (chargeType) { + case 'PER_DAY': + return chargeValue * totalDays + case 'FLAT': + return chargeValue + default: + return 0 + } +} + +export async function applyAdditionalDriversToReservation( + reservationId: string, + companyId: string, + drivers: AdditionalDriverInput[], + totalDays: number, +) { + if (drivers.length === 0) { + return { records: [], additionalDriverTotal: 0, requiresManualApproval: false } + } + + const settings = await prisma.contractSettings.findUnique({ where: { companyId } }) + const chargeType = settings?.additionalDriverCharge ?? 'FREE' + const chargeValue = + chargeType === 'PER_DAY' + ? settings?.additionalDriverDailyRate ?? 0 + : chargeType === 'FLAT' + ? settings?.additionalDriverFlatRate ?? 0 + : 0 + + const records = drivers.map((driver) => { + const licenseResult = validateLicense(driver.licenseExpiry ? new Date(driver.licenseExpiry) : null) + const totalCharge = calculateAdditionalDriverCharge(chargeType, chargeValue, totalDays) + + return { + reservationId, + companyId, + firstName: driver.firstName, + lastName: driver.lastName, + email: driver.email ?? null, + phone: driver.phone ?? null, + driverLicense: driver.driverLicense, + licenseExpiry: driver.licenseExpiry ? new Date(driver.licenseExpiry) : null, + licenseIssuedAt: driver.licenseIssuedAt ? new Date(driver.licenseIssuedAt) : null, + dateOfBirth: driver.dateOfBirth ? new Date(driver.dateOfBirth) : null, + nationality: driver.nationality ?? null, + chargeType, + chargeValue, + totalCharge, + licenseExpired: licenseResult.status === 'EXPIRED', + licenseExpiringSoon: licenseResult.status === 'EXPIRING', + requiresApproval: licenseResult.requiresApproval, + approvalNote: licenseResult.requiresApproval ? licenseResult.message : null, + } + }) + + const additionalDriverTotal = records.reduce((sum, record) => sum + record.totalCharge, 0) + + await prisma.$transaction([ + prisma.additionalDriver.createMany({ data: records }), + prisma.reservation.update({ + where: { id: reservationId }, + data: { + additionalDriverTotal, + totalAmount: { increment: additionalDriverTotal }, + }, + }), + ]) + + return { + records, + additionalDriverTotal, + requiresManualApproval: records.some((record) => record.requiresApproval), + } +} diff --git a/apps/api/src/services/amanpayService.ts b/apps/api/src/services/amanpayService.ts new file mode 100644 index 0000000..636bc50 --- /dev/null +++ b/apps/api/src/services/amanpayService.ts @@ -0,0 +1,97 @@ +import crypto from 'crypto' + +const BASE_URL = process.env.AMANPAY_BASE_URL ?? 'https://api.amanpay.net' +const MERCHANT_ID = process.env.AMANPAY_MERCHANT_ID ?? '' +const SECRET_KEY = process.env.AMANPAY_SECRET_KEY ?? '' +const WEBHOOK_SECRET = process.env.AMANPAY_WEBHOOK_SECRET ?? '' + +export interface AmanPayCreateParams { + amount: number + currency: string + orderId: string + description: string + customerEmail?: string + customerName?: string + successUrl: string + failureUrl: string + webhookUrl: string +} + +export interface AmanPayCheckoutResult { + transactionId: string + checkoutUrl: string + status: string +} + +async function getAuthHeaders() { + return { + 'Content-Type': 'application/json', + 'x-merchant-id': MERCHANT_ID, + 'x-api-key': SECRET_KEY, + } +} + +export async function createCheckout(params: AmanPayCreateParams): Promise { + const res = await fetch(`${BASE_URL}/v1/payments`, { + method: 'POST', + headers: await getAuthHeaders(), + body: JSON.stringify({ + merchant_id: MERCHANT_ID, + amount: params.amount, + currency: params.currency, + order_id: params.orderId, + description: params.description, + customer_email: params.customerEmail, + customer_name: params.customerName, + success_url: params.successUrl, + failure_url: params.failureUrl, + webhook_url: params.webhookUrl, + }), + }) + + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(`AmanPay checkout failed: ${err?.message ?? res.statusText}`) + } + + const json = await res.json() + return { + transactionId: json.transaction_id ?? json.id, + checkoutUrl: json.checkout_url ?? json.payment_url, + status: json.status ?? 'PENDING', + } +} + +export async function getTransaction(transactionId: string) { + const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}`, { + headers: await getAuthHeaders(), + }) + if (!res.ok) throw new Error('AmanPay: failed to fetch transaction') + return res.json() +} + +export async function refundTransaction(transactionId: string, amount: number, reason?: string) { + const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}/refund`, { + method: 'POST', + headers: await getAuthHeaders(), + body: JSON.stringify({ amount, reason }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(`AmanPay refund failed: ${err?.message ?? res.statusText}`) + } + return res.json() +} + +export function verifyWebhookSignature(rawBody: string, signature: string): boolean { + if (!WEBHOOK_SECRET) return false + const expected = crypto + .createHmac('sha256', WEBHOOK_SECRET) + .update(rawBody) + .digest('hex') + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) +} + +export function isConfigured(): boolean { + return !!(MERCHANT_ID && SECRET_KEY && MERCHANT_ID !== 'your-amanpay-merchant-id') +} diff --git a/apps/api/src/services/financialReportService.ts b/apps/api/src/services/financialReportService.ts index 258863d..bfea0ef 100644 --- a/apps/api/src/services/financialReportService.ts +++ b/apps/api/src/services/financialReportService.ts @@ -15,17 +15,17 @@ export async function generateFinancialReport(companyId: string, startDate: Date const summary = { totalReservations: reservations.length, - totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0), - totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0), - totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0), - totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0), - totalPricingRulesAdj: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0), - totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0), - totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0), - averageRentalDays: reservations.length > 0 ? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0, + totalRentalRevenue: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.dailyRate * r.totalDays, 0), + totalDiscounts: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.discountAmount, 0), + totalInsurance: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.insuranceTotal, 0), + totalAdditionalDrivers: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.additionalDriverTotal, 0), + totalPricingRulesAdj: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.pricingRulesTotal, 0), + totalDeposits: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.depositAmount, 0), + totalCollected: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalAmount, 0), + averageRentalDays: reservations.length > 0 ? reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalDays, 0) / reservations.length : 0, } - const rows = reservations.map((r) => ({ + const rows = reservations.map((r: (typeof reservations)[number]) => ({ reservationId: r.id, contractNumber: r.contractNumber ?? '—', invoiceNumber: r.invoiceNumber ?? '—', diff --git a/apps/api/src/services/insuranceService.ts b/apps/api/src/services/insuranceService.ts index 068cb25..3925a1c 100644 --- a/apps/api/src/services/insuranceService.ts +++ b/apps/api/src/services/insuranceService.ts @@ -22,8 +22,8 @@ export async function applyInsurancesToReservation( baseRentalAmount: number ) { const allPolicies = await prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } }) - const required = allPolicies.filter((p) => p.isRequired) - const selected = allPolicies.filter((p) => selectedPolicyIds.includes(p.id) && !p.isRequired) + const required = allPolicies.filter((p: InsurancePolicy) => p.isRequired) + const selected = allPolicies.filter((p: InsurancePolicy) => selectedPolicyIds.includes(p.id) && !p.isRequired) const toApply = [...required, ...selected] const records = toApply.map((policy) => ({ @@ -40,7 +40,13 @@ export async function applyInsurancesToReservation( await prisma.$transaction([ prisma.reservationInsurance.createMany({ data: records }), - prisma.reservation.update({ where: { id: reservationId }, data: { insuranceTotal } }), + prisma.reservation.update({ + where: { id: reservationId }, + data: { + insuranceTotal, + totalAmount: { increment: insuranceTotal }, + }, + }), ]) return { records, insuranceTotal } diff --git a/apps/api/src/services/licenseValidationService.ts b/apps/api/src/services/licenseValidationService.ts index 1c50706..b5cc164 100644 --- a/apps/api/src/services/licenseValidationService.ts +++ b/apps/api/src/services/licenseValidationService.ts @@ -43,6 +43,8 @@ export async function validateAndFlagLicense(customerId: string) { licenseExpired: result.status === 'EXPIRED', licenseExpiringSoon: result.status === 'EXPIRING', licenseValidationStatus: status, + flagged: result.requiresApproval ? true : customer.flagged, + flagReason: result.requiresApproval ? result.message : customer.flagReason, }, }) diff --git a/apps/api/src/services/notificationService.ts b/apps/api/src/services/notificationService.ts index e0db42b..9bab526 100644 --- a/apps/api/src/services/notificationService.ts +++ b/apps/api/src/services/notificationService.ts @@ -5,21 +5,93 @@ import { prisma } from '../lib/prisma' import { redis } from '../lib/redis' import { NotificationType, NotificationChannel } from '@rentaldrivego/database' -const resend = new Resend(process.env.RESEND_API_KEY) +const resend = + process.env.RESEND_API_KEY && process.env.RESEND_API_KEY !== 're_...' + ? new Resend(process.env.RESEND_API_KEY) + : null -const twilioClient = twilio( - process.env.TWILIO_ACCOUNT_SID, - process.env.TWILIO_AUTH_TOKEN -) +const emailFromAddress = + process.env.EMAIL_FROM && process.env.EMAIL_FROM !== 'noreply@example.com' + ? process.env.EMAIL_FROM + : null -if (!admin.apps.length) { - admin.initializeApp({ - credential: admin.credential.cert({ - projectId: process.env.FIREBASE_PROJECT_ID, - privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'), - clientEmail: process.env.FIREBASE_CLIENT_EMAIL, - }), - }) +const emailFromName = + process.env.EMAIL_FROM_NAME && process.env.EMAIL_FROM_NAME !== 'Example App' + ? process.env.EMAIL_FROM_NAME + : null + +const smtpHost = process.env.MAIL_HOST +const smtpPort = Number(process.env.MAIL_PORT ?? 0) +const smtpUser = process.env.MAIL_USERNAME +const smtpPass = process.env.MAIL_PASSWORD +const smtpSecure = + process.env.MAIL_SCHEME === 'smtps' || + process.env.MAIL_ENCRYPTION === 'ssl' || + smtpPort === 465 + +const smtpFromAddress = + process.env.MAIL_FROM_ADDRESS && !process.env.MAIL_FROM_ADDRESS.includes('${') + ? process.env.MAIL_FROM_ADDRESS + : null + +const smtpFromName = + process.env.MAIL_FROM_NAME && !process.env.MAIL_FROM_NAME.includes('${') + ? process.env.MAIL_FROM_NAME + : null + +type SmtpTransport = { + sendMail(options: Record): Promise<{ messageId?: string }> +} + +let smtpTransport: SmtpTransport | null = null + +if (smtpHost && smtpPort && smtpUser && smtpPass) { + try { + const nodemailer = require('nodemailer') as { + createTransport(options: Record): SmtpTransport + } + + smtpTransport = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { + user: smtpUser, + pass: smtpPass, + }, + }) + } catch (err: any) { + console.warn('[Notifications] SMTP transport unavailable:', err?.message ?? String(err)) + } +} + +const twilioClient = + process.env.TWILIO_ACCOUNT_SID && + process.env.TWILIO_AUTH_TOKEN && + process.env.TWILIO_ACCOUNT_SID !== 'AC...' && + process.env.TWILIO_AUTH_TOKEN !== 'your-twilio-auth-token' + ? twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN) + : null + +const firebasePrivateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n') +const hasFirebaseConfig = + !!process.env.FIREBASE_PROJECT_ID && + !!process.env.FIREBASE_CLIENT_EMAIL && + !!firebasePrivateKey && + !firebasePrivateKey.includes('BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY') + +if (hasFirebaseConfig && !admin.apps.length) { + try { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: process.env.FIREBASE_PROJECT_ID, + privateKey: firebasePrivateKey, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + }), + }) + } catch (err: any) { + console.warn('[Notifications] Firebase init skipped:', err.message) + } } interface SendNotificationOptions { @@ -37,6 +109,22 @@ interface SendNotificationOptions { locale?: string } +function escapeHtml(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function renderEmailHtml(body: string) { + return body + .split(/\n{2,}/) + .map((paragraph) => `

${escapeHtml(paragraph).replace(/\n/g, '
')}

`) + .join('') +} + export async function sendNotification(opts: SendNotificationOptions) { const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = [] @@ -60,19 +148,48 @@ export async function sendNotification(opts: SendNotificationOptions) { let success = false if (channel === 'EMAIL' && opts.email) { - const { data, error } = await resend.emails.send({ - from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM}>`, - to: opts.email, - subject: opts.title, - html: `

${opts.body}

`, - }) - if (!error) { + if (resend) { + if (!emailFromAddress || !emailFromName) { + throw new Error('Email sender identity is not configured') + } + const { data, error } = await resend.emails.send({ + from: `${emailFromName} <${emailFromAddress}>`, + to: opts.email, + subject: opts.title, + html: renderEmailHtml(opts.body), + text: opts.body, + }) + if (error) { + throw new Error(error.message) + } providerMessageId = data?.id ?? null success = true + } else if (smtpTransport) { + if (!smtpFromAddress) { + throw new Error('SMTP sender identity is not configured') + } + const info = await smtpTransport.sendMail({ + from: smtpFromName ? `${smtpFromName} <${smtpFromAddress}>` : smtpFromAddress, + to: opts.email, + subject: opts.title, + html: renderEmailHtml(opts.body), + text: opts.body, + replyTo: + process.env.MAIL_REPLY_TO_ADDRESS && !process.env.MAIL_REPLY_TO_ADDRESS.includes('${') + ? process.env.MAIL_REPLY_TO_NAME && !process.env.MAIL_REPLY_TO_NAME.includes('${') + ? `${process.env.MAIL_REPLY_TO_NAME} <${process.env.MAIL_REPLY_TO_ADDRESS}>` + : process.env.MAIL_REPLY_TO_ADDRESS + : undefined, + }) + providerMessageId = info.messageId + success = true + } else { + throw new Error('No email provider is configured') } } if (channel === 'SMS' && opts.phone) { + if (!twilioClient) throw new Error('Twilio is not configured') const msg = await twilioClient.messages.create({ body: opts.body, from: process.env.TWILIO_PHONE_NUMBER!, @@ -83,6 +200,7 @@ export async function sendNotification(opts: SendNotificationOptions) { } if (channel === 'WHATSAPP' && opts.phone) { + if (!twilioClient) throw new Error('Twilio is not configured') const msg = await twilioClient.messages.create({ body: opts.body, from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`, @@ -93,6 +211,7 @@ export async function sendNotification(opts: SendNotificationOptions) { } if (channel === 'PUSH' && opts.fcmToken) { + if (!admin.apps.length) throw new Error('Firebase is not configured') const response = await admin.messaging().send({ token: opts.fcmToken, notification: { title: opts.title, body: opts.body }, diff --git a/apps/api/src/services/paypalService.ts b/apps/api/src/services/paypalService.ts new file mode 100644 index 0000000..7eb68af --- /dev/null +++ b/apps/api/src/services/paypalService.ts @@ -0,0 +1,127 @@ +const BASE_URL = process.env.PAYPAL_BASE_URL ?? 'https://api-m.paypal.com' +const CLIENT_ID = process.env.PAYPAL_CLIENT_ID ?? '' +const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET ?? '' + +let cachedToken: { token: string; expiresAt: number } | null = null + +async function getAccessToken(): Promise { + if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) { + return cachedToken.token + } + + const res = await fetch(`${BASE_URL}/v1/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`, + }, + body: 'grant_type=client_credentials', + }) + + if (!res.ok) throw new Error('PayPal: failed to obtain access token') + const json = await res.json() + cachedToken = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 } + return cachedToken.token +} + +async function ppFetch(path: string, init?: RequestInit) { + const token = await getAccessToken() + const res = await fetch(`${BASE_URL}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + ...(init?.headers as Record ?? {}), + }, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(`PayPal ${path} failed: ${err?.message ?? res.statusText}`) + } + return res.json() +} + +export interface PayPalCreateOrderParams { + amount: number + currency: string + orderId: string + description: string + returnUrl: string + cancelUrl: string +} + +export interface PayPalOrderResult { + orderId: string + approveUrl: string + status: string +} + +export async function createOrder(params: PayPalCreateOrderParams): Promise { + const json = await ppFetch('/v2/checkout/orders', { + method: 'POST', + body: JSON.stringify({ + intent: 'CAPTURE', + purchase_units: [ + { + reference_id: params.orderId, + description: params.description, + amount: { + currency_code: params.currency, + value: (params.amount / 100).toFixed(2), + }, + }, + ], + application_context: { + return_url: params.returnUrl, + cancel_url: params.cancelUrl, + brand_name: 'RentalDriveGo', + user_action: 'PAY_NOW', + }, + }), + }) + + const approveLink = json.links?.find((l: { rel: string; href: string }) => l.rel === 'approve') + return { + orderId: json.id, + approveUrl: approveLink?.href ?? '', + status: json.status, + } +} + +export async function captureOrder(paypalOrderId: string) { + return ppFetch(`/v2/checkout/orders/${paypalOrderId}/capture`, { method: 'POST', body: '{}' }) +} + +export async function refundCapture(captureId: string, amount: number, currency: string, note?: string) { + return ppFetch(`/v2/payments/captures/${captureId}/refund`, { + method: 'POST', + body: JSON.stringify({ + amount: { currency_code: currency, value: (amount / 100).toFixed(2) }, + note_to_payer: note, + }), + }) +} + +export async function verifyWebhookEvent(headers: Record, rawBody: string) { + try { + const json = await ppFetch('/v1/notifications/verify-webhook-signature', { + method: 'POST', + body: JSON.stringify({ + auth_algo: headers['paypal-auth-algo'], + cert_url: headers['paypal-cert-url'], + transmission_id: headers['paypal-transmission-id'], + transmission_sig: headers['paypal-transmission-sig'], + transmission_time: headers['paypal-transmission-time'], + webhook_id: process.env.PAYPAL_WEBHOOK_ID ?? '', + webhook_event: JSON.parse(rawBody), + }), + }) + return json.verification_status === 'SUCCESS' + } catch { + return false + } +} + +export function isConfigured(): boolean { + return !!(CLIENT_ID && CLIENT_SECRET && CLIENT_ID !== 'your-paypal-client-id') +} diff --git a/apps/api/src/services/teamService.ts b/apps/api/src/services/teamService.ts index 3fef18e..dff9568 100644 --- a/apps/api/src/services/teamService.ts +++ b/apps/api/src/services/teamService.ts @@ -31,7 +31,7 @@ export async function listEmployees(companyId: string): Promise { }) const hydrated = await Promise.allSettled( - employees.map(async (emp) => { + employees.map(async (emp: (typeof employees)[number]) => { try { const clerkUser = await clerkClient.users.getUser(emp.clerkUserId) return { ...emp, lastActiveAt: clerkUser.lastActiveAt ? new Date(clerkUser.lastActiveAt) : null, invitationStatus: 'accepted' as const } @@ -41,7 +41,7 @@ export async function listEmployees(companyId: string): Promise { }) ) - return hydrated.map((r) => (r.status === 'fulfilled' ? r.value : (r as any).value)) + return hydrated.flatMap((r): TeamMember[] => (r.status === 'fulfilled' ? [r.value] : [])) } export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) { diff --git a/apps/dashboard/next-env.d.ts b/apps/dashboard/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/dashboard/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx new file mode 100644 index 0000000..b2694a0 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx @@ -0,0 +1,350 @@ +'use client' + +import { useEffect, useState } from 'react' +import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types' +import { apiFetch } from '@/lib/api' + +type Plan = 'STARTER' | 'GROWTH' | 'PRO' +type BillingPeriod = 'MONTHLY' | 'ANNUAL' +type Currency = 'MAD' | 'USD' | 'EUR' + +interface Subscription { + id: string + plan: Plan + billingPeriod: BillingPeriod + status: string + currency: Currency + trialEndAt: string | null + currentPeriodEnd: string | null + cancelAtPeriodEnd: boolean +} + +interface Invoice { + id: string + amount: number + currency: Currency + status: string + paymentProvider: string + paidAt: string | null + createdAt: string +} + +const STATUS_BADGE: Record = { + TRIALING: 'bg-sky-100 text-sky-700', + ACTIVE: 'bg-green-100 text-green-700', + PAST_DUE: 'bg-amber-100 text-amber-700', + CANCELLED: 'bg-slate-100 text-slate-600', + UNPAID: 'bg-red-100 text-red-700', +} + +const INVOICE_STATUS: Record = { + PAID: 'bg-green-100 text-green-700', + PENDING: 'bg-amber-100 text-amber-700', + FAILED: 'bg-red-100 text-red-700', + REFUNDED: 'bg-slate-100 text-slate-600', +} + +const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO'] +const PLAN_FEATURES: Record = { + STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'], + GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'], + PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'], +} + +export default function BillingPage() { + const [subscription, setSubscription] = useState(null) + const [invoices, setInvoices] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [selectedPlan, setSelectedPlan] = useState('STARTER') + const [billingPeriod, setBillingPeriod] = useState('MONTHLY') + const [currency, setCurrency] = useState('MAD') + const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY') + const [paying, setPaying] = useState(false) + const [cancelling, setCancelling] = useState(false) + + useEffect(() => { + Promise.all([ + apiFetch('/subscriptions/me'), + apiFetch('/subscriptions/invoices'), + ]) + .then(([sub, inv]) => { + if (sub) { + setSubscription(sub) + setSelectedPlan(sub.plan) + setBillingPeriod(sub.billingPeriod) + setCurrency(sub.currency as Currency) + } + setInvoices(inv ?? []) + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + }, []) + + async function handleCheckout() { + setPaying(true) + setError(null) + try { + const base = window.location.origin + const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', { + method: 'POST', + body: JSON.stringify({ + plan: selectedPlan, + billingPeriod, + currency, + provider, + successUrl: `${base}/dashboard/billing?payment=success`, + failureUrl: `${base}/dashboard/billing?payment=failed`, + }), + }) + window.location.href = result.checkoutUrl + } catch (err: any) { + setError(err.message) + setPaying(false) + } + } + + async function handleCancel() { + setCancelling(true) + setError(null) + try { + const sub = await apiFetch('/subscriptions/cancel', { method: 'POST' }) + setSubscription(sub) + } catch (err: any) { + setError(err.message) + } finally { + setCancelling(false) + } + } + + async function handleResume() { + setCancelling(true) + setError(null) + try { + const sub = await apiFetch('/subscriptions/resume', { method: 'POST' }) + setSubscription(sub) + } catch (err: any) { + setError(err.message) + } finally { + setCancelling(false) + } + } + + const planPrice = PLAN_PRICES[selectedPlan]?.[billingPeriod]?.[currency] + const daysLeft = subscription?.trialEndAt + ? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000) + : null + + return ( +
+
+

Billing

+

Manage your plan, payment provider, and invoice history.

+
+ + {error && ( +
{error}
+ )} + + {/* Trial banner */} + {subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && ( +
+

+ Free trial — {daysLeft} days remaining. Subscribe before it ends to keep access. +

+
+ )} + + {/* Current plan */} + {subscription && ( +
+
+
+

Current plan

+
+

{subscription.plan}

+ + {subscription.status} + +
+

+ {subscription.billingPeriod} · {subscription.currency} + {subscription.currentPeriodEnd && ` · renews ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`} +

+ {subscription.cancelAtPeriodEnd && ( +

+ Cancellation scheduled at end of billing period.{' '} + +

+ )} +
+ {!subscription.cancelAtPeriodEnd && ( + + )} +
+
+ )} + + {/* Plan selector + checkout */} +
+
+

+ {subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe'} +

+

Select a plan and payment provider to proceed.

+
+ + {/* Billing period toggle */} +
+ {(['MONTHLY', 'ANNUAL'] as BillingPeriod[]).map((p) => ( + + ))} +
+ + {/* Currency selector */} +
+ {(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => ( + + ))} +
+ + {/* Plan cards */} +
+ {PLANS.map((plan) => { + const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency] + const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE' + return ( + + ) + })} +
+ + {/* Provider selector */} +
+

Payment provider

+
+ {(['AMANPAY', 'PAYPAL'] as const).map((p) => ( + + ))} +
+
+ + {/* Checkout CTA */} +
+
+

Total

+

+ {planPrice ? formatCurrency(planPrice, currency) : '—'} + /{billingPeriod === 'MONTHLY' ? 'month' : 'year'} +

+
+ +
+
+ + {/* Invoice history */} +
+
+

Invoice history

+
+
+ + + + + + + + + + + + {loading ? ( + + ) : invoices.length === 0 ? ( + + ) : invoices.map((inv) => ( + + + + + + + + ))} + +
DateProviderStatusPaidAmount
Loading…
No invoices yet.
{new Date(inv.createdAt).toLocaleDateString()}{inv.paymentProvider} + + {inv.status} + + + {inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'} + + {formatCurrency(inv.amount, inv.currency)} +
+
+
+
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx new file mode 100644 index 0000000..d33be7a --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx @@ -0,0 +1,71 @@ +'use client' + +import { useEffect, useState } from 'react' +import type { ApiPaginated } from '@rentaldrivego/types' +import { apiFetch } from '@/lib/api' + +interface CustomerRow { + id: string + firstName: string + lastName: string + email: string + phone: string | null + flagged: boolean + licenseValidationStatus: string +} + +export default function CustomersPage() { + const [rows, setRows] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + apiFetch>('/customers?pageSize=100') + .then((result) => setRows(result.data)) + .catch((err) => setError(err.message)) + }, []) + + return ( +
+
+

Customers

+

Company-scoped CRM with license validation and risk flags.

+
+
+ {error ? ( +
{error}
+ ) : ( +
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
CustomerContactLicenseFlags
{row.firstName} {row.lastName} +

{row.email}

+

{row.phone ?? 'No phone'}

+
{row.licenseValidationStatus}{row.flagged ? Flagged : Clear}
No customers yet.
+
+ )} +
+
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx new file mode 100644 index 0000000..c3759dd --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import Image from 'next/image' +import { formatCurrency } from '@rentaldrivego/types' +import { apiFetch } from '@/lib/api' + +interface VehicleDetail { + id: string + make: string + model: string + year: number + category: string + dailyRate: number + status: string + color: string + transmission: string + fuelType: string + seats: number + licensePlate: string + photos: string[] + notes: string | null +} + +export default function FleetDetailPage() { + const params = useParams<{ id: string }>() + const [vehicle, setVehicle] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + apiFetch(`/vehicles/${params.id}`) + .then(setVehicle) + .catch((err) => setError(err.message)) + }, [params.id]) + + if (error) return
{error}
+ if (!vehicle) return
Loading vehicle…
+ + return ( +
+
+

{vehicle.make} {vehicle.model}

+

{vehicle.year} · {vehicle.licensePlate} · {vehicle.status}

+
+
+
+

Photos

+
+ {vehicle.photos.map((photo, index) => ( +
+ {`${vehicle.make} +
+ ))} + {vehicle.photos.length === 0 &&
No photos uploaded yet.
} +
+
+
+

Vehicle details

+
+
Category
{vehicle.category}
+
Daily rate
{formatCurrency(vehicle.dailyRate, 'MAD')}
+
Seats
{vehicle.seats}
+
Transmission
{vehicle.transmission}
+
Fuel type
{vehicle.fuelType}
+
Color
{vehicle.color}
+
Notes
{vehicle.notes ?? '—'}
+
+
+
+
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx index 5a2ba66..dea2898 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx @@ -7,6 +7,7 @@ import Link from 'next/link' import { apiFetch } from '@/lib/api' import { formatCurrency } from '@rentaldrivego/types' import dayjs from 'dayjs' +import type { ApiPaginated } from '@rentaldrivego/types' interface Vehicle { id: string @@ -90,7 +91,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
@@ -126,7 +127,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
@@ -158,8 +159,8 @@ export default function FleetPage() { const fetchVehicles = () => { setLoading(true) - apiFetch('/vehicles') - .then(setVehicles) + apiFetch>('/vehicles?pageSize=100') + .then((result) => setVehicles(result.data)) .catch((err) => setError(err.message)) .finally(() => setLoading(false)) } @@ -224,7 +225,7 @@ export default function FleetPage() { onChange={(e) => setCategoryFilter(e.target.value)} > - {['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'CONVERTIBLE', 'PICKUP'].map((c) => ( + {['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => ( ))} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/notifications/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/notifications/page.tsx new file mode 100644 index 0000000..bba55db --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/notifications/page.tsx @@ -0,0 +1,208 @@ +'use client' + +import { useEffect, useState } from 'react' +import { apiFetch } from '@/lib/api' + +type NotificationItem = { + id: string + type: string + title: string + body: string + readAt: string | null + createdAt: string +} + +type PreferenceItem = { + notificationType: string + channel: string + enabled: boolean +} + +const COMPANY_EVENTS = [ + 'NEW_RESERVATION', + 'RESERVATION_CANCELLED', + 'PAYMENT_RECEIVED', + 'SUBSCRIPTION_TRIAL_ENDING', + 'MAINTENANCE_DUE', + 'OFFER_EXPIRING', +] + +const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH'] + +export default function DashboardNotificationsPage() { + const [notifications, setNotifications] = useState([]) + const [preferences, setPreferences] = useState>({}) + const [activeTab, setActiveTab] = useState<'inbox' | 'preferences'>('inbox') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + async function load() { + setLoading(true) + setError(null) + try { + const [notificationData, preferenceData] = await Promise.all([ + apiFetch('/notifications/company'), + apiFetch('/notifications/company/preferences'), + ]) + setNotifications(notificationData) + const mapped = Object.fromEntries( + preferenceData.map((item) => [`${item.notificationType}:${item.channel}`, item.enabled]), + ) + setPreferences(mapped) + } catch (err: any) { + setError(err.message ?? 'Failed to load notifications') + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, []) + + async function markRead(id: string) { + await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' }) + setNotifications((current) => + current.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item)), + ) + } + + async function markAllRead() { + await apiFetch('/notifications/company/read-all', { method: 'POST' }) + setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() }))) + } + + async function savePreferences() { + setSaving(true) + setError(null) + try { + const body = Object.entries(preferences).map(([key, enabled]) => { + const [notificationType, channel] = key.split(':') + return { notificationType, channel, enabled } + }) + await apiFetch('/notifications/company/preferences', { + method: 'PATCH', + body: JSON.stringify(body), + }) + } catch (err: any) { + setError(err.message ?? 'Failed to save preferences') + } finally { + setSaving(false) + } + } + + function preferenceValue(notificationType: string, channel: string) { + return preferences[`${notificationType}:${channel}`] ?? true + } + + function setPreference(notificationType: string, channel: string, enabled: boolean) { + setPreferences((current) => ({ + ...current, + [`${notificationType}:${channel}`]: enabled, + })) + } + + return ( +
+
+
+

Notifications

+

Track in-app alerts and control delivery preferences for your team account.

+
+
+ + +
+
+ + {error ?
{error}
: null} + + {activeTab === 'inbox' ? ( +
+
+ +
+ {loading ?
Loading notifications…
: null} + {!loading && notifications.length === 0 ?
No notifications yet.
: null} + {!loading + ? notifications.map((notification) => ( +
+
+
+

+ {notification.type.replaceAll('_', ' ')} +

+

{notification.title}

+
+ {notification.readAt ? ( + Read + ) : ( + + )} +
+

{notification.body}

+

{new Date(notification.createdAt).toLocaleString()}

+
+ )) + : null} +
+ ) : ( +
+
+ + + + + {COMPANY_CHANNELS.map((channel) => ( + + ))} + + + + {COMPANY_EVENTS.map((eventName) => ( + + + {COMPANY_CHANNELS.map((channel) => ( + + ))} + + ))} + +
Event + {channel.replace('_', ' ')} +
{eventName.replaceAll('_', ' ')} + setPreference(eventName, channel, event.target.checked)} + className="h-4 w-4 rounded border-slate-300 text-blue-600" + /> +
+
+
+ +
+
+ )} +
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx new file mode 100644 index 0000000..d8741cf --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx @@ -0,0 +1,63 @@ +'use client' + +import { useEffect, useState } from 'react' +import dayjs from 'dayjs' +import { formatCurrency } from '@rentaldrivego/types' +import { apiFetch } from '@/lib/api' + +interface OfferRow { + id: string + title: string + type: string + discountValue: number + isActive: boolean + isPublic: boolean + isFeatured: boolean + validUntil: string + promoCode: string | null +} + +export default function OffersPage() { + const [rows, setRows] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + apiFetch('/offers') + .then(setRows) + .catch((err) => setError(err.message)) + }, []) + + return ( +
+
+

Offers

+

Promotions shown on your site and optionally on the marketplace.

+
+
+ {rows.map((offer) => ( +
+
+
+

{offer.title}

+

{offer.type} · ends {dayjs(offer.validUntil).format('MMM D, YYYY')}

+
+ {offer.isActive ? 'Active' : 'Inactive'} +
+
+ {offer.isPublic && Marketplace} + {offer.isFeatured && Featured} + {offer.promoCode && {offer.promoCode}} +
+

+ {offer.type === 'PERCENTAGE' ? `${offer.discountValue}%` : formatCurrency(offer.discountValue, 'MAD')} +

+
+ ))} + {rows.length === 0 && !error && ( +
No offers created yet.
+ )} +
+ {error &&
{error}
} +
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx new file mode 100644 index 0000000..39df2a9 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useEffect, useState } from 'react' +import { formatCurrency } from '@rentaldrivego/types' +import { apiFetch } from '@/lib/api' + +interface ReportRow { + reservationId: string + customerName: string + vehicle: string + startDate: string + endDate: string + totalAmount: number + source: string + paymentStatus: string +} + +interface ReportData { + summary: { + totalReservations: number + totalRentalRevenue: number + totalDiscounts: number + totalInsurance: number + totalAdditionalDrivers: number + totalCollected: number + totalPricingRulesAdj?: number + } + rows: ReportRow[] +} + +type ReportPeriod = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'ANNUAL' + +const periodLabels: Record = { + WEEKLY: 'Weekly', + MONTHLY: 'Monthly', + QUARTERLY: 'Quarterly', + ANNUAL: 'Annual', +} + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1' + +export default function ReportsPage() { + const [period, setPeriod] = useState('MONTHLY') + const [report, setReport] = useState(null) + const [error, setError] = useState(null) + const [exporting, setExporting] = useState(false) + + useEffect(() => { + apiFetch(`/analytics/report?period=${period}`) + .then(setReport) + .catch((err) => setError(err.message)) + }, [period]) + + async function exportCsv() { + setExporting(true) + setError(null) + try { + const token = (window as any).__clerk?.session ? await (window as any).__clerk.session.getToken() : null + const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + if (!res.ok) { + const json = await res.json().catch(() => null) + throw new Error(json?.message ?? 'Export failed') + } + const csv = await res.text() + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) + const url = window.URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `rentaldrivego-${period.toLowerCase()}-report.csv` + anchor.click() + window.URL.revokeObjectURL(url) + } catch (err: any) { + setError(err.message ?? 'Export failed') + } finally { + setExporting(false) + } + } + + return ( +
+
+
+

Reports

+

Accountant-ready exports with insurance, additional-driver, and pricing-rule totals.

+
+
+ {(Object.keys(periodLabels) as ReportPeriod[]).map((option) => ( + + ))} + +
+
+ + {error &&
{error}
} + + {report && ( +
+

Bookings

{report.summary.totalReservations}

+

Rental

{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}

+

Insurance

{formatCurrency(report.summary.totalInsurance, 'MAD')}

+

Drivers

{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}

+

Discounts

{formatCurrency(report.summary.totalDiscounts, 'MAD')}

+

Collected

{formatCurrency(report.summary.totalCollected, 'MAD')}

+
+ )} + +
+
+ + + + + + + + + + + + + {(report?.rows ?? []).map((row) => ( + + + + + + + + + ))} + {(report?.rows.length ?? 0) === 0 && ( + + + + )} + +
ReservationVehiclePeriodSourcePaymentTotal
{row.customerName}{row.vehicle}{row.startDate} - {row.endDate}{row.source}{row.paymentStatus}{formatCurrency(row.totalAmount, 'MAD')}
No report rows available for this period.
+
+
+
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx new file mode 100644 index 0000000..e6efe90 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx @@ -0,0 +1,272 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import dayjs from 'dayjs' +import { formatCurrency } from '@rentaldrivego/types' +import { apiFetch } from '@/lib/api' +import DamageInspectionCard, { DamageInspection } from '@/components/reservations/DamageInspectionCard' + +interface ReservationDetail { + id: string + status: string + source: string + startDate: string + endDate: string + totalAmount: number + discountAmount: number + insuranceTotal: number + additionalDriverTotal: number + pricingRulesTotal: number + paymentStatus: string + pricingRulesApplied: { name: string; amount: number; type: string }[] | null + customer: { + firstName: string + lastName: string + email: string + phone: string | null + driverLicense: string | null + licenseValidationStatus: string + flagged: boolean + } + vehicle: { + make: string + model: string + licensePlate: string + } + insurances: { id: string; policyName: string; totalCharge: number }[] + additionalDrivers: { + id: string + firstName: string + lastName: string + driverLicense: string + totalCharge: number + requiresApproval: boolean + approvedAt: string | null + approvalNote: string | null + }[] +} + +export default function ReservationDetailPage() { + const params = useParams<{ id: string }>() + const [reservation, setReservation] = useState(null) + const [inspections, setInspections] = useState([]) + const [error, setError] = useState(null) + const [actionError, setActionError] = useState(null) + const [acting, setActing] = useState(false) + + async function loadReservation() { + try { + const [reservationData, inspectionData] = await Promise.all([ + apiFetch(`/reservations/${params.id}`), + apiFetch(`/reservations/${params.id}/inspections`), + ]) + setReservation(reservationData) + setInspections(inspectionData) + setError(null) + } catch (err: any) { + setError(err.message ?? 'Failed to load reservation') + } + } + + useEffect(() => { + loadReservation() + }, [params.id]) + + async function runAction(action: 'confirm' | 'checkin' | 'checkout') { + setActing(true) + setActionError(null) + try { + await apiFetch(`/reservations/${params.id}/${action}`, { + method: 'POST', + body: JSON.stringify({}), + }) + await loadReservation() + } catch (err: any) { + setActionError(err.message ?? `Failed to ${action} reservation`) + } finally { + setActing(false) + } + } + + async function approveDriver(driverId: string) { + setActing(true) + setActionError(null) + try { + await apiFetch(`/reservations/${params.id}/additional-drivers/${driverId}/approval`, { + method: 'PATCH', + body: JSON.stringify({ approved: true }), + }) + await loadReservation() + } catch (err: any) { + setActionError(err.message ?? 'Failed to approve additional driver') + } finally { + setActing(false) + } + } + + if (error) return
{error}
+ if (!reservation) return
Loading reservation…
+ + const checkinInspection = inspections.find((inspection) => inspection.type === 'CHECKIN') + const checkoutInspection = inspections.find((inspection) => inspection.type === 'CHECKOUT') + + return ( +
+
+
+

Reservation {reservation.id.slice(-8).toUpperCase()}

+

{reservation.source} · {reservation.status} · {reservation.paymentStatus}

+
+
+ {reservation.status === 'DRAFT' && ( + + )} + {reservation.status === 'CONFIRMED' && ( + + )} + {reservation.status === 'ACTIVE' && ( + + )} +
+
+ + {actionError &&
{actionError}
} + +
+
+

Customer

+
+

{reservation.customer.firstName} {reservation.customer.lastName}

+

{reservation.customer.email}

+

{reservation.customer.phone ?? 'No phone provided'}

+

License: {reservation.customer.driverLicense ?? 'Not captured'}

+
+ {reservation.customer.licenseValidationStatus} + {reservation.customer.flagged && Flagged} +
+
+
+ +
+

Vehicle

+
+

{reservation.vehicle.make} {reservation.vehicle.model}

+

{reservation.vehicle.licensePlate}

+

{dayjs(reservation.startDate).format('MMM D, YYYY')} - {dayjs(reservation.endDate).format('MMM D, YYYY')}

+
+
+
+ +
+
+
+

Charges

+
+
Discount
{formatCurrency(reservation.discountAmount, 'MAD')}
+
Insurance
{formatCurrency(reservation.insuranceTotal, 'MAD')}
+
Additional drivers
{formatCurrency(reservation.additionalDriverTotal, 'MAD')}
+
Pricing adjustments
{formatCurrency(reservation.pricingRulesTotal, 'MAD')}
+
Grand total
{formatCurrency(reservation.totalAmount, 'MAD')}
+
+ + {reservation.insurances.length > 0 && ( +
+

Applied insurance

+
+ {reservation.insurances.map((insurance) => ( +
+ {insurance.policyName} + {formatCurrency(insurance.totalCharge, 'MAD')} +
+ ))} +
+
+ )} + + {reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && ( +
+

Pricing rules applied

+
+ {reservation.pricingRulesApplied.map((rule) => ( +
+ {rule.name} + + {rule.amount < 0 ? '-' : '+'}{formatCurrency(Math.abs(rule.amount), 'MAD')} + +
+ ))} +
+
+ )} +
+ + setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])} + /> + setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])} + /> +
+ +
+
+

Additional drivers

+
+ {reservation.additionalDrivers.map((driver) => ( +
+
+
+

{driver.firstName} {driver.lastName}

+

License: {driver.driverLicense}

+

Charge: {formatCurrency(driver.totalCharge, 'MAD')}

+
+ {driver.requiresApproval && !driver.approvedAt ? ( + + ) : ( + + {driver.approvedAt ? 'Approved' : 'No approval needed'} + + )} +
+ {driver.approvalNote &&

{driver.approvalNote}

} +
+ ))} + {reservation.additionalDrivers.length === 0 && ( +
No additional drivers were added to this reservation.
+ )} +
+
+ +
+

Inspection summary

+
+
+ Check-in inspection + {checkinInspection ? 'Saved' : 'Pending'} +
+
+ Check-out inspection + {checkoutInspection ? 'Saved' : 'Pending'} +
+
+
+
+
+
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/reservations/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/reservations/page.tsx new file mode 100644 index 0000000..7f16fdb --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/reservations/page.tsx @@ -0,0 +1,81 @@ +'use client' + +import { useEffect, useState } from 'react' +import dayjs from 'dayjs' +import Link from 'next/link' +import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types' +import { apiFetch } from '@/lib/api' + +interface ReservationRow { + id: string + status: string + source: string + startDate: string + endDate: string + totalAmount: number + totalDays: number + vehicle: { make: string; model: string } + customer: { firstName: string; lastName: string; email: string } +} + +export default function ReservationsPage() { + const [rows, setRows] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + apiFetch>('/reservations?pageSize=100') + .then((result) => setRows(result.data)) + .catch((err) => setError(err.message)) + }, []) + + return ( +
+
+

Reservations

+

All booking sources, including dashboard, public site, and marketplace.

+
+
+ {error ? ( +
{error}
+ ) : ( +
+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
CustomerVehicleDatesSourceStatusTotal
+ + {row.customer.firstName} {row.customer.lastName} + +

{row.customer.email}

+
{row.vehicle.make} {row.vehicle.model}{dayjs(row.startDate).format('MMM D')} - {dayjs(row.endDate).format('MMM D, YYYY')}{row.source}{row.status}{formatCurrency(row.totalAmount, 'MAD')}
No reservations yet.
+
+ )} +
+
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/settings/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/settings/page.tsx new file mode 100644 index 0000000..0cfd38e --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/settings/page.tsx @@ -0,0 +1,591 @@ +'use client' + +import { useEffect, useState } from 'react' +import { apiFetch } from '@/lib/api' + +interface BrandSettings { + displayName: string + tagline: string | null + primaryColor: string + accentColor?: string | null + publicEmail: string | null + publicPhone: string | null + publicAddress?: string | null + publicCity: string | null + publicCountry?: string | null + subdomain: string + logoUrl?: string | null + heroImageUrl?: string | null + websiteUrl?: string | null + whatsappNumber?: string | null + paypalEmail: string | null + amanpayMerchantId: string | null + amanpaySecretKey?: string | null + paypalMerchantId?: string | null + customDomain?: string | null + customDomainVerified?: boolean + defaultCurrency?: string + isListedOnMarketplace: boolean +} + +interface ContractSettings { + fuelPolicyType: string + fuelPolicyNote: string | null + additionalDriverCharge: 'FREE' | 'PER_DAY' | 'FLAT' + additionalDriverDailyRate: number + additionalDriverFlatRate: number + damagePolicy: string +} + +interface InsurancePolicy { + id: string + name: string + type: string + chargeType: string + chargeValue: number + isRequired: boolean + isActive: boolean +} + +interface PricingRule { + id: string + name: string + type: string + condition: string + conditionValue: number + adjustmentType: string + adjustmentValue: number + isActive: boolean +} + +interface AccountingSettings { + reportingPeriod: string + accountantEmail: string | null + accountantName: string | null + autoSendReport: boolean + reportFormat: string +} + +const emptyInsurance = { name: '', type: 'BASIC', chargeType: 'PER_DAY', chargeValue: 0, isRequired: false, isActive: true } +const emptyRule = { name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 0, isActive: true } + +export default function SettingsPage() { + const [brand, setBrand] = useState(null) + const [contractSettings, setContractSettings] = useState(null) + const [insurancePolicies, setInsurancePolicies] = useState([]) + const [pricingRules, setPricingRules] = useState([]) + const [accountingSettings, setAccountingSettings] = useState(null) + const [newInsurance, setNewInsurance] = useState(emptyInsurance) + const [newRule, setNewRule] = useState(emptyRule) + const [customDomain, setCustomDomain] = useState('') + const [uploadingAsset, setUploadingAsset] = useState<'logo' | 'hero' | null>(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + + async function load() { + try { + const [brandData, contractData, insuranceData, ruleData, accountingData] = await Promise.all([ + apiFetch('/companies/me/brand'), + apiFetch('/companies/me/contract-settings'), + apiFetch('/companies/me/insurance-policies'), + apiFetch('/companies/me/pricing-rules'), + apiFetch('/companies/me/accounting-settings'), + ]) + setBrand(brandData) + setCustomDomain(brandData?.customDomain ?? '') + setContractSettings(contractData ?? { + fuelPolicyType: 'FULL_TO_FULL', + fuelPolicyNote: '', + additionalDriverCharge: 'FREE', + additionalDriverDailyRate: 0, + additionalDriverFlatRate: 0, + damagePolicy: '', + }) + setInsurancePolicies(insuranceData) + setPricingRules(ruleData) + setAccountingSettings(accountingData ?? { + reportingPeriod: 'MONTHLY', + accountantEmail: '', + accountantName: '', + autoSendReport: false, + reportFormat: 'CSV', + }) + setError(null) + } catch (err: any) { + setError(err.message ?? 'Failed to load settings') + } + } + + async function saveBrandSettings() { + if (!brand) return + setSaving(true) + setError(null) + try { + const updated = await apiFetch('/companies/me/brand', { + method: 'PATCH', + body: JSON.stringify({ + displayName: brand.displayName, + tagline: brand.tagline || undefined, + primaryColor: brand.primaryColor, + accentColor: brand.accentColor || undefined, + publicEmail: brand.publicEmail || undefined, + publicPhone: brand.publicPhone || undefined, + publicAddress: brand.publicAddress || undefined, + publicCity: brand.publicCity || undefined, + publicCountry: brand.publicCountry || undefined, + websiteUrl: brand.websiteUrl || undefined, + whatsappNumber: brand.whatsappNumber || undefined, + paypalEmail: brand.paypalEmail || undefined, + amanpayMerchantId: brand.amanpayMerchantId || undefined, + amanpaySecretKey: brand.amanpaySecretKey || undefined, + paypalMerchantId: brand.paypalMerchantId || undefined, + defaultCurrency: brand.defaultCurrency || undefined, + isListedOnMarketplace: brand.isListedOnMarketplace, + }), + }) + setBrand(updated) + } catch (err: any) { + setError(err.message ?? 'Failed to save brand settings') + } finally { + setSaving(false) + } + } + + async function uploadBrandAsset(kind: 'logo' | 'hero', file: File | null) { + if (!file) return + setUploadingAsset(kind) + setError(null) + try { + const formData = new FormData() + formData.append('file', file) + const updated = await apiFetch(`/companies/me/brand/${kind}`, { + method: 'POST', + body: formData, + }) + setBrand(updated) + } catch (err: any) { + setError(err.message ?? `Failed to upload ${kind}`) + } finally { + setUploadingAsset(null) + } + } + + async function saveCustomDomain() { + setSaving(true) + setError(null) + try { + await apiFetch('/companies/me/brand/custom-domain', { + method: 'POST', + body: JSON.stringify({ customDomain }), + }) + await load() + } catch (err: any) { + setError(err.message ?? 'Failed to save custom domain') + } finally { + setSaving(false) + } + } + + async function removeCustomDomain() { + setSaving(true) + setError(null) + try { + await apiFetch('/companies/me/brand/custom-domain', { + method: 'DELETE', + }) + setCustomDomain('') + await load() + } catch (err: any) { + setError(err.message ?? 'Failed to remove custom domain') + } finally { + setSaving(false) + } + } + + useEffect(() => { + load() + }, []) + + async function saveContractSettings() { + if (!contractSettings) return + setSaving(true) + setError(null) + try { + await apiFetch('/companies/me/contract-settings', { + method: 'PATCH', + body: JSON.stringify(contractSettings), + }) + } catch (err: any) { + setError(err.message ?? 'Failed to save contract settings') + } finally { + setSaving(false) + } + } + + async function saveAccountingSettings() { + if (!accountingSettings) return + setSaving(true) + setError(null) + try { + await apiFetch('/companies/me/accounting-settings', { + method: 'PATCH', + body: JSON.stringify(accountingSettings), + }) + } catch (err: any) { + setError(err.message ?? 'Failed to save accounting settings') + } finally { + setSaving(false) + } + } + + async function createInsurance() { + setSaving(true) + setError(null) + try { + await apiFetch('/companies/me/insurance-policies', { + method: 'POST', + body: JSON.stringify(newInsurance), + }) + setNewInsurance(emptyInsurance) + await load() + } catch (err: any) { + setError(err.message ?? 'Failed to create insurance policy') + } finally { + setSaving(false) + } + } + + async function toggleInsurance(policy: InsurancePolicy) { + try { + await apiFetch(`/companies/me/insurance-policies/${policy.id}`, { + method: 'PATCH', + body: JSON.stringify({ isActive: !policy.isActive }), + }) + await load() + } catch (err: any) { + setError(err.message ?? 'Failed to update insurance policy') + } + } + + async function createRule() { + setSaving(true) + setError(null) + try { + await apiFetch('/companies/me/pricing-rules', { + method: 'POST', + body: JSON.stringify(newRule), + }) + setNewRule(emptyRule) + await load() + } catch (err: any) { + setError(err.message ?? 'Failed to create pricing rule') + } finally { + setSaving(false) + } + } + + async function toggleRule(rule: PricingRule) { + try { + await apiFetch(`/companies/me/pricing-rules/${rule.id}`, { + method: 'PATCH', + body: JSON.stringify({ isActive: !rule.isActive }), + }) + await load() + } catch (err: any) { + setError(err.message ?? 'Failed to update pricing rule') + } + } + + return ( +
+
+

Advanced settings

+

Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.

+
+ + {error &&
{error}
} + + {brand && ( +
+
+
+
+

Brand and public profile

+

Control how your company appears on the marketplace and public booking site.

+
+ +
+
+
+ + setBrand((current) => current ? { ...current, displayName: event.target.value } : current)} /> +
+
+ + setBrand((current) => current ? { ...current, tagline: event.target.value } : current)} /> +
+
+ + setBrand((current) => current ? { ...current, publicEmail: event.target.value } : current)} /> +
+
+ + setBrand((current) => current ? { ...current, publicPhone: event.target.value } : current)} /> +
+
+ + setBrand((current) => current ? { ...current, publicCity: event.target.value } : current)} /> +
+
+ + setBrand((current) => current ? { ...current, publicCountry: event.target.value } : current)} /> +
+
+ + setBrand((current) => current ? { ...current, websiteUrl: event.target.value } : current)} /> +
+
+ + setBrand((current) => current ? { ...current, whatsappNumber: event.target.value } : current)} /> +
+
+ + setBrand((current) => current ? { ...current, primaryColor: event.target.value } : current)} /> +
+
+ +
+
+
+ + +
+
+ +
+
+
+
+

Rental payment methods

+

Configure how renters pay your company on the public booking site.

+
+ +
+
+
+ + setBrand((current) => current ? { ...current, amanpayMerchantId: event.target.value } : current)} /> +
+
+ + setBrand((current) => current ? { ...current, amanpaySecretKey: event.target.value } : current)} /> +
+
+ + setBrand((current) => current ? { ...current, paypalEmail: event.target.value } : current)} /> +
+
+ If no payment method is configured, renters can still submit reservation requests and pay on pickup. +
+
+
+ +
+
+
+

Custom domain

+

Point your own domain to the branded booking site.

+
+ +
+
+
+ +
{brand.subdomain}.RentalDriveGo.com
+
+
+ + setCustomDomain(event.target.value)} /> +
+
+ Status: {brand.customDomain ? (brand.customDomainVerified ? 'Verified' : 'Pending DNS verification') : 'Not configured'} +
+ {brand.customDomain ? ( + + ) : null} +
+
+
+
+ )} + + {contractSettings && ( +
+
+

Contract and driver policies

+ +
+
+
+ + +
+
+ + +
+
+ + setContractSettings((current) => current ? { ...current, additionalDriverDailyRate: Number(event.target.value) } : current)} /> +
+
+ + setContractSettings((current) => current ? { ...current, additionalDriverFlatRate: Number(event.target.value) } : current)} /> +
+
+ +