diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ba0d71e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +.turbo +.git +.gitignore +.codex +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.next +dist +coverage +tmp +.env.local +.env.*.local + diff --git a/.env.docker.dev b/.env.docker.dev new file mode 100644 index 0000000..a730102 --- /dev/null +++ b/.env.docker.dev @@ -0,0 +1,23 @@ +DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego +REDIS_URL=redis://redis:6379 +API_PORT=4000 +API_URL=http://localhost:4000 +API_INTERNAL_URL=http://api:4000/api/v1 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_MARKETING_URL=http://localhost:3000 +NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore +NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001 +NEXT_PUBLIC_ADMIN_URL=http://localhost:3002 +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 +DASHBOARD_URL=http://localhost:3001 +JWT_SECRET=dev-secret +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +ADMIN_SEED_EMAIL=admin@rentaldrivego.com +ADMIN_SEED_PASSWORD=changeme123 +ADMIN_SEED_FIRST_NAME=Platform +ADMIN_SEED_LAST_NAME=Admin +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= +NODE_ENV=development +CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003 diff --git a/.env.docker.production.example b/.env.docker.production.example new file mode 100644 index 0000000..f8d01e1 --- /dev/null +++ b/.env.docker.production.example @@ -0,0 +1,17 @@ +DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego +REDIS_URL=redis://redis:6379 +API_PORT=4000 +API_URL=http://localhost:4000 +API_INTERNAL_URL=http://api:4000/api/v1 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_MARKETING_URL=http://localhost:3000 +NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore +NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001 +NEXT_PUBLIC_ADMIN_URL=http://localhost:3002 +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 +DASHBOARD_URL=http://localhost:3001 +JWT_SECRET=replace-with-a-long-random-secret +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +POSTGRES_PASSWORD=replace-with-a-real-password +NODE_ENV=production diff --git a/.env.docker.test b/.env.docker.test new file mode 100644 index 0000000..5a91bd4 --- /dev/null +++ b/.env.docker.test @@ -0,0 +1,10 @@ +DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test +REDIS_URL=redis://redis:6379 +API_PORT=4000 +API_URL=http://localhost:4000 +API_INTERNAL_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +JWT_SECRET=test-secret +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +NODE_ENV=test diff --git a/.env.example b/.env.example index f2d3764..704be9d 100644 --- a/.env.example +++ b/.env.example @@ -26,14 +26,14 @@ NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding # ─── AmanPay (Primary payment provider) ─────────────────────── -# RentFlow's own AmanPay account (for collecting subscription fees) +# RentalDriveGo's own AmanPay account (for collecting subscription fees) AMANPAY_MERCHANT_ID=your-amanpay-merchant-id AMANPAY_SECRET_KEY=your-amanpay-secret-key AMANPAY_BASE_URL=https://api.amanpay.net AMANPAY_WEBHOOK_SECRET=your-amanpay-webhook-secret # ─── PayPal (Secondary payment provider) ────────────────────── -# RentFlow's own PayPal account (for collecting subscription fees) +# RentalDriveGo's own PayPal account (for collecting subscription fees) PAYPAL_CLIENT_ID=your-paypal-client-id PAYPAL_CLIENT_SECRET=your-paypal-client-secret PAYPAL_BASE_URL=https://api-m.paypal.com @@ -67,6 +67,9 @@ NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789 NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123 +# The Node API uses Resend for email delivery. +# MAIL_* SMTP variables are intentionally omitted here. + # ─── Redis (Real-time / Socket.io) ──────────────────────────── REDIS_URL=redis://localhost:6379 diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..aead9ca --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,131 @@ +## Docker Environments + +Three Docker environments are available: + +- `Dockerfile.dev` with `docker-compose.dev.yml` +- `Dockerfile.test` with `docker-compose.test.yml` +- `Dockerfile.production` with `docker-compose.production.yml` + +### Development + +Use the full dev stack for local work with hot reload and bundled Postgres and Redis: + +```bash +docker compose -f docker-compose.dev.yml --profile full up --build +``` + +Services: + +- marketplace: `http://localhost:3000` +- dashboard: `http://localhost:3001` +- admin: `http://localhost:3002` +- public-site: `http://localhost:3003` +- api: `http://localhost:4000` +- pgAdmin: `http://localhost:5050` + +Each dev app now runs in its own container and can be started independently with a profile tag: + +```bash +docker compose -f docker-compose.dev.yml --profile api up --build +docker compose -f docker-compose.dev.yml --profile marketplace up --build +docker compose -f docker-compose.dev.yml --profile dashboard up --build +docker compose -f docker-compose.dev.yml --profile admin up --build +docker compose -f docker-compose.dev.yml --profile public-site up --build +docker compose -f docker-compose.dev.yml --profile tools up --build +``` + +Notes: + +- `api` starts `postgres`, `redis`, and `migrate` automatically through dependencies. +- frontend profiles also start `api` and its dependencies automatically. +- `tools` starts only `pgadmin` plus its required `postgres` dependency. + +On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts. + +Default dev platform administrator: + +- email: `admin@rentaldrivego.com` +- password: `changeme123` + +If you intentionally want a fresh dev bootstrap: + +```bash +docker compose -f docker-compose.dev.yml down -v +``` + +If you want to keep the database and only apply new schema changes manually: + +```bash +docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy" +``` + +pgAdmin dev login: + +- email: `admin@rentaldrivego.local` +- email: `admin@rentaldrivego.dev` +- password: `admin` + +pgAdmin opens with the dev Postgres server pre-registered as `RentalDriveGo Dev DB`. + +pgAdmin Postgres connection: + +- host: `postgres` +- port: `5432` +- database: `rentaldrivego` +- username: `postgres` +- password: `password` + +### Test + +Use the test stack to run repeatable containerized verification: + +```bash +docker compose -f docker-compose.test.yml up --build --abort-on-container-exit +``` + +The test container runs: + +- `npm run db:deploy` +- `npm run db:generate` +- `npm run type-check` +- `npm run build` + +### Production + +1. Copy `.env.docker.production.example` to `.env.docker.production` +2. Fill in real secrets and domain values +3. Start the stack: + +```bash +docker compose -f docker-compose.production.yml up --build -d +``` + +Production compose starts separate containers for: + +- postgres +- redis +- api +- marketplace +- dashboard +- admin +- public-site + +### Notes + +- The production image builds the whole monorepo once, then each service overrides its runtime command. +- The dev compose file bind-mounts the repo and keeps `node_modules` in a named volume. +- `API_INTERNAL_URL` is used for server-side container-to-container calls, while `NEXT_PUBLIC_API_URL` is used by the browser. +- The Dockerfiles activate the repo's pinned `npm@10.5.0` with `corepack` before install so container builds do not depend on the npm version bundled with the base image. +- The dev compose stack stores Postgres data in `postgres_dev_data` and the bootstrap marker in `postgres_bootstrap_state`, so `up --build` does not reseed an existing local database. +- If you need database schema updates inside Docker, run: + +```bash +docker compose -f docker-compose.dev.yml run --rm migrate +``` + +If a cached base image still fails during `npm ci`, refresh it and rebuild without cache: + +```bash +docker pull node:20-bookworm +docker compose -f docker-compose.dev.yml build --no-cache dashboard +``` diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..287ad82 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM node:20-bookworm + +WORKDIR /app + +RUN npm install -g npm@10.5.0 --no-fund --no-audit + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm ci --no-fund --no-audit + +EXPOSE 3000 3001 3002 3003 4000 + +CMD ["sh", "-c", "npm run db:generate && npm run dev"] diff --git a/Dockerfile.production b/Dockerfile.production new file mode 100644 index 0000000..4b1bee3 --- /dev/null +++ b/Dockerfile.production @@ -0,0 +1,30 @@ +FROM node:20-bookworm AS builder + +WORKDIR /app + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm install +RUN npm run db:generate +RUN npm run build + +FROM node:20-bookworm AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY --from=builder /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/apps ./apps +COPY --from=builder /app/packages ./packages + +EXPOSE 3000 3001 3002 3003 4000 + +CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"] diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..2b313a6 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,15 @@ +FROM node:20-bookworm + +WORKDIR /app + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm install + +ENV NODE_ENV=test + +CMD ["sh", "-c", "npm run db:generate && npm run type-check && npm run build"] diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index 4f89111..ff15bca 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -1,9 +1,19 @@ /** @type {import('next').NextConfig} */ +const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') + const nextConfig = { images: { domains: ['res.cloudinary.com'], }, transpilePackages: ['@rentaldrivego/types'], + async rewrites() { + return [ + { + source: '/api/:path*', + destination: `${apiOrigin}/api/:path*`, + }, + ] + }, } module.exports = nextConfig diff --git a/apps/admin/src/app/dashboard/admin-users/page.tsx b/apps/admin/src/app/dashboard/admin-users/page.tsx new file mode 100644 index 0000000..389cc54 --- /dev/null +++ b/apps/admin/src/app/dashboard/admin-users/page.tsx @@ -0,0 +1,211 @@ +'use client' + +import { useEffect, useState } from 'react' + +const API_BASE = '/api/v1' + +interface AdminUser { + id: string + firstName: string + lastName: string + email: string + role: string + isActive: boolean + createdAt: string + permissions?: { id: string; resource: string; actions: string[] }[] +} + +const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'] + +export default function AdminUsersPage() { + const [admins, setAdmins] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showModal, setShowModal] = useState(false) + const [form, setForm] = useState({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' }) + const [creating, setCreating] = useState(false) + + function getToken() { return localStorage.getItem('admin_token') ?? '' } + + async function fetchAdmins() { + try { + const res = await fetch(`${API_BASE}/admin/admins`, { + headers: { Authorization: `Bearer ${getToken()}` }, + cache: 'no-store', + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Failed') + setAdmins(json.data ?? []) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchAdmins() }, []) + + async function createAdmin(e: React.FormEvent) { + e.preventDefault() + setCreating(true) + setError(null) + try { + const res = await fetch(`${API_BASE}/admin/admins`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify(form), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Failed to create') + setShowModal(false) + setForm({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' }) + await fetchAdmins() + } catch (err: any) { + setError(err.message) + } finally { + setCreating(false) + } + } + + const ROLE_COLORS: Record = { + SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40', + ADMIN: 'text-sky-400 bg-sky-950/40', + SUPPORT: 'text-amber-400 bg-amber-950/40', + FINANCE: 'text-violet-400 bg-violet-950/40', + VIEWER: 'text-zinc-400 bg-zinc-800', + } + + return ( +
+
+
+

