add first files
This commit is contained in:
@@ -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
|
||||||
+33
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
domains: ['res.cloudinary.com'],
|
||||||
|
},
|
||||||
|
transpilePackages: ['@rentaldrivego/types'],
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
await cloudinary.uploader.destroy(publicId)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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<AdminRole, number> = {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express'
|
||||||
|
import { EmployeeRole } from '@rentaldrivego/database'
|
||||||
|
|
||||||
|
const ROLE_RANK: Record<EmployeeRole, number> = {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<string, string>
|
||||||
|
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<string, string>
|
||||||
|
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<string, string>
|
||||||
|
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
|
||||||
@@ -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<string, string>
|
||||||
|
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<string, string>
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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<string, string>
|
||||||
|
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
|
||||||
@@ -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<string, string>
|
||||||
|
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<string, string>
|
||||||
|
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
|
||||||
@@ -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<string, string>
|
||||||
|
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
|
||||||
@@ -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<string, string>
|
||||||
|
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
|
||||||
@@ -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<string, string>
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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<string, string>
|
||||||
|
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<string, string>
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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, any>[]): 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')
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
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: `<p>${opts.body}</p>`,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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<TeamMember[]> {
|
||||||
|
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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
Vendored
+13
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"jsx": "react"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
domains: ['res.cloudinary.com'],
|
||||||
|
},
|
||||||
|
transpilePackages: ['@rentaldrivego/types'],
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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<Vehicle['status'], string> = {
|
||||||
|
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<string | null>(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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 mb-5">Add New Vehicle</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label>
|
||||||
|
<input className="input-field" placeholder="Toyota" required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label>
|
||||||
|
<input className="input-field" placeholder="Corolla" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label>
|
||||||
|
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} required value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">Category</label>
|
||||||
|
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||||
|
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'CONVERTIBLE', 'PICKUP'].map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label>
|
||||||
|
<input type="number" className="input-field" placeholder="350.00" min="0" step="0.01" required value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">License Plate *</label>
|
||||||
|
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label>
|
||||||
|
<input className="input-field" placeholder="White" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
|
||||||
|
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">Transmission</label>
|
||||||
|
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
|
||||||
|
<option value="MANUAL">Manual</option>
|
||||||
|
<option value="AUTOMATIC">Automatic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label>
|
||||||
|
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
|
||||||
|
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID', 'LPG'].map((f) => (
|
||||||
|
<option key={f} value={f}>{f}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="btn-secondary flex-1">Cancel</button>
|
||||||
|
<button type="submit" disabled={loading} className="btn-primary flex-1">
|
||||||
|
{loading ? 'Adding…' : 'Add Vehicle'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FleetPage() {
|
||||||
|
const [vehicles, setVehicles] = useState<Vehicle[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(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<Vehicle[]>('/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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900">Fleet</h2>
|
||||||
|
<p className="text-sm text-slate-500 mt-0.5">{vehicles.length} vehicle{vehicles.length !== 1 ? 's' : ''} total</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowAddModal(true)} className="btn-primary">
|
||||||
|
<Plus className="w-4 h-4" /> Add Vehicle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="card p-4 flex flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-48">
|
||||||
|
<Search className="w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
className="flex-1 text-sm outline-none placeholder:text-slate-400"
|
||||||
|
placeholder="Search make, model, plate…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="input-field w-36"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="input-field w-36"
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'CONVERTIBLE', 'PICKUP'].map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="input-field w-36"
|
||||||
|
value={publishedFilter}
|
||||||
|
onChange={(e) => setPublishedFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All visibility</option>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
<option value="unpublished">Unpublished</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="text-red-600 font-medium">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-100 bg-slate-50">
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Category</th>
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Daily Rate</th>
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Published</th>
|
||||||
|
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{filtered.map((vehicle) => (
|
||||||
|
<tr key={vehicle.id} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-10 rounded-lg bg-slate-100 overflow-hidden flex-shrink-0">
|
||||||
|
{vehicle.primaryPhoto ? (
|
||||||
|
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">No photo</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
|
||||||
|
<p className="text-xs text-slate-400">{vehicle.year} · {vehicle.licensePlate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="badge-gray">{vehicle.category}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={STATUS_BADGE[vehicle.status]}>{vehicle.status}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm font-medium text-slate-900">
|
||||||
|
{formatCurrency(vehicle.dailyRate, 'MAD')}/day
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
|
||||||
|
className={[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none',
|
||||||
|
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition duration-200',
|
||||||
|
vehicle.isPublished ? 'translate-x-5' : 'translate-x-0',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Link href={`/dashboard/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<Link href={`/dashboard/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-12 text-center text-sm text-slate-400">
|
||||||
|
{vehicles.length === 0 ? 'No vehicles yet. Add your first vehicle to get started.' : 'No vehicles match your filters.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddVehicleModal open={showAddModal} onClose={() => setShowAddModal(false)} onSaved={fetchVehicles} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string, { bg: string; icon: React.ReactNode; message: string }> = {
|
||||||
|
TRIALING: {
|
||||||
|
bg: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||||
|
icon: <Clock className="w-4 h-4" />,
|
||||||
|
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: <AlertTriangle className="w-4 h-4" />,
|
||||||
|
message: 'Your payment is past due. Please update your billing information.',
|
||||||
|
},
|
||||||
|
SUSPENDED: {
|
||||||
|
bg: 'bg-red-50 border-red-200 text-red-800',
|
||||||
|
icon: <XCircle className="w-4 h-4" />,
|
||||||
|
message: 'Your account has been suspended. Please contact support or renew your subscription.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = configs[status]
|
||||||
|
if (!config) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl border mb-6 ${config.bg}`}>
|
||||||
|
{config.icon}
|
||||||
|
<p className="text-sm font-medium">{config.message}</p>
|
||||||
|
<a href="/dashboard/billing" className="ml-auto text-sm font-semibold underline hover:no-underline">
|
||||||
|
Manage billing →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<DashboardData>('/analytics/dashboard')
|
||||||
|
.then(setData)
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="card p-6 text-center">
|
||||||
|
<p className="text-red-600 font-medium">Failed to load dashboard</p>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{data?.subscription && (
|
||||||
|
<SubscriptionBanner
|
||||||
|
status={data.subscription.status}
|
||||||
|
planName={data.subscription.planName}
|
||||||
|
trialEndsAt={data.subscription.trialEndsAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
title="Total Bookings"
|
||||||
|
value={kpis.totalBookings.toLocaleString()}
|
||||||
|
change={kpis.bookingsChange}
|
||||||
|
icon={Calendar}
|
||||||
|
iconColor="text-blue-600"
|
||||||
|
iconBg="bg-blue-50"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Active Vehicles"
|
||||||
|
value={kpis.activeVehicles.toLocaleString()}
|
||||||
|
change={kpis.vehiclesChange}
|
||||||
|
icon={Car}
|
||||||
|
iconColor="text-green-600"
|
||||||
|
iconBg="bg-green-50"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Monthly Revenue"
|
||||||
|
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
|
||||||
|
change={kpis.revenueChange}
|
||||||
|
icon={DollarSign}
|
||||||
|
iconColor="text-amber-600"
|
||||||
|
iconBg="bg-amber-50"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Total Customers"
|
||||||
|
value={kpis.totalCustomers.toLocaleString()}
|
||||||
|
change={kpis.customersChange}
|
||||||
|
icon={Users}
|
||||||
|
iconColor="text-purple-600"
|
||||||
|
iconBg="bg-purple-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Booking Sources Chart */}
|
||||||
|
<div className="card p-6 lg:col-span-2">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-4">Booking Sources</h2>
|
||||||
|
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={data.sourceBreakdown}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||||
|
<XAxis dataKey="source" tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||||
|
<Bar dataKey="count" fill="#3b82f6" name="Bookings" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="revenue" fill="#10b981" name="Revenue (centimes)" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-48 flex items-center justify-center text-slate-400 text-sm">
|
||||||
|
No booking data available yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
<div className="card p-6">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-4">Quick Stats</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(data?.sourceBreakdown ?? []).map((item) => (
|
||||||
|
<div key={item.source} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-slate-600 capitalize">{item.source.toLowerCase()}</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">{item.count}</span>
|
||||||
|
<p className="text-xs text-slate-400">{formatCurrency(item.revenue, 'MAD')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!data?.sourceBreakdown || data.sourceBreakdown.length === 0) && (
|
||||||
|
<p className="text-sm text-slate-400">No data yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Reservations */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900">Recent Reservations</h2>
|
||||||
|
<a href="/dashboard/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
|
||||||
|
View all →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-100">
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Booking #</th>
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Dates</th>
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
|
||||||
|
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{(data?.recentReservations ?? []).map((res) => (
|
||||||
|
<tr key={res.id} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<a href={`/dashboard/reservations/${res.id}`} className="text-sm font-medium text-blue-600 hover:text-blue-700">
|
||||||
|
#{res.bookingRef}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-slate-700">{res.customerName}</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-slate-500">
|
||||||
|
{dayjs(res.startDate).format('MMM D')} – {dayjs(res.endDate).format('MMM D, YYYY')}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<span className={STATUS_COLORS[res.status] ?? 'badge-gray'}>
|
||||||
|
{res.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-sm font-semibold text-slate-900 text-right">
|
||||||
|
{formatCurrency(res.totalAmount, 'MAD')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{(!data?.recentReservations || data.recentReservations.length === 0) && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-8 text-center text-sm text-slate-400">
|
||||||
|
No reservations yet. Once customers book vehicles, they'll appear here.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex h-screen bg-slate-50">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex-1 flex flex-col ml-64 min-w-0">
|
||||||
|
<TopBar />
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<ClerkProvider>
|
||||||
|
<html lang="en">
|
||||||
|
<body className={inter.className}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</ClerkProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<aside className="fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
|
||||||
|
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||||
|
<Car className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-white text-sm tracking-wide">RentalDriveGo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const active = isActive(item)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={[
|
||||||
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User menu */}
|
||||||
|
<div className="px-3 py-4 border-t border-slate-800">
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 rounded-lg">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
|
||||||
|
{user?.firstName?.[0]?.toUpperCase() ?? 'U'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{user?.firstName} {user?.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 truncate">
|
||||||
|
{user?.primaryEmailAddress?.emailAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => signOut()}
|
||||||
|
className="mt-2 w-full flex items-center gap-3 px-3 py-2 text-sm text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
'/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 (
|
||||||
|
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6">
|
||||||
|
<h1 className="text-lg font-semibold text-slate-900">{title}</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Notification bell */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNotifs(!showNotifs)}
|
||||||
|
className="relative p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute top-1 right-1 min-w-[16px] h-4 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1">
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showNotifs && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-slate-200 rounded-xl shadow-lg z-50 p-4">
|
||||||
|
<p className="text-sm font-medium text-slate-900 mb-2">Notifications</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{unreadCount === 0 ? 'No new notifications' : `${unreadCount} unread notification${unreadCount > 1 ? 's' : ''}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User avatar */}
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-semibold">
|
||||||
|
{user?.firstName?.[0]?.toUpperCase() ?? 'U'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500 font-medium">{title}</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 mt-1">{value}</p>
|
||||||
|
{change !== undefined && (
|
||||||
|
<div className="flex items-center gap-1 mt-2">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'text-xs font-medium',
|
||||||
|
isPositive ? 'text-green-600' : isNegative ? 'text-red-600' : 'text-slate-500',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{isPositive ? '+' : ''}{change.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">vs last month</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`w-12 h-12 rounded-xl ${iconBg} flex items-center justify-center flex-shrink-0`}>
|
||||||
|
<Icon className={`w-6 h-6 ${iconColor}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||||
|
|
||||||
|
async function getClerkToken(): Promise<string | null> {
|
||||||
|
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<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const token = await getClerkToken()
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options?.headers as Record<string, string> ?? {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(path: string, token: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
...(options?.headers as Record<string, string> ?? {}),
|
||||||
|
},
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)(.*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
domains: ['res.cloudinary.com'],
|
||||||
|
},
|
||||||
|
transpilePackages: ['@rentaldrivego/types'],
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
domains: ['res.cloudinary.com'],
|
||||||
|
},
|
||||||
|
transpilePackages: ['@rentaldrivego/types'],
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["prisma/**/*"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Standard API response shapes
|
||||||
|
|
||||||
|
export interface ApiSuccess<T> {
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPaginated<T> {
|
||||||
|
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<string, Record<string, Record<string, number>>> = {
|
||||||
|
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<Locale, string> = { en: 'en-US', fr: 'fr-FR', ar: 'ar-MA' }
|
||||||
|
return new Intl.NumberFormat(localeMap[locale], {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
@@ -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<VehicleZone, { x: number; y: number; w: number; h: number }> = {
|
||||||
|
'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<DamageZone['severity'], string> = {
|
||||||
|
SCRATCH: '#F59E0B',
|
||||||
|
DENT: '#EF4444',
|
||||||
|
CRACK: '#8B5CF6',
|
||||||
|
MISSING: '#374151',
|
||||||
|
OTHER: '#6B7280',
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export type FuelPolicyType = 'FULL_TO_FULL' | 'FULL_TO_EMPTY' | 'SAME_TO_SAME' | 'PREPAID' | 'FREE'
|
||||||
|
|
||||||
|
export const FUEL_POLICY_LABELS: Record<FuelPolicyType, Record<'en' | 'fr' | 'ar', string>> = {
|
||||||
|
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: 'الوقود مشمول: تكلفة الوقود مشمولة في سعر الإيجار.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './damage'
|
||||||
|
export * from './fuel'
|
||||||
|
export * from './api'
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
+32
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user