From 695a7f7cc7acdf286418cf1c28d1f706212977df Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Apr 2026 14:59:57 -0400 Subject: [PATCH] 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 + } + } +}