Platform

+

Admin Users

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

New admin user

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

Platform

+

Audit Logs

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

{company.name}

+

{company.slug}

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

{kpi.label}

+

{kpi.value}

+
+ ))} +
+ +
+
+

Details

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

Actions

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

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

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

Recent audit events

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

{dict.platform}

+

{copy.title}

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

{c.name}

+

{c.email}

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

{dict.admin}

+

{copy.platformOverview}

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

{kpi.label}

+

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

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

{title}

+

{body}

+

{cta}

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

{dict.platform}

+

{copy.title}

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

{dict.brand}

+

{dict.access}

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

{dict.enterCode}

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

+ {dict.preferences} +

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

RentalDriveGo API

+

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

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

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

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

${opts.body}

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

Billing

+

Manage your plan, payment provider, and invoice history.

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

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

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

Current plan

+
+

{subscription.plan}

+ + {subscription.status} + +
+

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

+ {subscription.cancelAtPeriodEnd && ( +

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

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

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

+

Select a plan and payment provider to proceed.

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

Payment provider

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

Total

+

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

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

Invoice history

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

Customers

+

Company-scoped CRM with license validation and risk flags.

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

{row.email}

+

{row.phone ?? 'No phone'}

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

{vehicle.make} {vehicle.model}

+

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

+
+
+
+

Photos

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

Vehicle details

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

Notifications

+

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

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

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

+

{notification.title}

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

{notification.body}

+

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

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

Offers

+

Promotions shown on your site and optionally on the marketplace.

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

{offer.title}

+

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

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

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

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

Reports

+

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

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

Bookings

{report.summary.totalReservations}

+

Rental

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

+

Insurance

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

+

Drivers

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

+

Discounts

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

+

Collected

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

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

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

+

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

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

Customer

+
+

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

+

{reservation.customer.email}

+

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

+

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

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

Vehicle

+
+

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

+

{reservation.vehicle.licensePlate}

+

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

+
+
+
+ +
+
+
+

Charges

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

Applied insurance

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

Pricing rules applied

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

Additional drivers

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

{driver.firstName} {driver.lastName}

+

License: {driver.driverLicense}

+

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

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

{driver.approvalNote}

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

Inspection summary

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

Reservations

+

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

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

{row.customer.email}

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

Advanced settings

+

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

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

Brand and public profile

+

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

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

Rental payment methods

+

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

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

Custom domain

+

Point your own domain to the branded booking site.

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

Contract and driver policies

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