Merge branch 'develop' into 'main'
Develop See merge request rental_car/car_management_system!1
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
node_modules
|
||||
.turbo
|
||||
.git
|
||||
.gitignore
|
||||
.codex
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.next
|
||||
dist
|
||||
coverage
|
||||
tmp
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego
|
||||
REDIS_URL=redis://redis:6379
|
||||
API_PORT=4000
|
||||
API_URL=http://localhost:4000
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore
|
||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3002
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
||||
DASHBOARD_URL=http://localhost:3001
|
||||
JWT_SECRET=dev-secret
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
ADMIN_SEED_EMAIL=admin@rentaldrivego.com
|
||||
ADMIN_SEED_PASSWORD=changeme123
|
||||
ADMIN_SEED_FIRST_NAME=Platform
|
||||
ADMIN_SEED_LAST_NAME=Admin
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
CLERK_SECRET_KEY=
|
||||
NODE_ENV=development
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003
|
||||
|
||||
# Email - disable Resend (placeholder), use SMTP instead
|
||||
RESEND_API_KEY=re_...
|
||||
EMAIL_FROM=rentaldrivego@gmail.com
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=smtp
|
||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||
MAIL_PASSWORD=xmhg ibqy muoc rntc
|
||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_FROM_NAME=RentalDriveGo
|
||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||
MAIL_REPLY_TO_NAME=RentalDriveGo
|
||||
@@ -0,0 +1,66 @@
|
||||
# ── Traefik domains — used in docker-compose labels ──────────────────────────
|
||||
ACME_EMAIL=rentaldrivego@gmail.com
|
||||
MARKETPLACE_DOMAIN=rentaldrivego.ma
|
||||
API_DOMAIN=api.rentaldrivego.ma
|
||||
DASHBOARD_DOMAIN=dashboard.rentaldrivego.ma
|
||||
ADMIN_DOMAIN=admin.rentaldrivego.ma
|
||||
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
|
||||
|
||||
DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# ── API ────────────────────────────────────────────────────────────────────────
|
||||
API_PORT=4000
|
||||
# SSR server-side calls go via the internal Docker network
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
# Public-facing API URL visible to browsers — MUST be your real domain, not localhost
|
||||
API_URL=https://api.rentaldrivego.ma
|
||||
# Baked into Next.js bundles at build time — MUST be set before running `npm run build`
|
||||
NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
|
||||
|
||||
# ── Frontend public URLs ───────────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma/explore
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://dashboard.rentaldrivego.ma
|
||||
NEXT_PUBLIC_ADMIN_URL=https://admin.rentaldrivego.ma
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||
DASHBOARD_URL=https://dashboard.rentaldrivego.ma
|
||||
|
||||
# ── CORS ──────────────────────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed browser origins. REQUIRED in production.
|
||||
CORS_ORIGINS=https://rentaldrivego.ma,https://dashboard.rentaldrivego.ma,https://admin.rentaldrivego.ma
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
JWT_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
POSTGRES_PASSWORD=24DY@1u5FLCkNMeiO@p
|
||||
NODE_ENV=production
|
||||
|
||||
# ── Email (choose one: Resend API or SMTP) ────────────────────────────────────
|
||||
# Option A — Resend
|
||||
RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
||||
EMAIL_FROM=noreply@rentaldrivego.ma
|
||||
EMAIL_FROM_NAME=RentalDriveGo
|
||||
# Option B — SMTP
|
||||
# MAIL_HOST=smtp.rentaldrivego.ma
|
||||
# MAIL_PORT=587
|
||||
# MAIL_USERNAME=smtp-user
|
||||
# MAIL_PASSWORD=smtp-password
|
||||
# MAIL_SCHEME=smtp
|
||||
# MAIL_FROM_ADDRESS=noreply@rentaldrivego.ma
|
||||
# MAIL_FROM_NAME=RentalDriveGo
|
||||
|
||||
# ── Firebase push notifications (optional) ────────────────────────────────────
|
||||
# FIREBASE_PROJECT_ID=your-firebase-project-id
|
||||
# FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
|
||||
# FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
||||
|
||||
# ── Twilio SMS / WhatsApp (optional) ─────────────────────────────────────────
|
||||
# TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# TWILIO_AUTH_TOKEN=your_auth_token
|
||||
# TWILIO_PHONE_NUMBER=+1234567890
|
||||
# TWILIO_WHATSAPP_NUMBER=+1234567890
|
||||
@@ -0,0 +1,10 @@
|
||||
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test
|
||||
REDIS_URL=redis://redis:6379
|
||||
API_PORT=4000
|
||||
API_URL=http://localhost:4000
|
||||
API_INTERNAL_URL=http://localhost:4000/api/v1
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
JWT_SECRET=test-secret
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
NODE_ENV=test
|
||||
@@ -0,0 +1,92 @@
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 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) ───────────────────────
|
||||
# RentalDriveGo's own AmanPay account (for collecting subscription fees)
|
||||
AMANPAY_MERCHANT_ID=your-amanpay-merchant-id
|
||||
AMANPAY_SECRET_KEY=your-amanpay-secret-key
|
||||
AMANPAY_BASE_URL=https://api.amanpay.net
|
||||
AMANPAY_WEBHOOK_SECRET=your-amanpay-webhook-secret
|
||||
|
||||
# ─── PayPal (Secondary payment provider) ──────────────────────
|
||||
# RentalDriveGo's own PayPal account (for collecting subscription fees)
|
||||
PAYPAL_CLIENT_ID=your-paypal-client-id
|
||||
PAYPAL_CLIENT_SECRET=your-paypal-client-secret
|
||||
PAYPAL_BASE_URL=https://api-m.paypal.com
|
||||
# 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
|
||||
|
||||
# The Node API uses Resend for email delivery.
|
||||
# MAIL_* SMTP variables are intentionally omitted here.
|
||||
|
||||
# ─── Redis (Real-time / Socket.io) ────────────────────────────
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# ─── 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
|
||||
@@ -0,0 +1,3 @@
|
||||
ACME_EMAIL=rentaldrivego@gmail.com
|
||||
TRAEFIK_DOMAIN=rentaldrivego.ma
|
||||
TRAEFIK_DOMAIN_WWW=www.rentaldrivego.ma
|
||||
+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,28 @@
|
||||
stages:
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
||||
|
||||
build:
|
||||
stage: build
|
||||
image: docker:24
|
||||
services:
|
||||
- docker:dind
|
||||
script:
|
||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
- docker build -t $DOCKER_IMAGE .
|
||||
- docker push $DOCKER_IMAGE
|
||||
only:
|
||||
- main
|
||||
|
||||
deploy:
|
||||
stage: deploy
|
||||
before_script:
|
||||
- apk add --no-cache openssh-client
|
||||
script:
|
||||
- ssh $VPS_USER@$VPS_IP "docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY"
|
||||
- ssh $VPS_USER@$VPS_IP "docker pull $DOCKER_IMAGE"
|
||||
- ssh $VPS_USER@$VPS_IP "docker stop myapp || true"
|
||||
- ssh $VPS_USER@$VPS_IP "docker run -d --name myapp -p 80:3000 $DOCKER_IMAGE"
|
||||
@@ -0,0 +1,248 @@
|
||||
## Docker Environments
|
||||
|
||||
Three Docker environments are available:
|
||||
|
||||
- `Dockerfile.dev` with `docker-compose.dev.yml`
|
||||
- `Dockerfile.test` with `docker-compose.test.yml`
|
||||
- `Dockerfile.production` with `docker-compose.production.yml`
|
||||
- `docker-compose.pgmanage.yml` for a standalone pgManage container
|
||||
|
||||
### Development
|
||||
|
||||
Use the full dev stack for local work with hot reload and bundled Postgres and Redis:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml --profile full up --build
|
||||
```
|
||||
|
||||
Services:
|
||||
|
||||
- marketplace: `http://localhost:3000`
|
||||
- dashboard: `http://localhost:3001`
|
||||
- admin: `http://localhost:3002`
|
||||
- public-site: `http://localhost:3003`
|
||||
- api: `http://localhost:4000`
|
||||
- pgAdmin: `http://localhost:5050`
|
||||
|
||||
Each dev app now runs in its own container and can be started independently with a profile tag:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml --profile api up --build
|
||||
docker compose -f docker-compose.dev.yml --profile marketplace up --build
|
||||
docker compose -f docker-compose.dev.yml --profile dashboard up --build
|
||||
docker compose -f docker-compose.dev.yml --profile admin up --build
|
||||
docker compose -f docker-compose.dev.yml --profile public-site up --build
|
||||
docker compose -f docker-compose.dev.yml --profile tools up --build
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `api` starts `postgres`, `redis`, and `migrate` automatically through dependencies.
|
||||
- frontend profiles also start `api` and its dependencies automatically.
|
||||
- `tools` starts only `pgadmin` plus its required `postgres` dependency.
|
||||
|
||||
On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts.
|
||||
|
||||
Default dev platform administrator:
|
||||
|
||||
- email: `admin@rentaldrivego.com`
|
||||
- password: `changeme123`
|
||||
|
||||
If you intentionally want a fresh dev bootstrap:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml down -v
|
||||
```
|
||||
|
||||
If you want to keep the database and only apply new schema changes manually:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy"
|
||||
```
|
||||
|
||||
pgAdmin dev login:
|
||||
|
||||
- email: `admin@rentaldrivego.local`
|
||||
- email: `admin@rentaldrivego.dev`
|
||||
- password: `admin`
|
||||
|
||||
pgAdmin opens with the dev Postgres server pre-registered as `RentalDriveGo Dev DB`.
|
||||
|
||||
pgAdmin Postgres connection:
|
||||
|
||||
- host: `postgres`
|
||||
- port: `5432`
|
||||
- database: `rentaldrivego`
|
||||
- username: `postgres`
|
||||
- password: `password`
|
||||
|
||||
### Standalone pgManage
|
||||
|
||||
If you want a standalone Postgres management UI without starting the full development stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.pgmanage.yml up -d
|
||||
```
|
||||
|
||||
It publishes `http://localhost:8000` with a standard Docker port mapping and persists its data in the named Docker volume `pgmanage_data`.
|
||||
From inside the container, connect to the local Postgres service through `host.docker.internal:5432`.
|
||||
|
||||
### Test
|
||||
|
||||
Use the test stack to run repeatable containerized verification:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
|
||||
```
|
||||
|
||||
The test container runs:
|
||||
|
||||
- `npm run db:deploy`
|
||||
- `npm run db:generate`
|
||||
- `npm run type-check`
|
||||
- `npm run build`
|
||||
|
||||
### Production
|
||||
|
||||
The production stack runs behind **Traefik** (reverse proxy + automatic HTTPS via Let's Encrypt). All services communicate over a private Docker network (`internal`). Traefik reaches public-facing services via a separate `traefik-proxy` network.
|
||||
|
||||
#### 1. Point DNS to your server
|
||||
|
||||
Add an A record for every subdomain to your server's public IP before deploying so Let's Encrypt can issue certificates:
|
||||
|
||||
| Subdomain | Service |
|
||||
|---|---|
|
||||
| `rentaldrivego.ma` | marketplace + public site |
|
||||
| `api.rentaldrivego.ma` | API |
|
||||
| `dashboard.rentaldrivego.ma` | dashboard |
|
||||
| `admin.rentaldrivego.ma` | admin panel |
|
||||
| `pgmanage.rentaldrivego.ma` | pgManage (DB admin) |
|
||||
|
||||
#### 2. Install Docker and clone the repo
|
||||
|
||||
```bash
|
||||
# Install Docker (if not already installed)
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
git clone <repo-url> rentaldrivego
|
||||
cd rentaldrivego
|
||||
```
|
||||
|
||||
#### 3. Create the shared Traefik network
|
||||
|
||||
Only needs to be done once per server. If it already exists this is a no-op.
|
||||
|
||||
```bash
|
||||
docker network create traefik-proxy
|
||||
```
|
||||
|
||||
#### 4. Configure environment variables
|
||||
|
||||
```bash
|
||||
cp .env.docker.production.example .env.docker.production
|
||||
```
|
||||
|
||||
Open `.env.docker.production` and fill in every value. The minimum required secrets are:
|
||||
|
||||
| Variable | What to set |
|
||||
|---|---|
|
||||
| `POSTGRES_PASSWORD` | Strong random password |
|
||||
| `JWT_SECRET` | Long random string (e.g. `openssl rand -hex 64`) |
|
||||
| `ACME_EMAIL` | Your email for Let's Encrypt notifications |
|
||||
| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) |
|
||||
|
||||
All domain vars are pre-filled with `rentaldrivego.ma` subdomains and do not need changing unless you use a different domain.
|
||||
|
||||
#### 5. Start Traefik
|
||||
|
||||
Traefik must be running before the app stack so it can wire up routes at startup.
|
||||
|
||||
```bash
|
||||
docker compose -f traefik.yaml up -d
|
||||
```
|
||||
|
||||
#### 6. Build and start the app stack
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml up --build -d
|
||||
```
|
||||
|
||||
Docker will:
|
||||
1. Build the monorepo image
|
||||
2. Run database migrations (`migrate` service)
|
||||
3. Start all app services (api, marketplace, dashboard, admin, public-site, pgmanage)
|
||||
|
||||
Traefik automatically picks up the containers and provisions TLS certificates. Services are live at their `https://` URLs within ~30 seconds.
|
||||
|
||||
#### Updating after a code change
|
||||
|
||||
Pull the latest code and rebuild only the changed service:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.production.yml up --build -d --no-deps <service>
|
||||
# e.g. to redeploy only the API:
|
||||
docker compose -f docker-compose.production.yml up --build -d --no-deps api
|
||||
```
|
||||
|
||||
To rebuild everything:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml up --build -d
|
||||
```
|
||||
|
||||
#### Apply database migrations without downtime
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml run --rm migrate
|
||||
```
|
||||
|
||||
#### View logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
|
||||
# Single service
|
||||
docker compose -f docker-compose.production.yml logs -f api
|
||||
```
|
||||
|
||||
#### Stop the stack
|
||||
|
||||
```bash
|
||||
# Stop containers but keep volumes (data is preserved)
|
||||
docker compose -f docker-compose.production.yml down
|
||||
|
||||
# Stop and delete all data (destructive — irreversible)
|
||||
docker compose -f docker-compose.production.yml down -v
|
||||
```
|
||||
|
||||
#### pgManage (DB admin UI)
|
||||
|
||||
pgManage is available at `https://pgmanage.rentaldrivego.ma`. To connect to the production database, add a connection inside pgManage with:
|
||||
|
||||
- **Host:** `localhost`
|
||||
- **Port:** `5432`
|
||||
- **Database:** `rentaldrivego`
|
||||
- **Username:** `postgres`
|
||||
- **Password:** value of `POSTGRES_PASSWORD` from `.env.docker.production`
|
||||
|
||||
### Notes
|
||||
|
||||
- The production image builds the whole monorepo once, then each service overrides its runtime command.
|
||||
- The dev compose file bind-mounts the repo and keeps `node_modules` in a named volume.
|
||||
- `API_INTERNAL_URL` is used for server-side container-to-container calls, while `NEXT_PUBLIC_API_URL` is used by the browser.
|
||||
- The Dockerfiles activate the repo's pinned `npm@10.5.0` with `corepack` before install so container builds do not depend on the npm version bundled with the base image.
|
||||
- The dev compose stack stores Postgres data in `postgres_dev_data` and the bootstrap marker in `postgres_bootstrap_state`, so `up --build` does not reseed an existing local database.
|
||||
- If you need database schema updates inside Docker, run:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml run --rm migrate
|
||||
```
|
||||
|
||||
If a cached base image still fails during `npm ci`, refresh it and rebuild without cache:
|
||||
|
||||
```bash
|
||||
docker pull node:20-bookworm
|
||||
docker compose -f docker-compose.dev.yml build --no-cache dashboard
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM node:20-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g npm@10.5.0 --no-fund --no-audit
|
||||
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
|
||||
RUN npm ci --no-fund --no-audit
|
||||
|
||||
EXPOSE 3000 3001 3002 3003 4000
|
||||
|
||||
CMD ["sh", "-c", "npm run db:generate && npm run dev"]
|
||||
@@ -0,0 +1,30 @@
|
||||
FROM node:20-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
|
||||
RUN npm install
|
||||
RUN npm run db:generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-bookworm AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
|
||||
COPY --from=builder /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/apps ./apps
|
||||
COPY --from=builder /app/packages ./packages
|
||||
|
||||
EXPOSE 3000 3001 3002 3003 4000
|
||||
|
||||
CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"]
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM node:20-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
|
||||
RUN npm install
|
||||
|
||||
ENV NODE_ENV=test
|
||||
|
||||
CMD ["sh", "-c", "npm run db:generate && npm run type-check && npm run build"]
|
||||
@@ -1,93 +0,0 @@
|
||||
# Car_Management_System
|
||||
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
||||
|
||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
||||
|
||||
## Add your files
|
||||
|
||||
* [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
||||
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
||||
|
||||
```
|
||||
cd existing_repo
|
||||
git remote add origin https://gitlab.rentaldrivego.ma/rental_car/car_management_system.git
|
||||
git branch -M main
|
||||
git push -uf origin main
|
||||
```
|
||||
|
||||
## Integrate with your tools
|
||||
|
||||
* [Set up project integrations](https://gitlab.rentaldrivego.ma/rental_car/car_management_system/-/settings/integrations)
|
||||
|
||||
## Collaborate with your team
|
||||
|
||||
* [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
||||
* [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
||||
* [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
||||
* [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
||||
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
||||
|
||||
## Test and Deploy
|
||||
|
||||
Use the built-in continuous integration in GitLab.
|
||||
|
||||
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
|
||||
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
||||
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
||||
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
||||
* [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
||||
|
||||
***
|
||||
|
||||
# Editing this README
|
||||
|
||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
||||
|
||||
## Suggestions for a good README
|
||||
|
||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
||||
|
||||
## Name
|
||||
Choose a self-explaining name for your project.
|
||||
|
||||
## Description
|
||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
||||
|
||||
## Badges
|
||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
||||
|
||||
## Visuals
|
||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
||||
|
||||
## Installation
|
||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
||||
|
||||
## Usage
|
||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
||||
|
||||
## Support
|
||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
||||
|
||||
## Roadmap
|
||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
||||
|
||||
## Contributing
|
||||
State if you are open to contributions and what your requirements are for accepting them.
|
||||
|
||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
||||
|
||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
||||
|
||||
## Authors and acknowledgment
|
||||
Show your appreciation to those who have contributed to the project.
|
||||
|
||||
## License
|
||||
For open source projects, say how it is licensed.
|
||||
|
||||
## Project status
|
||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,31 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||
const apiUrl = new URL(apiOrigin)
|
||||
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'res.cloudinary.com',
|
||||
},
|
||||
{
|
||||
protocol: apiUrl.protocol.replace(':', ''),
|
||||
hostname: apiUrl.hostname,
|
||||
...(apiUrl.port ? { port: apiUrl.port } : {}),
|
||||
pathname: '/storage/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
transpilePackages: ['@rentaldrivego/types'],
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${apiOrigin}/api/:path*`,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
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,29 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function AuthRedirectPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
||||
const token = params.get('token')
|
||||
const next = params.get('next') || '/dashboard'
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem('admin_token', token)
|
||||
// Clear the token from the URL
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
|
||||
router.replace(next)
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-950">
|
||||
<p className="text-sm text-zinc-400">Redirecting to admin dashboard…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface AdminUser {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
role: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
permissions?: { id: string; resource: string; actions: string[] }[]
|
||||
}
|
||||
|
||||
const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [admins, setAdmins] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
|
||||
async function fetchAdmins() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/admins`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||
setAdmins(json.data ?? [])
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchAdmins() }, [])
|
||||
|
||||
async function createAdmin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/admins`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to create')
|
||||
setShowModal(false)
|
||||
setForm({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
||||
await fetchAdmins()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40',
|
||||
ADMIN: 'text-sky-400 bg-sky-950/40',
|
||||
SUPPORT: 'text-amber-400 bg-amber-950/40',
|
||||
FINANCE: 'text-violet-400 bg-violet-950/40',
|
||||
VIEWER: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Admin Users</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
+ New admin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Name</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Email</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Role</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Permissions</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : admins.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">No admin users found</td></tr>
|
||||
) : admins.map((a) => (
|
||||
<tr key={a.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-zinc-100">{a.firstName} {a.lastName}</td>
|
||||
<td className="px-6 py-4 text-zinc-400">{a.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ROLE_COLORS[a.role] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{a.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs text-zinc-500">
|
||||
{a.permissions && a.permissions.length > 0
|
||||
? a.permissions.map((permission) => permission.resource).join(', ')
|
||||
: 'Role-based only'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${a.isActive ? 'text-emerald-400 bg-emerald-950/40' : 'text-zinc-500 bg-zinc-800'}`}>
|
||||
{a.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-500 text-xs">{new Date(a.createdAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 p-8 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold">New admin user</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-zinc-500 hover:text-zinc-200">✕</button>
|
||||
</div>
|
||||
<form onSubmit={createAdmin} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
|
||||
<input
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Last name</label>
|
||||
<input
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
||||
>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" disabled={creating} className="flex-1 py-2.5 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold disabled:opacity-50">
|
||||
{creating ? 'Creating…' : 'Create admin'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface AuditLog {
|
||||
id: string
|
||||
action: string
|
||||
resource: string
|
||||
resourceId: string | null
|
||||
createdAt: string
|
||||
adminUser: { email: string; firstName: string; lastName: string } | null
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
CREATE: 'text-emerald-400 bg-emerald-950/40',
|
||||
UPDATE: 'text-sky-400 bg-sky-950/40',
|
||||
DELETE: 'text-red-400 bg-red-950/40',
|
||||
SUSPEND: 'text-amber-400 bg-amber-950/40',
|
||||
ACTIVATE: 'text-emerald-400 bg-emerald-950/40',
|
||||
LOGIN: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
export default function AdminAuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
fetch(`${API_BASE}/admin/audit-logs`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setLogs(json.data ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const filtered = filter
|
||||
? logs.filter((l) => l.action.toLowerCase().includes(filter.toLowerCase()) || l.resource.toLowerCase().includes(filter.toLowerCase()))
|
||||
: logs
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Audit Logs</h1>
|
||||
</div>
|
||||
<input
|
||||
className="w-64 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
placeholder="Filter by action or entity…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Action</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Entity</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Entity ID</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Admin</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={5} className="px-6 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-6 py-12 text-center text-zinc-500">No logs found</td></tr>
|
||||
) : filtered.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ACTION_COLORS[log.action] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-zinc-300">{log.resource}</td>
|
||||
<td className="px-6 py-3 text-zinc-500 font-mono text-xs">{log.resourceId ? `${log.resourceId.slice(0, 12)}…` : '—'}</td>
|
||||
<td className="px-6 py-3 text-zinc-400">{log.adminUser ? `${log.adminUser.firstName} ${log.adminUser.lastName}` : '—'}</td>
|
||||
<td className="px-6 py-3 text-zinc-500 text-xs">{new Date(log.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
slug: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
paymentProvider: string
|
||||
amanpayTransactionId?: string | null
|
||||
paypalCaptureId?: string | null
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function buildInvoiceNumber(invoiceId: string, createdAt: string) {
|
||||
const year = new Date(createdAt).getFullYear()
|
||||
const short = invoiceId.slice(-6).toUpperCase()
|
||||
return `INV-${year}-${short}`
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: string
|
||||
companyId: string
|
||||
company: Company
|
||||
plan: string
|
||||
billingPeriod: string
|
||||
status: string
|
||||
currency: string
|
||||
currentPeriodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
invoices: Invoice[]
|
||||
_count: { invoices: number }
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
mrr: number
|
||||
activeCount: number
|
||||
trialingCount: number
|
||||
pastDueCount: number
|
||||
cancelledCount: number
|
||||
}
|
||||
|
||||
const SUB_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||
PAST_DUE: 'text-amber-400 bg-amber-900/30',
|
||||
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||
UNPAID: 'text-red-400 bg-red-900/30',
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_COLORS: Record<string, string> = {
|
||||
PAID: 'text-emerald-400 bg-emerald-900/30',
|
||||
PENDING: 'text-amber-400 bg-amber-900/30',
|
||||
FAILED: 'text-red-400 bg-red-900/30',
|
||||
REFUNDED: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
const PLAN_COLORS: Record<string, string> = {
|
||||
STARTER: 'text-zinc-300',
|
||||
GROWTH: 'text-sky-400',
|
||||
PRO: 'text-violet-400',
|
||||
}
|
||||
|
||||
function fmt(amount: number, currency: string) {
|
||||
return `${(amount / 100).toFixed(2)} ${currency}`
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default function AdminBillingPage() {
|
||||
const { language } = useAdminI18n()
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Billing',
|
||||
eyebrow: 'Platform',
|
||||
mrr: 'MRR (MAD)',
|
||||
active: 'Active',
|
||||
trialing: 'Trialing',
|
||||
pastDue: 'Past Due',
|
||||
cancelled: 'Cancelled',
|
||||
company: 'Company',
|
||||
plan: 'Plan',
|
||||
period: 'Period',
|
||||
status: 'Status',
|
||||
currency: 'Currency',
|
||||
nextRenewal: 'Next Renewal',
|
||||
invoices: 'Invoices',
|
||||
actions: 'Actions',
|
||||
viewInvoices: 'Invoices',
|
||||
loading: 'Loading…',
|
||||
empty: 'No subscriptions found',
|
||||
filterAll: 'All',
|
||||
filterActive: 'Active',
|
||||
filterTrialing: 'Trialing',
|
||||
filterPastDue: 'Past Due',
|
||||
filterCancelled: 'Cancelled',
|
||||
closeModal: 'Close',
|
||||
invoicesFor: 'Invoices for',
|
||||
noInvoices: 'No invoices found',
|
||||
amount: 'Amount',
|
||||
invoiceStatus: 'Status',
|
||||
provider: 'Provider',
|
||||
paidAt: 'Paid At',
|
||||
date: 'Date',
|
||||
invoiceNo: 'Invoice #',
|
||||
download: 'PDF',
|
||||
cancelAtEnd: 'Cancels at period end',
|
||||
},
|
||||
fr: {
|
||||
title: 'Facturation',
|
||||
eyebrow: 'Plateforme',
|
||||
mrr: 'MRR (MAD)',
|
||||
active: 'Actifs',
|
||||
trialing: 'Essai',
|
||||
pastDue: 'En retard',
|
||||
cancelled: 'Annulés',
|
||||
company: 'Entreprise',
|
||||
plan: 'Plan',
|
||||
period: 'Période',
|
||||
status: 'Statut',
|
||||
currency: 'Devise',
|
||||
nextRenewal: 'Prochain renouvellement',
|
||||
invoices: 'Factures',
|
||||
actions: 'Actions',
|
||||
viewInvoices: 'Factures',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucun abonnement trouvé',
|
||||
filterAll: 'Tous',
|
||||
filterActive: 'Actifs',
|
||||
filterTrialing: 'Essai',
|
||||
filterPastDue: 'En retard',
|
||||
filterCancelled: 'Annulés',
|
||||
closeModal: 'Fermer',
|
||||
invoicesFor: 'Factures pour',
|
||||
noInvoices: 'Aucune facture trouvée',
|
||||
amount: 'Montant',
|
||||
invoiceStatus: 'Statut',
|
||||
provider: 'Fournisseur',
|
||||
paidAt: 'Payé le',
|
||||
date: 'Date',
|
||||
invoiceNo: 'N° Facture',
|
||||
download: 'PDF',
|
||||
cancelAtEnd: 'Annulé en fin de période',
|
||||
},
|
||||
ar: {
|
||||
title: 'الفوترة',
|
||||
eyebrow: 'المنصة',
|
||||
mrr: 'الإيراد الشهري (MAD)',
|
||||
active: 'نشط',
|
||||
trialing: 'تجريبي',
|
||||
pastDue: 'متأخر',
|
||||
cancelled: 'ملغى',
|
||||
company: 'الشركة',
|
||||
plan: 'الخطة',
|
||||
period: 'الفترة',
|
||||
status: 'الحالة',
|
||||
currency: 'العملة',
|
||||
nextRenewal: 'التجديد القادم',
|
||||
invoices: 'الفواتير',
|
||||
actions: 'الإجراءات',
|
||||
viewInvoices: 'الفواتير',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لا توجد اشتراكات',
|
||||
filterAll: 'الكل',
|
||||
filterActive: 'نشط',
|
||||
filterTrialing: 'تجريبي',
|
||||
filterPastDue: 'متأخر',
|
||||
filterCancelled: 'ملغى',
|
||||
closeModal: 'إغلاق',
|
||||
invoicesFor: 'فواتير',
|
||||
noInvoices: 'لا توجد فواتير',
|
||||
amount: 'المبلغ',
|
||||
invoiceStatus: 'الحالة',
|
||||
provider: 'المزود',
|
||||
paidAt: 'تاريخ الدفع',
|
||||
date: 'التاريخ',
|
||||
invoiceNo: 'رقم الفاتورة',
|
||||
download: 'PDF',
|
||||
cancelAtEnd: 'يُلغى في نهاية الفترة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
const [invoiceModal, setInvoiceModal] = useState<{ companyId: string; companyName: string } | null>(null)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [invoicesLoading, setInvoicesLoading] = useState(false)
|
||||
const [invoicesError, setInvoicesError] = useState<string | null>(null)
|
||||
|
||||
async function fetchBilling(status?: string) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const params = new URLSearchParams({ pageSize: '50' })
|
||||
if (status) params.set('status', status)
|
||||
const res = await fetch(`${API_BASE}/admin/billing?${params}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
||||
setSubscriptions(json.data ?? [])
|
||||
setStats(json.stats ?? null)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchBilling(statusFilter || undefined) }, [statusFilter])
|
||||
|
||||
async function downloadInvoicePdf(invoiceId: string, createdAt: string) {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/billing/invoices/${invoiceId}/pdf`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to generate PDF')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${buildInvoiceNumber(invoiceId, createdAt)}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
// silent — no toast system available here
|
||||
}
|
||||
}
|
||||
|
||||
async function openInvoices(companyId: string, companyName: string) {
|
||||
setInvoiceModal({ companyId, companyName })
|
||||
setInvoices([])
|
||||
setInvoicesError(null)
|
||||
setInvoicesLoading(true)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/billing/${companyId}/invoices?pageSize=50`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? `Error ${res.status}`)
|
||||
setInvoices(json.data ?? [])
|
||||
} catch (err: any) {
|
||||
setInvoicesError(err.message)
|
||||
} finally {
|
||||
setInvoicesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: copy.filterAll },
|
||||
{ key: 'ACTIVE', label: copy.filterActive },
|
||||
{ key: 'TRIALING', label: copy.filterTrialing },
|
||||
{ key: 'PAST_DUE', label: copy.filterPastDue },
|
||||
{ key: 'CANCELLED', label: copy.filterCancelled },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{copy.eyebrow}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
<div className="col-span-2 sm:col-span-1 panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.mrr}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-emerald-400">{(stats.mrr / 100).toFixed(0)}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.active}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-zinc-100">{stats.activeCount}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.trialing}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-sky-400">{stats.trialingCount}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.pastDue}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-amber-400">{stats.pastDueCount}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.cancelled}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-zinc-400">{stats.cancelledCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setStatusFilter(f.key)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
statusFilter === f.key
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.company}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.plan}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.period}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.status}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.currency}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.nextRenewal}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.invoices}</th>
|
||||
<th className="px-6 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={8} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td></tr>
|
||||
) : subscriptions.length === 0 ? (
|
||||
<tr><td colSpan={8} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td></tr>
|
||||
) : subscriptions.map((sub) => (
|
||||
<tr key={sub.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-zinc-100">{sub.company.name}</p>
|
||||
<p className="text-xs text-zinc-500">{sub.company.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`font-semibold ${PLAN_COLORS[sub.plan] ?? 'text-zinc-300'}`}>{sub.plan}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{sub.billingPeriod}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium w-fit ${SUB_STATUS_COLORS[sub.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{sub.status}
|
||||
</span>
|
||||
{sub.cancelAtPeriodEnd && (
|
||||
<span className="text-xs text-amber-500">{copy.cancelAtEnd}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{sub.currency}</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{fmtDate(sub.currentPeriodEnd)}</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{sub._count.invoices}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => openInvoices(sub.company.id, sub.company.name)}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-xs font-medium text-zinc-200 transition-colors"
|
||||
>
|
||||
{copy.viewInvoices}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invoiceModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-zinc-900 border border-zinc-800 shadow-2xl flex flex-col max-h-[80vh]">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.invoicesFor}</p>
|
||||
<p className="font-semibold text-zinc-100">{invoiceModal.companyName}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setInvoiceModal(null); setInvoicesError(null) }}
|
||||
className="p-2 rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{invoicesLoading ? (
|
||||
<div className="px-6 py-12 text-center text-zinc-500">{copy.loading}</div>
|
||||
) : invoicesError ? (
|
||||
<div className="px-6 py-12 text-center text-red-400 text-sm">{invoicesError}</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-zinc-500">{copy.noInvoices}</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-zinc-900">
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.invoiceNo}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.date}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.amount}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.invoiceStatus}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.provider}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.paidAt}</th>
|
||||
<th className="px-6 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-3 text-zinc-300 text-xs font-mono">{buildInvoiceNumber(inv.id, inv.createdAt)}</td>
|
||||
<td className="px-6 py-3 text-zinc-400 text-xs">{fmtDate(inv.createdAt)}</td>
|
||||
<td className="px-6 py-3 font-medium text-zinc-100">{fmt(inv.amount, inv.currency)}</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS_COLORS[inv.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{inv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-zinc-500 text-xs">{inv.paymentProvider}</td>
|
||||
<td className="px-6 py-3 text-zinc-400 text-xs">{fmtDate(inv.paidAt)}</td>
|
||||
<td className="px-6 py-3">
|
||||
<button
|
||||
onClick={() => downloadInvoicePdf(inv.id, inv.createdAt)}
|
||||
title="Download PDF"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-xs font-medium text-zinc-300 transition-colors"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
{copy.download}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex justify-end">
|
||||
<button
|
||||
onClick={() => { setInvoiceModal(null); setInvoicesError(null) }}
|
||||
className="px-4 py-2 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-sm font-medium text-zinc-200 transition-colors"
|
||||
>
|
||||
{copy.closeModal}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
PUBLIC_HOMEPAGE_GRID_COLUMNS,
|
||||
PUBLIC_HOMEPAGE_GRID_ROWS,
|
||||
getPublicHomepageSectionLimits,
|
||||
type PublicHomepageLayout,
|
||||
type PublicHomepageLayoutItem,
|
||||
type PublicHomepageSectionType,
|
||||
} from '@rentaldrivego/types'
|
||||
|
||||
type Props = {
|
||||
layout: PublicHomepageLayout
|
||||
availableSections: PublicHomepageSectionType[]
|
||||
onChange: (layout: PublicHomepageLayout) => void
|
||||
onAddSection: (type: PublicHomepageSectionType) => void
|
||||
onRemoveSection: (type: PublicHomepageSectionType) => void
|
||||
}
|
||||
|
||||
type Interaction = {
|
||||
itemId: string
|
||||
mode: 'move' | 'resize'
|
||||
startX: number
|
||||
startY: number
|
||||
origin: PublicHomepageLayoutItem
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 52
|
||||
|
||||
const SECTION_META: Record<PublicHomepageSectionType, { label: string; tint: string; description: string }> = {
|
||||
hero: { label: 'Hero', tint: 'from-sky-500 to-cyan-400', description: 'Headline, CTA buttons, and brand media.' },
|
||||
offers: { label: 'Offers', tint: 'from-emerald-500 to-lime-400', description: 'Promotions and campaign cards.' },
|
||||
vehicles: { label: 'Vehicles', tint: 'from-amber-500 to-orange-400', description: 'Published fleet grid.' },
|
||||
pricing: { label: 'Pricing', tint: 'from-fuchsia-500 to-rose-400', description: 'Plans and pricing content.' },
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
export function HomepageLayoutEditor({ layout, availableSections, onChange, onAddSection, onRemoveSection }: Props) {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||
const [interaction, setInteraction] = useState<Interaction | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!interaction) return
|
||||
const snap = interaction
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const colWidth = rect.width / PUBLIC_HOMEPAGE_GRID_COLUMNS
|
||||
const deltaCols = Math.round((event.clientX - snap.startX) / colWidth)
|
||||
const deltaRows = Math.round((event.clientY - snap.startY) / ROW_HEIGHT)
|
||||
const limits = getPublicHomepageSectionLimits(snap.origin.type)
|
||||
|
||||
onChange({
|
||||
items: layout.items.map((item) => {
|
||||
if (item.id !== snap.itemId) return item
|
||||
if (snap.mode === 'move') {
|
||||
return {
|
||||
...item,
|
||||
x: clamp(snap.origin.x + deltaCols, 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.w + 1),
|
||||
y: clamp(snap.origin.y + deltaRows, 1, PUBLIC_HOMEPAGE_GRID_ROWS - snap.origin.h + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const nextW = clamp(
|
||||
snap.origin.w + deltaCols,
|
||||
limits.minW,
|
||||
Math.min(limits.maxW, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.x + 1),
|
||||
)
|
||||
const nextH = clamp(
|
||||
snap.origin.h + deltaRows,
|
||||
limits.minH,
|
||||
Math.min(limits.maxH, PUBLIC_HOMEPAGE_GRID_ROWS - snap.origin.y + 1),
|
||||
)
|
||||
|
||||
return {
|
||||
...item,
|
||||
w: nextW,
|
||||
h: nextH,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
setInteraction(null)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [interaction, layout.items, onChange])
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/70 p-4">
|
||||
<div className="flex flex-col gap-4 border-b border-zinc-800 pb-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-100">Homepage layout</h4>
|
||||
<p className="mt-1 text-xs text-zinc-500">Add sections, drag them anywhere on the grid, and resize them from the bottom-right handle.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableSections.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => onAddSection(type)}
|
||||
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-300 transition hover:bg-emerald-500/20"
|
||||
>
|
||||
Add {SECTION_META[type].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative mt-4 hidden overflow-hidden rounded-[1.5rem] border border-zinc-800 bg-zinc-950 lg:block"
|
||||
style={{
|
||||
height: ROW_HEIGHT * PUBLIC_HOMEPAGE_GRID_ROWS,
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: `${100 / PUBLIC_HOMEPAGE_GRID_COLUMNS}% ${ROW_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{layout.items.map((item) => {
|
||||
const meta = SECTION_META[item.type]
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="absolute overflow-hidden rounded-[1.25rem] border border-white/10 bg-zinc-900/95 shadow-xl shadow-black/30"
|
||||
style={{
|
||||
left: `${((item.x - 1) / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
|
||||
top: `${(item.y - 1) * ROW_HEIGHT}px`,
|
||||
width: `${(item.w / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
|
||||
height: `${item.h * ROW_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
setInteraction({
|
||||
itemId: item.id,
|
||||
mode: 'move',
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
origin: item,
|
||||
})
|
||||
}}
|
||||
className={`flex w-full cursor-grab items-start justify-between bg-gradient-to-r ${meta.tint} px-4 py-3 text-left active:cursor-grabbing`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/75">Drag</p>
|
||||
<p className="mt-1 text-sm font-black text-white">{meta.label}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-black/20 px-2 py-1 text-[11px] font-semibold text-white">
|
||||
{item.w}x{item.h}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex h-[calc(100%-60px)] flex-col justify-between p-4">
|
||||
<p className="max-w-xs text-sm leading-6 text-zinc-300">{meta.description}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-zinc-500">x{item.x} y{item.y}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveSection(item.type)}
|
||||
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300 transition hover:bg-rose-500/20"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Resize ${meta.label}`}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
setInteraction({
|
||||
itemId: item.id,
|
||||
mode: 'resize',
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
origin: item,
|
||||
})
|
||||
}}
|
||||
className="absolute bottom-2 right-2 h-7 w-7 rounded-full border border-white/15 bg-white/10 text-white"
|
||||
>
|
||||
<span className="block rotate-45 text-sm">+</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:hidden">
|
||||
{layout.items.map((item) => {
|
||||
const meta = SECTION_META[item.type]
|
||||
return (
|
||||
<div key={item.id} className="rounded-2xl border border-zinc-800 bg-zinc-900/70 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-100">{meta.label}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">{meta.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveSection(item.type)}
|
||||
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-zinc-500">Desktop drag canvas is available on larger screens.</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
email: string
|
||||
subscription: { plan: string; status: string } | null
|
||||
_count: { employees: number; vehicles: number }
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||
SUSPENDED: 'text-red-400 bg-red-900/30',
|
||||
PENDING: 'text-amber-400 bg-amber-900/30',
|
||||
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
export default function AdminCompaniesPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Companies',
|
||||
search: 'Search by name or slug…',
|
||||
loading: 'Loading…',
|
||||
empty: 'No companies found',
|
||||
company: 'Company',
|
||||
slug: 'Slug',
|
||||
status: 'Status',
|
||||
plan: 'Plan',
|
||||
fleet: 'Fleet',
|
||||
vehicles: 'vehicles',
|
||||
view: 'View',
|
||||
suspend: 'Suspend',
|
||||
reactivate: 'Reactivate',
|
||||
},
|
||||
fr: {
|
||||
title: 'Entreprises',
|
||||
search: 'Rechercher par nom ou slug…',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucune entreprise trouvée',
|
||||
company: 'Entreprise',
|
||||
slug: 'Slug',
|
||||
status: 'Statut',
|
||||
plan: 'Plan',
|
||||
fleet: 'Flotte',
|
||||
vehicles: 'véhicules',
|
||||
view: 'Voir',
|
||||
suspend: 'Suspendre',
|
||||
reactivate: 'Réactiver',
|
||||
},
|
||||
ar: {
|
||||
title: 'الشركات',
|
||||
search: 'ابحث بالاسم أو الـ slug…',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لم يتم العثور على شركات',
|
||||
company: 'الشركة',
|
||||
slug: 'Slug',
|
||||
status: 'الحالة',
|
||||
plan: 'الخطة',
|
||||
fleet: 'الأسطول',
|
||||
vehicles: 'سيارات',
|
||||
view: 'عرض',
|
||||
suspend: 'تعليق',
|
||||
reactivate: 'إعادة التفعيل',
|
||||
},
|
||||
}[language]
|
||||
const [companies, setCompanies] = useState<Company[]>([])
|
||||
const [filtered, setFiltered] = useState<Company[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actioning, setActioning] = useState<string | null>(null)
|
||||
|
||||
async function fetchCompanies() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/companies`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
||||
setCompanies(json.data ?? [])
|
||||
setFiltered(json.data ?? [])
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchCompanies() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
const q = search.toLowerCase()
|
||||
setFiltered(companies.filter((c) => c.name.toLowerCase().includes(q) || c.slug.includes(q) || c.email.toLowerCase().includes(q)))
|
||||
}, [search, companies])
|
||||
|
||||
async function changeStatus(id: string, status: string) {
|
||||
setActioning(id)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
await fetchCompanies()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setActioning(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.platform}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
|
||||
</div>
|
||||
<input
|
||||
className="w-64 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
placeholder={copy.search}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.company}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.slug}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.status}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.plan}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.fleet}</th>
|
||||
<th className="px-6 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td></tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td></tr>
|
||||
) : filtered.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-zinc-100">{c.name}</p>
|
||||
<p className="text-xs text-zinc-500">{c.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400 font-mono text-xs">{c.slug}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[c.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400">{c.subscription?.plan ?? '—'}</td>
|
||||
<td className="px-6 py-4 text-zinc-400">{c._count?.vehicles ?? 0} {copy.vehicles}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Link href={`/dashboard/companies/${c.id}`} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-xs font-medium text-zinc-200 transition-colors">
|
||||
{copy.view}
|
||||
</Link>
|
||||
{c.status === 'ACTIVE' || c.status === 'TRIALING' ? (
|
||||
<button
|
||||
onClick={() => changeStatus(c.id, 'SUSPENDED')}
|
||||
disabled={actioning === c.id}
|
||||
className="px-3 py-1.5 rounded-lg bg-red-950/50 hover:bg-red-900/50 text-xs font-medium text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{copy.suspend}
|
||||
</button>
|
||||
) : c.status === 'SUSPENDED' ? (
|
||||
<button
|
||||
onClick={() => changeStatus(c.id, 'ACTIVE')}
|
||||
disabled={actioning === c.id}
|
||||
className="px-3 py-1.5 rounded-lg bg-emerald-950/50 hover:bg-emerald-900/50 text-xs font-medium text-emerald-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{copy.reactivate}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
|
||||
|
||||
interface CompanyContainer {
|
||||
id: string
|
||||
companyId: string
|
||||
dockerId: string | null
|
||||
containerName: string
|
||||
status: ContainerStatus
|
||||
port: number
|
||||
image: string
|
||||
errorMessage: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
company: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : ''
|
||||
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ContainerStatus }) {
|
||||
const map: Record<ContainerStatus, { label: string; className: string }> = {
|
||||
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
|
||||
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
|
||||
RUNNING: { label: 'Running', className: 'bg-emerald-900 text-emerald-300' },
|
||||
STOPPED: { label: 'Stopped', className: 'bg-yellow-900 text-yellow-300' },
|
||||
RESTARTING: { label: 'Restarting…',className: 'bg-orange-900 text-orange-300 animate-pulse' },
|
||||
REMOVING: { label: 'Removing…', className: 'bg-red-900 text-red-300 animate-pulse' },
|
||||
ERROR: { label: 'Error', className: 'bg-red-950 text-red-400' },
|
||||
}
|
||||
const { label, className } = map[status] ?? map.ERROR
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${status === 'RUNNING' ? 'bg-emerald-400' : 'bg-current opacity-60'}`} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LogsModal({ companyId, companyName, onClose }: { companyId: string; companyName: string; onClose: () => void }) {
|
||||
const [logs, setLogs] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tail, setTail] = useState(150)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const fetchLogs = useCallback(async (lines: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders() })
|
||||
const json = await res.json()
|
||||
setLogs(json.data?.logs ?? '')
|
||||
} catch {
|
||||
setLogs('Failed to fetch logs.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [companyId])
|
||||
|
||||
useEffect(() => { fetchLogs(tail) }, [fetchLogs, tail])
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-100">Container Logs</p>
|
||||
<p className="text-xs text-zinc-400">{companyName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={tail}
|
||||
onChange={(e) => setTail(Number(e.target.value))}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-200 focus:outline-none"
|
||||
>
|
||||
<option value={50}>Last 50 lines</option>
|
||||
<option value={150}>Last 150 lines</option>
|
||||
<option value={500}>Last 500 lines</option>
|
||||
<option value={1000}>Last 1000 lines</option>
|
||||
</select>
|
||||
<button onClick={() => fetchLogs(tail)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
||||
Refresh
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<p className="text-xs text-zinc-500">Loading…</p>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-relaxed text-zinc-300">
|
||||
{logs || 'No logs available.'}
|
||||
</pre>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ProvisionResult = { companyId: string; name: string; status: 'created' | 'error'; error?: string }
|
||||
|
||||
export default function ContainersPage() {
|
||||
const [containers, setContainers] = useState<CompanyContainer[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
const [logsFor, setLogsFor] = useState<{ companyId: string; companyName: string } | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [provisioning, setProvisioning] = useState(false)
|
||||
const [provisionResults, setProvisionResults] = useState<ProvisionResult[] | null>(null)
|
||||
|
||||
const fetchContainers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers`, { headers: authHeaders() })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? 'Failed to load containers.')
|
||||
}
|
||||
setContainers(json.data ?? [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load containers.')
|
||||
/* silent — keep old data */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchContainers()
|
||||
const id = setInterval(fetchContainers, 8000)
|
||||
return () => clearInterval(id)
|
||||
}, [fetchContainers])
|
||||
|
||||
async function provisionAll() {
|
||||
setProvisioning(true)
|
||||
setProvisionResults(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders() })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
|
||||
setProvisionResults(json.data?.results ?? [])
|
||||
setError(null)
|
||||
await fetchContainers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Provisioning failed.')
|
||||
} finally {
|
||||
setProvisioning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function act(companyId: string, action: 'start' | 'stop' | 'restart' | 'deploy' | 'remove') {
|
||||
setBusy((b) => ({ ...b, [companyId]: true }))
|
||||
try {
|
||||
const method = action === 'remove' ? 'DELETE' : 'POST'
|
||||
const url =
|
||||
action === 'remove'
|
||||
? `${API_BASE}/admin/containers/${companyId}`
|
||||
: `${API_BASE}/admin/containers/${companyId}/${action}`
|
||||
const res = await fetch(url, { method, headers: authHeaders() })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? `Failed to ${action} container.`)
|
||||
}
|
||||
setError(null)
|
||||
await fetchContainers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to ${action} container.`)
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [companyId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = containers.filter(
|
||||
(c) =>
|
||||
c.company.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.containerName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.company.slug.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
|
||||
const stats = {
|
||||
running: containers.filter((c) => c.status === 'RUNNING').length,
|
||||
stopped: containers.filter((c) => c.status === 'STOPPED').length,
|
||||
error: containers.filter((c) => c.status === 'ERROR').length,
|
||||
total: containers.length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-8">
|
||||
{logsFor && (
|
||||
<LogsModal
|
||||
companyId={logsFor.companyId}
|
||||
companyName={logsFor.companyName}
|
||||
onClose={() => setLogsFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Containers</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">Manage isolated Docker Compose services for each company workspace.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={provisionAll}
|
||||
disabled={provisioning}
|
||||
className="flex items-center gap-2 rounded-xl bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{provisioning ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
Provisioning…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
Provision All Accounts
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-xl border border-red-900 bg-red-950/70 px-4 py-3 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provisionResults !== null && (
|
||||
<div className="mb-6 rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-zinc-200">
|
||||
Provisioning complete —{' '}
|
||||
<span className="text-emerald-400">{provisionResults.filter((r) => r.status === 'created').length} created</span>
|
||||
{provisionResults.some((r) => r.status === 'error') && (
|
||||
<>, <span className="text-red-400">{provisionResults.filter((r) => r.status === 'error').length} failed</span></>
|
||||
)}
|
||||
</p>
|
||||
<button onClick={() => setProvisionResults(null)} className="text-xs text-zinc-500 hover:text-zinc-300">Dismiss</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{provisionResults.map((r) => (
|
||||
<div key={r.companyId} className="flex items-center gap-3 rounded-lg px-3 py-2 bg-zinc-800/60">
|
||||
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${r.status === 'created' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
||||
<span className="text-sm text-zinc-300 flex-1">{r.name}</span>
|
||||
{r.status === 'error' && <span className="text-xs text-red-400 truncate max-w-xs">{r.error}</span>}
|
||||
{r.status === 'created' && <span className="text-xs text-emerald-500">Service created</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mb-6 grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'text-zinc-100' },
|
||||
{ label: 'Running', value: stats.running, color: 'text-emerald-400' },
|
||||
{ label: 'Stopped', value: stats.stopped, color: 'text-yellow-400' },
|
||||
{ label: 'Error', value: stats.error, color: 'text-red-400' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
||||
<p className="text-xs text-zinc-500">{s.label}</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by company name or container…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full max-w-sm rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-zinc-500">
|
||||
{search ? 'No containers match your search.' : 'No containers yet. They are created automatically on company signup.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-left text-xs text-zinc-500">
|
||||
<th className="px-5 py-3 font-medium">Company</th>
|
||||
<th className="px-5 py-3 font-medium">Container</th>
|
||||
<th className="px-5 py-3 font-medium">Status</th>
|
||||
<th className="px-5 py-3 font-medium">Port</th>
|
||||
<th className="px-5 py-3 font-medium">Image</th>
|
||||
<th className="px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{filtered.map((c) => {
|
||||
const isBusy = busy[c.companyId] ?? false
|
||||
const isRunning = c.status === 'RUNNING'
|
||||
const isStopped = c.status === 'STOPPED' || c.status === 'ERROR'
|
||||
const isTransitioning = ['CREATING', 'RESTARTING', 'REMOVING'].includes(c.status)
|
||||
|
||||
return (
|
||||
<tr key={c.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-5 py-4">
|
||||
<p className="font-medium text-zinc-100">{c.company.name}</p>
|
||||
<p className="text-xs text-zinc-500">{c.company.slug}</p>
|
||||
{c.errorMessage && (
|
||||
<p className="mt-1 text-xs text-red-400" title={c.errorMessage}>
|
||||
{c.errorMessage.slice(0, 60)}{c.errorMessage.length > 60 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-400">
|
||||
{c.containerName}
|
||||
{c.dockerId && (
|
||||
<p className="mt-0.5 text-zinc-600">{c.dockerId.slice(0, 12)}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<StatusBadge status={c.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-400">:{c.port}</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-500">{c.image}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStopped && (
|
||||
<ActionButton
|
||||
label="Start"
|
||||
color="emerald"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'start')}
|
||||
/>
|
||||
)}
|
||||
{isRunning && (
|
||||
<ActionButton
|
||||
label="Stop"
|
||||
color="yellow"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'stop')}
|
||||
/>
|
||||
)}
|
||||
{(isRunning || isStopped) && (
|
||||
<ActionButton
|
||||
label="Restart"
|
||||
color="blue"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'restart')}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
label="Redeploy"
|
||||
color="purple"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'deploy')}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Logs"
|
||||
color="zinc"
|
||||
disabled={isBusy || !c.dockerId}
|
||||
onClick={() => setLogsFor({ companyId: c.companyId, companyName: c.company.name })}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Remove"
|
||||
color="red"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => {
|
||||
if (confirm(`Remove container for ${c.company.name}? This cannot be undone.`)) {
|
||||
act(c.companyId, 'remove')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
color,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
color: 'emerald' | 'yellow' | 'blue' | 'purple' | 'zinc' | 'red'
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
emerald: 'border-emerald-800 text-emerald-400 hover:bg-emerald-900/40',
|
||||
yellow: 'border-yellow-800 text-yellow-400 hover:bg-yellow-900/40',
|
||||
blue: 'border-blue-800 text-blue-400 hover:bg-blue-900/40',
|
||||
purple: 'border-purple-800 text-purple-400 hover:bg-purple-900/40',
|
||||
zinc: 'border-zinc-700 text-zinc-400 hover:bg-zinc-700/40',
|
||||
red: 'border-red-900 text-red-400 hover:bg-red-900/30',
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${colorMap[color]}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
function buildUnifiedLoginUrl(nextPath: string) {
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
|
||||
const params = new URLSearchParams({
|
||||
portal: 'admin',
|
||||
next: nextPath || '/dashboard',
|
||||
})
|
||||
return `${dashboardUrl}/sign-in?${params.toString()}`
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/dashboard', key: 'overview', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||
{ href: '/dashboard/companies', key: 'companies', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' },
|
||||
{ href: '/dashboard/site-config', key: 'siteConfig', icon: 'M4.5 12a7.5 7.5 0 1015 0 7.5 7.5 0 00-15 0zm7.5-4.5v4.5l3 3' },
|
||||
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
|
||||
{ href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
|
||||
]
|
||||
|
||||
export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { dict } = useAdminI18n()
|
||||
const pathname = usePathname()
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
} else {
|
||||
setReady(true)
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('admin_token')
|
||||
window.location.href = buildUnifiedLoginUrl('/dashboard')
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-zinc-950">
|
||||
<div className="h-6 w-6 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" aria-label={dict.loading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-zinc-950 text-zinc-100 transition-colors">
|
||||
<aside className="w-60 flex-shrink-0 flex flex-col border-r border-zinc-800 bg-zinc-900 transition-colors">
|
||||
<Link href="/" className="block px-5 py-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-zinc-100">RentalDriveGo</p>
|
||||
</Link>
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{navLinks.map((link) => {
|
||||
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
|
||||
</svg>
|
||||
{dict.nav[link.key]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="px-3 py-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-zinc-500 transition-colors hover:bg-zinc-800/50 hover:text-red-400"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{dict.logout}
|
||||
</button>
|
||||
<div className="mt-4 flex flex-col items-center gap-3 border-t border-zinc-800 pt-4">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto bg-zinc-950 transition-colors">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface Metrics {
|
||||
totalCompanies: number
|
||||
activeCompanies: number
|
||||
totalRenters: number
|
||||
totalReservations: number
|
||||
mrr?: number
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
|
||||
platformOverview: 'Platform overview',
|
||||
cards: [
|
||||
['Companies', 'List, search, suspend, reactivate, and review subscription state.', 'View all →'],
|
||||
['Renters', 'Support flows for blocking and unblocking marketplace renters.', 'View all →'],
|
||||
['Audit logs', 'Full trace of every admin action taken on the platform.', 'View logs →'],
|
||||
],
|
||||
},
|
||||
fr: {
|
||||
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
||||
platformOverview: 'Vue plateforme',
|
||||
cards: [
|
||||
['Entreprises', 'Lister, rechercher, suspendre, réactiver et revoir l’état des abonnements.', 'Voir tout →'],
|
||||
['Locataires', 'Flux de support pour bloquer et débloquer les locataires marketplace.', 'Voir tout →'],
|
||||
['Journaux d’audit', 'Trace complète de chaque action admin sur la plateforme.', 'Voir les journaux →'],
|
||||
],
|
||||
},
|
||||
ar: {
|
||||
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
||||
platformOverview: 'نظرة عامة على المنصة',
|
||||
cards: [
|
||||
['الشركات', 'اعرض وابحث وعلّق وأعد التفعيل وراجع حالة الاشتراك.', 'عرض الكل ←'],
|
||||
['المستأجرون', 'مسارات دعم لحظر وفك حظر مستأجري السوق.', 'عرض الكل ←'],
|
||||
['سجلات التدقيق', 'تتبع كامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'],
|
||||
],
|
||||
},
|
||||
}[language]
|
||||
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
fetch(`${API_BASE}/admin/metrics`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setMetrics(json.data))
|
||||
.catch(() => null)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const kpis = metrics
|
||||
? [
|
||||
{ label: 'Total companies', value: metrics.totalCompanies },
|
||||
{ label: copy.kpis[1], value: metrics.activeCompanies },
|
||||
{ label: copy.kpis[2], value: metrics.totalRenters },
|
||||
{ label: copy.kpis[3], value: metrics.totalReservations },
|
||||
]
|
||||
: []
|
||||
if (kpis.length > 0) kpis[0].label = copy.kpis[0]
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-8">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.platformOverview}</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{loading
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="panel p-6 animate-pulse">
|
||||
<div className="h-3 w-24 rounded bg-zinc-800" />
|
||||
<div className="mt-3 h-8 w-16 rounded bg-zinc-800" />
|
||||
</div>
|
||||
))
|
||||
: kpis.map((kpi) => (
|
||||
<div key={kpi.label} className="panel p-6">
|
||||
<p className="text-xs text-zinc-500">{kpi.label}</p>
|
||||
<p className="mt-1 text-3xl font-black">{kpi.value?.toLocaleString() ?? '—'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[
|
||||
['/dashboard/companies', ...copy.cards[0]],
|
||||
['/dashboard/renters', ...copy.cards[1]],
|
||||
['/dashboard/audit-logs', ...copy.cards[2]],
|
||||
].map(([href, title, body, cta]) => (
|
||||
<Link key={href} href={href} className="panel p-6 hover:border-zinc-700 transition-colors block">
|
||||
<p className="text-sm font-semibold text-zinc-200">{title}</p>
|
||||
<p className="mt-2 text-sm text-zinc-500">{body}</p>
|
||||
<p className="mt-4 text-xs text-emerald-400 font-medium">{cta}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface Renter {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
isBlocked: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function AdminRentersPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Renters',
|
||||
search: 'Search renters…',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
status: 'Status',
|
||||
joined: 'Joined',
|
||||
loading: 'Loading…',
|
||||
empty: 'No renters found',
|
||||
blocked: 'Blocked',
|
||||
active: 'Active',
|
||||
unblock: 'Unblock',
|
||||
block: 'Block',
|
||||
},
|
||||
fr: {
|
||||
title: 'Locataires',
|
||||
search: 'Rechercher des locataires…',
|
||||
name: 'Nom',
|
||||
email: 'Email',
|
||||
phone: 'Téléphone',
|
||||
status: 'Statut',
|
||||
joined: 'Inscrit',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucun locataire trouvé',
|
||||
blocked: 'Bloqué',
|
||||
active: 'Actif',
|
||||
unblock: 'Débloquer',
|
||||
block: 'Bloquer',
|
||||
},
|
||||
ar: {
|
||||
title: 'المستأجرون',
|
||||
search: 'ابحث عن مستأجرين…',
|
||||
name: 'الاسم',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
status: 'الحالة',
|
||||
joined: 'تاريخ الانضمام',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لم يتم العثور على مستأجرين',
|
||||
blocked: 'محظور',
|
||||
active: 'نشط',
|
||||
unblock: 'فك الحظر',
|
||||
block: 'حظر',
|
||||
},
|
||||
}[language]
|
||||
const [renters, setRenters] = useState<Renter[]>([])
|
||||
const [filtered, setFiltered] = useState<Renter[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actioning, setActioning] = useState<string | null>(null)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
|
||||
async function fetchRenters() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/renters`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||
setRenters(json.data ?? [])
|
||||
setFiltered(json.data ?? [])
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchRenters() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
const q = search.toLowerCase()
|
||||
setFiltered(renters.filter((r) =>
|
||||
`${r.firstName} ${r.lastName}`.toLowerCase().includes(q) || r.email.toLowerCase().includes(q)
|
||||
))
|
||||
}, [search, renters])
|
||||
|
||||
async function toggleBlock(id: string, isBlocked: boolean) {
|
||||
setActioning(id)
|
||||
try {
|
||||
const endpoint = isBlocked ? 'unblock' : 'block'
|
||||
const res = await fetch(`${API_BASE}/admin/renters/${id}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
await fetchRenters()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setActioning(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.platform}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
|
||||
</div>
|
||||
<input
|
||||
className="w-64 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
placeholder={copy.search}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.name}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.email}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.phone}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.status}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.joined}</th>
|
||||
<th className="px-6 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td></tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td></tr>
|
||||
) : filtered.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-zinc-100">{r.firstName} {r.lastName}</td>
|
||||
<td className="px-6 py-4 text-zinc-400">{r.email}</td>
|
||||
<td className="px-6 py-4 text-zinc-400">{r.phone ?? '—'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
r.isBlocked ? 'text-red-400 bg-red-950/40' : 'text-emerald-400 bg-emerald-950/40'
|
||||
}`}>
|
||||
{r.isBlocked ? copy.blocked : copy.active}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-500 text-xs">{new Date(r.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => toggleBlock(r.id, r.isBlocked)}
|
||||
disabled={actioning === r.id}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
r.isBlocked
|
||||
? 'bg-emerald-950/50 hover:bg-emerald-900/50 text-emerald-400'
|
||||
: 'bg-red-950/50 hover:bg-red-900/50 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{r.isBlocked ? copy.unblock : copy.block}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
cloneMarketplaceHomepageContent,
|
||||
resolveMarketplaceHomepageSections,
|
||||
type MarketplaceHomepageConfig,
|
||||
type MarketplaceHomepageContent,
|
||||
type MarketplaceHomepageSectionType,
|
||||
type MarketplaceLanguage,
|
||||
} from '@rentaldrivego/types'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
email: string
|
||||
brand: {
|
||||
displayName: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||
SUSPENDED: 'text-red-400 bg-red-900/30',
|
||||
PENDING: 'text-amber-400 bg-amber-900/30',
|
||||
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500'
|
||||
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
|
||||
|
||||
function cloneHomepageContent(content: MarketplaceHomepageConfig) {
|
||||
const cloned = JSON.parse(JSON.stringify(content)) as MarketplaceHomepageConfig
|
||||
;(['en', 'fr', 'ar'] as MarketplaceLanguage[]).forEach((language) => {
|
||||
cloned[language].sections = resolveMarketplaceHomepageSections(cloned[language].sections)
|
||||
})
|
||||
return cloned
|
||||
}
|
||||
|
||||
const HOMEPAGE_SECTIONS: MarketplaceHomepageSectionType[] = [
|
||||
'hero',
|
||||
'surface',
|
||||
'pillars',
|
||||
'audiences',
|
||||
'features',
|
||||
'steps',
|
||||
'closing',
|
||||
]
|
||||
|
||||
export default function AdminSiteConfigPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Site configuration',
|
||||
description: 'Edit the main marketplace homepage here, or jump into a company to manage its branded public homepage and menu.',
|
||||
homepageTitle: 'Main website homepage',
|
||||
homepageDescription: 'This controls the marketplace homepage shown on the main RentalDriveGo website.',
|
||||
saveHomepage: 'Save homepage',
|
||||
savingHomepage: 'Saving…',
|
||||
homepageSaved: 'Marketplace homepage saved.',
|
||||
search: 'Search by company or slug…',
|
||||
loading: 'Loading…',
|
||||
empty: 'No companies found',
|
||||
company: 'Company',
|
||||
slug: 'Slug',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
configure: 'Configure site',
|
||||
manageCompany: 'Open company',
|
||||
companiesTitle: 'Company branded sites',
|
||||
languageLabel: 'Language',
|
||||
hero: 'Hero',
|
||||
surface: 'Surface',
|
||||
companySection: 'Operator card',
|
||||
renterSection: 'Renter card',
|
||||
valueSection: 'Value blocks',
|
||||
featureSection: 'Feature list',
|
||||
stepsSection: 'Launch steps',
|
||||
closingSection: 'Closing call to action',
|
||||
previewTitle: 'Live preview',
|
||||
previewDescription: 'Draft changes appear here before you save them.',
|
||||
homepageSections: 'Homepage sections',
|
||||
homepageSectionsDescription: 'Add or remove blocks from the main marketplace homepage.',
|
||||
addSection: 'Add',
|
||||
removeSection: 'Remove',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
},
|
||||
fr: {
|
||||
title: 'Configuration du site',
|
||||
description: 'Modifiez ici la homepage principale de la marketplace, ou ouvrez une entreprise pour gérer sa homepage publique et son menu.',
|
||||
homepageTitle: 'Homepage du site principal',
|
||||
homepageDescription: 'Cette section contrôle la homepage marketplace affichée sur le site principal RentalDriveGo.',
|
||||
saveHomepage: 'Enregistrer la homepage',
|
||||
savingHomepage: 'Enregistrement…',
|
||||
homepageSaved: 'Homepage marketplace enregistrée.',
|
||||
search: 'Rechercher par entreprise ou slug…',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucune entreprise trouvée',
|
||||
company: 'Entreprise',
|
||||
slug: 'Slug',
|
||||
status: 'Statut',
|
||||
actions: 'Actions',
|
||||
configure: 'Configurer le site',
|
||||
manageCompany: 'Ouvrir l’entreprise',
|
||||
companiesTitle: 'Sites de marque des entreprises',
|
||||
languageLabel: 'Langue',
|
||||
hero: 'Hero',
|
||||
surface: 'Bloc marketplace',
|
||||
companySection: 'Bloc opérateur',
|
||||
renterSection: 'Bloc client',
|
||||
valueSection: 'Blocs de valeur',
|
||||
featureSection: 'Liste des fonctionnalités',
|
||||
stepsSection: 'Étapes de lancement',
|
||||
closingSection: 'Appel à l’action final',
|
||||
previewTitle: 'Aperçu en direct',
|
||||
previewDescription: 'Les brouillons apparaissent ici avant l’enregistrement.',
|
||||
homepageSections: 'Sections de la homepage',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la homepage marketplace principale.',
|
||||
addSection: 'Ajouter',
|
||||
removeSection: 'Retirer',
|
||||
expand: 'Ouvrir',
|
||||
collapse: 'Réduire',
|
||||
},
|
||||
ar: {
|
||||
title: 'إعدادات الموقع',
|
||||
description: 'عدّل الصفحة الرئيسية للموقع الرئيسي هنا، أو افتح شركة لإدارة صفحتها العامة وقائمة التنقل الخاصة بها.',
|
||||
homepageTitle: 'الصفحة الرئيسية للموقع الرئيسي',
|
||||
homepageDescription: 'هذا القسم يتحكم في الصفحة الرئيسية للمنصة على موقع RentalDriveGo الرئيسي.',
|
||||
saveHomepage: 'حفظ الصفحة الرئيسية',
|
||||
savingHomepage: 'جارٍ الحفظ…',
|
||||
homepageSaved: 'تم حفظ الصفحة الرئيسية للمنصة.',
|
||||
search: 'ابحث باسم الشركة أو slug…',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لم يتم العثور على شركات',
|
||||
company: 'الشركة',
|
||||
slug: 'Slug',
|
||||
status: 'الحالة',
|
||||
actions: 'الإجراءات',
|
||||
configure: 'إعداد الموقع',
|
||||
manageCompany: 'فتح الشركة',
|
||||
companiesTitle: 'المواقع العامة للشركات',
|
||||
languageLabel: 'اللغة',
|
||||
hero: 'البطاقة الرئيسية',
|
||||
surface: 'قسم المنصة',
|
||||
companySection: 'بطاقة المشغل',
|
||||
renterSection: 'بطاقة المستأجر',
|
||||
valueSection: 'عناصر القيمة',
|
||||
featureSection: 'قائمة الميزات',
|
||||
stepsSection: 'خطوات الإطلاق',
|
||||
closingSection: 'الدعوة الختامية',
|
||||
previewTitle: 'معاينة مباشرة',
|
||||
previewDescription: 'تظهر التغييرات هنا قبل الحفظ.',
|
||||
homepageSections: 'أقسام الصفحة الرئيسية',
|
||||
homepageSectionsDescription: 'أضف أو احذف كتل الصفحة الرئيسية للمنصة.',
|
||||
addSection: 'إضافة',
|
||||
removeSection: 'إزالة',
|
||||
expand: 'توسيع',
|
||||
collapse: 'طي',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [companies, setCompanies] = useState<Company[]>([])
|
||||
const [filtered, setFiltered] = useState<Company[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [homepage, setHomepage] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||
const [homepageLanguage, setHomepageLanguage] = useState<MarketplaceLanguage>(language)
|
||||
const [homepageSaving, setHomepageSaving] = useState(false)
|
||||
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
|
||||
const [homepageExpanded, setHomepageExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setHomepageLanguage(language)
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const [companiesRes, homepageRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/admin/companies`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
cache: 'no-store',
|
||||
}),
|
||||
fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
cache: 'no-store',
|
||||
}),
|
||||
])
|
||||
|
||||
const companiesJson = await companiesRes.json()
|
||||
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
|
||||
setCompanies(companiesJson.data ?? [])
|
||||
setFiltered(companiesJson.data ?? [])
|
||||
|
||||
const homepageJson = await homepageRes.json()
|
||||
if (homepageRes.ok && homepageJson?.data) {
|
||||
setHomepage(cloneHomepageContent(homepageJson.data as MarketplaceHomepageConfig))
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const q = search.toLowerCase()
|
||||
setFiltered(
|
||||
companies.filter((company) =>
|
||||
company.name.toLowerCase().includes(q)
|
||||
|| company.slug.toLowerCase().includes(q)
|
||||
|| company.email.toLowerCase().includes(q),
|
||||
),
|
||||
)
|
||||
}, [search, companies])
|
||||
|
||||
function updateHomepageContent(patch: Partial<MarketplaceHomepageContent>) {
|
||||
setHomepage((current) => ({
|
||||
...current,
|
||||
[homepageLanguage]: {
|
||||
...current[homepageLanguage],
|
||||
...patch,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function updateMetric(index: number, label: string) {
|
||||
const metrics = homepage[homepageLanguage].metrics.map((metric, metricIndex) => (
|
||||
metricIndex === index ? { ...metric, label } : metric
|
||||
))
|
||||
updateHomepageContent({ metrics })
|
||||
}
|
||||
|
||||
function updatePillar(index: number, patch: { title?: string; body?: string }) {
|
||||
const pillars = homepage[homepageLanguage].pillars.map((pillar, pillarIndex) => (
|
||||
pillarIndex === index ? { ...pillar, ...patch } : pillar
|
||||
))
|
||||
updateHomepageContent({ pillars })
|
||||
}
|
||||
|
||||
function updateStep(index: number, patch: { title?: string; body?: string }) {
|
||||
const steps = homepage[homepageLanguage].steps.map((step, stepIndex) => (
|
||||
stepIndex === index ? { ...step, ...patch } : step
|
||||
))
|
||||
updateHomepageContent({ steps })
|
||||
}
|
||||
|
||||
function getActiveSections() {
|
||||
return resolveMarketplaceHomepageSections(activeContent.sections)
|
||||
}
|
||||
|
||||
function addHomepageSection(section: MarketplaceHomepageSectionType) {
|
||||
const sections = getActiveSections()
|
||||
if (sections.includes(section)) return
|
||||
updateHomepageContent({ sections: [...sections, section] })
|
||||
}
|
||||
|
||||
function removeHomepageSection(section: MarketplaceHomepageSectionType) {
|
||||
const sections = getActiveSections().filter((item) => item !== section)
|
||||
if (sections.length === 0) return
|
||||
updateHomepageContent({ sections })
|
||||
}
|
||||
|
||||
async function saveHomepage() {
|
||||
setHomepageSaving(true)
|
||||
setHomepageMessage(null)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ homepage }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
|
||||
setHomepage(cloneHomepageContent(json.data as MarketplaceHomepageConfig))
|
||||
setHomepageMessage(copy.homepageSaved)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setHomepageSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const activeContent = homepage[homepageLanguage]
|
||||
const activeSections = getActiveSections()
|
||||
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
|
||||
const sectionLabels: Record<MarketplaceHomepageSectionType, string> = {
|
||||
hero: copy.hero,
|
||||
surface: copy.surface,
|
||||
pillars: copy.valueSection,
|
||||
audiences: `${copy.companySection} / ${copy.renterSection}`,
|
||||
features: copy.featureSection,
|
||||
steps: copy.stepsSection,
|
||||
closing: copy.closingSection,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell space-y-6 py-8">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.platform}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-zinc-500">{copy.description}</p>
|
||||
</div>
|
||||
<input
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 xl:w-72"
|
||||
placeholder={copy.search}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <div className="panel p-4 text-sm text-red-400">{error}</div> : null}
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-100">{copy.homepageTitle}</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">{copy.homepageDescription}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
|
||||
{(['en', 'fr', 'ar'] as MarketplaceLanguage[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setHomepageLanguage(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
homepageLanguage === value ? 'bg-emerald-500 text-white' : 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHomepageExpanded((current) => !current)}
|
||||
className="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-zinc-200 transition hover:border-zinc-600 hover:bg-zinc-800"
|
||||
>
|
||||
{homepageExpanded ? copy.collapse : copy.expand}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{homepageExpanded ? (
|
||||
<>
|
||||
{homepageMessage ? <div className="mt-4 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">{homepageMessage}</div> : null}
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-zinc-800 bg-zinc-950/60 p-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">{copy.homepageSections}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">{copy.homepageSectionsDescription}</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{activeSections.map((section) => (
|
||||
<div key={section} className="inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-zinc-200">
|
||||
<span>{sectionLabels[section]}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHomepageSection(section)}
|
||||
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-rose-300"
|
||||
>
|
||||
{copy.removeSection}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{availableSections.length ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{availableSections.map((section) => (
|
||||
<button
|
||||
key={section}
|
||||
type="button"
|
||||
onClick={() => addHomepageSection(section)}
|
||||
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-300 transition hover:bg-emerald-500/20"
|
||||
>
|
||||
{copy.addSection} {sectionLabels[section]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[1.75rem] border border-zinc-800 bg-zinc-950/60 p-4">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-zinc-800 pb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-100">{copy.previewTitle}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">{copy.previewDescription}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-300">
|
||||
{homepageLanguage.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-stone-200 bg-[linear-gradient(180deg,#f8f5ef_0%,#f4efe6_28%,#fffdf8_58%,#ffffff_100%)] shadow-2xl shadow-black/20">
|
||||
<div className="border-b border-stone-200 bg-white/90 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.32em] text-amber-700">{activeContent.heroKicker}</p>
|
||||
<p className="mt-2 text-sm text-stone-500">rentaldrivego.com</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs font-semibold text-stone-600">
|
||||
{activeContent.metrics.map((metric) => (
|
||||
<span key={metric.value} className="rounded-full border border-stone-300 bg-white px-3 py-1.5">
|
||||
{metric.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-10 px-5 py-6 lg:px-8">
|
||||
{activeSections.includes('hero') ? <section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
|
||||
<div>
|
||||
<h4 className="text-4xl font-black tracking-[-0.04em] text-stone-950">{activeContent.heroTitle}</h4>
|
||||
<p className="mt-5 max-w-2xl text-base leading-8 text-stone-600">{activeContent.heroBody}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<span className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white">{activeContent.startTrial}</span>
|
||||
<span className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-700">{activeContent.exploreVehicles}</span>
|
||||
</div>
|
||||
</div>
|
||||
{activeSections.includes('surface') ? (
|
||||
<div className="rounded-[1.5rem] bg-stone-950 p-6 text-white">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
|
||||
{activeContent.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
{activeContent.liveLabel}
|
||||
</span>
|
||||
</div>
|
||||
<h5 className="mt-5 text-2xl font-black tracking-[-0.03em]">{activeContent.surfaceTitle}</h5>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-300">{activeContent.surfaceBody}</p>
|
||||
<div className="mt-6 space-y-3">
|
||||
{[activeContent.trustedFleets, activeContent.brandedFlows, activeContent.multiTenant].map((item, index) => (
|
||||
<div key={`${item}-${index}`} className="rounded-[1.25rem] border border-white/10 bg-white/5 px-4 py-3 text-sm font-semibold">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section> : null}
|
||||
|
||||
{activeSections.includes('surface') && !activeSections.includes('hero') ? (
|
||||
<section className="rounded-[1.5rem] bg-stone-950 p-6 text-white">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
|
||||
{activeContent.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
{activeContent.liveLabel}
|
||||
</span>
|
||||
</div>
|
||||
<h5 className="mt-5 text-2xl font-black tracking-[-0.03em]">{activeContent.surfaceTitle}</h5>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-300">{activeContent.surfaceBody}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeSections.includes('pillars') ? <section className="grid gap-4 lg:grid-cols-3">
|
||||
{activeContent.pillars.map((pillar, index) => (
|
||||
<article
|
||||
key={`${pillar.title}-${index}`}
|
||||
className={`rounded-[1.5rem] border p-5 ${index === 1 ? 'border-stone-900 bg-stone-950 text-white' : 'border-stone-200 bg-white text-stone-900'}`}
|
||||
>
|
||||
<p className={`text-xs font-bold uppercase tracking-[0.24em] ${index === 1 ? 'text-stone-300' : 'text-stone-400'}`}>0{index + 1}</p>
|
||||
<h5 className="mt-3 text-xl font-black">{pillar.title}</h5>
|
||||
<p className={`mt-3 text-sm leading-7 ${index === 1 ? 'text-stone-200' : 'text-stone-600'}`}>{pillar.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</section> : null}
|
||||
|
||||
{activeSections.includes('audiences') ? <section className="grid gap-4 lg:grid-cols-2">
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.companyKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.companyTitle}</h5>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600">{activeContent.companyBody}</p>
|
||||
</article>
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-[#f7efe2] p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700">{activeContent.renterKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.renterTitle}</h5>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-700">{activeContent.renterBody}</p>
|
||||
</article>
|
||||
</section> : null}
|
||||
|
||||
{(activeSections.includes('features') || activeSections.includes('steps')) ? <section className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
{activeSections.includes('features') ? (
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-[#fffdf8] p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.featureLabel}</p>
|
||||
<div className="mt-5 space-y-3">
|
||||
{activeContent.features.map((feature, index) => (
|
||||
<div key={`${feature}-${index}`} className="flex items-start gap-3 rounded-[1rem] border border-stone-200 bg-white px-4 py-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-stone-950 text-xs font-bold text-white">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm font-semibold leading-6 text-stone-800">{feature}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
) : <div />}
|
||||
{activeSections.includes('steps') ? (
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700">{activeContent.readyKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.stepsTitle}</h5>
|
||||
<div className="mt-5 space-y-3">
|
||||
{activeContent.steps.map((step) => (
|
||||
<div key={step.step} className="rounded-[1rem] border border-stone-200 bg-stone-50 p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.stepLabel} {step.step}</p>
|
||||
<h6 className="mt-2 text-lg font-black text-stone-950">{step.title}</h6>
|
||||
<p className="mt-2 text-sm leading-7 text-stone-600">{step.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
) : <div />}
|
||||
</section> : null}
|
||||
|
||||
{activeSections.includes('closing') ? <section className="rounded-[1.5rem] bg-stone-950 px-6 py-7 text-white">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-amber-300">{activeContent.readyKicker}</p>
|
||||
<h5 className="mt-4 max-w-3xl text-3xl font-black tracking-[-0.04em]">{activeContent.readyTitle}</h5>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-8 text-stone-300">{activeContent.readyBody}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<span className="rounded-full border border-white/20 px-5 py-3 text-sm font-semibold">{activeContent.viewPricing}</span>
|
||||
<span className="rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold text-stone-950">{activeContent.createWorkspace}</span>
|
||||
</div>
|
||||
</section> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-8">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Brand kicker</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.heroKicker} onChange={(event) => updateHomepageContent({ heroKicker: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Primary CTA</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.startTrial} onChange={(event) => updateHomepageContent({ startTrial: event.target.value })} />
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className={LABEL_CLASS}>{copy.hero} title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.heroTitle} onChange={(event) => updateHomepageContent({ heroTitle: event.target.value })} />
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className={LABEL_CLASS}>{copy.hero} body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.heroBody} onChange={(event) => updateHomepageContent({ heroBody: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Secondary CTA</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.exploreVehicles} onChange={(event) => updateHomepageContent({ exploreVehicles: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.surface} badge</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.surfaceLabel} onChange={(event) => updateHomepageContent({ surfaceLabel: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="md:col-span-2">
|
||||
<span className={LABEL_CLASS}>{copy.surface} title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.surfaceTitle} onChange={(event) => updateHomepageContent({ surfaceTitle: event.target.value })} />
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className={LABEL_CLASS}>{copy.surface} body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.surfaceBody} onChange={(event) => updateHomepageContent({ surfaceBody: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Live label</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.liveLabel} onChange={(event) => updateHomepageContent({ liveLabel: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Trusted fleets</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.trustedFleets} onChange={(event) => updateHomepageContent({ trustedFleets: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Branded flows</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.brandedFlows} onChange={(event) => updateHomepageContent({ brandedFlows: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Multi-tenant</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.multiTenant} onChange={(event) => updateHomepageContent({ multiTenant: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.companySection} kicker</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.companyKicker} onChange={(event) => updateHomepageContent({ companyKicker: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.renterSection} kicker</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.renterKicker} onChange={(event) => updateHomepageContent({ renterKicker: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.companySection} title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.companyTitle} onChange={(event) => updateHomepageContent({ companyTitle: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.renterSection} title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.renterTitle} onChange={(event) => updateHomepageContent({ renterTitle: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.companySection} body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.companyBody} onChange={(event) => updateHomepageContent({ companyBody: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.renterSection} body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.renterBody} onChange={(event) => updateHomepageContent({ renterBody: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-200">{copy.valueSection}</h3>
|
||||
<div className="grid gap-4">
|
||||
{activeContent.metrics.map((metric, index) => (
|
||||
<label key={metric.value}>
|
||||
<span className={LABEL_CLASS}>Metric {metric.value}</span>
|
||||
<input className={INPUT_CLASS} value={metric.label} onChange={(event) => updateMetric(index, event.target.value)} />
|
||||
</label>
|
||||
))}
|
||||
{activeContent.pillars.map((pillar, index) => (
|
||||
<div key={`${pillar.title}-${index}`} className="grid gap-4 rounded-2xl border border-zinc-800 bg-zinc-950/50 p-4">
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Pillar {index + 1} title</span>
|
||||
<input className={INPUT_CLASS} value={pillar.title} onChange={(event) => updatePillar(index, { title: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Pillar {index + 1} body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={pillar.body} onChange={(event) => updatePillar(index, { body: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-200">{copy.featureSection}</h3>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Feature section label</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.featureLabel} onChange={(event) => updateHomepageContent({ featureLabel: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Features, one per line</span>
|
||||
<textarea
|
||||
className={`${INPUT_CLASS} min-h-40`}
|
||||
value={activeContent.features.join('\n')}
|
||||
onChange={(event) => updateHomepageContent({ features: event.target.value.split('\n').map((item) => item.trim()).filter(Boolean) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-200">{copy.stepsSection}</h3>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Steps title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.stepsTitle} onChange={(event) => updateHomepageContent({ stepsTitle: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Step label</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.stepLabel} onChange={(event) => updateHomepageContent({ stepLabel: event.target.value })} />
|
||||
</label>
|
||||
{activeContent.steps.map((step, index) => (
|
||||
<div key={step.step} className="grid gap-4 rounded-2xl border border-zinc-800 bg-zinc-950/50 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">Step {step.step}</p>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Title</span>
|
||||
<input className={INPUT_CLASS} value={step.title} onChange={(event) => updateStep(index, { title: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={step.body} onChange={(event) => updateStep(index, { body: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-200">{copy.closingSection}</h3>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Closing kicker</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.readyKicker} onChange={(event) => updateHomepageContent({ readyKicker: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Closing title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.readyTitle} onChange={(event) => updateHomepageContent({ readyTitle: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Closing body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.readyBody} onChange={(event) => updateHomepageContent({ readyBody: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Pricing CTA</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.viewPricing} onChange={(event) => updateHomepageContent({ viewPricing: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Workspace CTA</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.createWorkspace} onChange={(event) => updateHomepageContent({ createWorkspace: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveHomepage}
|
||||
disabled={homepageSaving}
|
||||
className="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{homepageSaving ? copy.savingHomepage : copy.saveHomepage}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="panel overflow-hidden">
|
||||
<div className="border-b border-zinc-800 px-6 py-4">
|
||||
<h2 className="text-base font-semibold text-zinc-100">{copy.companiesTitle}</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.company}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.slug}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.status}</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td>
|
||||
</tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td>
|
||||
</tr>
|
||||
) : filtered.map((company) => (
|
||||
<tr key={company.id} className="transition-colors hover:bg-zinc-800/30">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-zinc-100">{company.brand?.displayName ?? company.name}</p>
|
||||
<p className="text-xs text-zinc-500">{company.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-mono text-xs text-zinc-400">{company.slug}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[company.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{company.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
href={`/dashboard/companies/${company.id}#site-config`}
|
||||
className="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-500"
|
||||
>
|
||||
{copy.configure}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/companies/${company.id}`}
|
||||
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-200 transition-colors hover:bg-zinc-700"
|
||||
>
|
||||
{copy.manageCompany}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export function GET(request: Request) {
|
||||
const url = new URL('/icon', request.url)
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import PublicShell from '@/components/PublicShell'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
export default function AdminForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
setSent(true)
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex flex-1 items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight">Forgot password</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">Enter your admin email to receive a reset link.</p>
|
||||
</div>
|
||||
|
||||
<div className="panel p-8">
|
||||
{sent ? (
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900/40">
|
||||
<svg className="h-6 w-6 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300">Check your email</p>
|
||||
<p className="text-xs text-zinc-500">If that email is registered, a reset link has been sent. It expires in 60 minutes.</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@rentaldrivego.com"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Sending…' : 'Send reset link'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link href="/login" className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html.light {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-zinc-950 text-zinc-50 antialiased transition-colors;
|
||||
}
|
||||
|
||||
.shell {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply rounded-2xl border border-zinc-800 bg-zinc-900 shadow-sm;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.light body {
|
||||
background-color: rgb(248 250 252);
|
||||
color: rgb(15 23 42);
|
||||
}
|
||||
|
||||
.light .bg-zinc-950 {
|
||||
background-color: rgb(248 250 252);
|
||||
}
|
||||
|
||||
.light .bg-zinc-950\/90 {
|
||||
background-color: rgb(255 255 255 / 0.9);
|
||||
}
|
||||
|
||||
.light .bg-zinc-900 {
|
||||
background-color: rgb(255 255 255);
|
||||
}
|
||||
|
||||
.light .bg-zinc-800,
|
||||
.light .bg-zinc-800\/50 {
|
||||
background-color: rgb(241 245 249);
|
||||
}
|
||||
|
||||
.light .border-zinc-800,
|
||||
.light .border-zinc-700 {
|
||||
border-color: rgb(226 232 240);
|
||||
}
|
||||
|
||||
.light .text-zinc-100,
|
||||
.light .text-zinc-200 {
|
||||
color: rgb(15 23 42);
|
||||
}
|
||||
|
||||
.light .text-zinc-500 {
|
||||
color: rgb(100 116 139);
|
||||
}
|
||||
|
||||
.light .text-zinc-400,
|
||||
.light .text-zinc-300 {
|
||||
color: rgb(71 85 105);
|
||||
}
|
||||
|
||||
.light .hover\:bg-zinc-800:hover,
|
||||
.light .hover\:bg-zinc-800\/50:hover {
|
||||
background-color: rgb(241 245 249);
|
||||
}
|
||||
|
||||
.light .hover\:text-zinc-200:hover {
|
||||
color: rgb(15 23 42);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
|
||||
export const size = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
}
|
||||
|
||||
export const contentType = 'image/png'
|
||||
|
||||
export default function Icon() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#111827',
|
||||
color: '#ffffff',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
A
|
||||
</div>
|
||||
),
|
||||
size,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { AdminI18nProvider } from '@/components/I18nProvider'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Admin',
|
||||
description: 'Platform administration for RentalDriveGo.',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var theme=localStorage.getItem('admin-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning><AdminI18nProvider>{children}</AdminI18nProvider></body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const requestHeaders = headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
redirect(`${dashboardUrl}/sign-in?portal=admin&next=/dashboard`)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function AdminRootPage() {
|
||||
redirect('/login')
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import PublicShell from '@/components/PublicShell'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
export default function AdminResetPasswordPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AdminResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminResetPasswordContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (password.length < 8) { setError('Password must be at least 8 characters.'); return }
|
||||
if (password !== confirm) { setError('Passwords do not match.'); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
if (json?.error === 'invalid_token') throw new Error('This reset link is invalid or has expired.')
|
||||
throw new Error('Something went wrong. Please try again.')
|
||||
}
|
||||
setDone(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Something went wrong.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<AdminResetShell>
|
||||
<p className="text-sm text-zinc-400 text-center">No reset token found.</p>
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/forgot-password" className="text-sm text-emerald-400 hover:text-emerald-300">
|
||||
Request a new reset link
|
||||
</Link>
|
||||
</div>
|
||||
</AdminResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<AdminResetShell>
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900/40">
|
||||
<svg className="h-6 w-6 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300 font-semibold">Password updated</p>
|
||||
<p className="text-xs text-zinc-500">You can now sign in with your new password.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/login')}
|
||||
className="mt-2 w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</AdminResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminResetShell>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Resetting…' : 'Reset password'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link href="/login" className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</AdminResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminResetShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex flex-1 items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight">Reset password</h1>
|
||||
</div>
|
||||
<div className="panel p-8">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export type AdminLanguage = 'en' | 'fr' | 'ar'
|
||||
export type AdminTheme = 'light' | 'dark'
|
||||
|
||||
type AdminDictionary = {
|
||||
nav: Record<string, string>
|
||||
logout: string
|
||||
language: string
|
||||
theme: string
|
||||
light: string
|
||||
dark: string
|
||||
overview: string
|
||||
admin: string
|
||||
platform: string
|
||||
loading: string
|
||||
}
|
||||
|
||||
const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
en: {
|
||||
nav: {
|
||||
overview: 'Overview',
|
||||
companies: 'Companies',
|
||||
siteConfig: 'Site Config',
|
||||
renters: 'Renters',
|
||||
auditLogs: 'Audit Logs',
|
||||
adminUsers: 'Admin Users',
|
||||
billing: 'Billing',
|
||||
},
|
||||
logout: 'Logout',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
overview: 'Platform overview',
|
||||
admin: 'Admin',
|
||||
platform: 'Platform',
|
||||
loading: 'Loading',
|
||||
},
|
||||
fr: {
|
||||
nav: {
|
||||
overview: "Vue d'ensemble",
|
||||
companies: 'Entreprises',
|
||||
siteConfig: 'Config site',
|
||||
renters: 'Locataires',
|
||||
auditLogs: "Journaux d'audit",
|
||||
adminUsers: 'Utilisateurs admin',
|
||||
billing: 'Facturation',
|
||||
},
|
||||
logout: 'Déconnexion',
|
||||
language: 'Langue',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
overview: 'Vue plateforme',
|
||||
admin: 'Admin',
|
||||
platform: 'Plateforme',
|
||||
loading: 'Chargement',
|
||||
},
|
||||
ar: {
|
||||
nav: {
|
||||
overview: 'نظرة عامة',
|
||||
companies: 'الشركات',
|
||||
siteConfig: 'إعدادات الموقع',
|
||||
renters: 'المستأجرون',
|
||||
auditLogs: 'سجلات التدقيق',
|
||||
adminUsers: 'مستخدمو الإدارة',
|
||||
billing: 'الفوترة',
|
||||
},
|
||||
logout: 'تسجيل الخروج',
|
||||
language: 'اللغة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
dark: 'داكن',
|
||||
overview: 'نظرة عامة على المنصة',
|
||||
admin: 'الإدارة',
|
||||
platform: 'المنصة',
|
||||
loading: 'جارٍ التحميل',
|
||||
},
|
||||
}
|
||||
|
||||
type AdminI18nContext = {
|
||||
language: AdminLanguage
|
||||
setLanguage: (value: AdminLanguage) => void
|
||||
theme: AdminTheme
|
||||
setTheme: (value: AdminTheme) => void
|
||||
dict: AdminDictionary
|
||||
}
|
||||
|
||||
const Context = createContext<AdminI18nContext | null>(null)
|
||||
|
||||
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<AdminLanguage>('en')
|
||||
const [theme, setTheme] = useState<AdminTheme>('dark')
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('admin-language')
|
||||
const storedTheme = window.localStorage.getItem('admin-theme')
|
||||
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||
setLanguage(stored)
|
||||
}
|
||||
|
||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||
setTheme(storedTheme)
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('light')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
window.localStorage.setItem('admin-language', language)
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(theme)
|
||||
document.documentElement.style.colorScheme = theme
|
||||
document.body.dataset.theme = theme
|
||||
window.localStorage.setItem('admin-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }),
|
||||
[language, theme],
|
||||
)
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function useAdminI18n() {
|
||||
const context = useContext(Context)
|
||||
if (!context) throw new Error('useAdminI18n must be used within AdminI18nProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
export function AdminLanguageSwitcher() {
|
||||
const { language, setLanguage, dict } = useAdminI18n()
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm transition-colors">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">{dict.language}</span>
|
||||
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
|
||||
const active = value === language
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setLanguage(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminThemeSwitcher() {
|
||||
const { theme, setTheme, dict } = useAdminI18n()
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm transition-colors">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
{dict.theme}
|
||||
</span>
|
||||
{(['light', 'dark'] as AdminTheme[]).map((value) => {
|
||||
const active = value === theme
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{value === 'light' ? dict.light : dict.dark}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
|
||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useAdminI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
admin: 'Admin Console',
|
||||
signIn: 'Sign in',
|
||||
preferences: 'Admin preferences',
|
||||
},
|
||||
fr: {
|
||||
admin: 'Console admin',
|
||||
signIn: 'Connexion',
|
||||
preferences: 'Preferences admin',
|
||||
},
|
||||
ar: {
|
||||
admin: 'لوحة الإدارة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
preferences: 'تفضيلات الإدارة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 transition-colors">
|
||||
<header className="sticky top-0 z-30 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur-md transition-colors">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-400">RentalDriveGo</span>
|
||||
<span className="hidden text-sm font-semibold text-zinc-400 sm:inline">{dict.admin}</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<Link href="/login" className="rounded-full bg-zinc-100 px-4 py-2 text-sm font-semibold text-zinc-950 transition hover:bg-zinc-300">
|
||||
{dict.signIn}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-zinc-800 dark:bg-zinc-950/90">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-zinc-500">
|
||||
{dict.preferences}
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
const API_BASE =
|
||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1')
|
||||
?? process.env.NEXT_PUBLIC_API_URL
|
||||
?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export async function adminFetch<T>(path: string, token?: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
return json.data as T
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function resolveBrowserAppUrl(fallback: string): string {
|
||||
if (typeof window === 'undefined') return fallback
|
||||
|
||||
try {
|
||||
const target = new URL(fallback)
|
||||
target.protocol = window.location.protocol
|
||||
target.hostname = window.location.hostname
|
||||
return target.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string {
|
||||
if (!host) return fallback
|
||||
|
||||
try {
|
||||
const target = new URL(fallback)
|
||||
target.protocol = `${proto || target.protocol.replace(':', '')}:`
|
||||
target.host = host
|
||||
return target.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -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": "node --env-file=../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^3.4.3",
|
||||
"@rentaldrivego/database": "*",
|
||||
"@rentaldrivego/types": "*",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.11",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.5.1",
|
||||
"firebase-admin": "^12.1.0",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"resend": "^3.2.0",
|
||||
"socket.io": "^4.7.5",
|
||||
"twilio": "^5.1.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.12.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
{
|
||||
"marketplaceHomepage": {
|
||||
"en": {
|
||||
"sections": [
|
||||
"hero",
|
||||
"surface",
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"steps",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Marketplace discovery with a sharper front door.",
|
||||
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the company’s own branded checkout.",
|
||||
"startTrial": "Start free trial",
|
||||
"exploreVehicles": "Explore vehicles",
|
||||
"surfaceLabel": "Marketplace surface",
|
||||
"surfaceTitle": "Designed for two audiences at once.",
|
||||
"surfaceBody": "Operators need control, renters need confidence. The marketplace should show both without feeling like a template.",
|
||||
"liveLabel": "Live network",
|
||||
"trustedFleets": "Trusted fleets",
|
||||
"brandedFlows": "Branded booking flows",
|
||||
"multiTenant": "Multi-tenant operations",
|
||||
"companyKicker": "Operator workflow",
|
||||
"companyTitle": "Control inventory, offers, billing, and staff from one command layer.",
|
||||
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
|
||||
"renterKicker": "Renter experience",
|
||||
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
|
||||
"renterBody": "The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Unified publishing",
|
||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages."
|
||||
},
|
||||
{
|
||||
"title": "Qualified discovery",
|
||||
"body": "Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast."
|
||||
},
|
||||
{
|
||||
"title": "Direct revenue path",
|
||||
"body": "Bookings move into the company’s own payment flow, so customer ownership and cash collection stay with the business."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Shared marketplace visibility"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
"label": "Direct company checkout"
|
||||
},
|
||||
{
|
||||
"value": "03",
|
||||
"label": "Company-owned payments"
|
||||
}
|
||||
],
|
||||
"featureLabel": "What the product covers",
|
||||
"features": [
|
||||
"Fleet management with multi-photo uploads",
|
||||
"Marketplace offers and redirect booking flow",
|
||||
"Branded public booking site per company",
|
||||
"Customer CRM, analytics, and billing controls"
|
||||
],
|
||||
"stepsTitle": "How companies launch",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Create your company workspace",
|
||||
"body": "Pick a plan, launch your 14-day trial, and verify the owner account."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publish vehicles and offers",
|
||||
"body": "Upload photos once and control what appears on the marketplace and branded site."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
"title": "Accept bookings on your own site",
|
||||
"body": "Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow."
|
||||
}
|
||||
],
|
||||
"stepLabel": "Step",
|
||||
"readyKicker": "Ready to launch",
|
||||
"readyTitle": "A marketplace homepage should sell the system in seconds.",
|
||||
"readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.",
|
||||
"viewPricing": "View pricing",
|
||||
"createWorkspace": "Create workspace"
|
||||
},
|
||||
"fr": {
|
||||
"sections": [
|
||||
"hero",
|
||||
"surface",
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"steps",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Une vitrine marketplace plus nette et plus forte.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune, et chaque réservation se termine sur le paiement de marque de la société.",
|
||||
"startTrial": "Commencer l’essai gratuit",
|
||||
"exploreVehicles": "Explorer les véhicules",
|
||||
"surfaceLabel": "Surface marketplace",
|
||||
"surfaceTitle": "Pensée pour deux audiences à la fois.",
|
||||
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.",
|
||||
"liveLabel": "Réseau actif",
|
||||
"trustedFleets": "Flottes fiables",
|
||||
"brandedFlows": "Parcours de marque",
|
||||
"multiTenant": "Opérations multi-tenant",
|
||||
"companyKicker": "Flux opérateur",
|
||||
"companyTitle": "Pilotez inventaire, offres, facturation et équipe depuis une seule couche de commande.",
|
||||
"companyBody": "Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.",
|
||||
"renterKicker": "Expérience client",
|
||||
"renterTitle": "Laissez les clients comparer rapidement puis dirigez-les vers le bon site entreprise.",
|
||||
"renterBody": "La marketplace agit comme moteur de découverte, pas comme agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Publication unifiée",
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque."
|
||||
},
|
||||
{
|
||||
"title": "Découverte qualifiée",
|
||||
"body": "Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement."
|
||||
},
|
||||
{
|
||||
"title": "Revenus en direct",
|
||||
"body": "Les réservations basculent vers le paiement propre à l’entreprise, pour garder la relation client et l’encaissement."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Visibilité marketplace partagée"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
"label": "Paiement direct entreprise"
|
||||
},
|
||||
{
|
||||
"value": "03",
|
||||
"label": "Paiements détenus par la société"
|
||||
}
|
||||
],
|
||||
"featureLabel": "Ce que couvre le produit",
|
||||
"features": [
|
||||
"Gestion de flotte avec téléversement multi-photos",
|
||||
"Offres marketplace et redirection vers la réservation",
|
||||
"Site public de réservation par entreprise",
|
||||
"CRM client, analytics et contrôle de facturation"
|
||||
],
|
||||
"stepsTitle": "Comment les entreprises démarrent",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Créez votre espace entreprise",
|
||||
"body": "Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publiez véhicules et offres",
|
||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
"title": "Acceptez les réservations sur votre site",
|
||||
"body": "Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation."
|
||||
}
|
||||
],
|
||||
"stepLabel": "Étape",
|
||||
"readyKicker": "Prêt à démarrer",
|
||||
"readyTitle": "Une homepage marketplace doit vendre le système en quelques secondes.",
|
||||
"readyBody": "Utilisez la marketplace pour capter la demande, puis faites passer les clients vers une expérience de marque qui garde prix, paiements et confiance liés à votre entreprise.",
|
||||
"viewPricing": "Voir les tarifs",
|
||||
"createWorkspace": "Créer l’espace"
|
||||
},
|
||||
"ar": {
|
||||
"sections": [
|
||||
"hero",
|
||||
"surface",
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"steps",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "واجهة سوق أوضح وأقوى.",
|
||||
"heroBody": "شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.",
|
||||
"startTrial": "ابدأ التجربة المجانية",
|
||||
"exploreVehicles": "استكشف السيارات",
|
||||
"surfaceLabel": "واجهة السوق",
|
||||
"surfaceTitle": "مصممة لجمهورين في الوقت نفسه.",
|
||||
"surfaceBody": "المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.",
|
||||
"liveLabel": "شبكة نشطة",
|
||||
"trustedFleets": "أساطيل موثوقة",
|
||||
"brandedFlows": "مسارات حجز مخصصة",
|
||||
"multiTenant": "عمليات متعددة الشركات",
|
||||
"companyKicker": "تدفق المشغل",
|
||||
"companyTitle": "تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.",
|
||||
"companyBody": "انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.",
|
||||
"renterKicker": "تجربة المستأجر",
|
||||
"renterTitle": "دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.",
|
||||
"renterBody": "السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "نشر موحد",
|
||||
"body": "صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة."
|
||||
},
|
||||
{
|
||||
"title": "اكتشاف مؤهل",
|
||||
"body": "العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة."
|
||||
},
|
||||
{
|
||||
"title": "مسار إيراد مباشر",
|
||||
"body": "تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "ظهور مشترك في السوق"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
"label": "دفع مباشر للشركة"
|
||||
},
|
||||
{
|
||||
"value": "03",
|
||||
"label": "مدفوعات مملوكة للشركة"
|
||||
}
|
||||
],
|
||||
"featureLabel": "ما الذي يغطيه المنتج",
|
||||
"features": [
|
||||
"إدارة الأسطول مع رفع عدة صور",
|
||||
"عروض السوق والتحويل إلى مسار الحجز",
|
||||
"موقع حجز عام مخصص لكل شركة",
|
||||
"إدارة العملاء والتحليلات والفوترة"
|
||||
],
|
||||
"stepsTitle": "كيف تبدأ الشركات",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "أنشئ مساحة شركتك",
|
||||
"body": "اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "انشر السيارات والعروض",
|
||||
"body": "ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
"title": "استقبل الحجوزات على موقعك",
|
||||
"body": "يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك."
|
||||
}
|
||||
],
|
||||
"stepLabel": "الخطوة",
|
||||
"readyKicker": "جاهز للانطلاق",
|
||||
"readyTitle": "يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.",
|
||||
"readyBody": "استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.",
|
||||
"viewPricing": "عرض الأسعار",
|
||||
"createWorkspace": "إنشاء المساحة"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
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 jwt from 'jsonwebtoken'
|
||||
import { z } from 'zod'
|
||||
import { redis } from './lib/redis'
|
||||
import { prisma } from './lib/prisma'
|
||||
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
|
||||
|
||||
// ─── Routes ───────────────────────────────────────────────────
|
||||
import webhookRouter from './routes/webhooks'
|
||||
import companyAuthRouter from './routes/auth.company'
|
||||
import employeeAuthRouter from './routes/auth.employee'
|
||||
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'
|
||||
import companiesRouter from './routes/companies'
|
||||
import subscriptionsRouter from './routes/subscriptions'
|
||||
import siteRouter from './routes/site'
|
||||
import paymentsRouter from './routes/payments'
|
||||
|
||||
const app = express()
|
||||
const server = http.createServer(app)
|
||||
|
||||
// Trust the first hop from a reverse proxy so req.ip and rate-limiting
|
||||
// use the real client IP (from X-Forwarded-For) rather than the proxy's IP.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.set('trust proxy', 1)
|
||||
}
|
||||
const v1 = '/api/v1'
|
||||
const defaultCorsOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3002',
|
||||
'http://localhost:3003',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://127.0.0.1:3001',
|
||||
'http://127.0.0.1:3002',
|
||||
'http://127.0.0.1:3003',
|
||||
]
|
||||
const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
|
||||
: defaultCorsOrigins
|
||||
|
||||
const routeDocs = [
|
||||
{ method: 'GET', path: '/health', description: 'Health check' },
|
||||
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
|
||||
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
|
||||
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
|
||||
{ method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' },
|
||||
{ method: 'GET', path: `${v1}/reservations`, description: 'List reservations' },
|
||||
{ method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' },
|
||||
{ method: 'GET', path: `${v1}/customers`, description: 'List customers' },
|
||||
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
|
||||
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
|
||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
|
||||
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
|
||||
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
|
||||
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
|
||||
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
|
||||
]
|
||||
|
||||
// ─── Socket.io ────────────────────────────────────────────────
|
||||
const io = new SocketIOServer(server, {
|
||||
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
|
||||
})
|
||||
|
||||
const redisMessageSchema = z.object({
|
||||
type: z.string(),
|
||||
payload: z.unknown(),
|
||||
})
|
||||
|
||||
// Authenticate socket connections via JWT before joining user rooms
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth?.token as string | undefined
|
||||
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
;(socket as any).authenticatedUserId = payload.sub
|
||||
next()
|
||||
} catch {
|
||||
next(new Error('invalid_token'))
|
||||
}
|
||||
})
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const userId = (socket as any).authenticatedUserId 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) => {
|
||||
try {
|
||||
const parsed = JSON.parse(message)
|
||||
const validated = redisMessageSchema.parse(parsed)
|
||||
const userId = channel.replace('notifications:', '')
|
||||
io.to(`user:${userId}`).emit('notification', validated)
|
||||
} catch (err) {
|
||||
console.error('[Redis] Invalid notification message:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Middleware ────────────────────────────────────────────────
|
||||
// Serve local file storage
|
||||
app.use('/storage', express.static(require('path').resolve(__dirname, 'lib/storage')))
|
||||
|
||||
// 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: corsOrigins, credentials: true }))
|
||||
app.use(morgan('combined'))
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// ─── API Routes ───────────────────────────────────────────────
|
||||
// Auth routes: strict brute-force protection
|
||||
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
|
||||
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
||||
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
|
||||
|
||||
// Admin routes: dedicated limit
|
||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||
|
||||
// Public unauthenticated routes
|
||||
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
|
||||
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
||||
|
||||
// Authenticated company/renter routes
|
||||
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
|
||||
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
|
||||
app.use(`${v1}/team`, apiLimiter, teamRouter)
|
||||
app.use(`${v1}/customers`, apiLimiter, customersRouter)
|
||||
app.use(`${v1}/offers`, apiLimiter, offersRouter)
|
||||
app.use(`${v1}/analytics`, apiLimiter, analyticsRouter)
|
||||
app.use(`${v1}/notifications`, apiLimiter, notificationsRouter)
|
||||
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
||||
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||
|
||||
// ─── Health check ─────────────────────────────────────────────
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
app.get(`${v1}/docs`, (_req, res) => {
|
||||
res.json({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: v1,
|
||||
docsUrl: '/docs',
|
||||
routes: routeDocs,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/docs', (_req, res) => {
|
||||
const rows = routeDocs
|
||||
.map(
|
||||
(route) => `
|
||||
<tr>
|
||||
<td>${route.method}</td>
|
||||
<td><code>${route.path}</code></td>
|
||||
<td>${route.description}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
res.type('html').send(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>RentalDriveGo API Docs</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; color: #0f172a; background: #f8fafc; }
|
||||
h1 { margin-bottom: 8px; }
|
||||
p { color: #475569; }
|
||||
.meta { margin-bottom: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 12px; overflow: hidden; }
|
||||
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid #e2e8f0; }
|
||||
th { background: #e2e8f0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
code { background: #f1f5f9; padding: 2px 6px; border-radius: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RentalDriveGo API</h1>
|
||||
<p class="meta">Base URL: <code>${v1}</code> · JSON index: <code>${v1}/docs</code></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Path</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`)
|
||||
})
|
||||
|
||||
// ─── 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,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,35 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const STORAGE_ROOT = path.resolve(__dirname, 'storage')
|
||||
|
||||
function getApiBase(): string {
|
||||
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export async function uploadImage(
|
||||
buffer: Buffer,
|
||||
folder: string,
|
||||
publicId?: string
|
||||
): Promise<string> {
|
||||
const folderPath = path.join(STORAGE_ROOT, folder)
|
||||
fs.mkdirSync(folderPath, { recursive: true })
|
||||
|
||||
const filename = publicId
|
||||
? `${publicId}.jpg`
|
||||
: `${crypto.randomBytes(16).toString('hex')}.jpg`
|
||||
|
||||
fs.writeFileSync(path.join(folderPath, filename), buffer)
|
||||
|
||||
return `${getApiBase()}/storage/${folder}/${filename}`
|
||||
}
|
||||
|
||||
export async function deleteImage(imageUrl: string): Promise<void> {
|
||||
const marker = '/storage/'
|
||||
const idx = imageUrl.indexOf(marker)
|
||||
if (idx === -1) return
|
||||
const relative = imageUrl.slice(idx + marker.length)
|
||||
const filePath = path.join(STORAGE_ROOT, relative)
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 418 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
@@ -0,0 +1,51 @@
|
||||
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
|
||||
import type { Request } from 'express'
|
||||
|
||||
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
|
||||
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
|
||||
|
||||
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 20,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false,
|
||||
keyGenerator: (req) => getClientIpKey(req),
|
||||
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Standard limiter for general authenticated API endpoints
|
||||
export const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 120,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
const ip = getClientIpKey(req)
|
||||
const companyId = (req as any).companyId ?? ''
|
||||
const renterId = (req as any).renterId ?? ''
|
||||
return `${ip}:${companyId || renterId}`
|
||||
},
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Limiter for public marketplace and site endpoints (no auth)
|
||||
export const publicLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => getClientIpKey(req),
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Tight limiter for admin endpoints
|
||||
export const adminLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => getClientIpKey(req),
|
||||
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
|
||||
})
|
||||
@@ -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 jwt from 'jsonwebtoken'
|
||||
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 payload = jwt.verify(sessionToken, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type === 'employee') {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: payload.sub },
|
||||
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
|
||||
return 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,856 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import crypto from 'crypto'
|
||||
import { authenticator } from 'otplib'
|
||||
import qrcode from 'qrcode'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAuth'
|
||||
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../services/platformContentService'
|
||||
import { generateInvoicePdf, buildInvoiceNumber } from '../services/invoicePdfService'
|
||||
import { sendTransactionalEmail } from '../services/notificationService'
|
||||
import type { MarketplaceHomepageContent, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep } from '@rentaldrivego/types'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const permissionSchema = z.object({
|
||||
resource: z.enum(['COMPANIES', 'COMPANY_EMPLOYEES', 'SUBSCRIPTIONS', 'PAYMENTS', 'OFFERS', 'RENTERS', 'ADMIN_USERS', 'AUDIT_LOGS', 'PLATFORM_METRICS']),
|
||||
actions: z.array(z.enum(['READ', 'CREATE', 'UPDATE', 'DELETE', 'SUSPEND', 'IMPERSONATE'])).min(1),
|
||||
})
|
||||
|
||||
const nullableString = z.union([z.string(), z.null()]).optional()
|
||||
const nullableEmail = z.union([z.string().email(), z.null()]).optional()
|
||||
const nullableUrl = z.union([z.string().url(), z.null()]).optional()
|
||||
const nullableDate = z.union([
|
||||
z.string().datetime(),
|
||||
z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
z.null(),
|
||||
]).optional()
|
||||
|
||||
const homePageConfigSchema = z.object({
|
||||
heroTitle: nullableString,
|
||||
heroDescription: nullableString,
|
||||
viewOffersLabel: nullableString,
|
||||
viewPricingLabel: nullableString,
|
||||
contactCompanyLabel: nullableString,
|
||||
activeOffersTitle: nullableString,
|
||||
seeAllOffersLabel: nullableString,
|
||||
noActiveOffersLabel: nullableString,
|
||||
publishedVehiclesTitle: nullableString,
|
||||
viewVehicleLabel: nullableString,
|
||||
pricingEyebrow: nullableString,
|
||||
pricingTitle: nullableString,
|
||||
pricingDescription: nullableString,
|
||||
showOffers: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
layout: z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(['hero', 'offers', 'vehicles', 'pricing']),
|
||||
x: z.number().int().min(1),
|
||||
y: z.number().int().min(1),
|
||||
w: z.number().int().min(1),
|
||||
h: z.number().int().min(1),
|
||||
})),
|
||||
}).optional(),
|
||||
}).optional()
|
||||
|
||||
const menuConfigSchema = z.object({
|
||||
aboutLabel: nullableString,
|
||||
vehiclesLabel: nullableString,
|
||||
offersLabel: nullableString,
|
||||
pricingLabel: nullableString,
|
||||
blogLabel: nullableString,
|
||||
contactLabel: nullableString,
|
||||
bookCarLabel: nullableString,
|
||||
siteNavigationLabel: nullableString,
|
||||
showAbout: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
showBlog: z.boolean().optional(),
|
||||
showContact: z.boolean().optional(),
|
||||
pageSections: z.object({
|
||||
home: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
offers: z.boolean().optional(),
|
||||
vehicles: z.boolean().optional(),
|
||||
pricing: z.boolean().optional(),
|
||||
}).optional(),
|
||||
about: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
highlights: z.boolean().optional(),
|
||||
details: z.boolean().optional(),
|
||||
}).optional(),
|
||||
offers: z.object({
|
||||
header: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
pricing: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
plans: z.boolean().optional(),
|
||||
payments: z.boolean().optional(),
|
||||
faq: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicles: z.object({
|
||||
header: z.boolean().optional(),
|
||||
filters: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicleDetail: z.object({
|
||||
gallery: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
}).optional(),
|
||||
blog: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
posts: z.boolean().optional(),
|
||||
}).optional(),
|
||||
contact: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
booking: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
bookingConfirmation: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
actions: z.boolean().optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
}).optional()
|
||||
|
||||
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({
|
||||
value: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplacePillarSchema: z.ZodType<MarketplaceHomepagePillar> = z.object({
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplaceStepSchema: z.ZodType<MarketplaceHomepageStep> = z.object({
|
||||
step: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum([
|
||||
'hero',
|
||||
'surface',
|
||||
'pillars',
|
||||
'audiences',
|
||||
'features',
|
||||
'steps',
|
||||
'closing',
|
||||
])
|
||||
|
||||
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
|
||||
sections: z.array(marketplaceSectionSchema).min(1),
|
||||
heroKicker: z.string().min(1),
|
||||
heroTitle: z.string().min(1),
|
||||
heroBody: z.string().min(1),
|
||||
startTrial: z.string().min(1),
|
||||
exploreVehicles: z.string().min(1),
|
||||
surfaceLabel: z.string().min(1),
|
||||
surfaceTitle: z.string().min(1),
|
||||
surfaceBody: z.string().min(1),
|
||||
liveLabel: z.string().min(1),
|
||||
trustedFleets: z.string().min(1),
|
||||
brandedFlows: z.string().min(1),
|
||||
multiTenant: z.string().min(1),
|
||||
companyKicker: z.string().min(1),
|
||||
companyTitle: z.string().min(1),
|
||||
companyBody: z.string().min(1),
|
||||
renterKicker: z.string().min(1),
|
||||
renterTitle: z.string().min(1),
|
||||
renterBody: z.string().min(1),
|
||||
pillars: z.array(marketplacePillarSchema).length(3),
|
||||
metrics: z.array(marketplaceMetricSchema).length(3),
|
||||
featureLabel: z.string().min(1),
|
||||
features: z.array(z.string().min(1)).min(1),
|
||||
stepsTitle: z.string().min(1),
|
||||
steps: z.array(marketplaceStepSchema).length(3),
|
||||
stepLabel: z.string().min(1),
|
||||
readyKicker: z.string().min(1),
|
||||
readyTitle: z.string().min(1),
|
||||
readyBody: z.string().min(1),
|
||||
viewPricing: z.string().min(1),
|
||||
createWorkspace: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplaceHomepageConfigSchema = z.object({
|
||||
en: marketplaceHomepageContentSchema,
|
||||
fr: marketplaceHomepageContentSchema,
|
||||
ar: marketplaceHomepageContentSchema,
|
||||
})
|
||||
|
||||
const adminCompanyUpdateSchema = z.object({
|
||||
company: z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
slug: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phone: nullableString,
|
||||
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
||||
subscriptionPaymentRef: nullableString,
|
||||
}).optional(),
|
||||
subscription: z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
|
||||
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
trialStartAt: nullableDate,
|
||||
trialEndAt: nullableDate,
|
||||
currentPeriodStart: nullableDate,
|
||||
currentPeriodEnd: nullableDate,
|
||||
cancelledAt: nullableDate,
|
||||
cancelAtPeriodEnd: z.boolean().optional(),
|
||||
}).optional(),
|
||||
brand: z.object({
|
||||
displayName: z.string().min(1).optional(),
|
||||
tagline: nullableString,
|
||||
subdomain: z.string().min(1).optional(),
|
||||
customDomain: nullableString,
|
||||
publicEmail: nullableEmail,
|
||||
publicPhone: nullableString,
|
||||
publicAddress: nullableString,
|
||||
publicCity: nullableString,
|
||||
publicCountry: nullableString,
|
||||
websiteUrl: nullableUrl,
|
||||
whatsappNumber: nullableString,
|
||||
defaultLocale: z.string().min(2).optional(),
|
||||
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: homePageConfigSchema,
|
||||
menuConfig: menuConfigSchema,
|
||||
}).optional(),
|
||||
contractSettings: z.object({
|
||||
legalName: nullableString,
|
||||
registrationNumber: nullableString,
|
||||
taxId: nullableString,
|
||||
terms: z.string().optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
lateFeePerHour: z.number().int().nullable().optional(),
|
||||
taxRate: z.number().nullable().optional(),
|
||||
signatureRequired: z.boolean().optional(),
|
||||
showTax: z.boolean().optional(),
|
||||
}).optional(),
|
||||
accountingSettings: z.object({
|
||||
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
|
||||
fiscalYearStart: z.number().int().min(1).max(12).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
accountantEmail: nullableEmail,
|
||||
accountantName: nullableString,
|
||||
autoSendReport: z.boolean().optional(),
|
||||
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
function parseOptionalDate(value?: string | null) {
|
||||
if (!value) return null
|
||||
return new Date(value)
|
||||
}
|
||||
|
||||
function toAuditJson<T>(value: T) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
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().max(255).trim().toLowerCase(), password: z.string().max(128), totpCode: z.string().length(6).optional() }).parse(req.body)
|
||||
const admin = await prisma.adminUser.findUnique({ where: { email } })
|
||||
if (!admin || !admin.isActive) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
||||
|
||||
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) }
|
||||
})
|
||||
|
||||
const ADMIN_RESET_TOKEN_TTL_MINUTES = 60
|
||||
|
||||
router.post('/auth/forgot-password', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = z.object({ email: z.string().email().max(255).trim().toLowerCase() }).parse(req.body)
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { email } })
|
||||
if (admin && admin.isActive) {
|
||||
const rawToken = crypto.randomBytes(32).toString('hex')
|
||||
const expiresAt = new Date(Date.now() + ADMIN_RESET_TOKEN_TTL_MINUTES * 60 * 1000)
|
||||
|
||||
await prisma.adminUser.update({
|
||||
where: { id: admin.id },
|
||||
data: { passwordResetToken: rawToken, passwordResetExpiresAt: expiresAt },
|
||||
})
|
||||
|
||||
const adminUrl = process.env.ADMIN_URL ?? 'http://localhost:3002'
|
||||
const resetUrl = `${adminUrl}/reset-password?token=${rawToken}`
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: email,
|
||||
subject: 'Reset your RentalDriveGo admin password',
|
||||
html: `<p>Hi ${admin.firstName},</p><p>Reset your admin password here:</p><p><a href="${resetUrl}" style="background:#059669;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset password</a></p><p>This link expires in ${ADMIN_RESET_TOKEN_TTL_MINUTES} minutes.</p><p>RentalDriveGo</p>`,
|
||||
text: `Hi ${admin.firstName},\n\nReset your admin password here: ${resetUrl}\n\nThis link expires in ${ADMIN_RESET_TOKEN_TTL_MINUTES} minutes.\n\nRentalDriveGo`,
|
||||
}).catch((err) => console.error('[AdminForgotPassword] Email send failed:', err?.message))
|
||||
}
|
||||
|
||||
res.json({ data: { message: 'If that email is registered, a reset link has been sent.' } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/auth/reset-password', async (req, res, next) => {
|
||||
try {
|
||||
const { token, password } = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8).max(128),
|
||||
}).parse(req.body)
|
||||
|
||||
const admin = await prisma.adminUser.findFirst({
|
||||
where: {
|
||||
passwordResetToken: token,
|
||||
passwordResetExpiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
|
||||
if (!admin) {
|
||||
return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 })
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
|
||||
await prisma.adminUser.update({
|
||||
where: { id: admin.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordResetToken: null,
|
||||
passwordResetExpiresAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: { message: 'Password updated successfully. You can now sign in.' } })
|
||||
} 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,
|
||||
contractSettings: true,
|
||||
accountingSettings: true,
|
||||
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
|
||||
employees: true,
|
||||
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
|
||||
},
|
||||
})
|
||||
res.json({ data: company })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
res.json({ data: await getMarketplaceHomepageContent() })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { homepage } = z.object({ homepage: marketplaceHomepageConfigSchema }).parse(req.body)
|
||||
const saved = await saveMarketplaceHomepageContent(homepage)
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
adminUserId: req.admin.id,
|
||||
action: 'UPDATE',
|
||||
resource: 'MarketplaceHomepage',
|
||||
after: toAuditJson(saved),
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
},
|
||||
})
|
||||
res.json({ data: saved })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const body = adminCompanyUpdateSchema.parse(req.body)
|
||||
const before = await prisma.company.findUniqueOrThrow({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
brand: true,
|
||||
contractSettings: true,
|
||||
accountingSettings: true,
|
||||
subscription: true,
|
||||
},
|
||||
})
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (body.company) {
|
||||
await tx.company.update({
|
||||
where: { id: req.params.id },
|
||||
data: body.company,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.subscription) {
|
||||
await tx.subscription.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: {
|
||||
...body.subscription,
|
||||
trialStartAt: parseOptionalDate(body.subscription.trialStartAt),
|
||||
trialEndAt: parseOptionalDate(body.subscription.trialEndAt),
|
||||
currentPeriodStart: parseOptionalDate(body.subscription.currentPeriodStart),
|
||||
currentPeriodEnd: parseOptionalDate(body.subscription.currentPeriodEnd),
|
||||
cancelledAt: parseOptionalDate(body.subscription.cancelledAt),
|
||||
},
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
plan: body.subscription.plan ?? 'STARTER',
|
||||
billingPeriod: body.subscription.billingPeriod ?? 'MONTHLY',
|
||||
status: body.subscription.status ?? 'TRIALING',
|
||||
currency: body.subscription.currency ?? 'MAD',
|
||||
trialStartAt: parseOptionalDate(body.subscription.trialStartAt),
|
||||
trialEndAt: parseOptionalDate(body.subscription.trialEndAt),
|
||||
currentPeriodStart: parseOptionalDate(body.subscription.currentPeriodStart),
|
||||
currentPeriodEnd: parseOptionalDate(body.subscription.currentPeriodEnd),
|
||||
cancelledAt: parseOptionalDate(body.subscription.cancelledAt),
|
||||
cancelAtPeriodEnd: body.subscription.cancelAtPeriodEnd ?? false,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.brand) {
|
||||
const currentCompany = await tx.company.findUniqueOrThrow({ where: { id: req.params.id } })
|
||||
await tx.brandSettings.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: body.brand,
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
displayName: body.brand.displayName ?? currentCompany.name,
|
||||
subdomain: body.brand.subdomain ?? currentCompany.slug,
|
||||
paymentMethodsEnabled: before.brand?.paymentMethodsEnabled ?? [],
|
||||
...body.brand,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.contractSettings) {
|
||||
await tx.contractSettings.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: body.contractSettings,
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
...body.contractSettings,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.accountingSettings) {
|
||||
await tx.accountingSettings.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: body.accountingSettings,
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
...body.accountingSettings,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
return tx.company.findUniqueOrThrow({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
brand: true,
|
||||
contractSettings: true,
|
||||
accountingSettings: true,
|
||||
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
|
||||
employees: true,
|
||||
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
adminUserId: req.admin.id,
|
||||
action: 'UPDATE_COMPANY',
|
||||
resource: 'Company',
|
||||
resourceId: req.params.id,
|
||||
companyId: req.params.id,
|
||||
before: toAuditJson(before),
|
||||
after: toAuditJson(body),
|
||||
ipAddress: req.ip,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: updated })
|
||||
} 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, entityId, 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
|
||||
if (entityId) where.resourceId = entityId
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({ where, include: { adminUser: { select: { firstName: true, lastName: true, email: true } } }, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }),
|
||||
prisma.auditLog.count({ where }),
|
||||
])
|
||||
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,
|
||||
permissions: true,
|
||||
},
|
||||
})
|
||||
res.json({ data: admins })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const body = z.object({
|
||||
email: z.string().email(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
password: z.string().min(8),
|
||||
permissions: z.array(permissionSchema).optional(),
|
||||
}).parse(req.body)
|
||||
const passwordHash = await bcrypt.hash(body.password, 12)
|
||||
const admin = await prisma.adminUser.create({
|
||||
data: {
|
||||
email: body.email,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
role: body.role,
|
||||
passwordHash,
|
||||
permissions: body.permissions ? {
|
||||
create: body.permissions,
|
||||
} : undefined,
|
||||
},
|
||||
include: { permissions: true },
|
||||
})
|
||||
const { passwordHash: _, ...safe } = admin
|
||||
res.status(201).json({ data: safe })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { role } = z.object({ role: z.enum(['SUPER_ADMIN', 'ADMIN','SUPPORT','FINANCE','VIEWER']) }).parse(req.body)
|
||||
await prisma.adminUser.update({ where: { id: req.params.id }, data: { role } })
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { permissions } = z.object({ permissions: z.array(permissionSchema) }).parse(req.body)
|
||||
await prisma.adminUser.findUniqueOrThrow({ where: { id: req.params.id } })
|
||||
await prisma.$transaction([
|
||||
prisma.adminPermission.deleteMany({ where: { adminUserId: req.params.id } }),
|
||||
prisma.adminPermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
adminUserId: req.params.id,
|
||||
resource: permission.resource,
|
||||
actions: permission.actions,
|
||||
})) as any,
|
||||
}),
|
||||
])
|
||||
const admin = await prisma.adminUser.findUniqueOrThrow({
|
||||
where: { id: req.params.id },
|
||||
include: { permissions: true },
|
||||
})
|
||||
res.json({ data: admin })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Billing ──────────────────────────────────────────────────
|
||||
|
||||
const PLAN_MONTHLY_AMOUNT: Record<string, number> = {
|
||||
STARTER: 29900,
|
||||
GROWTH: 59900,
|
||||
PRO: 99900,
|
||||
}
|
||||
|
||||
router.get('/billing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { status, plan, page = '1', pageSize = '20' } = req.query as Record<string, string>
|
||||
const where: any = {}
|
||||
if (status) where.status = status
|
||||
if (plan) where.plan = plan
|
||||
|
||||
const [subscriptions, total] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where,
|
||||
include: {
|
||||
company: { select: { id: true, name: true, email: true, slug: true, status: true } },
|
||||
invoices: { select: { id: true, amount: true, currency: true, status: true, paidAt: true, createdAt: true }, orderBy: { createdAt: 'desc' }, take: 5 },
|
||||
_count: { select: { invoices: true } },
|
||||
},
|
||||
skip: (parseInt(page) - 1) * parseInt(pageSize),
|
||||
take: parseInt(pageSize),
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.subscription.count({ where }),
|
||||
])
|
||||
|
||||
const activeStatuses = ['ACTIVE', 'TRIALING'] as any[]
|
||||
const allActive = await prisma.subscription.findMany({
|
||||
where: { status: { in: activeStatuses } },
|
||||
select: { plan: true, billingPeriod: true, currency: true },
|
||||
})
|
||||
|
||||
let mrr = 0
|
||||
for (const sub of allActive) {
|
||||
const monthly = PLAN_MONTHLY_AMOUNT[sub.plan] ?? 0
|
||||
mrr += sub.billingPeriod === 'ANNUAL' ? Math.round(monthly * 10 / 12) : monthly
|
||||
}
|
||||
|
||||
const [activeCount, trialingCount, pastDueCount, cancelledCount] = await Promise.all([
|
||||
prisma.subscription.count({ where: { status: 'ACTIVE' } }),
|
||||
prisma.subscription.count({ where: { status: 'TRIALING' } }),
|
||||
prisma.subscription.count({ where: { status: 'PAST_DUE' } }),
|
||||
prisma.subscription.count({ where: { status: 'CANCELLED' } }),
|
||||
])
|
||||
|
||||
res.json({
|
||||
data: subscriptions,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
totalPages: Math.ceil(total / parseInt(pageSize)),
|
||||
stats: { mrr, activeCount, trialingCount, pastDueCount, cancelledCount },
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/:companyId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const { page = '1', pageSize = '20' } = req.query as Record<string, string>
|
||||
const [invoices, total] = await Promise.all([
|
||||
prisma.subscriptionInvoice.findMany({
|
||||
where: { companyId: req.params.companyId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (parseInt(page) - 1) * parseInt(pageSize),
|
||||
take: parseInt(pageSize),
|
||||
}),
|
||||
prisma.subscriptionInvoice.count({ where: { companyId: req.params.companyId } }),
|
||||
])
|
||||
res.json({ data: invoices, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
|
||||
try {
|
||||
const invoice = await prisma.subscriptionInvoice.findUniqueOrThrow({
|
||||
where: { id: req.params.invoiceId },
|
||||
include: {
|
||||
company: { select: { name: true, email: true, phone: true, address: true } },
|
||||
subscription: { select: { plan: true, billingPeriod: true, currency: true, currentPeriodStart: true, currentPeriodEnd: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const invoiceNumber = buildInvoiceNumber(invoice.id, invoice.createdAt)
|
||||
const transactionId = invoice.amanpayTransactionId ?? invoice.paypalCaptureId ?? null
|
||||
|
||||
const pdfBuffer = await generateInvoicePdf({
|
||||
invoiceNumber,
|
||||
issueDate: invoice.createdAt.toISOString(),
|
||||
dueDate: invoice.status === 'PENDING' ? invoice.createdAt.toISOString() : null,
|
||||
company: {
|
||||
name: invoice.company.name,
|
||||
email: invoice.company.email,
|
||||
phone: invoice.company.phone,
|
||||
address: invoice.company.address,
|
||||
},
|
||||
subscription: {
|
||||
plan: invoice.subscription.plan,
|
||||
billingPeriod: invoice.subscription.billingPeriod,
|
||||
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
|
||||
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
|
||||
currency: invoice.subscription.currency,
|
||||
},
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status,
|
||||
paymentProvider: invoice.paymentProvider,
|
||||
transactionId,
|
||||
paidAt: invoice.paidAt?.toISOString(),
|
||||
})
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${invoiceNumber}.pdf"`)
|
||||
res.setHeader('Content-Length', pdfBuffer.length)
|
||||
res.end(pdfBuffer)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,180 @@
|
||||
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)
|
||||
|
||||
function getRangeFromPeriod(period: string) {
|
||||
const now = new Date()
|
||||
|
||||
switch (period) {
|
||||
case 'WEEKLY': {
|
||||
const start = new Date(now)
|
||||
start.setDate(now.getDate() - 7)
|
||||
return { from: start, to: now }
|
||||
}
|
||||
case 'QUARTERLY': {
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 2, 1)
|
||||
return { from: start, to: now }
|
||||
}
|
||||
case 'ANNUAL': {
|
||||
const start = new Date(now.getFullYear(), 0, 1)
|
||||
return { from: start, to: now }
|
||||
}
|
||||
case 'MONTHLY':
|
||||
default:
|
||||
return { from: new Date(now.getFullYear(), now.getMonth(), 1), to: now }
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/summary', async (req, res, next) => {
|
||||
try {
|
||||
const { period = '30d' } = req.query as Record<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('/dashboard', async (req, res, next) => {
|
||||
try {
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const prevMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
const prevMonthEnd = new Date(monthStart.getTime() - 1)
|
||||
|
||||
const [
|
||||
totalBookings,
|
||||
previousBookings,
|
||||
activeVehicles,
|
||||
previousActiveVehicles,
|
||||
totalCustomers,
|
||||
previousCustomers,
|
||||
monthlyRevenueAgg,
|
||||
previousRevenueAgg,
|
||||
recentReservations,
|
||||
sourceGroups,
|
||||
subscription,
|
||||
] = await Promise.all([
|
||||
prisma.reservation.count({ where: { companyId: req.companyId, createdAt: { gte: monthStart } } }),
|
||||
prisma.reservation.count({ where: { companyId: req.companyId, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } } }),
|
||||
prisma.vehicle.count({ where: { companyId: req.companyId, status: { in: ['AVAILABLE', 'RENTED'] } } }),
|
||||
prisma.vehicle.count({ where: { companyId: req.companyId, createdAt: { lte: prevMonthEnd }, status: { in: ['AVAILABLE', 'RENTED'] } } }),
|
||||
prisma.customer.count({ where: { companyId: req.companyId } }),
|
||||
prisma.customer.count({ where: { companyId: req.companyId, createdAt: { lte: prevMonthEnd } } }),
|
||||
prisma.reservation.aggregate({ where: { companyId: req.companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: monthStart } }, _sum: { totalAmount: true } }),
|
||||
prisma.reservation.aggregate({ where: { companyId: req.companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } }, _sum: { totalAmount: true } }),
|
||||
prisma.reservation.findMany({
|
||||
where: { companyId: req.companyId },
|
||||
include: { customer: true, vehicle: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
}),
|
||||
prisma.reservation.groupBy({
|
||||
by: ['source'],
|
||||
where: { companyId: req.companyId },
|
||||
_count: { id: true },
|
||||
_sum: { totalAmount: true },
|
||||
}),
|
||||
prisma.subscription.findUnique({ where: { companyId: req.companyId } }),
|
||||
])
|
||||
|
||||
const pct = (current: number, previous: number) => {
|
||||
if (previous === 0) return current > 0 ? 100 : 0
|
||||
return Math.round(((current - previous) / previous) * 100)
|
||||
}
|
||||
|
||||
const typedRecentReservations = recentReservations as Array<{
|
||||
id: string
|
||||
contractNumber: string | null
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
status: string
|
||||
totalAmount: number
|
||||
customer: { firstName: string; lastName: string }
|
||||
vehicle: { make: string; model: string }
|
||||
}>
|
||||
|
||||
const typedSourceGroups = sourceGroups as Array<{
|
||||
source: string
|
||||
_count: { id: number }
|
||||
_sum: { totalAmount: number | null }
|
||||
}>
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
kpis: {
|
||||
totalBookings,
|
||||
activeVehicles,
|
||||
monthlyRevenue: monthlyRevenueAgg._sum.totalAmount ?? 0,
|
||||
totalCustomers,
|
||||
bookingsChange: pct(totalBookings, previousBookings),
|
||||
vehiclesChange: pct(activeVehicles, previousActiveVehicles),
|
||||
revenueChange: pct(monthlyRevenueAgg._sum.totalAmount ?? 0, previousRevenueAgg._sum.totalAmount ?? 0),
|
||||
customersChange: pct(totalCustomers, previousCustomers),
|
||||
},
|
||||
recentReservations: typedRecentReservations.map((reservation) => ({
|
||||
id: reservation.id,
|
||||
bookingRef: reservation.contractNumber ?? reservation.id.slice(-8).toUpperCase(),
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
vehicleName: `${reservation.vehicle.make} ${reservation.vehicle.model}`,
|
||||
startDate: reservation.startDate,
|
||||
endDate: reservation.endDate,
|
||||
status: reservation.status,
|
||||
totalAmount: reservation.totalAmount,
|
||||
})),
|
||||
sourceBreakdown: typedSourceGroups.map((group) => ({
|
||||
source: group.source,
|
||||
count: group._count.id,
|
||||
revenue: group._sum.totalAmount ?? 0,
|
||||
})),
|
||||
subscription: subscription ? {
|
||||
status: subscription.status,
|
||||
planName: subscription.plan,
|
||||
trialEndsAt: subscription.trialEndAt,
|
||||
} : null,
|
||||
},
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/sources', async (req, res, next) => {
|
||||
try {
|
||||
const sources = await prisma.reservation.groupBy({ by: ['source'], where: { companyId: req.companyId }, _count: { id: true } })
|
||||
res.json({ data: sources })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/report', async (req, res, next) => {
|
||||
try {
|
||||
const { from, to, format = 'JSON', period } = req.query as Record<string, string>
|
||||
const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } })
|
||||
const derivedRange = getRangeFromPeriod(period || accountingSettings?.reportingPeriod || 'MONTHLY')
|
||||
const startDate = from ? new Date(from) : derivedRange.from
|
||||
const endDate = to ? new Date(to) : derivedRange.to
|
||||
|
||||
const report = await generateFinancialReport(req.companyId, startDate, endDate)
|
||||
|
||||
if (format === 'CSV') {
|
||||
res.setHeader('Content-Type', 'text/csv')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="report-${startDate.toISOString().slice(0, 10)}-${endDate.toISOString().slice(0, 10)}.csv"`)
|
||||
return res.send(toCsv(report.rows))
|
||||
}
|
||||
|
||||
res.json({ data: report })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendNotification } from '../services/notificationService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const signupSchema = z.object({
|
||||
firstName: z.string().min(1).max(80),
|
||||
lastName: z.string().min(1).max(80),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(128),
|
||||
companyName: z.string().min(2).max(120),
|
||||
companyPhone: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
})
|
||||
|
||||
type EmailDeliveryResult = {
|
||||
attempted: boolean
|
||||
success: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function slugifyCompanyName(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50) || 'company'
|
||||
}
|
||||
|
||||
async function generateUniqueSlug(baseName: string) {
|
||||
const base = slugifyCompanyName(baseName)
|
||||
|
||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||||
const slug = attempt === 0 ? base : `${base}-${attempt + 1}`
|
||||
const existing = await prisma.company.findUnique({ where: { slug } })
|
||||
if (!existing) return slug
|
||||
}
|
||||
|
||||
return `${base}-${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number) {
|
||||
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
function buildSignupConfirmationEmail(opts: {
|
||||
firstName: string
|
||||
companyName: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD' | 'USD' | 'EUR'
|
||||
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
||||
trialEndAt: Date
|
||||
}) {
|
||||
const greetingName = opts.firstName.trim() || 'there'
|
||||
|
||||
return [
|
||||
`Hi ${greetingName},`,
|
||||
'',
|
||||
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
|
||||
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
|
||||
`Currency: ${opts.currency}`,
|
||||
`Primary payment provider: ${opts.paymentProvider}`,
|
||||
`Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}.`,
|
||||
'',
|
||||
'Your workspace is ready and you can sign in with the email address and password you chose during signup.',
|
||||
'',
|
||||
'RentalDriveGo',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function sendSignupEmailNotification(opts: Parameters<typeof sendNotification>[0]): Promise<EmailDeliveryResult> {
|
||||
const result = await sendNotification(opts)
|
||||
const emailResult = result.find((entry) => entry.channel === 'EMAIL')
|
||||
|
||||
if (!emailResult) {
|
||||
return { attempted: false, success: false, error: null }
|
||||
}
|
||||
|
||||
return {
|
||||
attempted: true,
|
||||
success: emailResult.success,
|
||||
error: emailResult.error ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/signup', async (req, res, next) => {
|
||||
try {
|
||||
const body = signupSchema.parse(req.body)
|
||||
|
||||
const existingCompany = await prisma.company.findUnique({ where: { email: body.email } })
|
||||
if (existingCompany) {
|
||||
return res.status(409).json({
|
||||
error: 'email_taken',
|
||||
message: 'A company account with this email already exists',
|
||||
statusCode: 409,
|
||||
})
|
||||
}
|
||||
|
||||
const existingEmployee = await prisma.employee.findFirst({ where: { email: body.email } })
|
||||
if (existingEmployee) {
|
||||
return res.status(409).json({
|
||||
error: 'email_taken',
|
||||
message: 'An employee account with this email already exists',
|
||||
statusCode: 409,
|
||||
})
|
||||
}
|
||||
|
||||
const slug = await generateUniqueSlug(body.companyName)
|
||||
const now = new Date()
|
||||
const trialEndAt = addDays(now, 14)
|
||||
const passwordHash = await bcrypt.hash(body.password, 12)
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const company = await tx.company.create({
|
||||
data: {
|
||||
name: body.companyName,
|
||||
slug,
|
||||
email: body.email,
|
||||
phone: body.companyPhone || null,
|
||||
status: 'TRIALING',
|
||||
},
|
||||
})
|
||||
|
||||
await tx.brandSettings.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
displayName: body.companyName,
|
||||
subdomain: slug,
|
||||
publicEmail: body.email,
|
||||
publicPhone: body.companyPhone || null,
|
||||
publicCountry: body.country || null,
|
||||
publicCity: body.city || null,
|
||||
defaultCurrency: body.currency,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.subscription.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
plan: body.plan,
|
||||
billingPeriod: body.billingPeriod,
|
||||
currency: body.currency,
|
||||
status: 'TRIALING',
|
||||
trialStartAt: now,
|
||||
trialEndAt,
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: trialEndAt,
|
||||
},
|
||||
})
|
||||
|
||||
const employee = await tx.employee.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
clerkUserId: `local_owner_${company.id}`,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
email: body.email,
|
||||
phone: body.companyPhone || null,
|
||||
passwordHash,
|
||||
role: 'OWNER',
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
company,
|
||||
employeeId: employee.id,
|
||||
}
|
||||
})
|
||||
|
||||
const emailDelivery = await sendSignupEmailNotification({
|
||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||
title: 'Your workspace is ready',
|
||||
body: buildSignupConfirmationEmail({
|
||||
firstName: body.firstName,
|
||||
companyName: body.companyName,
|
||||
plan: body.plan,
|
||||
billingPeriod: body.billingPeriod,
|
||||
currency: body.currency,
|
||||
paymentProvider: body.paymentProvider,
|
||||
trialEndAt,
|
||||
}),
|
||||
companyId: result.company.id,
|
||||
employeeId: result.employeeId,
|
||||
email: body.email,
|
||||
channels: ['EMAIL', 'IN_APP'],
|
||||
})
|
||||
|
||||
res.status(201).json({
|
||||
data: {
|
||||
companyId: result.company.id,
|
||||
companyName: result.company.name,
|
||||
slug: result.company.slug,
|
||||
invitationId: null,
|
||||
trialEndsAt: trialEndAt.toISOString(),
|
||||
nextStep: 'workspace_created',
|
||||
emailDelivery,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/complete-signup', (_req, res) => {
|
||||
res.status(410).json({
|
||||
error: 'disabled',
|
||||
message: 'Clerk-based signup has been removed. Use /auth/company/signup instead.',
|
||||
statusCode: 410,
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/verify-email', (_req, res) => {
|
||||
res.status(410).json({
|
||||
error: 'disabled',
|
||||
message: 'Email verification resend via Clerk has been removed from this project.',
|
||||
statusCode: 410,
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import crypto from 'crypto'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendTransactionalEmail } from '../services/notificationService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const RESET_TOKEN_TTL_MINUTES = 60
|
||||
|
||||
function signEmployeeToken(employeeId: string) {
|
||||
return jwt.sign(
|
||||
{ sub: employeeId, type: 'employee' },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'] },
|
||||
)
|
||||
}
|
||||
|
||||
function buildResetEmailHtml(resetUrl: string, firstName: string) {
|
||||
return `
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>You requested a password reset for your RentalDriveGo workspace account.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset your password</a></p>
|
||||
<p>This link expires in ${RESET_TOKEN_TTL_MINUTES} minutes. If you did not request this, you can safely ignore this email.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
`
|
||||
}
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
password: z.string().max(128),
|
||||
}).parse(req.body)
|
||||
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { email },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
if (!employee || !employee.isActive) {
|
||||
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
||||
}
|
||||
|
||||
if (!employee.passwordHash) {
|
||||
return res.status(401).json({ error: 'password_not_set', message: 'This account does not have a password yet. Use the invitation or reset email to set one first.', statusCode: 401 })
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, employee.passwordHash)
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
||||
}
|
||||
|
||||
const token = signEmployeeToken(employee.id)
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
token,
|
||||
employee: {
|
||||
id: employee.id,
|
||||
email: employee.email,
|
||||
firstName: employee.firstName,
|
||||
lastName: employee.lastName,
|
||||
role: employee.role,
|
||||
companyId: employee.companyId,
|
||||
companyName: employee.company.name,
|
||||
companySlug: employee.company.slug,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/forgot-password', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = z.object({ email: z.string().email().max(255).trim().toLowerCase() }).parse(req.body)
|
||||
|
||||
// Always return success to avoid user enumeration
|
||||
const employee = await prisma.employee.findFirst({ where: { email, isActive: true } })
|
||||
if (employee) {
|
||||
const rawToken = crypto.randomBytes(32).toString('hex')
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000)
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: employee.id },
|
||||
data: { passwordResetToken: rawToken, passwordResetExpiresAt: expiresAt },
|
||||
})
|
||||
|
||||
const dashboardUrl = process.env.DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: email,
|
||||
subject: 'Reset your RentalDriveGo password',
|
||||
html: buildResetEmailHtml(resetUrl, employee.firstName),
|
||||
text: `Hi ${employee.firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${RESET_TOKEN_TTL_MINUTES} minutes.\n\nRentalDriveGo`,
|
||||
}).catch((err) => console.error('[ForgotPassword] Email send failed:', err?.message))
|
||||
}
|
||||
|
||||
res.json({ data: { message: 'If that email is registered, a reset link has been sent.' } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reset-password', async (req, res, next) => {
|
||||
try {
|
||||
const { token, password } = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8).max(128),
|
||||
}).parse(req.body)
|
||||
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: {
|
||||
passwordResetToken: token,
|
||||
passwordResetExpiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
|
||||
if (!employee) {
|
||||
return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 })
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: employee.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordResetToken: null,
|
||||
passwordResetExpiresAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: { message: 'Password updated successfully. You can now sign in.' } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireRenterAuth } from '../middleware/requireRenterAuth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/signup', async (_req, res) => {
|
||||
res.status(403).json({
|
||||
error: 'renter_signup_disabled',
|
||||
message: 'Renter account creation is disabled. Only company owners can sign in to the platform.',
|
||||
statusCode: 403,
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/login', async (_req, res) => {
|
||||
res.status(403).json({
|
||||
error: 'renter_login_disabled',
|
||||
message: 'Renter sign-in is disabled. Only company owners can sign in to the platform.',
|
||||
statusCode: 403,
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/me', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const renter = await prisma.renter.findUniqueOrThrow({
|
||||
where: { id: req.renterId },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
preferredLocale: true,
|
||||
preferredCurrency: true,
|
||||
emailVerified: true,
|
||||
savedCompanies: {
|
||||
select: {
|
||||
companyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const savedCompanyIds = renter.savedCompanies.map((saved) => saved.companyId)
|
||||
const companies = savedCompanyIds.length === 0
|
||||
? []
|
||||
: await prisma.company.findMany({
|
||||
where: { id: { in: savedCompanyIds } },
|
||||
select: {
|
||||
id: true,
|
||||
brand: {
|
||||
select: {
|
||||
displayName: true,
|
||||
subdomain: true,
|
||||
logoUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const companyMap = new Map(companies.map((company) => [company.id, company]))
|
||||
|
||||
const data = {
|
||||
...renter,
|
||||
savedCompanies: renter.savedCompanies.map((saved) => ({
|
||||
id: saved.companyId,
|
||||
brand: companyMap.get(saved.companyId)?.brand ?? null,
|
||||
})),
|
||||
}
|
||||
res.json({ data })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const body = z.object({
|
||||
firstName: z.string().min(1).max(100).trim().optional(),
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
phone: z.string().max(30).trim().optional(),
|
||||
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
|
||||
preferredCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
}).parse(req.body)
|
||||
|
||||
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,501 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import multer from 'multer'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { uploadImage } from '../lib/storage'
|
||||
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../middleware/requireTenant'
|
||||
import { requireSubscription } from '../middleware/requireSubscription'
|
||||
|
||||
const router = Router()
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
const companySchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
address: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
const brandSchema = z.object({
|
||||
displayName: z.string().min(1).optional(),
|
||||
tagline: z.string().optional(),
|
||||
primaryColor: z.string().optional(),
|
||||
accentColor: z.string().optional(),
|
||||
publicEmail: z.string().email().optional(),
|
||||
publicPhone: z.string().optional(),
|
||||
publicAddress: z.string().optional(),
|
||||
publicCity: z.string().optional(),
|
||||
publicCountry: z.string().optional(),
|
||||
websiteUrl: z.string().url().optional(),
|
||||
whatsappNumber: z.string().optional(),
|
||||
defaultLocale: z.string().optional(),
|
||||
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
amanpayMerchantId: z.string().optional(),
|
||||
amanpaySecretKey: z.string().optional(),
|
||||
paypalEmail: z.string().email().optional(),
|
||||
paypalMerchantId: z.string().optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: z.object({
|
||||
heroTitle: z.union([z.string(), z.null()]).optional(),
|
||||
heroDescription: z.union([z.string(), z.null()]).optional(),
|
||||
viewOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
viewPricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactCompanyLabel: z.union([z.string(), z.null()]).optional(),
|
||||
activeOffersTitle: z.union([z.string(), z.null()]).optional(),
|
||||
seeAllOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
noActiveOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
publishedVehiclesTitle: z.union([z.string(), z.null()]).optional(),
|
||||
viewVehicleLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingEyebrow: z.union([z.string(), z.null()]).optional(),
|
||||
pricingTitle: z.union([z.string(), z.null()]).optional(),
|
||||
pricingDescription: z.union([z.string(), z.null()]).optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
layout: z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(['hero', 'offers', 'vehicles', 'pricing']),
|
||||
x: z.number().int().min(1),
|
||||
y: z.number().int().min(1),
|
||||
w: z.number().int().min(1),
|
||||
h: z.number().int().min(1),
|
||||
})),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
menuConfig: z.object({
|
||||
aboutLabel: z.union([z.string(), z.null()]).optional(),
|
||||
vehiclesLabel: z.union([z.string(), z.null()]).optional(),
|
||||
offersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
blogLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactLabel: z.union([z.string(), z.null()]).optional(),
|
||||
bookCarLabel: z.union([z.string(), z.null()]).optional(),
|
||||
siteNavigationLabel: z.union([z.string(), z.null()]).optional(),
|
||||
showAbout: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
showBlog: z.boolean().optional(),
|
||||
showContact: z.boolean().optional(),
|
||||
pageSections: z.object({
|
||||
home: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
offers: z.boolean().optional(),
|
||||
vehicles: z.boolean().optional(),
|
||||
pricing: z.boolean().optional(),
|
||||
}).optional(),
|
||||
about: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
highlights: z.boolean().optional(),
|
||||
details: z.boolean().optional(),
|
||||
}).optional(),
|
||||
offers: z.object({
|
||||
header: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
pricing: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
plans: z.boolean().optional(),
|
||||
payments: z.boolean().optional(),
|
||||
faq: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicles: z.object({
|
||||
header: z.boolean().optional(),
|
||||
filters: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicleDetail: z.object({
|
||||
gallery: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
}).optional(),
|
||||
blog: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
posts: z.boolean().optional(),
|
||||
}).optional(),
|
||||
contact: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
booking: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
bookingConfirmation: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
actions: z.boolean().optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
const contractSettingsSchema = z.object({
|
||||
legalName: z.string().optional(),
|
||||
registrationNumber: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
terms: z.string().optional(),
|
||||
fuelPolicy: z.string().optional(),
|
||||
depositPolicy: z.string().optional(),
|
||||
lateFeePolicy: z.string().optional(),
|
||||
damagePolicy: z.string().optional(),
|
||||
contractFooterNote: z.string().optional(),
|
||||
invoiceFooterNote: z.string().optional(),
|
||||
signatureRequired: z.boolean().optional(),
|
||||
showTax: z.boolean().optional(),
|
||||
taxRate: z.number().optional(),
|
||||
taxLabel: z.string().optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
fuelPolicyNote: z.string().optional(),
|
||||
fuelChargePerLiter: z.number().int().optional(),
|
||||
fuelShortfallFee: z.number().int().optional(),
|
||||
lateFeePerHour: z.number().int().optional(),
|
||||
additionalDriverCharge: z.enum(['FREE', 'PER_DAY', 'FLAT']).optional(),
|
||||
additionalDriverDailyRate: z.number().int().optional(),
|
||||
additionalDriverFlatRate: z.number().int().optional(),
|
||||
})
|
||||
|
||||
const insurancePolicySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'FULL', 'BASIC', 'ROADSIDE', 'PERSONAL', 'CUSTOM']),
|
||||
chargeType: z.enum(['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL']),
|
||||
chargeValue: z.number().int().min(0),
|
||||
isRequired: z.boolean().default(false),
|
||||
isActive: z.boolean().default(true),
|
||||
sortOrder: z.number().int().default(0),
|
||||
})
|
||||
|
||||
const pricingRuleSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.enum(['SURCHARGE', 'DISCOUNT']),
|
||||
condition: z.enum(['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN']),
|
||||
conditionValue: z.number().int(),
|
||||
adjustmentType: z.enum(['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL']),
|
||||
adjustmentValue: z.number().int(),
|
||||
isActive: z.boolean().default(true),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
const accountingSettingsSchema = z.object({
|
||||
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
|
||||
fiscalYearStart: z.number().int().min(1).max(12).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
accountantEmail: z.string().email().optional(),
|
||||
accountantName: z.string().optional(),
|
||||
autoSendReport: z.boolean().optional(),
|
||||
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
|
||||
})
|
||||
|
||||
function buildPaymentMethodsEnabled(input: {
|
||||
amanpayMerchantId?: string | null
|
||||
amanpaySecretKey?: string | null
|
||||
paypalEmail?: string | null
|
||||
}) {
|
||||
const methods: Array<'AMANPAY' | 'PAYPAL'> = []
|
||||
if (input.amanpayMerchantId && input.amanpaySecretKey) methods.push('AMANPAY')
|
||||
if (input.paypalEmail) methods.push('PAYPAL')
|
||||
return methods
|
||||
}
|
||||
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findUniqueOrThrow({
|
||||
where: { id: req.companyId },
|
||||
include: {
|
||||
brand: true,
|
||||
subscription: true,
|
||||
_count: { select: { vehicles: true, customers: true, reservations: true } },
|
||||
},
|
||||
})
|
||||
res.json({ data: company })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me', async (req, res, next) => {
|
||||
try {
|
||||
const body = companySchema.parse(req.body)
|
||||
const company = await prisma.company.update({
|
||||
where: { id: req.companyId },
|
||||
data: body as any,
|
||||
})
|
||||
res.json({ data: company })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/brand', async (req, res, next) => {
|
||||
try {
|
||||
const brand = await prisma.brandSettings.findUnique({
|
||||
where: { companyId: req.companyId },
|
||||
})
|
||||
res.json({ data: brand })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/brand', async (req, res, next) => {
|
||||
try {
|
||||
const body = brandSchema.parse(req.body)
|
||||
const current = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId } })
|
||||
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
|
||||
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
|
||||
amanpaySecretKey: body.amanpaySecretKey ?? current?.amanpaySecretKey,
|
||||
paypalEmail: body.paypalEmail ?? current?.paypalEmail,
|
||||
})
|
||||
const brand = await prisma.brandSettings.upsert({
|
||||
where: { companyId: req.companyId },
|
||||
update: { ...body, paymentMethodsEnabled },
|
||||
create: {
|
||||
companyId: req.companyId,
|
||||
displayName: body.displayName ?? req.company.name,
|
||||
subdomain: req.company.slug,
|
||||
paymentMethodsEnabled,
|
||||
...body,
|
||||
},
|
||||
})
|
||||
res.json({ data: brand })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/logo', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'missing_file', message: 'A logo file is required', statusCode: 400 })
|
||||
}
|
||||
const url = await uploadImage(req.file.buffer, `companies/${req.companyId}/brand`, 'logo')
|
||||
const brand = await prisma.brandSettings.upsert({
|
||||
where: { companyId: req.companyId },
|
||||
update: { logoUrl: url },
|
||||
create: {
|
||||
companyId: req.companyId,
|
||||
displayName: req.company.name,
|
||||
subdomain: req.company.slug,
|
||||
logoUrl: url,
|
||||
},
|
||||
})
|
||||
res.json({ data: brand })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/hero', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'missing_file', message: 'A hero image file is required', statusCode: 400 })
|
||||
}
|
||||
const url = await uploadImage(req.file.buffer, `companies/${req.companyId}/brand`, 'hero')
|
||||
const brand = await prisma.brandSettings.upsert({
|
||||
where: { companyId: req.companyId },
|
||||
update: { heroImageUrl: url },
|
||||
create: {
|
||||
companyId: req.companyId,
|
||||
displayName: req.company.name,
|
||||
subdomain: req.company.slug,
|
||||
heroImageUrl: url,
|
||||
},
|
||||
})
|
||||
res.json({ data: brand })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/subdomain/check', async (req, res, next) => {
|
||||
try {
|
||||
const { subdomain } = z.object({ subdomain: z.string().min(3) }).parse(req.body)
|
||||
const existing = await prisma.brandSettings.findFirst({
|
||||
where: { subdomain, companyId: { not: req.companyId } },
|
||||
})
|
||||
res.json({ data: { available: !existing } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/custom-domain', async (req, res, next) => {
|
||||
try {
|
||||
const { customDomain } = z.object({ customDomain: z.string().min(3) }).parse(req.body)
|
||||
const normalized = customDomain.toLowerCase().trim()
|
||||
const existing = await prisma.brandSettings.findFirst({
|
||||
where: { customDomain: normalized, companyId: { not: req.companyId } },
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'domain_taken', message: 'This custom domain is already in use', statusCode: 409 })
|
||||
}
|
||||
const brand = await prisma.brandSettings.upsert({
|
||||
where: { companyId: req.companyId },
|
||||
update: {
|
||||
customDomain: normalized,
|
||||
customDomainVerified: false,
|
||||
customDomainAddedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
companyId: req.companyId,
|
||||
displayName: req.company.name,
|
||||
subdomain: req.company.slug,
|
||||
customDomain: normalized,
|
||||
customDomainVerified: false,
|
||||
customDomainAddedAt: new Date(),
|
||||
},
|
||||
})
|
||||
res.json({ data: brand })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/brand/custom-domain/status', async (req, res, next) => {
|
||||
try {
|
||||
const brand = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId } })
|
||||
const customDomain = brand?.customDomain ?? null
|
||||
res.json({
|
||||
data: {
|
||||
customDomain,
|
||||
verified: brand?.customDomainVerified ?? false,
|
||||
status: customDomain ? (brand?.customDomainVerified ? 'verified' : 'pending_dns') : 'not_configured',
|
||||
dnsTarget: process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET ?? 'cname.vercel-dns.com',
|
||||
},
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/brand/custom-domain', async (req, res, next) => {
|
||||
try {
|
||||
const brand = await prisma.brandSettings.updateMany({
|
||||
where: { companyId: req.companyId },
|
||||
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
|
||||
})
|
||||
res.json({ data: { success: brand.count > 0 } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/contract-settings', async (req, res, next) => {
|
||||
try {
|
||||
const settings = await prisma.contractSettings.findUnique({ where: { companyId: req.companyId } })
|
||||
res.json({ data: settings })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/contract-settings', async (req, res, next) => {
|
||||
try {
|
||||
const body = contractSettingsSchema.parse(req.body)
|
||||
const settings = await prisma.contractSettings.upsert({
|
||||
where: { companyId: req.companyId },
|
||||
update: body,
|
||||
create: { companyId: req.companyId, ...body },
|
||||
})
|
||||
res.json({ data: settings })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/insurance-policies', async (req, res, next) => {
|
||||
try {
|
||||
const policies = await prisma.insurancePolicy.findMany({
|
||||
where: { companyId: req.companyId },
|
||||
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
res.json({ data: policies })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/insurance-policies', async (req, res, next) => {
|
||||
try {
|
||||
const body = insurancePolicySchema.parse(req.body)
|
||||
const policy = await prisma.insurancePolicy.create({ data: { companyId: req.companyId, ...body } })
|
||||
res.status(201).json({ data: policy })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/insurance-policies/:id', async (req, res, next) => {
|
||||
try {
|
||||
const body = insurancePolicySchema.partial().parse(req.body)
|
||||
const policy = await prisma.insurancePolicy.updateMany({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
data: body,
|
||||
})
|
||||
if (policy.count === 0) return res.status(404).json({ error: 'not_found', message: 'Insurance policy not found', statusCode: 404 })
|
||||
const updated = await prisma.insurancePolicy.findUniqueOrThrow({ where: { id: req.params.id } })
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/insurance-policies/:id', async (req, res, next) => {
|
||||
try {
|
||||
const deleted = await prisma.insurancePolicy.deleteMany({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
if (deleted.count === 0) return res.status(404).json({ error: 'not_found', message: 'Insurance policy not found', statusCode: 404 })
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/pricing-rules', async (req, res, next) => {
|
||||
try {
|
||||
const rules = await prisma.pricingRule.findMany({
|
||||
where: { companyId: req.companyId },
|
||||
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
res.json({ data: rules })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/pricing-rules', async (req, res, next) => {
|
||||
try {
|
||||
const body = pricingRuleSchema.parse(req.body)
|
||||
const rule = await prisma.pricingRule.create({ data: { companyId: req.companyId, ...body } })
|
||||
res.status(201).json({ data: rule })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/pricing-rules/:id', async (req, res, next) => {
|
||||
try {
|
||||
const body = pricingRuleSchema.partial().parse(req.body)
|
||||
const updated = await prisma.pricingRule.updateMany({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
data: body,
|
||||
})
|
||||
if (updated.count === 0) return res.status(404).json({ error: 'not_found', message: 'Pricing rule not found', statusCode: 404 })
|
||||
const rule = await prisma.pricingRule.findUniqueOrThrow({ where: { id: req.params.id } })
|
||||
res.json({ data: rule })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/pricing-rules/:id', async (req, res, next) => {
|
||||
try {
|
||||
const deleted = await prisma.pricingRule.deleteMany({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
if (deleted.count === 0) return res.status(404).json({ error: 'not_found', message: 'Pricing rule not found', statusCode: 404 })
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/accounting-settings', async (req, res, next) => {
|
||||
try {
|
||||
const settings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } })
|
||||
res.json({ data: settings })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/accounting-settings', async (req, res, next) => {
|
||||
try {
|
||||
const body = accountingSettingsSchema.parse(req.body)
|
||||
const settings = await prisma.accountingSettings.upsert({
|
||||
where: { companyId: req.companyId },
|
||||
update: body,
|
||||
create: { companyId: req.companyId, ...body },
|
||||
})
|
||||
res.json({ data: settings })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/api-key', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findUniqueOrThrow({
|
||||
where: { id: req.companyId },
|
||||
select: { apiKey: true },
|
||||
})
|
||||
res.json({ data: company })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/api-key/regenerate', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.update({
|
||||
where: { id: req.companyId },
|
||||
data: { apiKey: `api_${Math.random().toString(36).slice(2)}${Date.now()}` },
|
||||
select: { apiKey: true },
|
||||
})
|
||||
res.json({ data: company })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,132 @@
|
||||
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 paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
const customerSchema = z.object({
|
||||
firstName: z.string().min(1).max(100).trim(),
|
||||
lastName: z.string().min(1).max(100).trim(),
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
phone: z.string().max(30).trim().optional(),
|
||||
driverLicense: z.string().max(50).trim().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().max(100).trim().optional(),
|
||||
address: z.record(z.unknown()).optional(),
|
||||
notes: z.string().max(2000).trim().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
licenseCountry: z.string().max(100).trim().optional(),
|
||||
licenseNumber: z.string().max(50).trim().optional(),
|
||||
licenseCategory: z.string().max(20).trim().optional(),
|
||||
})
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { q, flagged } = req.query as Record<string, string>
|
||||
const { page, pageSize } = paginationSchema.parse(req.query)
|
||||
const safeQ = q ? q.trim().slice(0, 100) : undefined
|
||||
const where: any = { companyId: req.companyId }
|
||||
if (flagged !== undefined) where.flagged = flagged === 'true'
|
||||
if (safeQ) where.OR = [{ firstName: { contains: safeQ, mode: 'insensitive' } }, { lastName: { contains: safeQ, mode: 'insensitive' } }, { email: { contains: safeQ, mode: 'insensitive' } }]
|
||||
|
||||
const [customers, total] = await Promise.all([
|
||||
prisma.customer.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: 'desc' } }),
|
||||
prisma.customer.count({ where }),
|
||||
])
|
||||
res.json({ data: customers, total, page, pageSize, totalPages: Math.ceil(total / 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,
|
||||
} as any,
|
||||
})
|
||||
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 } as any })
|
||||
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,364 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
|
||||
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
|
||||
import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService'
|
||||
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
|
||||
const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
function isDatabaseUnavailableError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
|
||||
const candidate = error as { code?: string; message?: string }
|
||||
return candidate.code === 'P1001' || candidate.message?.includes("Can't reach database server") === true
|
||||
}
|
||||
|
||||
router.get('/offers', async (req, res, next) => {
|
||||
try {
|
||||
const offers = await prisma.offer.findMany({
|
||||
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) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/companies', async (req, res, next) => {
|
||||
try {
|
||||
const { city, hasOffer } = req.query as Record<string, string>
|
||||
const { page, pageSize } = paginationSchema.parse(req.query)
|
||||
const safeCity = city ? city.trim().slice(0, 100) : undefined
|
||||
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }
|
||||
if (safeCity) where.brand = { ...where.brand, publicCity: { contains: safeCity, mode: 'insensitive' } }
|
||||
if (hasOffer === 'true') {
|
||||
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
|
||||
}
|
||||
|
||||
const companies = await prisma.company.findMany({
|
||||
where,
|
||||
include: {
|
||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
|
||||
_count: { select: { vehicles: { where: { isPublished: true } } } },
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
res.json({ data: companies })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/search', async (req, res, next) => {
|
||||
try {
|
||||
const searchSchema = z.object({
|
||||
city: z.string().trim().max(100).optional(),
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
category: z.string().trim().max(50).optional(),
|
||||
maxPrice: z.coerce.number().int().min(0).max(1_000_000).optional(),
|
||||
transmission: z.string().trim().max(20).optional(),
|
||||
make: z.string().trim().max(60).optional(),
|
||||
model: z.string().trim().max(60).optional(),
|
||||
})
|
||||
const { city, startDate, endDate, category, maxPrice, transmission, make, model } = searchSchema.parse(req.query)
|
||||
const { page, pageSize } = paginationSchema.parse(req.query)
|
||||
|
||||
const where: any = { isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } } }
|
||||
if (category) where.category = category
|
||||
if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice }
|
||||
if (transmission) where.transmission = transmission
|
||||
if (make) where.make = { contains: make, mode: 'insensitive' }
|
||||
if (model) where.model = { contains: model, mode: 'insensitive' }
|
||||
if (city) where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: city, mode: 'insensitive' } } }
|
||||
|
||||
const vehicles = await prisma.vehicle.findMany({
|
||||
where,
|
||||
include: {
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { dailyRate: 'asc' },
|
||||
})
|
||||
|
||||
const typedVehicles = vehicles as Array<(typeof vehicles)[number]>
|
||||
|
||||
const availabilityByVehicleId = new Map(
|
||||
await Promise.all(
|
||||
typedVehicles.map(async (vehicle) => {
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||
range: startDate && endDate
|
||||
? { startDate: new Date(startDate), endDate: new Date(endDate) }
|
||||
: undefined,
|
||||
})
|
||||
return [vehicle.id, availability] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
res.json({
|
||||
data: typedVehicles.map((vehicle) => ({
|
||||
...vehicle,
|
||||
availability: availabilityByVehicleId.get(vehicle.id)?.available ?? null,
|
||||
availabilityStatus: availabilityByVehicleId.get(vehicle.id)?.status ?? 'AVAILABLE',
|
||||
nextAvailableAt: availabilityByVehicleId.get(vehicle.id)?.nextAvailableAt ?? null,
|
||||
})),
|
||||
})
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/reservations', async (req, res, next) => {
|
||||
try {
|
||||
const schema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
companySlug: z.string().trim().max(100),
|
||||
firstName: z.string().min(1).max(100),
|
||||
lastName: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
phone: z.string().max(30).optional(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
notes: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
const body = schema.parse(req.body)
|
||||
const startDate = new Date(body.startDate)
|
||||
const endDate = new Date(body.endDate)
|
||||
|
||||
if (endDate <= startDate) {
|
||||
return res.status(400).json({ error: 'invalid_dates', message: 'End date must be after start date', statusCode: 400 })
|
||||
}
|
||||
|
||||
const vehicle = await prisma.vehicle.findFirst({
|
||||
where: { id: body.vehicleId, isPublished: true, company: { slug: body.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
if (!vehicle) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(body.vehicleId, {
|
||||
range: { startDate, endDate },
|
||||
})
|
||||
if (!availability.available) {
|
||||
return res.status(409).json({
|
||||
error: 'unavailable',
|
||||
message: 'Vehicle is not available for the selected dates',
|
||||
statusCode: 409,
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.upsert({
|
||||
where: { companyId_email: { companyId: vehicle.companyId, email: body.email } },
|
||||
create: { companyId: vehicle.companyId, firstName: body.firstName, lastName: body.lastName, email: body.email, phone: body.phone },
|
||||
update: { firstName: body.firstName, lastName: body.lastName, ...(body.phone ? { phone: body.phone } : {}) },
|
||||
})
|
||||
|
||||
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||
const totalAmount = vehicle.dailyRate * totalDays
|
||||
|
||||
const reservation = await prisma.reservation.create({
|
||||
data: {
|
||||
companyId: vehicle.companyId,
|
||||
vehicleId: body.vehicleId,
|
||||
customerId: customer.id,
|
||||
source: 'MARKETPLACE',
|
||||
status: 'DRAFT',
|
||||
startDate,
|
||||
endDate,
|
||||
dailyRate: vehicle.dailyRate,
|
||||
totalDays,
|
||||
totalAmount,
|
||||
notes: body.notes,
|
||||
},
|
||||
})
|
||||
|
||||
const companyName = vehicle.company.brand?.displayName ?? vehicle.company.name
|
||||
const rateDisplay = (vehicle.dailyRate / 100).toFixed(2)
|
||||
const totalDisplay = (totalAmount / 100).toFixed(2)
|
||||
const dateOpts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
|
||||
const startStr = startDate.toLocaleDateString('en-GB', dateOpts)
|
||||
const endStr = endDate.toLocaleDateString('en-GB', dateOpts)
|
||||
|
||||
try {
|
||||
await sendTransactionalEmail({
|
||||
to: body.email,
|
||||
subject: `Reservation Request Received — ${vehicle.make} ${vehicle.model}`,
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<h2 style="margin-bottom:4px">Hi ${body.firstName},</h2>
|
||||
<p style="color:#57534e">Your reservation request has been received and is pending company confirmation.</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
|
||||
<h3 style="margin-bottom:12px">${vehicle.year} ${vehicle.make} ${vehicle.model}</h3>
|
||||
<p><strong>Company:</strong> ${companyName}</p>
|
||||
<p><strong>Pick-up date:</strong> ${startStr}</p>
|
||||
<p><strong>Return date:</strong> ${endStr}</p>
|
||||
<p><strong>Duration:</strong> ${totalDays} day${totalDays > 1 ? 's' : ''}</p>
|
||||
<p><strong>Daily rate:</strong> ${rateDisplay} MAD</p>
|
||||
<p><strong>Estimated total:</strong> ${totalDisplay} MAD</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
|
||||
<p style="color:#57534e;font-size:13px">The company will review your request and get in touch with you shortly. You may be contacted at ${body.email}${body.phone ? ` or ${body.phone}` : ''}.</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hi ${body.firstName},\n\nYour reservation request for the ${vehicle.year} ${vehicle.make} ${vehicle.model} from ${companyName} has been received.\n\nDates: ${startStr} → ${endStr}\nDuration: ${totalDays} day(s)\nDaily rate: ${rateDisplay} MAD\nEstimated total: ${totalDisplay} MAD\n\nThe company will confirm your request shortly.\n\nThank you!`,
|
||||
})
|
||||
} catch {
|
||||
// email failure is non-blocking — reservation still created
|
||||
}
|
||||
|
||||
try {
|
||||
await sendNotification({
|
||||
type: 'NEW_BOOKING',
|
||||
title: 'New Marketplace Reservation Request',
|
||||
body: `${body.firstName} ${body.lastName} requested ${vehicle.make} ${vehicle.model} from ${startStr} to ${endStr}.`,
|
||||
companyId: vehicle.companyId,
|
||||
channels: ['IN_APP'],
|
||||
})
|
||||
} catch {
|
||||
// notification failure is non-blocking
|
||||
}
|
||||
|
||||
res.status(201).json({ data: { reservationId: reservation.id } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findFirst({
|
||||
where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
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 })
|
||||
const vehicles = await Promise.all(
|
||||
company.vehicles.map(async (vehicle) => {
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
return {
|
||||
...vehicle,
|
||||
availability: availability.available,
|
||||
availabilityStatus: availability.status,
|
||||
nextAvailableAt: availability.nextAvailableAt,
|
||||
}
|
||||
}),
|
||||
)
|
||||
res.json({ data: { ...company, vehicles } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/reviews', async (req, res, next) => {
|
||||
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) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
const vehicles = await prisma.vehicle.findMany({
|
||||
where: { companyId: company.id, isPublished: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ data: vehicles })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: company.id, isPublished: true },
|
||||
include: {
|
||||
company: { include: { brand: true } },
|
||||
},
|
||||
})
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
res.json({ data: { ...vehicle, availabilityStatus: availability.status, availability: availability.available, nextAvailableAt: availability.nextAvailableAt } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
const offers = await prisma.offer.findMany({
|
||||
where: {
|
||||
companyId: company.id,
|
||||
isPublic: true,
|
||||
isActive: true,
|
||||
validFrom: { lte: new Date() },
|
||||
validUntil: { gte: new Date() },
|
||||
},
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
res.json({ data: offers })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/offers/:code/validate', async (req, res, next) => {
|
||||
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) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Offer validation is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,128 @@
|
||||
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.get('/unread-count', ...companyAuth, async (req: any, res: any, next: any) => {
|
||||
try {
|
||||
const unread = await prisma.notification.count({
|
||||
where: { companyId: req.companyId, channel: 'IN_APP', readAt: null },
|
||||
})
|
||||
res.json({ data: { unread } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/company/:id/read', ...companyAuth, async (req: any, res: any, next: any) => {
|
||||
try {
|
||||
await prisma.notification.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { readAt: new Date(), status: 'READ' } })
|
||||
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) }
|
||||
})
|
||||
|
||||
router.post('/renter/read-all', requireRenterAuth, async (req: any, res: any, next: any) => {
|
||||
try {
|
||||
await prisma.notification.updateMany({
|
||||
where: { renterId: req.renterId, channel: 'IN_APP', readAt: null },
|
||||
data: { readAt: new Date(), status: 'READ' },
|
||||
})
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/renter/preferences', requireRenterAuth, async (req: any, res: any, next: any) => {
|
||||
try {
|
||||
const prefs = await prisma.notificationPreference.findMany({ where: { renterId: req.renterId } })
|
||||
res.json({ data: prefs })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/renter/preferences', requireRenterAuth, async (req: any, res: any, next: any) => {
|
||||
try {
|
||||
const body = z.array(z.object({ notificationType: z.string(), channel: z.string(), enabled: z.boolean() })).parse(req.body)
|
||||
for (const pref of body) {
|
||||
await prisma.notificationPreference.upsert({
|
||||
where: {
|
||||
renterId_notificationType_channel: {
|
||||
renterId: req.renterId,
|
||||
notificationType: pref.notificationType as NotificationType,
|
||||
channel: pref.channel as NotificationChannel,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
renterId: req.renterId,
|
||||
notificationType: pref.notificationType as NotificationType,
|
||||
channel: pref.channel as NotificationChannel,
|
||||
enabled: pref.enabled,
|
||||
},
|
||||
update: { enabled: pref.enabled },
|
||||
})
|
||||
}
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -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,252 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../middleware/requireTenant'
|
||||
import { requireSubscription } from '../middleware/requireSubscription'
|
||||
import * as amanpay from '../services/amanpayService'
|
||||
import * as paypal from '../services/paypalService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── Webhook endpoints (no auth — must come before middleware) ──────────────
|
||||
|
||||
router.post('/webhooks/amanpay', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const signature = req.headers['x-amanpay-signature'] as string ?? ''
|
||||
|
||||
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
|
||||
const event = req.body
|
||||
const transactionId = event.transaction_id ?? event.id
|
||||
const orderId = event.order_id ?? event.metadata?.order_id
|
||||
const status = event.status?.toUpperCase()
|
||||
|
||||
if (status === 'PAID' || status === 'SUCCEEDED') {
|
||||
const payment = await prisma.rentalPayment.findFirst({ where: { amanpayTransactionId: transactionId } })
|
||||
if (payment) {
|
||||
await prisma.rentalPayment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: 'SUCCEEDED', paidAt: new Date() },
|
||||
})
|
||||
await prisma.reservation.update({
|
||||
where: { id: payment.reservationId },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
|
||||
})
|
||||
}
|
||||
} else if (status === 'FAILED') {
|
||||
await prisma.rentalPayment.updateMany({
|
||||
where: { amanpayTransactionId: transactionId },
|
||||
data: { status: 'FAILED' },
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/webhooks/paypal', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const isValid = await paypal.verifyWebhookEvent(
|
||||
req.headers as Record<string, string>,
|
||||
rawBody,
|
||||
)
|
||||
if (paypal.isConfigured() && !isValid) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
|
||||
const event = req.body
|
||||
const eventType = event.event_type as string
|
||||
|
||||
if (eventType === 'PAYMENT.CAPTURE.COMPLETED') {
|
||||
const captureId = event.resource?.id as string
|
||||
const payment = await prisma.rentalPayment.findFirst({ where: { paypalCaptureId: captureId } })
|
||||
if (payment) {
|
||||
await prisma.rentalPayment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: 'SUCCEEDED', paidAt: new Date() },
|
||||
})
|
||||
await prisma.reservation.update({
|
||||
where: { id: payment.reservationId },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
|
||||
})
|
||||
}
|
||||
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
|
||||
const captureId = event.resource?.id as string
|
||||
await prisma.rentalPayment.updateMany({
|
||||
where: { paypalCaptureId: captureId },
|
||||
data: { status: 'FAILED' },
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Authenticated routes ────────────────────────────────────────────────────
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
const chargeSchema = z.object({
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
router.get('/reservations/:id', async (req, res, next) => {
|
||||
try {
|
||||
const payments = await prisma.rentalPayment.findMany({
|
||||
where: { reservationId: req.params.id, companyId: req.companyId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ data: payments })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/charge', async (req, res, next) => {
|
||||
try {
|
||||
const { provider, type, currency, successUrl, failureUrl } = chargeSchema.parse(req.body)
|
||||
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
|
||||
if (reservation.paymentStatus === 'PAID') {
|
||||
return res.status(409).json({ error: 'already_paid', message: 'Reservation is already fully paid', statusCode: 409 })
|
||||
}
|
||||
|
||||
const amount = type === 'DEPOSIT' ? reservation.depositAmount : reservation.totalAmount
|
||||
const description = `${type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
const orderId = `${reservation.id}-${type}-${Date.now()}`
|
||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||
|
||||
let checkoutUrl: string
|
||||
let amanpayTransactionId: string | null = null
|
||||
let paypalCaptureId: string | null = null
|
||||
|
||||
if (provider === 'AMANPAY') {
|
||||
if (!amanpay.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured', statusCode: 503 })
|
||||
}
|
||||
const result = await amanpay.createCheckout({
|
||||
amount,
|
||||
currency,
|
||||
orderId,
|
||||
description,
|
||||
customerEmail: reservation.customer.email,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
successUrl,
|
||||
failureUrl,
|
||||
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
amanpayTransactionId = result.transactionId
|
||||
} else {
|
||||
if (!paypal.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not configured', statusCode: 503 })
|
||||
}
|
||||
const result = await paypal.createOrder({
|
||||
amount,
|
||||
currency,
|
||||
orderId,
|
||||
description,
|
||||
returnUrl: successUrl,
|
||||
cancelUrl: failureUrl,
|
||||
})
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
|
||||
const payment = await prisma.rentalPayment.create({
|
||||
data: {
|
||||
companyId: req.companyId,
|
||||
reservationId: reservation.id,
|
||||
amount,
|
||||
currency,
|
||||
status: 'PENDING',
|
||||
type,
|
||||
paymentProvider: provider,
|
||||
amanpayTransactionId,
|
||||
paypalCaptureId,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: { payment, checkoutUrl } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/capture-paypal', async (req, res, next) => {
|
||||
try {
|
||||
const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body)
|
||||
|
||||
const payment = await prisma.rentalPayment.findFirstOrThrow({
|
||||
where: { paypalCaptureId: paypalOrderId, companyId: req.companyId },
|
||||
})
|
||||
|
||||
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||
|
||||
const updated = await prisma.rentalPayment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId },
|
||||
})
|
||||
|
||||
await prisma.reservation.update({
|
||||
where: { id: payment.reservationId },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
|
||||
})
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:reservationId/payments/:paymentId/refund', async (req, res, next) => {
|
||||
try {
|
||||
const { amount, reason } = z.object({
|
||||
amount: z.number().int().positive().optional(),
|
||||
reason: z.string().optional(),
|
||||
}).parse(req.body)
|
||||
|
||||
const payment = await prisma.rentalPayment.findFirstOrThrow({
|
||||
where: { id: req.params.paymentId, companyId: req.companyId, reservationId: req.params.reservationId },
|
||||
})
|
||||
|
||||
if (payment.status !== 'SUCCEEDED') {
|
||||
return res.status(400).json({ error: 'not_capturable', message: 'Only succeeded payments can be refunded', statusCode: 400 })
|
||||
}
|
||||
|
||||
const refundAmount = amount ?? payment.amount
|
||||
|
||||
if (payment.paymentProvider === 'AMANPAY') {
|
||||
if (!payment.amanpayTransactionId) throw new Error('No AmanPay transaction ID')
|
||||
await amanpay.refundTransaction(payment.amanpayTransactionId, refundAmount, reason)
|
||||
} else {
|
||||
if (!payment.paypalCaptureId) throw new Error('No PayPal capture ID')
|
||||
await paypal.refundCapture(payment.paypalCaptureId, refundAmount, payment.currency, reason)
|
||||
}
|
||||
|
||||
const isPartial = refundAmount < payment.amount
|
||||
const updated = await prisma.rentalPayment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: isPartial ? 'PARTIALLY_REFUNDED' : 'REFUNDED' },
|
||||
})
|
||||
|
||||
if (!isPartial) {
|
||||
await prisma.reservation.update({
|
||||
where: { id: payment.reservationId },
|
||||
data: { paymentStatus: 'REFUNDED' },
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,423 @@
|
||||
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 { applyAdditionalDriversToReservation } from '../services/additionalDriverService'
|
||||
import { applyInsurancesToReservation } from '../services/insuranceService'
|
||||
import { applyPricingRules } from '../services/pricingRuleService'
|
||||
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
||||
import { sendNotification } from '../services/notificationService'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
const additionalDriverSchema = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
driverLicense: z.string().min(1),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
})
|
||||
|
||||
const inspectionSchema = z.object({
|
||||
mileage: z.number().int().optional(),
|
||||
fuelLevel: z.enum(['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']),
|
||||
fuelCharge: z.number().int().min(0).optional(),
|
||||
generalCondition: z.string().optional(),
|
||||
employeeNotes: z.string().optional(),
|
||||
customerAgreed: z.boolean().default(false),
|
||||
damagePoints: z.array(
|
||||
z.object({
|
||||
viewType: z.enum(['TOP', 'FRONT', 'REAR', 'LEFT', 'RIGHT']),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
damageType: z.enum(['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']),
|
||||
severity: z.enum(['MINOR', 'MODERATE', 'MAJOR']).default('MINOR'),
|
||||
description: z.string().optional(),
|
||||
isPreExisting: z.boolean().default(false),
|
||||
}),
|
||||
).default([]),
|
||||
})
|
||||
|
||||
const createSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
customerId: z.string().cuid(),
|
||||
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([]),
|
||||
additionalDrivers: z.array(additionalDriverSchema).default([]),
|
||||
})
|
||||
|
||||
async function assertReservationLicenseCompliance(reservationId: string, companyId: string) {
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: reservationId, companyId },
|
||||
include: { customer: true, additionalDrivers: true },
|
||||
})
|
||||
|
||||
const customerLicense = validateLicense(reservation.customer.licenseExpiry)
|
||||
const customerDenied = ['DENIED', 'EXPIRED'].includes(reservation.customer.licenseValidationStatus)
|
||||
if (customerDenied || customerLicense.status === 'EXPIRED') {
|
||||
throw Object.assign(new Error('Primary driver license is not valid for this reservation'), {
|
||||
statusCode: 400,
|
||||
code: 'invalid_primary_license',
|
||||
})
|
||||
}
|
||||
|
||||
if (customerLicense.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') {
|
||||
throw Object.assign(new Error('Primary driver license requires manager approval before this reservation can proceed'), {
|
||||
statusCode: 400,
|
||||
code: 'primary_license_requires_approval',
|
||||
})
|
||||
}
|
||||
|
||||
const blockedDriver = reservation.additionalDrivers.find((driver) => {
|
||||
if (driver.licenseExpired) return true
|
||||
return driver.requiresApproval && !driver.approvedAt
|
||||
})
|
||||
|
||||
if (blockedDriver) {
|
||||
throw Object.assign(new Error(`Additional driver ${blockedDriver.firstName} ${blockedDriver.lastName} requires approval before this reservation can proceed`), {
|
||||
statusCode: 400,
|
||||
code: 'additional_driver_requires_approval',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { status, vehicleId, source, startDate, endDate, page = '1', pageSize = '20' } = req.query as Record<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, body.additionalDrivers as any[], 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)
|
||||
}
|
||||
|
||||
if (body.additionalDrivers.length > 0) {
|
||||
await applyAdditionalDriversToReservation(reservation.id, req.companyId, body.additionalDrivers, totalDays)
|
||||
}
|
||||
|
||||
// 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 })
|
||||
await assertReservationLicenseCompliance(reservation.id, req.companyId)
|
||||
|
||||
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 })
|
||||
await assertReservationLicenseCompliance(reservation.id, req.companyId)
|
||||
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'ACTIVE', checkedInAt: new Date(), checkInMileage: mileage ?? null } })
|
||||
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'RENTED' } })
|
||||
res.json({ data: updated })
|
||||
} 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: (typeof reservation.insurances)[number]) => ({ description: ins.policyName, qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, total: ins.totalCharge, category: 'INSURANCE' })),
|
||||
...reservation.additionalDrivers.filter((d: (typeof reservation.additionalDrivers)[number]) => d.totalCharge > 0).map((d: (typeof reservation.additionalDrivers)[number]) => ({ description: `Additional Driver — ${d.firstName} ${d.lastName}`, qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER' })),
|
||||
...(reservation.depositAmount > 0 ? [{ description: 'Security Deposit (refundable)', qty: 1, unitPrice: reservation.depositAmount, total: reservation.depositAmount, category: 'DEPOSIT' }] : []),
|
||||
]
|
||||
|
||||
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) }
|
||||
})
|
||||
|
||||
router.get('/:id/inspections', async (req, res, next) => {
|
||||
try {
|
||||
const inspections = await prisma.damageInspection.findMany({
|
||||
where: { reservationId: req.params.id, companyId: req.companyId },
|
||||
include: { damagePoints: true },
|
||||
orderBy: { inspectedAt: 'asc' },
|
||||
})
|
||||
res.json({ data: inspections })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:id/inspections/:type', async (req, res, next) => {
|
||||
try {
|
||||
const type = z.enum(['CHECKIN', 'CHECKOUT']).parse(req.params.type.toUpperCase())
|
||||
const body = inspectionSchema.parse(req.body)
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
|
||||
const inspection = await prisma.damageInspection.upsert({
|
||||
where: { reservationId_type: { reservationId: reservation.id, type } },
|
||||
update: {
|
||||
mileage: body.mileage ?? null,
|
||||
fuelLevel: body.fuelLevel,
|
||||
fuelCharge: body.fuelCharge ?? null,
|
||||
generalCondition: body.generalCondition ?? null,
|
||||
employeeNotes: body.employeeNotes ?? null,
|
||||
customerAgreed: body.customerAgreed,
|
||||
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
||||
inspectedAt: new Date(),
|
||||
damagePoints: {
|
||||
deleteMany: {},
|
||||
create: body.damagePoints,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
reservationId: reservation.id,
|
||||
companyId: req.companyId,
|
||||
type,
|
||||
mileage: body.mileage ?? null,
|
||||
fuelLevel: body.fuelLevel,
|
||||
fuelCharge: body.fuelCharge ?? null,
|
||||
generalCondition: body.generalCondition ?? null,
|
||||
employeeNotes: body.employeeNotes ?? null,
|
||||
customerAgreed: body.customerAgreed,
|
||||
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
||||
damagePoints: {
|
||||
create: body.damagePoints,
|
||||
},
|
||||
},
|
||||
include: { damagePoints: true },
|
||||
})
|
||||
|
||||
await prisma.damageReport.upsert({
|
||||
where: { reservationId_type: { reservationId: reservation.id, type } },
|
||||
update: {
|
||||
damages: body.damagePoints.map((point) => ({
|
||||
viewType: point.viewType,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
damageType: point.damageType,
|
||||
severity: point.severity,
|
||||
note: point.description ?? '',
|
||||
isPreExisting: point.isPreExisting,
|
||||
})),
|
||||
fuelLevel: body.fuelLevel,
|
||||
mileage: body.mileage ?? null,
|
||||
inspectedAt: new Date(),
|
||||
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
||||
customerSignedAt: body.customerAgreed ? new Date() : null,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
},
|
||||
create: {
|
||||
reservationId: reservation.id,
|
||||
companyId: req.companyId,
|
||||
type,
|
||||
damages: body.damagePoints.map((point) => ({
|
||||
viewType: point.viewType,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
damageType: point.damageType,
|
||||
severity: point.severity,
|
||||
note: point.description ?? '',
|
||||
isPreExisting: point.isPreExisting,
|
||||
})),
|
||||
photos: [],
|
||||
fuelLevel: body.fuelLevel,
|
||||
mileage: body.mileage ?? null,
|
||||
inspectedAt: new Date(),
|
||||
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
||||
customerSignedAt: body.customerAgreed ? new Date() : null,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: type === 'CHECKIN'
|
||||
? {
|
||||
checkInMileage: body.mileage ?? null,
|
||||
checkInFuelLevel: body.fuelLevel,
|
||||
}
|
||||
: {
|
||||
checkOutMileage: body.mileage ?? null,
|
||||
checkOutFuelLevel: body.fuelLevel,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: inspection })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next) => {
|
||||
try {
|
||||
const { approved, note } = z.object({ approved: z.boolean(), note: z.string().optional() }).parse(req.body)
|
||||
const driver = await prisma.additionalDriver.findFirstOrThrow({
|
||||
where: { id: req.params.driverId, reservationId: req.params.id, companyId: req.companyId },
|
||||
})
|
||||
|
||||
const updated = await prisma.additionalDriver.update({
|
||||
where: { id: driver.id },
|
||||
data: {
|
||||
approvedAt: approved ? new Date() : null,
|
||||
approvedBy: approved ? `${req.employee.firstName} ${req.employee.lastName}` : null,
|
||||
approvalNote: note ?? driver.approvalNote,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,521 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { applyAdditionalDriversToReservation } from '../services/additionalDriverService'
|
||||
import * as amanpay from '../services/amanpayService'
|
||||
import { applyInsurancesToReservation } from '../services/insuranceService'
|
||||
import { applyPricingRules } from '../services/pricingRuleService'
|
||||
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
||||
import * as paypal from '../services/paypalService'
|
||||
import { getMarketplaceHomepageContent } from '../services/platformContentService'
|
||||
import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/platform/homepage', async (_req, res, next) => {
|
||||
try {
|
||||
res.json({ data: await getMarketplaceHomepageContent() })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
function isDatabaseUnavailableError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
|
||||
const candidate = error as { code?: string; message?: string }
|
||||
return candidate.code === 'P1001' || candidate.message?.includes("Can't reach database server") === true
|
||||
}
|
||||
|
||||
async function findCompanyBySlug(slug: string) {
|
||||
return prisma.company.findFirstOrThrow({
|
||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE', 'SUSPENDED'] } },
|
||||
include: { brand: true, contractSettings: true },
|
||||
})
|
||||
}
|
||||
|
||||
router.get('/:slug/brand', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
res.json({
|
||||
data: {
|
||||
company: {
|
||||
id: company.id,
|
||||
slug: company.slug,
|
||||
name: company.name,
|
||||
phone: company.phone,
|
||||
},
|
||||
brand: company.brand,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.json({
|
||||
data: {
|
||||
company: { id: 'demo', slug: req.params.slug, name: 'Demo Company', phone: null },
|
||||
brand: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const vehicles = await prisma.vehicle.findMany({
|
||||
where: { companyId: company.id, isPublished: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const enrichedVehicles = await Promise.all(
|
||||
vehicles.map(async (vehicle) => {
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
return {
|
||||
...vehicle,
|
||||
availability: availability.available,
|
||||
availabilityStatus: availability.status,
|
||||
nextAvailableAt: availability.nextAvailableAt,
|
||||
}
|
||||
}),
|
||||
)
|
||||
res.json({ data: enrichedVehicles })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: company.id, isPublished: true },
|
||||
})
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
res.json({ data: { ...vehicle, availability: availability.available, availabilityStatus: availability.status, nextAvailableAt: availability.nextAvailableAt } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const offers = await prisma.offer.findMany({
|
||||
where: {
|
||||
companyId: company.id,
|
||||
isActive: true,
|
||||
validFrom: { lte: new Date() },
|
||||
validUntil: { gte: new Date() },
|
||||
},
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
res.json({ data: offers })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/booking-options', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const insurancePolicies = await prisma.insurancePolicy.findMany({
|
||||
where: { companyId: company.id, isActive: true },
|
||||
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
insurancePolicies,
|
||||
contractSettings: company.contractSettings,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: { insurancePolicies: [], contractSettings: null } })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/availability', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const { vehicleId, startDate, endDate } = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
}).parse(req.body)
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(vehicleId, {
|
||||
range: { startDate: new Date(startDate), endDate: new Date(endDate) },
|
||||
})
|
||||
res.json({ data: { available: availability.available, nextAvailableAt: availability.nextAvailableAt } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/book/validate-code', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const { code } = z.object({ code: z.string().min(1) }).parse(req.body)
|
||||
const offer = await prisma.offer.findFirst({
|
||||
where: {
|
||||
companyId: company.id,
|
||||
promoCode: code,
|
||||
isActive: true,
|
||||
validFrom: { lte: new Date() },
|
||||
validUntil: { gte: new Date() },
|
||||
},
|
||||
})
|
||||
if (!offer) {
|
||||
return res.status(404).json({ error: 'invalid_code', message: 'Promo code not found or expired', statusCode: 404 })
|
||||
}
|
||||
res.json({ data: offer })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Promo code validation is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/book', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const body = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z.string().optional(),
|
||||
driverLicense: z.string().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
offerId: z.string().cuid().optional(),
|
||||
promoCodeUsed: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
source: z.enum(['PUBLIC_SITE', 'MARKETPLACE']).default('PUBLIC_SITE'),
|
||||
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
||||
additionalDrivers: z.array(z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
driverLicense: z.string().min(1),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
})).default([]),
|
||||
}).parse(req.body)
|
||||
|
||||
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: body.vehicleId, companyId: company.id, isPublished: true },
|
||||
})
|
||||
const start = new Date(body.startDate)
|
||||
const end = new Date(body.endDate)
|
||||
if (end <= start) {
|
||||
return res.status(400).json({ error: 'invalid_dates', message: 'End date must be after start date', statusCode: 400 })
|
||||
}
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||
range: { startDate: start, endDate: end },
|
||||
})
|
||||
if (!availability.available) {
|
||||
return res.status(409).json({
|
||||
error: 'unavailable',
|
||||
message: 'Vehicle is not available for the selected dates',
|
||||
statusCode: 409,
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.upsert({
|
||||
where: { companyId_email: { companyId: company.id, email: body.email } },
|
||||
update: {
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
phone: body.phone ?? null,
|
||||
driverLicense: body.driverLicense ?? null,
|
||||
dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null,
|
||||
licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null,
|
||||
licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null,
|
||||
nationality: body.nationality ?? null,
|
||||
},
|
||||
create: {
|
||||
companyId: company.id,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
email: body.email,
|
||||
phone: body.phone ?? null,
|
||||
driverLicense: body.driverLicense ?? null,
|
||||
dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null,
|
||||
licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null,
|
||||
licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null,
|
||||
nationality: body.nationality ?? null,
|
||||
},
|
||||
})
|
||||
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000))
|
||||
const baseAmount = vehicle.dailyRate * totalDays
|
||||
|
||||
let discountAmount = 0
|
||||
if (body.promoCodeUsed) {
|
||||
const offer = await prisma.offer.findFirst({
|
||||
where: {
|
||||
companyId: company.id,
|
||||
promoCode: body.promoCodeUsed,
|
||||
isActive: true,
|
||||
validFrom: { lte: new Date() },
|
||||
validUntil: { gte: new Date() },
|
||||
},
|
||||
})
|
||||
if (offer) {
|
||||
if (offer.type === 'PERCENTAGE') discountAmount = Math.round(baseAmount * offer.discountValue / 100)
|
||||
else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue
|
||||
else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue
|
||||
}
|
||||
}
|
||||
|
||||
const { applied, total: pricingRulesTotal } = await applyPricingRules(
|
||||
company.id,
|
||||
customer.id,
|
||||
body.additionalDrivers as any[],
|
||||
vehicle.dailyRate,
|
||||
totalDays,
|
||||
)
|
||||
|
||||
const primaryLicenseResult = validateLicense(body.licenseExpiry ? new Date(body.licenseExpiry) : null)
|
||||
if (primaryLicenseResult.status === 'EXPIRED') {
|
||||
return res.status(400).json({ error: 'license_expired', message: 'The primary driver license is expired', statusCode: 400 })
|
||||
}
|
||||
|
||||
const reservation = await prisma.reservation.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
offerId: body.offerId ?? null,
|
||||
promoCodeUsed: body.promoCodeUsed ?? null,
|
||||
source: body.source,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
dailyRate: vehicle.dailyRate,
|
||||
totalDays,
|
||||
totalAmount: baseAmount - discountAmount + pricingRulesTotal,
|
||||
discountAmount,
|
||||
pricingRulesApplied: applied,
|
||||
pricingRulesTotal,
|
||||
notes: body.notes ?? null,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
})
|
||||
|
||||
if (body.selectedInsurancePolicyIds.length > 0) {
|
||||
await applyInsurancesToReservation(reservation.id, company.id, body.selectedInsurancePolicyIds, totalDays, baseAmount)
|
||||
}
|
||||
|
||||
if (body.additionalDrivers.length > 0) {
|
||||
await applyAdditionalDriversToReservation(reservation.id, company.id, body.additionalDrivers, totalDays)
|
||||
}
|
||||
|
||||
if (body.licenseExpiry) {
|
||||
await validateAndFlagLicense(customer.id).catch(() => null)
|
||||
}
|
||||
|
||||
const refreshedReservation = await prisma.reservation.findUniqueOrThrow({
|
||||
where: { id: reservation.id },
|
||||
include: { insurances: true, additionalDrivers: true },
|
||||
})
|
||||
|
||||
res.status(201).json({
|
||||
data: {
|
||||
...refreshedReservation,
|
||||
requiresManualApproval:
|
||||
primaryLicenseResult.requiresApproval ||
|
||||
refreshedReservation.additionalDrivers.some((driver) => driver.requiresApproval),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Booking is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/booking/:id', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: company.id },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
res.json({ data: reservation })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Booking details are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/booking/:id/pay', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: company.id },
|
||||
include: { vehicle: true, customer: true, additionalDrivers: true },
|
||||
})
|
||||
|
||||
if (reservation.paymentStatus === 'PAID') {
|
||||
return res.status(409).json({ error: 'already_paid', message: 'This reservation is already paid', statusCode: 409 })
|
||||
}
|
||||
|
||||
const customerLicenseResult = validateLicense(reservation.customer.licenseExpiry)
|
||||
if (
|
||||
reservation.customer.licenseValidationStatus === 'DENIED' ||
|
||||
customerLicenseResult.status === 'EXPIRED' ||
|
||||
(customerLicenseResult.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') ||
|
||||
reservation.additionalDrivers.some((driver) => driver.licenseExpired || (driver.requiresApproval && !driver.approvedAt))
|
||||
) {
|
||||
return res.status(409).json({
|
||||
error: 'license_review_required',
|
||||
message: 'This reservation requires license review before payment can be processed',
|
||||
statusCode: 409,
|
||||
})
|
||||
}
|
||||
|
||||
const { provider, currency, successUrl, failureUrl } = z.object({
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
}).parse(req.body)
|
||||
|
||||
const amount = reservation.totalAmount
|
||||
const description = `Rental: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
const orderId = `res-${reservation.id}-${Date.now()}`
|
||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||
|
||||
let checkoutUrl: string
|
||||
let amanpayTransactionId: string | null = null
|
||||
let paypalCaptureId: string | null = null
|
||||
|
||||
if (provider === 'AMANPAY') {
|
||||
if (!amanpay.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'Online payment is not available for this company', statusCode: 503 })
|
||||
}
|
||||
const brand = company.brand as any
|
||||
const merchantId = brand?.amanpayMerchantId ?? process.env.AMANPAY_MERCHANT_ID ?? ''
|
||||
const secretKey = brand?.amanpaySecretKey ?? process.env.AMANPAY_SECRET_KEY ?? ''
|
||||
if (!merchantId || !secretKey) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured for this company', statusCode: 503 })
|
||||
}
|
||||
const result = await amanpay.createCheckout({
|
||||
amount,
|
||||
currency,
|
||||
orderId,
|
||||
description,
|
||||
customerEmail: reservation.customer.email,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
successUrl,
|
||||
failureUrl,
|
||||
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
amanpayTransactionId = result.transactionId
|
||||
} else {
|
||||
if (!paypal.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not available for this company', statusCode: 503 })
|
||||
}
|
||||
const result = await paypal.createOrder({
|
||||
amount,
|
||||
currency,
|
||||
orderId,
|
||||
description,
|
||||
returnUrl: successUrl,
|
||||
cancelUrl: failureUrl,
|
||||
})
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
|
||||
await prisma.rentalPayment.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
reservationId: reservation.id,
|
||||
amount,
|
||||
currency,
|
||||
status: 'PENDING',
|
||||
type: 'CHARGE',
|
||||
paymentProvider: provider,
|
||||
amanpayTransactionId,
|
||||
paypalCaptureId,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: { checkoutUrl } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Payment initiation is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/booking/:id/capture-paypal', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body)
|
||||
|
||||
const payment = await prisma.rentalPayment.findFirstOrThrow({
|
||||
where: { paypalCaptureId: paypalOrderId, companyId: company.id },
|
||||
})
|
||||
|
||||
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||
|
||||
await prisma.rentalPayment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId },
|
||||
})
|
||||
await prisma.reservation.update({
|
||||
where: { id: payment.reservationId },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
|
||||
})
|
||||
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:slug/contact', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const body = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
message: z.string().min(1),
|
||||
}).parse(req.body)
|
||||
res.json({
|
||||
data: {
|
||||
success: true,
|
||||
deliveredTo: company.brand?.publicEmail ?? company.email,
|
||||
preview: body,
|
||||
},
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../middleware/requireTenant'
|
||||
import * as amanpay from '../services/amanpayService'
|
||||
import * as paypal from '../services/paypalService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/plans', (_req, res) => {
|
||||
res.json({ data: PLAN_PRICES })
|
||||
})
|
||||
|
||||
// ─── AmanPay subscription webhook (no auth) ──────────────────────────────────
|
||||
|
||||
router.post('/webhooks/amanpay', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const signature = req.headers['x-amanpay-signature'] as string ?? ''
|
||||
|
||||
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
|
||||
const event = req.body
|
||||
const transactionId = event.transaction_id ?? event.id
|
||||
const status = event.status?.toUpperCase()
|
||||
|
||||
if (status === 'PAID' || status === 'SUCCEEDED') {
|
||||
const invoice = await prisma.subscriptionInvoice.findFirst({
|
||||
where: { amanpayTransactionId: transactionId },
|
||||
include: { subscription: true },
|
||||
})
|
||||
if (invoice) {
|
||||
await prisma.subscriptionInvoice.update({
|
||||
where: { id: invoice.id },
|
||||
data: { status: 'PAID', paidAt: new Date() },
|
||||
})
|
||||
await prisma.subscription.update({
|
||||
where: { id: invoice.subscriptionId },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── PayPal subscription webhook (no auth) ───────────────────────────────────
|
||||
|
||||
router.post('/webhooks/paypal', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const isValid = await paypal.verifyWebhookEvent(req.headers as Record<string, string>, rawBody)
|
||||
if (paypal.isConfigured() && !isValid) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
|
||||
const event = req.body
|
||||
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
|
||||
const captureId = event.resource?.id as string
|
||||
const invoice = await prisma.subscriptionInvoice.findFirst({
|
||||
where: { paypalCaptureId: captureId },
|
||||
include: { subscription: true },
|
||||
})
|
||||
if (invoice) {
|
||||
await prisma.subscriptionInvoice.update({
|
||||
where: { id: invoice.id },
|
||||
data: { status: 'PAID', paidAt: new Date() },
|
||||
})
|
||||
await prisma.subscription.update({
|
||||
where: { id: invoice.subscriptionId },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── PayPal capture redirect (no full auth needed — just valid session) ───────
|
||||
|
||||
router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, res, next) => {
|
||||
try {
|
||||
const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body)
|
||||
|
||||
const invoice = await prisma.subscriptionInvoice.findFirstOrThrow({
|
||||
where: { paypalCaptureId: paypalOrderId, companyId: req.companyId },
|
||||
include: { subscription: true },
|
||||
})
|
||||
|
||||
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||
|
||||
await prisma.subscriptionInvoice.update({
|
||||
where: { id: invoice.id },
|
||||
data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId },
|
||||
})
|
||||
await prisma.subscription.update({
|
||||
where: { id: invoice.subscriptionId },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Authenticated subscription routes ───────────────────────────────────────
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant)
|
||||
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { companyId: req.companyId },
|
||||
include: { invoices: { orderBy: { createdAt: 'desc' }, take: 12 } },
|
||||
})
|
||||
res.json({ data: subscription })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/invoices', async (req, res, next) => {
|
||||
try {
|
||||
const invoices = await prisma.subscriptionInvoice.findMany({
|
||||
where: { companyId: req.companyId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
res.json({ data: invoices })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
const checkoutSchema = z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
router.post('/checkout', async (req, res, next) => {
|
||||
try {
|
||||
const body = checkoutSchema.parse(req.body)
|
||||
const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod]
|
||||
if (!prices) return res.status(400).json({ error: 'invalid_plan', message: 'Invalid plan or billing period', statusCode: 400 })
|
||||
const amount = prices[body.currency]
|
||||
if (!amount) return res.status(400).json({ error: 'invalid_currency', message: 'Currency not supported for this plan', statusCode: 400 })
|
||||
|
||||
const company = await prisma.company.findUniqueOrThrow({ where: { id: req.companyId } })
|
||||
|
||||
let subscription = await prisma.subscription.findUnique({ where: { companyId: req.companyId } })
|
||||
if (!subscription) {
|
||||
subscription = await prisma.subscription.create({
|
||||
data: {
|
||||
companyId: req.companyId,
|
||||
plan: body.plan,
|
||||
billingPeriod: body.billingPeriod,
|
||||
currency: body.currency,
|
||||
status: 'PENDING' as any,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const orderId = `sub-${req.companyId}-${Date.now()}`
|
||||
const description = `${body.plan} plan — ${body.billingPeriod}`
|
||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||
|
||||
let checkoutUrl: string
|
||||
let amanpayTransactionId: string | null = null
|
||||
let paypalCaptureId: string | null = null
|
||||
|
||||
if (body.provider === 'AMANPAY') {
|
||||
if (!amanpay.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured on this platform', statusCode: 503 })
|
||||
}
|
||||
const result = await amanpay.createCheckout({
|
||||
amount,
|
||||
currency: body.currency,
|
||||
orderId,
|
||||
description,
|
||||
customerEmail: company.email,
|
||||
customerName: company.name,
|
||||
successUrl: body.successUrl,
|
||||
failureUrl: body.failureUrl,
|
||||
webhookUrl: `${webhookBase}/api/v1/subscriptions/webhooks/amanpay`,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
amanpayTransactionId = result.transactionId
|
||||
} else {
|
||||
if (!paypal.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not configured on this platform', statusCode: 503 })
|
||||
}
|
||||
const result = await paypal.createOrder({
|
||||
amount,
|
||||
currency: body.currency,
|
||||
orderId,
|
||||
description,
|
||||
returnUrl: body.successUrl,
|
||||
cancelUrl: body.failureUrl,
|
||||
})
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
|
||||
const invoice = await prisma.subscriptionInvoice.create({
|
||||
data: {
|
||||
companyId: req.companyId,
|
||||
subscriptionId: subscription.id,
|
||||
amount,
|
||||
currency: body.currency,
|
||||
status: 'PENDING',
|
||||
paymentProvider: body.provider,
|
||||
amanpayTransactionId,
|
||||
paypalCaptureId,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: { invoice, checkoutUrl } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/change-plan', async (req, res, next) => {
|
||||
try {
|
||||
const { plan, billingPeriod, currency } = z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
}).parse(req.body)
|
||||
|
||||
const updated = await prisma.subscription.update({
|
||||
where: { companyId: req.companyId },
|
||||
data: { plan, billingPeriod, currency },
|
||||
})
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/cancel', async (req, res, next) => {
|
||||
try {
|
||||
const updated = await prisma.subscription.update({
|
||||
where: { companyId: req.companyId },
|
||||
data: { cancelAtPeriodEnd: true },
|
||||
})
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/resume', async (req, res, next) => {
|
||||
try {
|
||||
const updated = await prisma.subscription.update({
|
||||
where: { companyId: req.companyId },
|
||||
data: { cancelAtPeriodEnd: false },
|
||||
})
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function addPeriod(date: Date, period: string): Date {
|
||||
const d = new Date(date)
|
||||
if (period === 'ANNUAL') {
|
||||
d.setFullYear(d.getFullYear() + 1)
|
||||
} else {
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,66 @@
|
||||
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)
|
||||
const memberId = req.params.id!
|
||||
res.json({ data: await updateEmployeeRole(req.companyId!, req.employee!.id, req.employee!.role, memberId, { role }) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/deactivate', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const memberId = req.params.id!
|
||||
res.json({ data: await deactivateEmployee(req.companyId!, req.employee!.role, memberId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/reactivate', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const memberId = req.params.id!
|
||||
res.json({ data: await reactivateEmployee(req.companyId!, req.employee!.role, memberId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:id', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const memberId = req.params.id!
|
||||
res.json({ data: await removeEmployee(req.companyId!, req.employee!.role, memberId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import multer from 'multer'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { uploadImage } from '../lib/storage'
|
||||
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().default(''),
|
||||
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, `companies/${req.companyId}/vehicles`)))
|
||||
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((_: string, i: number) => i !== idx)
|
||||
const updated = await prisma.vehicle.update({ where: { id: vehicle.id }, data: { photos } })
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
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 start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
|
||||
const [reservationConflicts, blockConflicts] = await Promise.all([
|
||||
prisma.reservation.findMany({
|
||||
where: {
|
||||
vehicleId: req.params.id,
|
||||
companyId: req.companyId,
|
||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: end },
|
||||
endDate: { gt: start },
|
||||
},
|
||||
select: { id: true, startDate: true, endDate: true, status: true },
|
||||
}),
|
||||
prisma.vehicleCalendarBlock.findMany({
|
||||
where: {
|
||||
vehicleId: req.params.id,
|
||||
startDate: { lt: end },
|
||||
endDate: { gt: start },
|
||||
},
|
||||
select: { id: true, startDate: true, endDate: true, type: true, reason: true },
|
||||
}),
|
||||
])
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
available: reservationConflicts.length === 0 && blockConflicts.length === 0,
|
||||
conflicts: reservationConflicts,
|
||||
blocks: blockConflicts,
|
||||
},
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id/calendar', async (req, res, next) => {
|
||||
try {
|
||||
const year = parseInt(req.query.year as string)
|
||||
const month = parseInt(req.query.month as string)
|
||||
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
|
||||
return res.status(400).json({ error: 'invalid_params', message: 'year and month (1-12) required', statusCode: 400 })
|
||||
}
|
||||
|
||||
// Validate vehicle belongs to company
|
||||
await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
|
||||
const rangeStart = new Date(year, month - 1, 1)
|
||||
const rangeEnd = new Date(year, month, 1)
|
||||
|
||||
const [reservations, blocks] = await Promise.all([
|
||||
prisma.reservation.findMany({
|
||||
where: {
|
||||
vehicleId: req.params.id,
|
||||
companyId: req.companyId,
|
||||
status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: rangeEnd },
|
||||
endDate: { gt: rangeStart },
|
||||
},
|
||||
select: {
|
||||
id: true, startDate: true, endDate: true, status: true,
|
||||
customer: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
}),
|
||||
prisma.vehicleCalendarBlock.findMany({
|
||||
where: {
|
||||
vehicleId: req.params.id,
|
||||
startDate: { lt: rangeEnd },
|
||||
endDate: { gt: rangeStart },
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const events = [
|
||||
...reservations.map((r) => ({
|
||||
id: r.id,
|
||||
type: 'RESERVATION' as const,
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate,
|
||||
status: r.status,
|
||||
label: r.customer ? `${r.customer.firstName} ${r.customer.lastName}` : 'Reserved',
|
||||
})),
|
||||
...blocks.map((b) => ({
|
||||
id: b.id,
|
||||
type: b.type === 'MAINTENANCE' ? 'MAINTENANCE' as const : 'BLOCK' as const,
|
||||
startDate: b.startDate,
|
||||
endDate: b.endDate,
|
||||
status: null,
|
||||
label: b.reason ?? (b.type === 'MAINTENANCE' ? 'Maintenance' : 'Blocked'),
|
||||
})),
|
||||
]
|
||||
|
||||
res.json({ data: events })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/calendar/blocks', async (req, res, next) => {
|
||||
try {
|
||||
const body = z.object({
|
||||
startDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
|
||||
endDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
|
||||
reason: z.string().optional(),
|
||||
type: z.enum(['MANUAL', 'MAINTENANCE']).default('MANUAL'),
|
||||
}).parse(req.body)
|
||||
|
||||
const start = new Date(body.startDate)
|
||||
const end = new Date(body.endDate)
|
||||
if (end <= start) return res.status(400).json({ error: 'invalid_range', message: 'endDate must be after startDate', statusCode: 400 })
|
||||
|
||||
await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
|
||||
const block = await prisma.vehicleCalendarBlock.create({
|
||||
data: {
|
||||
vehicleId: req.params.id,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
reason: body.reason,
|
||||
type: body.type,
|
||||
},
|
||||
})
|
||||
res.status(201).json({ data: block })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:id/calendar/blocks/:blockId', async (req, res, next) => {
|
||||
try {
|
||||
await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
await prisma.vehicleCalendarBlock.deleteMany({
|
||||
where: { id: req.params.blockId, vehicleId: req.params.id },
|
||||
})
|
||||
res.json({ data: { success: true } })
|
||||
} 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,13 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/clerk', (_req, res) => {
|
||||
res.status(410).json({
|
||||
error: 'disabled',
|
||||
message: 'Clerk webhooks are disabled because Clerk has been removed from this project.',
|
||||
statusCode: 410,
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,96 @@
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE'
|
||||
import { validateLicense } from './licenseValidationService'
|
||||
|
||||
export interface AdditionalDriverInput {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email?: string
|
||||
phone?: string
|
||||
driverLicense: string
|
||||
licenseExpiry?: string | null
|
||||
licenseIssuedAt?: string | null
|
||||
dateOfBirth?: string | null
|
||||
nationality?: string
|
||||
}
|
||||
|
||||
export function calculateAdditionalDriverCharge(
|
||||
chargeType: AdditionalDriverCharge,
|
||||
chargeValue: number,
|
||||
totalDays: number,
|
||||
) {
|
||||
switch (chargeType) {
|
||||
case 'PER_DAY':
|
||||
return chargeValue * totalDays
|
||||
case 'FLAT':
|
||||
return chargeValue
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAdditionalDriversToReservation(
|
||||
reservationId: string,
|
||||
companyId: string,
|
||||
drivers: AdditionalDriverInput[],
|
||||
totalDays: number,
|
||||
) {
|
||||
if (drivers.length === 0) {
|
||||
return { records: [], additionalDriverTotal: 0, requiresManualApproval: false }
|
||||
}
|
||||
|
||||
const settings = await prisma.contractSettings.findUnique({ where: { companyId } })
|
||||
const chargeType = settings?.additionalDriverCharge ?? 'FREE'
|
||||
const chargeValue =
|
||||
chargeType === 'PER_DAY'
|
||||
? settings?.additionalDriverDailyRate ?? 0
|
||||
: chargeType === 'FLAT'
|
||||
? settings?.additionalDriverFlatRate ?? 0
|
||||
: 0
|
||||
|
||||
const records = drivers.map((driver) => {
|
||||
const licenseResult = validateLicense(driver.licenseExpiry ? new Date(driver.licenseExpiry) : null)
|
||||
const totalCharge = calculateAdditionalDriverCharge(chargeType, chargeValue, totalDays)
|
||||
|
||||
return {
|
||||
reservationId,
|
||||
companyId,
|
||||
firstName: driver.firstName,
|
||||
lastName: driver.lastName,
|
||||
email: driver.email ?? null,
|
||||
phone: driver.phone ?? null,
|
||||
driverLicense: driver.driverLicense,
|
||||
licenseExpiry: driver.licenseExpiry ? new Date(driver.licenseExpiry) : null,
|
||||
licenseIssuedAt: driver.licenseIssuedAt ? new Date(driver.licenseIssuedAt) : null,
|
||||
dateOfBirth: driver.dateOfBirth ? new Date(driver.dateOfBirth) : null,
|
||||
nationality: driver.nationality ?? null,
|
||||
chargeType,
|
||||
chargeValue,
|
||||
totalCharge,
|
||||
licenseExpired: licenseResult.status === 'EXPIRED',
|
||||
licenseExpiringSoon: licenseResult.status === 'EXPIRING',
|
||||
requiresApproval: licenseResult.requiresApproval,
|
||||
approvalNote: licenseResult.requiresApproval ? licenseResult.message : null,
|
||||
}
|
||||
})
|
||||
|
||||
const additionalDriverTotal = records.reduce((sum, record) => sum + record.totalCharge, 0)
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.additionalDriver.createMany({ data: records }),
|
||||
prisma.reservation.update({
|
||||
where: { id: reservationId },
|
||||
data: {
|
||||
additionalDriverTotal,
|
||||
totalAmount: { increment: additionalDriverTotal },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
records,
|
||||
additionalDriverTotal,
|
||||
requiresManualApproval: records.some((record) => record.requiresApproval),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
const BASE_URL = process.env.AMANPAY_BASE_URL ?? 'https://api.amanpay.net'
|
||||
const MERCHANT_ID = process.env.AMANPAY_MERCHANT_ID ?? ''
|
||||
const SECRET_KEY = process.env.AMANPAY_SECRET_KEY ?? ''
|
||||
const WEBHOOK_SECRET = process.env.AMANPAY_WEBHOOK_SECRET ?? ''
|
||||
|
||||
export interface AmanPayCreateParams {
|
||||
amount: number
|
||||
currency: string
|
||||
orderId: string
|
||||
description: string
|
||||
customerEmail?: string
|
||||
customerName?: string
|
||||
successUrl: string
|
||||
failureUrl: string
|
||||
webhookUrl: string
|
||||
}
|
||||
|
||||
export interface AmanPayCheckoutResult {
|
||||
transactionId: string
|
||||
checkoutUrl: string
|
||||
status: string
|
||||
}
|
||||
|
||||
async function getAuthHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-merchant-id': MERCHANT_ID,
|
||||
'x-api-key': SECRET_KEY,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCheckout(params: AmanPayCreateParams): Promise<AmanPayCheckoutResult> {
|
||||
const res = await fetch(`${BASE_URL}/v1/payments`, {
|
||||
method: 'POST',
|
||||
headers: await getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
merchant_id: MERCHANT_ID,
|
||||
amount: params.amount,
|
||||
currency: params.currency,
|
||||
order_id: params.orderId,
|
||||
description: params.description,
|
||||
customer_email: params.customerEmail,
|
||||
customer_name: params.customerName,
|
||||
success_url: params.successUrl,
|
||||
failure_url: params.failureUrl,
|
||||
webhook_url: params.webhookUrl,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({})) as Record<string, unknown>
|
||||
throw new Error(`AmanPay checkout failed: ${(err?.message as string) ?? res.statusText}`)
|
||||
}
|
||||
|
||||
const json = await res.json() as Record<string, unknown>
|
||||
return {
|
||||
transactionId: (json.transaction_id ?? json.id) as string,
|
||||
checkoutUrl: (json.checkout_url ?? json.payment_url) as string,
|
||||
status: (json.status ?? 'PENDING') as string,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTransaction(transactionId: string) {
|
||||
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}`, {
|
||||
headers: await getAuthHeaders(),
|
||||
})
|
||||
if (!res.ok) throw new Error('AmanPay: failed to fetch transaction')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function refundTransaction(transactionId: string, amount: number, reason?: string) {
|
||||
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}/refund`, {
|
||||
method: 'POST',
|
||||
headers: await getAuthHeaders(),
|
||||
body: JSON.stringify({ amount, reason }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({})) as Record<string, unknown>
|
||||
throw new Error(`AmanPay refund failed: ${(err?.message as string) ?? res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function verifyWebhookSignature(rawBody: string, signature: string): boolean {
|
||||
if (!WEBHOOK_SECRET) return false
|
||||
const expected = crypto
|
||||
.createHmac('sha256', WEBHOOK_SECRET)
|
||||
.update(rawBody)
|
||||
.digest('hex')
|
||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
|
||||
}
|
||||
|
||||
export function isConfigured(): boolean {
|
||||
return !!(MERCHANT_ID && SECRET_KEY && MERCHANT_ID !== 'your-amanpay-merchant-id')
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { prisma as _prisma } from '../lib/prisma'
|
||||
|
||||
const db = _prisma as any
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
let composeCommand: 'docker-compose' | 'docker' | null = null
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const COMPOSE_DIR = process.env.COMPANIES_COMPOSE_DIR || '/opt/companies'
|
||||
const COMPOSE_FILE = path.join(COMPOSE_DIR, 'docker-compose.companies.yml')
|
||||
const SERVICES_FILE = path.join(COMPOSE_DIR, 'companies-services.json') // our source of truth
|
||||
|
||||
const DASHBOARD_IMAGE = process.env.DASHBOARD_CONTAINER_IMAGE || 'rentaldrivego/dashboard:latest'
|
||||
const PORT_RANGE_START = parseInt(process.env.CONTAINER_PORT_START || '5100', 10)
|
||||
const PORT_RANGE_END = parseInt(process.env.CONTAINER_PORT_END || '5999', 10)
|
||||
const API_INTERNAL_URL = process.env.API_INTERNAL_URL || 'http://api:4000'
|
||||
const CONTAINER_NETWORK = process.env.CONTAINER_NETWORK || 'rentaldrivego_default'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ServiceDef {
|
||||
companyId: string
|
||||
slug: string
|
||||
image: string
|
||||
port: number
|
||||
}
|
||||
|
||||
type ServicesMap = Record<string, ServiceDef> // key = service name e.g. "company-slug"
|
||||
|
||||
type ServiceStatus = 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'ERROR'
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function serviceName(slug: string) {
|
||||
return `company-${slug}`
|
||||
}
|
||||
|
||||
function containerName(slug: string) {
|
||||
return `rdg-company-${slug}`
|
||||
}
|
||||
|
||||
async function ensureDir(): Promise<void> {
|
||||
await fs.mkdir(COMPOSE_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
async function readServices(): Promise<ServicesMap> {
|
||||
try {
|
||||
const raw = await fs.readFile(SERVICES_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeServices(services: ServicesMap): Promise<void> {
|
||||
await ensureDir()
|
||||
await fs.writeFile(SERVICES_FILE, JSON.stringify(services, null, 2), 'utf8')
|
||||
await fs.writeFile(COMPOSE_FILE, buildComposeYaml(services), 'utf8')
|
||||
}
|
||||
|
||||
async function composeFilesExist(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(COMPOSE_FILE)
|
||||
await fs.access(SERVICES_FILE)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildServicesFromDatabase(): Promise<ServicesMap> {
|
||||
const records = await db.companyContainer.findMany({
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
return Object.fromEntries(
|
||||
records.map((record: any) => [
|
||||
serviceName(record.company.slug),
|
||||
{
|
||||
companyId: record.companyId,
|
||||
slug: record.company.slug,
|
||||
image: record.image,
|
||||
port: record.port,
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
async function ensureComposeFiles(): Promise<void> {
|
||||
if (await composeFilesExist()) return
|
||||
|
||||
const services = await rebuildServicesFromDatabase()
|
||||
await writeServices(services)
|
||||
}
|
||||
|
||||
function buildComposeYaml(services: ServicesMap): string {
|
||||
const lines: string[] = ['services:']
|
||||
|
||||
for (const [name, svc] of Object.entries(services)) {
|
||||
lines.push(` ${name}:`)
|
||||
lines.push(` image: "${svc.image}"`)
|
||||
lines.push(` container_name: "${containerName(svc.slug)}"`)
|
||||
lines.push(` restart: unless-stopped`)
|
||||
lines.push(` environment:`)
|
||||
lines.push(` COMPANY_ID: "${svc.companyId}"`)
|
||||
lines.push(` COMPANY_SLUG: "${svc.slug}"`)
|
||||
lines.push(` API_URL: "${API_INTERNAL_URL}"`)
|
||||
lines.push(` NEXT_PUBLIC_API_URL: "${API_INTERNAL_URL}"`)
|
||||
lines.push(` PORT: "3000"`)
|
||||
lines.push(` NODE_ENV: "production"`)
|
||||
lines.push(` ports:`)
|
||||
lines.push(` - "${svc.port}:3000"`)
|
||||
lines.push(` networks:`)
|
||||
lines.push(` - ${CONTAINER_NETWORK}`)
|
||||
lines.push(` labels:`)
|
||||
lines.push(` rdg.managed: "true"`)
|
||||
lines.push(` rdg.company.id: "${svc.companyId}"`)
|
||||
lines.push(` rdg.company.slug: "${svc.slug}"`)
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
lines.push('networks:')
|
||||
lines.push(` ${CONTAINER_NETWORK}:`)
|
||||
lines.push(` external: true`)
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function resolveComposeCommand(): Promise<'docker-compose' | 'docker'> {
|
||||
if (composeCommand) return composeCommand
|
||||
|
||||
try {
|
||||
await execFileAsync('docker-compose', ['version'])
|
||||
composeCommand = 'docker-compose'
|
||||
return composeCommand
|
||||
} catch {
|
||||
try {
|
||||
await execFileAsync('docker', ['compose', 'version'])
|
||||
composeCommand = 'docker'
|
||||
return composeCommand
|
||||
} catch (err) {
|
||||
throw mapDockerError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function compose(...args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
try {
|
||||
await ensureComposeFiles()
|
||||
const command = await resolveComposeCommand()
|
||||
if (command === 'docker-compose') {
|
||||
return await execFileAsync('docker-compose', ['-f', COMPOSE_FILE, ...args])
|
||||
}
|
||||
return await execFileAsync('docker', ['compose', '-f', COMPOSE_FILE, ...args])
|
||||
} catch (err) {
|
||||
throw mapDockerError(err)
|
||||
}
|
||||
}
|
||||
|
||||
function dockerUnavailableError(message: string, details?: string) {
|
||||
const err = Object.assign(new Error(message), {
|
||||
statusCode: 503,
|
||||
code: 'docker_unavailable',
|
||||
})
|
||||
|
||||
if (details) {
|
||||
;(err as Error & { details?: string }).details = details
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
function mapDockerError(err: unknown) {
|
||||
if (err && typeof err === 'object') {
|
||||
const error = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }
|
||||
const stderr = error.stderr?.trim()
|
||||
|
||||
if (error.code === 'ENOENT' && error.path === 'docker') {
|
||||
return dockerUnavailableError(
|
||||
'Docker CLI is not available to the API service.',
|
||||
'Install Docker in the API container or run the API on a host with Docker available in PATH.',
|
||||
)
|
||||
}
|
||||
|
||||
if (error.code === 'ENOENT' && error.path === 'docker-compose') {
|
||||
return dockerUnavailableError(
|
||||
'Docker Compose is not available to the API service.',
|
||||
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
stderr?.includes("docker: 'compose' is not a docker command.") ||
|
||||
stderr?.includes("unknown shorthand flag: 'f' in -f")
|
||||
) {
|
||||
return dockerUnavailableError(
|
||||
'Docker Compose is not available to the API service.',
|
||||
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
stderr?.includes('Cannot connect to the Docker daemon') ||
|
||||
stderr?.includes('permission denied while trying to connect to the Docker daemon socket') ||
|
||||
stderr?.includes('error during connect')
|
||||
) {
|
||||
return dockerUnavailableError(
|
||||
'Docker daemon is not reachable from the API service.',
|
||||
'Mount `/var/run/docker.sock` into the API container and ensure the Docker daemon is running.',
|
||||
)
|
||||
}
|
||||
|
||||
// Catch-all: docker ran but exited non-zero — surface stderr as a readable 502
|
||||
if (typeof error.code === 'number' && error.code !== 0) {
|
||||
const detail = stderr || error.stdout?.trim() || 'docker compose exited with a non-zero status'
|
||||
return Object.assign(new Error(detail), { statusCode: 502, code: 'docker_error' })
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
async function setContainerError(companyId: string, message: string): Promise<void> {
|
||||
await db.companyContainer.update({
|
||||
where: { companyId },
|
||||
data: { status: 'ERROR', errorMessage: message },
|
||||
}).catch(() => null)
|
||||
}
|
||||
|
||||
async function runContainerAction(
|
||||
companyId: string,
|
||||
slug: string,
|
||||
command: 'start' | 'stop' | 'restart',
|
||||
successStatus: Exclude<ServiceStatus, 'ERROR'>,
|
||||
preStatus?: Exclude<ServiceStatus, 'ERROR'>,
|
||||
): Promise<void> {
|
||||
if (preStatus) {
|
||||
await db.companyContainer.update({ where: { companyId }, data: { status: preStatus, errorMessage: null } })
|
||||
}
|
||||
|
||||
try {
|
||||
// Use `up -d --no-recreate` for start so it creates the container if it doesn't exist yet
|
||||
const args = command === 'start'
|
||||
? ['up', '-d', '--no-recreate', serviceName(slug)]
|
||||
: [command, serviceName(slug)]
|
||||
await compose(...args)
|
||||
await db.companyContainer.update({
|
||||
where: { companyId },
|
||||
data: { status: successStatus, errorMessage: null },
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : `Failed to ${command} container`
|
||||
await setContainerError(companyId, message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function allocatePort(): Promise<number> {
|
||||
const last = await db.companyContainer.findFirst({
|
||||
orderBy: { port: 'desc' },
|
||||
select: { port: true },
|
||||
})
|
||||
const next = last ? last.port + 1 : PORT_RANGE_START
|
||||
if (next > PORT_RANGE_END) throw new Error('No available ports in container port range')
|
||||
return next
|
||||
}
|
||||
|
||||
async function getDockerServiceId(slug: string): Promise<string | null> {
|
||||
try {
|
||||
const name = serviceName(slug)
|
||||
const { stdout } = await compose('ps', '--format', 'json', name)
|
||||
const line = stdout.trim().split('\n')[0]
|
||||
if (!line) return null
|
||||
const info = JSON.parse(line)
|
||||
return info.ID ?? info.Id ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Maps docker compose State strings to our DB enum
|
||||
function mapDockerState(state: string): ServiceStatus {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'running': return 'RUNNING'
|
||||
case 'restarting': return 'RESTARTING'
|
||||
case 'exited':
|
||||
case 'created':
|
||||
case 'paused': return 'STOPPED'
|
||||
default: return 'ERROR'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createCompanyContainer(company: {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
}): Promise<void> {
|
||||
const name = serviceName(company.slug)
|
||||
const port = await allocatePort()
|
||||
|
||||
const record = await db.companyContainer.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
containerName: containerName(company.slug),
|
||||
status: 'CREATING',
|
||||
port,
|
||||
image: DASHBOARD_IMAGE,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const services = await readServices()
|
||||
services[name] = { companyId: company.id, slug: company.slug, image: DASHBOARD_IMAGE, port }
|
||||
await writeServices(services)
|
||||
|
||||
await compose('up', '-d', '--no-recreate', name)
|
||||
|
||||
const dockerId = await getDockerServiceId(company.slug)
|
||||
await db.companyContainer.update({
|
||||
where: { id: record.id },
|
||||
data: { dockerId, status: 'RUNNING' },
|
||||
})
|
||||
} catch (err) {
|
||||
await db.companyContainer.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
status: 'ERROR',
|
||||
errorMessage: err instanceof Error ? err.message : 'Unknown error during service creation',
|
||||
},
|
||||
})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
await runContainerAction(companyId, record.company.slug, 'start', 'RUNNING')
|
||||
}
|
||||
|
||||
export async function stopCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
await runContainerAction(companyId, record.company.slug, 'stop', 'STOPPED')
|
||||
}
|
||||
|
||||
export async function restartCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
await runContainerAction(companyId, record.company.slug, 'restart', 'RUNNING', 'RESTARTING')
|
||||
}
|
||||
|
||||
export async function removeCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
const name = serviceName(record.company.slug)
|
||||
await db.companyContainer.update({ where: { companyId }, data: { status: 'REMOVING' } })
|
||||
|
||||
try {
|
||||
await compose('stop', name)
|
||||
} catch { /* already stopped */ }
|
||||
|
||||
try {
|
||||
await compose('rm', '-f', name)
|
||||
} catch { /* already removed */ }
|
||||
|
||||
const services = await readServices()
|
||||
delete services[name]
|
||||
await writeServices(services)
|
||||
|
||||
await db.companyContainer.delete({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function redeployCompanyContainer(company: {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
}): Promise<void> {
|
||||
const name = serviceName(company.slug)
|
||||
|
||||
const existing = await db.companyContainer.findUnique({ where: { companyId: company.id } })
|
||||
if (existing) {
|
||||
try { await compose('stop', name) } catch { /* ok */ }
|
||||
try { await compose('rm', '-f', name) } catch { /* ok */ }
|
||||
await db.companyContainer.delete({ where: { companyId: company.id } })
|
||||
}
|
||||
|
||||
// Remove from compose file too, then recreate
|
||||
const services = await readServices()
|
||||
delete services[name]
|
||||
await writeServices(services)
|
||||
|
||||
await createCompanyContainer(company)
|
||||
}
|
||||
|
||||
export async function getContainerLogs(companyId: string, tail = 150): Promise<string> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
try {
|
||||
const { stdout } = await compose(
|
||||
'logs',
|
||||
'--no-log-prefix',
|
||||
`--tail=${tail}`,
|
||||
serviceName(record.company.slug),
|
||||
)
|
||||
return stdout
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncContainerStatuses(): Promise<void> {
|
||||
const records = await db.companyContainer.findMany({
|
||||
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
try {
|
||||
// Get all compose service states in one shot
|
||||
const { stdout } = await compose('ps', '--format', 'json')
|
||||
const rows = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try { return JSON.parse(line) } catch { return null }
|
||||
})
|
||||
.filter(Boolean) as Array<{ Service: string; State: string }>
|
||||
|
||||
const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State]))
|
||||
|
||||
await Promise.allSettled(
|
||||
records.map(async (rec: any) => {
|
||||
const name = serviceName(rec.company.slug)
|
||||
const dockerState = stateByService[name]
|
||||
const status = dockerState ? mapDockerState(dockerState) : 'STOPPED'
|
||||
|
||||
if (rec.status !== status) {
|
||||
await db.companyContainer.update({ where: { id: rec.id }, data: { status } })
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
// Docker daemon unreachable — leave statuses as-is
|
||||
}
|
||||
}
|
||||
@@ -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: number, r: (typeof reservations)[number]) => s + r.dailyRate * r.totalDays, 0),
|
||||
totalDiscounts: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.discountAmount, 0),
|
||||
totalInsurance: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.insuranceTotal, 0),
|
||||
totalAdditionalDrivers: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.additionalDriverTotal, 0),
|
||||
totalPricingRulesAdj: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.pricingRulesTotal, 0),
|
||||
totalDeposits: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.depositAmount, 0),
|
||||
totalCollected: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalAmount, 0),
|
||||
averageRentalDays: reservations.length > 0 ? reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalDays, 0) / reservations.length : 0,
|
||||
}
|
||||
|
||||
const rows = reservations.map((r: (typeof reservations)[number]) => ({
|
||||
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,53 @@
|
||||
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: InsurancePolicy) => p.isRequired)
|
||||
const selected = allPolicies.filter((p: InsurancePolicy) => 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,
|
||||
totalAmount: { increment: insuranceTotal },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return { records, insuranceTotal }
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import React from 'react'
|
||||
import { renderToBuffer, Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
|
||||
|
||||
interface InvoiceData {
|
||||
invoiceNumber: string
|
||||
issueDate: string
|
||||
dueDate: string | null
|
||||
company: {
|
||||
name: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
address?: any
|
||||
}
|
||||
subscription: {
|
||||
plan: string
|
||||
billingPeriod: string
|
||||
currentPeriodStart?: string | null
|
||||
currentPeriodEnd?: string | null
|
||||
currency: string
|
||||
}
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
paymentProvider: string
|
||||
transactionId?: string | null
|
||||
paidAt?: string | null
|
||||
}
|
||||
|
||||
const PLAN_LABEL: Record<string, string> = {
|
||||
STARTER: 'Starter',
|
||||
GROWTH: 'Growth',
|
||||
PRO: 'Pro',
|
||||
}
|
||||
|
||||
const PERIOD_LABEL: Record<string, string> = {
|
||||
MONTHLY: 'Monthly',
|
||||
ANNUAL: 'Annual',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PAID: '#10b981',
|
||||
PENDING: '#f59e0b',
|
||||
FAILED: '#ef4444',
|
||||
REFUNDED: '#6b7280',
|
||||
}
|
||||
|
||||
const colors = {
|
||||
bg: '#0f0f11',
|
||||
surface: '#18181b',
|
||||
border: '#27272a',
|
||||
textPrimary: '#f4f4f5',
|
||||
textSecondary: '#a1a1aa',
|
||||
textMuted: '#71717a',
|
||||
accent: '#10b981',
|
||||
white: '#ffffff',
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
page: {
|
||||
backgroundColor: colors.bg,
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 48,
|
||||
fontFamily: 'Helvetica',
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 40,
|
||||
paddingBottom: 24,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
brandName: {
|
||||
fontSize: 22,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.accent,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
brandTagline: {
|
||||
fontSize: 9,
|
||||
color: colors.textMuted,
|
||||
marginTop: 3,
|
||||
},
|
||||
invoiceMeta: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
invoiceTitle: {
|
||||
fontSize: 26,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.white,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 11,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 8,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 1.2,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 8,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 24,
|
||||
marginBottom: 24,
|
||||
},
|
||||
col: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
colLabel: {
|
||||
fontSize: 8,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 10,
|
||||
},
|
||||
companyName: {
|
||||
fontSize: 13,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
companyDetail: {
|
||||
fontSize: 10,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 6,
|
||||
},
|
||||
metaKey: {
|
||||
fontSize: 9,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
metaValue: {
|
||||
fontSize: 9,
|
||||
color: colors.textSecondary,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
},
|
||||
table: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 20,
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#1c1c1f',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
tableHeaderCell: {
|
||||
fontSize: 8,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
tableCell: {
|
||||
fontSize: 10,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
tableCellMuted: {
|
||||
fontSize: 10,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
col60: { flex: 6 },
|
||||
col20: { flex: 2, textAlign: 'right' as const },
|
||||
col20Center: { flex: 2, textAlign: 'center' as const },
|
||||
totalRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: 6,
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textSecondary,
|
||||
marginRight: 32,
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: 11,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.white,
|
||||
minWidth: 100,
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
grandTotalRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 8,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
grandTotalLabel: {
|
||||
fontSize: 13,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.textSecondary,
|
||||
marginRight: 32,
|
||||
},
|
||||
grandTotalValue: {
|
||||
fontSize: 15,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: colors.accent,
|
||||
minWidth: 100,
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 9,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
letterSpacing: 0.5,
|
||||
color: colors.white,
|
||||
},
|
||||
paymentBox: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: 24,
|
||||
},
|
||||
paymentRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
paymentKey: {
|
||||
fontSize: 9,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
paymentValue: {
|
||||
fontSize: 9,
|
||||
color: colors.textSecondary,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
maxWidth: 280,
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
left: 48,
|
||||
right: 48,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
paddingTop: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 8,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
})
|
||||
|
||||
function fmt(amount: number, currency: string) {
|
||||
return `${(amount / 100).toFixed(2)} ${currency}`
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null | undefined) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
function formatAddress(address: any): string {
|
||||
if (!address) return ''
|
||||
if (typeof address === 'string') return address
|
||||
const parts = [address.street, address.city, address.region, address.country, address.zip]
|
||||
return parts.filter(Boolean).join(', ')
|
||||
}
|
||||
|
||||
function InvoiceDocument({ data }: { data: InvoiceData }) {
|
||||
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
|
||||
const addressStr = formatAddress(data.company.address)
|
||||
const planLabel = PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan
|
||||
const periodLabel = PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod
|
||||
const description = `${planLabel} Plan — ${periodLabel} Subscription`
|
||||
const periodStr = data.subscription.currentPeriodStart && data.subscription.currentPeriodEnd
|
||||
? `${fmtDate(data.subscription.currentPeriodStart)} – ${fmtDate(data.subscription.currentPeriodEnd)}`
|
||||
: '—'
|
||||
|
||||
return React.createElement(
|
||||
Document,
|
||||
{ author: 'RentalDriveGo', title: `Invoice ${data.invoiceNumber}`, subject: 'Subscription Invoice' },
|
||||
React.createElement(
|
||||
Page,
|
||||
{ size: 'A4', style: s.page },
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.header },
|
||||
React.createElement(
|
||||
View,
|
||||
null,
|
||||
React.createElement(Text, { style: s.brandName }, 'RentalDriveGo'),
|
||||
React.createElement(Text, { style: s.brandTagline }, 'Car Rental Management Platform'),
|
||||
),
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.invoiceMeta },
|
||||
React.createElement(Text, { style: s.invoiceTitle }, 'INVOICE'),
|
||||
React.createElement(Text, { style: s.invoiceNumber }, data.invoiceNumber),
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: [s.statusBadge, { backgroundColor: statusColor }] },
|
||||
React.createElement(Text, { style: s.statusText }, data.status),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Bill To / Invoice Dates ──────────────────────────────────
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.row },
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.col },
|
||||
React.createElement(Text, { style: s.colLabel }, 'Bill To'),
|
||||
React.createElement(Text, { style: s.companyName }, data.company.name),
|
||||
React.createElement(Text, { style: s.companyDetail }, data.company.email),
|
||||
data.company.phone
|
||||
? React.createElement(Text, { style: s.companyDetail }, data.company.phone)
|
||||
: null,
|
||||
addressStr
|
||||
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, addressStr)
|
||||
: null,
|
||||
),
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.col },
|
||||
React.createElement(Text, { style: s.colLabel }, 'Invoice Details'),
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.metaRow },
|
||||
React.createElement(Text, { style: s.metaKey }, 'Invoice Number'),
|
||||
React.createElement(Text, { style: s.metaValue }, data.invoiceNumber),
|
||||
),
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.metaRow },
|
||||
React.createElement(Text, { style: s.metaKey }, 'Issue Date'),
|
||||
React.createElement(Text, { style: s.metaValue }, fmtDate(data.issueDate)),
|
||||
),
|
||||
data.dueDate
|
||||
? React.createElement(
|
||||
View,
|
||||
{ style: s.metaRow },
|
||||
React.createElement(Text, { style: s.metaKey }, 'Due Date'),
|
||||
React.createElement(Text, { style: s.metaValue }, fmtDate(data.dueDate)),
|
||||
)
|
||||
: null,
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.metaRow },
|
||||
React.createElement(Text, { style: s.metaKey }, 'Currency'),
|
||||
React.createElement(Text, { style: s.metaValue }, data.currency),
|
||||
),
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.metaRow },
|
||||
React.createElement(Text, { style: s.metaKey }, 'Billing Period'),
|
||||
React.createElement(Text, { style: s.metaValue }, periodLabel),
|
||||
),
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.metaRow },
|
||||
React.createElement(Text, { style: s.metaKey }, 'Plan'),
|
||||
React.createElement(Text, { style: s.metaValue }, planLabel),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Line Items ───────────────────────────────────────────────
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.table },
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.tableHeader },
|
||||
React.createElement(Text, { style: [s.tableHeaderCell, s.col60] }, 'Description'),
|
||||
React.createElement(Text, { style: [s.tableHeaderCell, s.col20Center] }, 'Period'),
|
||||
React.createElement(Text, { style: [s.tableHeaderCell, s.col20] }, 'Amount'),
|
||||
),
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.tableRow },
|
||||
React.createElement(Text, { style: [s.tableCell, s.col60] }, description),
|
||||
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr),
|
||||
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(data.amount, data.currency)),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Totals ───────────────────────────────────────────────────
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.totalRow },
|
||||
React.createElement(Text, { style: s.totalLabel }, 'Subtotal'),
|
||||
React.createElement(Text, { style: s.totalValue }, fmt(data.amount, data.currency)),
|
||||
),
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.grandTotalRow },
|
||||
React.createElement(Text, { style: s.grandTotalLabel }, 'Total Due'),
|
||||
React.createElement(Text, { style: s.grandTotalValue }, fmt(data.amount, data.currency)),
|
||||
),
|
||||
|
||||
// ── Payment Info ─────────────────────────────────────────────
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: [s.paymentBox, { marginTop: 24 }] },
|
||||
React.createElement(Text, { style: [s.colLabel, { marginBottom: 10 }] }, 'Payment Information'),
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.paymentRow },
|
||||
React.createElement(Text, { style: s.paymentKey }, 'Payment Provider'),
|
||||
React.createElement(Text, { style: s.paymentValue }, data.paymentProvider),
|
||||
),
|
||||
data.transactionId
|
||||
? React.createElement(
|
||||
View,
|
||||
{ style: s.paymentRow },
|
||||
React.createElement(Text, { style: s.paymentKey }, 'Transaction ID'),
|
||||
React.createElement(Text, { style: s.paymentValue }, data.transactionId),
|
||||
)
|
||||
: null,
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.paymentRow },
|
||||
React.createElement(Text, { style: s.paymentKey }, 'Payment Date'),
|
||||
React.createElement(Text, { style: s.paymentValue }, fmtDate(data.paidAt)),
|
||||
),
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.paymentRow },
|
||||
React.createElement(Text, { style: s.paymentKey }, 'Status'),
|
||||
React.createElement(Text, { style: [s.paymentValue, { color: statusColor }] }, data.status),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Footer ───────────────────────────────────────────────────
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.footer },
|
||||
React.createElement(Text, { style: s.footerText }, 'RentalDriveGo — Car Rental Management Platform'),
|
||||
React.createElement(Text, { style: s.footerText }, `Generated on ${fmtDate(new Date().toISOString())}`),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
|
||||
const doc = React.createElement(InvoiceDocument, { data })
|
||||
const buffer = await renderToBuffer(doc as any)
|
||||
return buffer as unknown as Buffer
|
||||
}
|
||||
|
||||
export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
|
||||
const year = createdAt.getFullYear()
|
||||
const short = invoiceId.slice(-6).toUpperCase()
|
||||
return `INV-${year}-${short}`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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,
|
||||
flagged: result.requiresApproval ? true : customer.flagged,
|
||||
flagReason: result.requiresApproval ? result.message : customer.flagReason,
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
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 =
|
||||
process.env.RESEND_API_KEY && process.env.RESEND_API_KEY !== 're_...'
|
||||
? new Resend(process.env.RESEND_API_KEY)
|
||||
: null
|
||||
|
||||
const emailFromAddress =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_FROM !== 'noreply@example.com'
|
||||
? process.env.EMAIL_FROM
|
||||
: null
|
||||
|
||||
const emailFromName =
|
||||
process.env.EMAIL_FROM_NAME && process.env.EMAIL_FROM_NAME !== 'Example App'
|
||||
? process.env.EMAIL_FROM_NAME
|
||||
: null
|
||||
|
||||
const smtpHost = process.env.MAIL_HOST
|
||||
const smtpPort = Number(process.env.MAIL_PORT ?? 0)
|
||||
const smtpUser = process.env.MAIL_USERNAME
|
||||
const smtpPass = process.env.MAIL_PASSWORD
|
||||
const smtpSecure =
|
||||
process.env.MAIL_SCHEME === 'smtps' ||
|
||||
process.env.MAIL_ENCRYPTION === 'ssl' ||
|
||||
smtpPort === 465
|
||||
|
||||
const smtpFromAddress =
|
||||
process.env.MAIL_FROM_ADDRESS && !process.env.MAIL_FROM_ADDRESS.includes('${')
|
||||
? process.env.MAIL_FROM_ADDRESS
|
||||
: null
|
||||
|
||||
const smtpFromName =
|
||||
process.env.MAIL_FROM_NAME && !process.env.MAIL_FROM_NAME.includes('${')
|
||||
? process.env.MAIL_FROM_NAME
|
||||
: null
|
||||
|
||||
type SmtpTransport = {
|
||||
sendMail(options: Record<string, unknown>): Promise<{ messageId?: string }>
|
||||
}
|
||||
|
||||
let smtpTransport: SmtpTransport | null = null
|
||||
|
||||
if (smtpHost && smtpPort && smtpUser && smtpPass) {
|
||||
try {
|
||||
const nodemailer = require('nodemailer') as {
|
||||
createTransport(options: Record<string, unknown>): SmtpTransport
|
||||
}
|
||||
|
||||
smtpTransport = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass,
|
||||
},
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.warn('[Notifications] SMTP transport unavailable:', err?.message ?? String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const twilioClient =
|
||||
process.env.TWILIO_ACCOUNT_SID &&
|
||||
process.env.TWILIO_AUTH_TOKEN &&
|
||||
process.env.TWILIO_ACCOUNT_SID !== 'AC...' &&
|
||||
process.env.TWILIO_AUTH_TOKEN !== 'your-twilio-auth-token'
|
||||
? twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
|
||||
: null
|
||||
|
||||
const firebasePrivateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
|
||||
const hasFirebaseConfig =
|
||||
!!process.env.FIREBASE_PROJECT_ID &&
|
||||
!!process.env.FIREBASE_CLIENT_EMAIL &&
|
||||
!!firebasePrivateKey &&
|
||||
!firebasePrivateKey.includes('BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY')
|
||||
|
||||
if (hasFirebaseConfig && !admin.apps.length) {
|
||||
try {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
privateKey: firebasePrivateKey,
|
||||
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
}),
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.warn('[Notifications] Firebase init skipped:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
interface SendNotificationOptions {
|
||||
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
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function renderEmailHtml(body: string) {
|
||||
return body
|
||||
.split(/\n{2,}/)
|
||||
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
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 ?? {}) as any,
|
||||
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) {
|
||||
if (resend) {
|
||||
if (!emailFromAddress || !emailFromName) {
|
||||
throw new Error('Email sender identity is not configured')
|
||||
}
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: `${emailFromName} <${emailFromAddress}>`,
|
||||
to: opts.email,
|
||||
subject: opts.title,
|
||||
html: renderEmailHtml(opts.body),
|
||||
text: opts.body,
|
||||
})
|
||||
if (error) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
providerMessageId = data?.id ?? null
|
||||
success = true
|
||||
} else if (smtpTransport) {
|
||||
if (!smtpFromAddress) {
|
||||
throw new Error('SMTP sender identity is not configured')
|
||||
}
|
||||
const info = await smtpTransport.sendMail({
|
||||
from: smtpFromName ? `${smtpFromName} <${smtpFromAddress}>` : smtpFromAddress,
|
||||
to: opts.email,
|
||||
subject: opts.title,
|
||||
html: renderEmailHtml(opts.body),
|
||||
text: opts.body,
|
||||
replyTo:
|
||||
process.env.MAIL_REPLY_TO_ADDRESS && !process.env.MAIL_REPLY_TO_ADDRESS.includes('${')
|
||||
? process.env.MAIL_REPLY_TO_NAME && !process.env.MAIL_REPLY_TO_NAME.includes('${')
|
||||
? `${process.env.MAIL_REPLY_TO_NAME} <${process.env.MAIL_REPLY_TO_ADDRESS}>`
|
||||
: process.env.MAIL_REPLY_TO_ADDRESS
|
||||
: undefined,
|
||||
})
|
||||
providerMessageId = info.messageId ?? null
|
||||
success = true
|
||||
} else {
|
||||
throw new Error('No email provider is configured')
|
||||
}
|
||||
}
|
||||
|
||||
if (channel === 'SMS' && opts.phone) {
|
||||
if (!twilioClient) throw new Error('Twilio is not configured')
|
||||
const msg = await twilioClient.messages.create({
|
||||
body: opts.body,
|
||||
from: process.env.TWILIO_PHONE_NUMBER!,
|
||||
to: opts.phone,
|
||||
})
|
||||
providerMessageId = msg.sid
|
||||
success = true
|
||||
}
|
||||
|
||||
if (channel === 'WHATSAPP' && opts.phone) {
|
||||
if (!twilioClient) throw new Error('Twilio is not configured')
|
||||
const msg = await twilioClient.messages.create({
|
||||
body: opts.body,
|
||||
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
|
||||
to: `whatsapp:${opts.phone}`,
|
||||
})
|
||||
providerMessageId = msg.sid
|
||||
success = true
|
||||
}
|
||||
|
||||
if (channel === 'PUSH' && opts.fcmToken) {
|
||||
if (!admin.apps.length) throw new Error('Firebase is not configured')
|
||||
const response = await admin.messaging().send({
|
||||
token: opts.fcmToken,
|
||||
notification: { title: opts.title, body: opts.body },
|
||||
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
|
||||
}
|
||||
|
||||
export async function sendTransactionalEmail(opts: {
|
||||
to: string
|
||||
subject: string
|
||||
html: string
|
||||
text: string
|
||||
}) {
|
||||
if (resend) {
|
||||
if (!emailFromAddress || !emailFromName) throw new Error('Email sender identity is not configured')
|
||||
const { error } = await resend.emails.send({
|
||||
from: `${emailFromName} <${emailFromAddress}>`,
|
||||
to: opts.to,
|
||||
subject: opts.subject,
|
||||
html: opts.html,
|
||||
text: opts.text,
|
||||
})
|
||||
if (error) throw new Error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
if (smtpTransport) {
|
||||
if (!smtpFromAddress) throw new Error('SMTP sender identity is not configured')
|
||||
await smtpTransport.sendMail({
|
||||
from: smtpFromName ? `${smtpFromName} <${smtpFromAddress}>` : smtpFromAddress,
|
||||
to: opts.to,
|
||||
subject: opts.subject,
|
||||
html: opts.html,
|
||||
text: opts.text,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('No email provider is configured')
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
const BASE_URL = process.env.PAYPAL_BASE_URL ?? 'https://api-m.paypal.com'
|
||||
const CLIENT_ID = process.env.PAYPAL_CLIENT_ID ?? ''
|
||||
const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET ?? ''
|
||||
|
||||
let cachedToken: { token: string; expiresAt: number } | null = null
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) {
|
||||
return cachedToken.token
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
|
||||
},
|
||||
body: 'grant_type=client_credentials',
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('PayPal: failed to obtain access token')
|
||||
const json = await res.json() as Record<string, unknown>
|
||||
cachedToken = { token: json.access_token as string, expiresAt: Date.now() + (json.expires_in as number) * 1000 }
|
||||
return cachedToken.token
|
||||
}
|
||||
|
||||
async function ppFetch(path: string, init?: RequestInit) {
|
||||
const token = await getAccessToken()
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init?.headers as Record<string, string> ?? {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({})) as Record<string, unknown>
|
||||
throw new Error(`PayPal ${path} failed: ${(err?.message as string) ?? res.statusText}`)
|
||||
}
|
||||
return res.json() as Promise<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface PayPalCreateOrderParams {
|
||||
amount: number
|
||||
currency: string
|
||||
orderId: string
|
||||
description: string
|
||||
returnUrl: string
|
||||
cancelUrl: string
|
||||
}
|
||||
|
||||
export interface PayPalOrderResult {
|
||||
orderId: string
|
||||
approveUrl: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export async function createOrder(params: PayPalCreateOrderParams): Promise<PayPalOrderResult> {
|
||||
const json = await ppFetch('/v2/checkout/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
intent: 'CAPTURE',
|
||||
purchase_units: [
|
||||
{
|
||||
reference_id: params.orderId,
|
||||
description: params.description,
|
||||
amount: {
|
||||
currency_code: params.currency,
|
||||
value: (params.amount / 100).toFixed(2),
|
||||
},
|
||||
},
|
||||
],
|
||||
application_context: {
|
||||
return_url: params.returnUrl,
|
||||
cancel_url: params.cancelUrl,
|
||||
brand_name: 'RentalDriveGo',
|
||||
user_action: 'PAY_NOW',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const approveLink = (json.links as Array<{ rel: string; href: string }> | undefined)?.find((l) => l.rel === 'approve')
|
||||
return {
|
||||
orderId: json.id as string,
|
||||
approveUrl: approveLink?.href ?? '',
|
||||
status: json.status as string,
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureOrder(paypalOrderId: string) {
|
||||
return ppFetch(`/v2/checkout/orders/${paypalOrderId}/capture`, { method: 'POST', body: '{}' })
|
||||
}
|
||||
|
||||
export async function refundCapture(captureId: string, amount: number, currency: string, note?: string) {
|
||||
return ppFetch(`/v2/payments/captures/${captureId}/refund`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
amount: { currency_code: currency, value: (amount / 100).toFixed(2) },
|
||||
note_to_payer: note,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifyWebhookEvent(headers: Record<string, string | undefined>, rawBody: string) {
|
||||
try {
|
||||
const json = await ppFetch('/v1/notifications/verify-webhook-signature', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
auth_algo: headers['paypal-auth-algo'],
|
||||
cert_url: headers['paypal-cert-url'],
|
||||
transmission_id: headers['paypal-transmission-id'],
|
||||
transmission_sig: headers['paypal-transmission-sig'],
|
||||
transmission_time: headers['paypal-transmission-time'],
|
||||
webhook_id: process.env.PAYPAL_WEBHOOK_ID ?? '',
|
||||
webhook_event: JSON.parse(rawBody),
|
||||
}),
|
||||
})
|
||||
return (json as Record<string, unknown>).verification_status === 'SUCCESS'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isConfigured(): boolean {
|
||||
return !!(CLIENT_ID && CLIENT_SECRET && CLIENT_ID !== 'your-paypal-client-id')
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { cloneMarketplaceHomepageContent, type MarketplaceHomepageConfig } from '@rentaldrivego/types'
|
||||
|
||||
function resolveContentPath() {
|
||||
const cwd = process.cwd()
|
||||
const appScoped = path.resolve(cwd, 'src/data/platform-content.json')
|
||||
const repoScoped = path.resolve(cwd, 'apps/api/src/data/platform-content.json')
|
||||
return cwd.includes('/apps/api') ? appScoped : repoScoped
|
||||
}
|
||||
|
||||
const platformContentPath = resolveContentPath()
|
||||
|
||||
type PlatformContentFile = {
|
||||
marketplaceHomepage?: MarketplaceHomepageConfig
|
||||
}
|
||||
|
||||
async function readPlatformContentFile(): Promise<PlatformContentFile> {
|
||||
try {
|
||||
const raw = await readFile(platformContentPath, 'utf8')
|
||||
return JSON.parse(raw) as PlatformContentFile
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function writePlatformContentFile(content: PlatformContentFile) {
|
||||
await mkdir(path.dirname(platformContentPath), { recursive: true })
|
||||
await writeFile(platformContentPath, JSON.stringify(content, null, 2))
|
||||
}
|
||||
|
||||
export async function getMarketplaceHomepageContent() {
|
||||
const content = await readPlatformContentFile()
|
||||
return content.marketplaceHomepage ?? cloneMarketplaceHomepageContent()
|
||||
}
|
||||
|
||||
export async function saveMarketplaceHomepageContent(homepage: MarketplaceHomepageConfig) {
|
||||
const content = await readPlatformContentFile()
|
||||
await writePlatformContentFile({
|
||||
...content,
|
||||
marketplaceHomepage: homepage,
|
||||
})
|
||||
return homepage
|
||||
}
|
||||
@@ -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,142 @@
|
||||
import crypto from 'crypto'
|
||||
import { EmployeeRole } from '@rentaldrivego/database'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendTransactionalEmail } from './notificationService'
|
||||
|
||||
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function buildInviteEmail(resetUrl: string, companyName: string, firstName: string, role: EmployeeRole) {
|
||||
const greetingName = firstName.trim() || 'there'
|
||||
|
||||
return {
|
||||
subject: `You have been invited to ${companyName}`,
|
||||
html: `
|
||||
<p>Hi ${greetingName},</p>
|
||||
<p>You were invited to join ${companyName} on RentalDriveGo as a ${role.toLowerCase()}.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Set your password</a></p>
|
||||
<p>This invitation link expires in 7 days.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
`,
|
||||
text: `Hi ${greetingName},\n\nYou were invited to join ${companyName} on RentalDriveGo as a ${role.toLowerCase()}.\n\nSet your password here: ${resetUrl}\n\nThis invitation link expires in 7 days.\n\nRentalDriveGo`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { companyId },
|
||||
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
|
||||
return employees.map((employee) => ({
|
||||
...employee,
|
||||
lastActiveAt: null,
|
||||
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
|
||||
}))
|
||||
}
|
||||
|
||||
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 } })
|
||||
const rawToken = crypto.randomBytes(32).toString('hex')
|
||||
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
|
||||
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
companyId,
|
||||
clerkUserId: `local_member_${crypto.randomUUID()}`,
|
||||
firstName: payload.firstName,
|
||||
lastName: payload.lastName,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
isActive: true,
|
||||
passwordResetToken: rawToken,
|
||||
passwordResetExpiresAt: expiresAt,
|
||||
},
|
||||
})
|
||||
|
||||
const dashboardUrl = process.env.DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
|
||||
const message = buildInviteEmail(resetUrl, company.name, payload.firstName, payload.role)
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: payload.email,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
text: message.text,
|
||||
})
|
||||
|
||||
return {
|
||||
employee: {
|
||||
...employee,
|
||||
lastActiveAt: null,
|
||||
invitationStatus: 'pending' as const,
|
||||
},
|
||||
invitationId: employee.id,
|
||||
invitedBy: inviterId,
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
|
||||
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 })
|
||||
await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
|
||||
|
||||
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 })
|
||||
|
||||
await prisma.employee.delete({ where: { id: employeeId } })
|
||||
return { success: true }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
const BLOCKING_RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE'] as const
|
||||
|
||||
type AvailabilityStatus = 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
|
||||
type DateRange = {
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
}
|
||||
|
||||
type AvailabilitySource = {
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
kind: 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
|
||||
}
|
||||
|
||||
export interface VehicleAvailabilitySummary {
|
||||
available: boolean
|
||||
status: AvailabilityStatus
|
||||
nextAvailableAt: Date | null
|
||||
blockingReason: 'RESERVATION' | 'MAINTENANCE' | 'BLOCK' | 'STATUS' | null
|
||||
}
|
||||
|
||||
function startOfTodayUtc() {
|
||||
const now = new Date()
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
||||
}
|
||||
|
||||
function overlaps(range: DateRange, source: AvailabilitySource) {
|
||||
return source.startDate < range.endDate && source.endDate > range.startDate
|
||||
}
|
||||
|
||||
function getNextAvailableAt(anchor: Date, sources: AvailabilitySource[]) {
|
||||
const sorted = sources
|
||||
.filter((source) => source.endDate > anchor)
|
||||
.sort((a, b) => {
|
||||
const startDelta = a.startDate.getTime() - b.startDate.getTime()
|
||||
if (startDelta !== 0) return startDelta
|
||||
return a.endDate.getTime() - b.endDate.getTime()
|
||||
})
|
||||
|
||||
let candidate = anchor
|
||||
let advanced = false
|
||||
|
||||
for (const source of sorted) {
|
||||
if (source.endDate <= candidate) continue
|
||||
if (source.startDate > candidate) break
|
||||
candidate = new Date(Math.max(candidate.getTime(), source.endDate.getTime()))
|
||||
advanced = true
|
||||
}
|
||||
|
||||
return advanced ? candidate : null
|
||||
}
|
||||
|
||||
export async function getVehicleAvailabilitySummary(
|
||||
vehicleId: string,
|
||||
{
|
||||
range,
|
||||
includeDraftReservations = true,
|
||||
}: {
|
||||
range?: DateRange
|
||||
includeDraftReservations?: boolean
|
||||
} = {},
|
||||
): Promise<VehicleAvailabilitySummary> {
|
||||
const vehicle = await prisma.vehicle.findUniqueOrThrow({
|
||||
where: { id: vehicleId },
|
||||
select: { status: true },
|
||||
})
|
||||
|
||||
if (vehicle.status === 'OUT_OF_SERVICE') {
|
||||
return {
|
||||
available: false,
|
||||
status: 'UNAVAILABLE',
|
||||
nextAvailableAt: null,
|
||||
blockingReason: 'STATUS',
|
||||
}
|
||||
}
|
||||
|
||||
const anchor = range?.startDate ?? startOfTodayUtc()
|
||||
const reservationStatuses = includeDraftReservations
|
||||
? [...BLOCKING_RESERVATION_STATUSES]
|
||||
: BLOCKING_RESERVATION_STATUSES.filter((status) => status !== 'DRAFT')
|
||||
|
||||
const [reservations, blocks] = await Promise.all([
|
||||
prisma.reservation.findMany({
|
||||
where: {
|
||||
vehicleId,
|
||||
status: { in: reservationStatuses },
|
||||
endDate: { gt: anchor },
|
||||
},
|
||||
select: { startDate: true, endDate: true },
|
||||
orderBy: { startDate: 'asc' },
|
||||
}),
|
||||
prisma.vehicleCalendarBlock.findMany({
|
||||
where: {
|
||||
vehicleId,
|
||||
endDate: { gt: anchor },
|
||||
},
|
||||
select: { startDate: true, endDate: true, type: true },
|
||||
orderBy: { startDate: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const sources: AvailabilitySource[] = [
|
||||
...reservations.map((reservation) => ({
|
||||
startDate: reservation.startDate,
|
||||
endDate: reservation.endDate,
|
||||
kind: 'RESERVATION' as const,
|
||||
})),
|
||||
...blocks.map((block) => ({
|
||||
startDate: block.startDate,
|
||||
endDate: block.endDate,
|
||||
kind: block.type === 'MAINTENANCE' ? 'MAINTENANCE' as const : 'BLOCK' as const,
|
||||
})),
|
||||
]
|
||||
|
||||
const overlappingSources = range ? sources.filter((source) => overlaps(range, source)) : sources.filter((source) => source.startDate <= anchor && source.endDate > anchor)
|
||||
const currentSource = overlappingSources.sort((a, b) => {
|
||||
const priority = { MAINTENANCE: 0, BLOCK: 1, RESERVATION: 2 }
|
||||
return priority[a.kind] - priority[b.kind]
|
||||
})[0]
|
||||
|
||||
if (vehicle.status === 'MAINTENANCE') {
|
||||
return {
|
||||
available: false,
|
||||
status: 'MAINTENANCE',
|
||||
nextAvailableAt: getNextAvailableAt(anchor, sources) ?? null,
|
||||
blockingReason: currentSource?.kind ?? 'STATUS',
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentSource) {
|
||||
return {
|
||||
available: true,
|
||||
status: 'AVAILABLE',
|
||||
nextAvailableAt: null,
|
||||
blockingReason: null,
|
||||
}
|
||||
}
|
||||
|
||||
const nextAvailableAnchor = currentSource.startDate > anchor ? currentSource.startDate : anchor
|
||||
|
||||
return {
|
||||
available: false,
|
||||
status: currentSource.kind === 'RESERVATION' ? 'RESERVED' : currentSource.kind === 'MAINTENANCE' ? 'MAINTENANCE' : 'UNAVAILABLE',
|
||||
nextAvailableAt: getNextAvailableAt(nextAvailableAnchor, sources),
|
||||
blockingReason: currentSource.kind,
|
||||
}
|
||||
}
|
||||
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/**/*"]
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,48 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const toStoragePattern = (rawUrl) => {
|
||||
try {
|
||||
const u = new URL(rawUrl.replace(/\/api\/v1\/?$/, ''))
|
||||
return {
|
||||
protocol: u.protocol.replace(':', ''),
|
||||
hostname: u.hostname,
|
||||
...(u.port ? { port: u.port } : {}),
|
||||
pathname: '/storage/**',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const storagePatterns = [
|
||||
process.env.API_INTERNAL_URL ?? 'http://localhost:4000',
|
||||
process.env.API_URL,
|
||||
process.env.NEXT_PUBLIC_API_URL,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map(toStoragePattern)
|
||||
.filter(Boolean)
|
||||
.filter((p, i, arr) => arr.findIndex((q) => q.hostname === p.hostname && q.port === p.port) === i)
|
||||
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'res.cloudinary.com',
|
||||
},
|
||||
...storagePatterns,
|
||||
],
|
||||
},
|
||||
transpilePackages: ['@rentaldrivego/types'],
|
||||
async rewrites() {
|
||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${apiOrigin}/api/:path*`,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
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": "*",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dayjs": "^1.11.11",
|
||||
"lucide-react": "^0.376.0",
|
||||
"next": "14.2.3",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.12.7",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"zod": "^3.23.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,350 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
|
||||
type Currency = 'MAD' | 'USD' | 'EUR'
|
||||
|
||||
interface Subscription {
|
||||
id: string
|
||||
plan: Plan
|
||||
billingPeriod: BillingPeriod
|
||||
status: string
|
||||
currency: Currency
|
||||
trialEndAt: string | null
|
||||
currentPeriodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string
|
||||
amount: number
|
||||
currency: Currency
|
||||
status: string
|
||||
paymentProvider: string
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
TRIALING: 'bg-sky-100 text-sky-700',
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
PAST_DUE: 'bg-amber-100 text-amber-700',
|
||||
CANCELLED: 'bg-slate-100 text-slate-600',
|
||||
UNPAID: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const INVOICE_STATUS: Record<string, string> = {
|
||||
PAID: 'bg-green-100 text-green-700',
|
||||
PENDING: 'bg-amber-100 text-amber-700',
|
||||
FAILED: 'bg-red-100 text-red-700',
|
||||
REFUNDED: 'bg-slate-100 text-slate-600',
|
||||
}
|
||||
|
||||
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||
const PLAN_FEATURES: Record<Plan, string[]> = {
|
||||
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
|
||||
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
|
||||
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
|
||||
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
|
||||
const [currency, setCurrency] = useState<Currency>('MAD')
|
||||
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
|
||||
const [paying, setPaying] = useState(false)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
apiFetch<Subscription | null>('/subscriptions/me'),
|
||||
apiFetch<Invoice[]>('/subscriptions/invoices'),
|
||||
])
|
||||
.then(([sub, inv]) => {
|
||||
if (sub) {
|
||||
setSubscription(sub)
|
||||
setSelectedPlan(sub.plan)
|
||||
setBillingPeriod(sub.billingPeriod)
|
||||
setCurrency(sub.currency as Currency)
|
||||
}
|
||||
setInvoices(inv ?? [])
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function handleCheckout() {
|
||||
setPaying(true)
|
||||
setError(null)
|
||||
try {
|
||||
const base = window.location.origin
|
||||
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
plan: selectedPlan,
|
||||
billingPeriod,
|
||||
currency,
|
||||
provider,
|
||||
successUrl: `${base}/dashboard/billing?payment=success`,
|
||||
failureUrl: `${base}/dashboard/billing?payment=failed`,
|
||||
}),
|
||||
})
|
||||
window.location.href = result.checkoutUrl
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setPaying(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
setCancelling(true)
|
||||
setError(null)
|
||||
try {
|
||||
const sub = await apiFetch<Subscription>('/subscriptions/cancel', { method: 'POST' })
|
||||
setSubscription(sub)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResume() {
|
||||
setCancelling(true)
|
||||
setError(null)
|
||||
try {
|
||||
const sub = await apiFetch<Subscription>('/subscriptions/resume', { method: 'POST' })
|
||||
setSubscription(sub)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const planPrice = PLAN_PRICES[selectedPlan]?.[billingPeriod]?.[currency]
|
||||
const daysLeft = subscription?.trialEndAt
|
||||
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Billing</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Manage your plan, payment provider, and invoice history.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="card p-4 border-red-200 bg-red-50 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Trial banner */}
|
||||
{subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (
|
||||
<div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-sky-800">
|
||||
Free trial — <strong>{daysLeft} days</strong> remaining. Subscribe before it ends to keep access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current plan */}
|
||||
{subscription && (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Current plan</p>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{subscription.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{subscription.billingPeriod} · {subscription.currency}
|
||||
{subscription.currentPeriodEnd && ` · renews ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
|
||||
</p>
|
||||
{subscription.cancelAtPeriodEnd && (
|
||||
<p className="mt-2 text-sm font-medium text-amber-700">
|
||||
Cancellation scheduled at end of billing period.{' '}
|
||||
<button onClick={handleResume} disabled={cancelling} className="underline">Undo</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!subscription.cancelAtPeriodEnd && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={cancelling}
|
||||
className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm"
|
||||
>
|
||||
{cancelling ? 'Cancelling…' : 'Cancel plan'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan selector + checkout */}
|
||||
<div className="card p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">
|
||||
{subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe'}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Select a plan and payment provider to proceed.</p>
|
||||
</div>
|
||||
|
||||
{/* Billing period toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(['MONTHLY', 'ANNUAL'] as BillingPeriod[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setBillingPeriod(p)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
billingPeriod === p ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{p === 'MONTHLY' ? 'Monthly' : 'Annual (save ~17%)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Currency selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCurrency(c)}
|
||||
className={`px-3 py-1 rounded-lg text-sm font-medium border transition-colors ${
|
||||
currency === c ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{PLANS.map((plan) => {
|
||||
const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency]
|
||||
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
|
||||
return (
|
||||
<button
|
||||
key={plan}
|
||||
onClick={() => setSelectedPlan(plan)}
|
||||
className={`text-left p-5 rounded-xl border-2 transition-all ${
|
||||
selectedPlan === plan
|
||||
? 'border-blue-500 bg-blue-50/50'
|
||||
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||||
} ${isActive ? 'ring-2 ring-green-200' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold text-slate-900">{plan}</p>
|
||||
{isActive && <span className="badge-green">Active</span>}
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-black text-slate-900">
|
||||
{price ? formatCurrency(price, currency) : '—'}
|
||||
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? 'mo' : 'yr'}</span>
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{PLAN_FEATURES[plan].map((f) => (
|
||||
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
|
||||
<span className="text-green-500">✓</span> {f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Provider selector */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 mb-2">Payment provider</p>
|
||||
<div className="flex gap-3">
|
||||
{(['AMANPAY', 'PAYPAL'] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setProvider(p)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
provider === p ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{p === 'AMANPAY' ? '🏦 AmanPay' : '🔵 PayPal'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkout CTA */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Total</p>
|
||||
<p className="text-xl font-black text-slate-900">
|
||||
{planPrice ? formatCurrency(planPrice, currency) : '—'}
|
||||
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? 'month' : 'year'}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
disabled={paying || loading}
|
||||
className="btn-primary px-8 py-3"
|
||||
>
|
||||
{paying ? 'Redirecting…' : subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice history */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-base font-semibold text-slate-900">Invoice history</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Date</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Provider</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">Paid</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">Loading…</td></tr>
|
||||
) : invoices.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">No invoices yet.</td></tr>
|
||||
) : invoices.map((inv) => (
|
||||
<tr key={inv.id}>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{inv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">
|
||||
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
|
||||
{formatCurrency(inv.amount, inv.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface CustomerRow {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
flagged: boolean
|
||||
licenseValidationStatus: string
|
||||
}
|
||||
|
||||
export default function CustomersPage() {
|
||||
const [rows, setRows] = useState<CustomerRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<CustomerRow[]>('/customers?pageSize=100')
|
||||
.then((result) => setRows(result ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Customers</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Company-scoped CRM with license validation and risk flags.</p>
|
||||
</div>
|
||||
<div className="card overflow-hidden">
|
||||
{error ? (
|
||||
<div className="p-8 text-sm text-red-600">{error}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<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">Contact</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">License</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="px-6 py-4 text-sm font-semibold text-slate-900">{row.firstName} {row.lastName}</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm text-slate-700">{row.email}</p>
|
||||
<p className="text-xs text-slate-500">{row.phone ?? 'No phone'}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4"><span className="badge-gray">{row.licenseValidationStatus}</span></td>
|
||||
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">Flagged</span> : <span className="badge-green">Clear</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">No customers yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user