From 750ae56a29aa0cc565a5ad7282ed00f5936a0220 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 22:58:23 -0400 Subject: [PATCH] fixing platform admin --- .dockerignore | 16 + .env.docker.dev | 23 + .env.docker.production.example | 17 + .env.docker.test | 10 + .env.example | 7 +- DOCKER.md | 131 + Dockerfile.dev | 15 + Dockerfile.production | 30 + Dockerfile.test | 15 + apps/admin/next-env.d.ts | 5 + apps/admin/next.config.js | 10 + .../src/app/dashboard/admin-users/page.tsx | 211 + .../src/app/dashboard/audit-logs/page.tsx | 100 + .../src/app/dashboard/companies/[id]/page.tsx | 204 + .../src/app/dashboard/companies/page.tsx | 207 + apps/admin/src/app/dashboard/layout.tsx | 90 + apps/admin/src/app/dashboard/page.tsx | 111 + apps/admin/src/app/dashboard/renters/page.tsx | 188 + apps/admin/src/app/favicon.ico/route.ts | 6 + apps/admin/src/app/globals.css | 15 + apps/admin/src/app/icon.tsx | 32 + apps/admin/src/app/layout.tsx | 16 + apps/admin/src/app/login/page.tsx | 212 + apps/admin/src/app/page.tsx | 5 + apps/admin/src/components/I18nProvider.tsx | 129 + apps/admin/src/components/PublicShell.tsx | 54 + apps/admin/src/lib/api.ts | 14 + apps/api/package.json | 49 +- apps/api/src/index.ts | 165 +- apps/api/src/middleware/rateLimiter.ts | 53 + apps/api/src/routes/admin.ts | 72 +- apps/api/src/routes/analytics.ts | 136 +- apps/api/src/routes/auth.company.ts | 540 + apps/api/src/routes/auth.renter.ts | 105 +- apps/api/src/routes/companies.ts | 410 + apps/api/src/routes/customers.ts | 35 +- apps/api/src/routes/marketplace.ts | 157 +- apps/api/src/routes/notifications.ts | 51 + apps/api/src/routes/payments.ts | 252 + apps/api/src/routes/reservations.ts | 216 +- apps/api/src/routes/site.ts | 499 + apps/api/src/routes/subscriptions.ts | 288 + apps/api/src/routes/team.ts | 20 +- apps/api/src/routes/vehicles.ts | 2 +- .../src/services/additionalDriverService.ts | 95 + apps/api/src/services/amanpayService.ts | 97 + .../src/services/financialReportService.ts | 18 +- apps/api/src/services/insuranceService.ts | 12 +- .../src/services/licenseValidationService.ts | 2 + apps/api/src/services/notificationService.ts | 159 +- apps/api/src/services/paypalService.ts | 127 + apps/api/src/services/teamService.ts | 4 +- apps/dashboard/next-env.d.ts | 5 + .../(dashboard)/dashboard/billing/page.tsx | 350 + .../(dashboard)/dashboard/customers/page.tsx | 71 + .../(dashboard)/dashboard/fleet/[id]/page.tsx | 73 + .../app/(dashboard)/dashboard/fleet/page.tsx | 11 +- .../dashboard/notifications/page.tsx | 208 + .../app/(dashboard)/dashboard/offers/page.tsx | 63 + .../(dashboard)/dashboard/reports/page.tsx | 152 + .../dashboard/reservations/[id]/page.tsx | 272 + .../dashboard/reservations/page.tsx | 81 + .../(dashboard)/dashboard/settings/page.tsx | 591 ++ .../app/(dashboard)/dashboard/team/page.tsx | 317 + apps/dashboard/src/app/(dashboard)/layout.tsx | 19 +- apps/dashboard/src/app/icon.tsx | 32 + apps/dashboard/src/app/layout.tsx | 21 +- .../src/app/onboarding/accept-invite/page.tsx | 25 + apps/dashboard/src/app/onboarding/page.tsx | 269 + apps/dashboard/src/app/page.tsx | 5 + .../src/app/sign-in/[[...sign-in]]/page.tsx | 153 + .../src/app/sign-up/[[...sign-up]]/page.tsx | 632 ++ .../dashboard/src/components/I18nProvider.tsx | 192 + .../src/components/layout/PublicShell.tsx | 61 + .../src/components/layout/Sidebar.tsx | 95 +- .../src/components/layout/TopBar.tsx | 77 +- .../reservations/DamageInspectionCard.tsx | 249 + .../src/components/team/EditMemberModal.tsx | 261 + .../src/components/team/InviteModal.tsx | 199 + .../src/components/team/PermissionsMatrix.tsx | 113 + apps/dashboard/src/hooks/useTeam.ts | 138 + apps/dashboard/src/lib/api.ts | 14 +- apps/dashboard/src/lib/clerk.ts | 5 + apps/dashboard/src/lib/urls.ts | 1 + apps/dashboard/src/middleware.ts | 14 +- apps/marketplace/next-env.d.ts | 5 + apps/marketplace/public/favicon.ico | Bin 0 -> 1172463 bytes apps/marketplace/public/rentalcardrive.png | Bin 0 -> 1172463 bytes apps/marketplace/src/app/HomeContent.tsx | 365 + .../src/app/branded-website/page.tsx | 5 + .../src/app/company-workspace/page.tsx | 5 + .../src/app/explore/[slug]/page.tsx | 148 + .../app/explore/[slug]/vehicles/[id]/page.tsx | 118 + apps/marketplace/src/app/explore/page.tsx | 286 + apps/marketplace/src/app/features/page.tsx | 71 + apps/marketplace/src/app/globals.css | 15 + apps/marketplace/src/app/layout.tsx | 23 + apps/marketplace/src/app/page.tsx | 5 + .../src/app/platform-operations/page.tsx | 5 + .../src/app/pricing/PricingClient.tsx | 269 + .../src/app/pricing/PricingPageContent.tsx | 74 + apps/marketplace/src/app/pricing/page.tsx | 11 + .../src/app/renter/dashboard/page.tsx | 331 + .../src/app/renter/notifications/page.tsx | 164 + .../src/app/renter/profile/page.tsx | 351 + .../src/app/renter/saved-companies/page.tsx | 113 + .../src/app/renter/sign-in/page.tsx | 6 + .../src/app/renter/sign-up/page.tsx | 6 + .../src/components/MarketplaceShell.tsx | 264 + .../src/components/WorkspaceFrame.tsx | 71 + .../src/components/WorkspaceTabs.tsx | 233 + apps/marketplace/src/lib/api.ts | 31 + apps/marketplace/src/lib/i18n.server.ts | 7 + apps/marketplace/src/lib/i18n.ts | 7 + apps/marketplace/src/lib/renter.ts | 115 + apps/public-site/next-env.d.ts | 5 + apps/public-site/public/rentalcardrive.png | Bin 0 -> 1172463 bytes apps/public-site/src/app/about/page.tsx | 90 + apps/public-site/src/app/blog/page.tsx | 31 + apps/public-site/src/app/book/BookClient.tsx | 754 ++ .../src/app/book/confirmation/page.tsx | 124 + apps/public-site/src/app/book/page.tsx | 21 + apps/public-site/src/app/contact/page.tsx | 17 + apps/public-site/src/app/globals.css | 15 + apps/public-site/src/app/layout.tsx | 101 + apps/public-site/src/app/offers/page.tsx | 35 + apps/public-site/src/app/page.tsx | 105 + apps/public-site/src/app/pricing/page.tsx | 126 + .../src/app/vehicles/VehicleFilters.tsx | 127 + .../src/app/vehicles/[id]/page.tsx | 70 + apps/public-site/src/app/vehicles/page.tsx | 141 + .../src/components/PublicLanguageSwitcher.tsx | 57 + apps/public-site/src/lib/api.ts | 32 + apps/public-site/src/lib/i18n.server.ts | 7 + apps/public-site/src/lib/i18n.ts | 987 ++ apps/public-site/src/middleware.ts | 63 + command_to_run_dev.txt | 24 + docker-compose.dev.yml | 154 + docker-compose.production.yml | 107 + docker-compose.test.yml | 40 + docker/pgadmin/servers.json | 13 + docker/scripts/dev-bootstrap.sh | 22 + docs/project-design/AUDIT.md | 57 + docs/project-design/FEATURES.md | 310 + docs/project-design/INTEGRATION.md | 182 + docs/project-design/PAGES.md | 316 + docs/project-design/README.md | 109 + docs/project-design/advanced-features.md | 1308 +++ docs/project-design/api-routes.md | 498 + docs/project-design/schema.md | 980 ++ docs/project-design/stubs/EditMemberModal.tsx | 268 + docs/project-design/stubs/InviteModal.tsx | 205 + .../stubs/PermissionsMatrix.tsx | 115 + docs/project-design/stubs/requireRole.ts | 47 + docs/project-design/stubs/team.ts | 169 + docs/project-design/stubs/team.tsx | 337 + docs/project-design/stubs/teamService.ts | 365 + docs/project-design/stubs/useTeam.ts | 172 + docs/project-design/stubs/webhooks.ts | 82 + package-lock.json | 8922 +++++++++++++++++ package.json | 1 + packages/database/package.json | 7 +- packages/database/prisma/schema.prisma | 348 +- packages/database/src/index.js | 5 + packages/database/src/index.ts | 73 + packages/types/tsconfig.json | 17 + project_design/FEATURES.md | 14 +- project_design/INTEGRATION.md | 8 +- project_design/PAGES.md | 30 +- project_design/README.md | 20 +- project_design/advanced-features.md | 14 +- project_design/api-routes.md | 14 +- project_design/rentalcardrive.png | Bin 0 -> 1172463 bytes project_design/schema.md | 4 +- project_design/useTeam.ts | 2 +- 175 files changed, 31249 insertions(+), 328 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.docker.dev create mode 100644 .env.docker.production.example create mode 100644 .env.docker.test create mode 100644 DOCKER.md create mode 100644 Dockerfile.dev create mode 100644 Dockerfile.production create mode 100644 Dockerfile.test create mode 100644 apps/admin/next-env.d.ts create mode 100644 apps/admin/src/app/dashboard/admin-users/page.tsx create mode 100644 apps/admin/src/app/dashboard/audit-logs/page.tsx create mode 100644 apps/admin/src/app/dashboard/companies/[id]/page.tsx create mode 100644 apps/admin/src/app/dashboard/companies/page.tsx create mode 100644 apps/admin/src/app/dashboard/layout.tsx create mode 100644 apps/admin/src/app/dashboard/page.tsx create mode 100644 apps/admin/src/app/dashboard/renters/page.tsx create mode 100644 apps/admin/src/app/favicon.ico/route.ts create mode 100644 apps/admin/src/app/globals.css create mode 100644 apps/admin/src/app/icon.tsx create mode 100644 apps/admin/src/app/layout.tsx create mode 100644 apps/admin/src/app/login/page.tsx create mode 100644 apps/admin/src/app/page.tsx create mode 100644 apps/admin/src/components/I18nProvider.tsx create mode 100644 apps/admin/src/components/PublicShell.tsx create mode 100644 apps/admin/src/lib/api.ts create mode 100644 apps/api/src/middleware/rateLimiter.ts create mode 100644 apps/api/src/routes/auth.company.ts create mode 100644 apps/api/src/routes/companies.ts create mode 100644 apps/api/src/routes/payments.ts create mode 100644 apps/api/src/routes/site.ts create mode 100644 apps/api/src/routes/subscriptions.ts create mode 100644 apps/api/src/services/additionalDriverService.ts create mode 100644 apps/api/src/services/amanpayService.ts create mode 100644 apps/api/src/services/paypalService.ts create mode 100644 apps/dashboard/next-env.d.ts create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/notifications/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/reservations/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/settings/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/team/page.tsx create mode 100644 apps/dashboard/src/app/icon.tsx create mode 100644 apps/dashboard/src/app/onboarding/accept-invite/page.tsx create mode 100644 apps/dashboard/src/app/onboarding/page.tsx create mode 100644 apps/dashboard/src/app/page.tsx create mode 100644 apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx create mode 100644 apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx create mode 100644 apps/dashboard/src/components/I18nProvider.tsx create mode 100644 apps/dashboard/src/components/layout/PublicShell.tsx create mode 100644 apps/dashboard/src/components/reservations/DamageInspectionCard.tsx create mode 100644 apps/dashboard/src/components/team/EditMemberModal.tsx create mode 100644 apps/dashboard/src/components/team/InviteModal.tsx create mode 100644 apps/dashboard/src/components/team/PermissionsMatrix.tsx create mode 100644 apps/dashboard/src/hooks/useTeam.ts create mode 100644 apps/dashboard/src/lib/clerk.ts create mode 100644 apps/dashboard/src/lib/urls.ts create mode 100644 apps/marketplace/next-env.d.ts create mode 100644 apps/marketplace/public/favicon.ico create mode 100644 apps/marketplace/public/rentalcardrive.png create mode 100644 apps/marketplace/src/app/HomeContent.tsx create mode 100644 apps/marketplace/src/app/branded-website/page.tsx create mode 100644 apps/marketplace/src/app/company-workspace/page.tsx create mode 100644 apps/marketplace/src/app/explore/[slug]/page.tsx create mode 100644 apps/marketplace/src/app/explore/[slug]/vehicles/[id]/page.tsx create mode 100644 apps/marketplace/src/app/explore/page.tsx create mode 100644 apps/marketplace/src/app/features/page.tsx create mode 100644 apps/marketplace/src/app/globals.css create mode 100644 apps/marketplace/src/app/layout.tsx create mode 100644 apps/marketplace/src/app/page.tsx create mode 100644 apps/marketplace/src/app/platform-operations/page.tsx create mode 100644 apps/marketplace/src/app/pricing/PricingClient.tsx create mode 100644 apps/marketplace/src/app/pricing/PricingPageContent.tsx create mode 100644 apps/marketplace/src/app/pricing/page.tsx create mode 100644 apps/marketplace/src/app/renter/dashboard/page.tsx create mode 100644 apps/marketplace/src/app/renter/notifications/page.tsx create mode 100644 apps/marketplace/src/app/renter/profile/page.tsx create mode 100644 apps/marketplace/src/app/renter/saved-companies/page.tsx create mode 100644 apps/marketplace/src/app/renter/sign-in/page.tsx create mode 100644 apps/marketplace/src/app/renter/sign-up/page.tsx create mode 100644 apps/marketplace/src/components/MarketplaceShell.tsx create mode 100644 apps/marketplace/src/components/WorkspaceFrame.tsx create mode 100644 apps/marketplace/src/components/WorkspaceTabs.tsx create mode 100644 apps/marketplace/src/lib/api.ts create mode 100644 apps/marketplace/src/lib/i18n.server.ts create mode 100644 apps/marketplace/src/lib/i18n.ts create mode 100644 apps/marketplace/src/lib/renter.ts create mode 100644 apps/public-site/next-env.d.ts create mode 100644 apps/public-site/public/rentalcardrive.png create mode 100644 apps/public-site/src/app/about/page.tsx create mode 100644 apps/public-site/src/app/blog/page.tsx create mode 100644 apps/public-site/src/app/book/BookClient.tsx create mode 100644 apps/public-site/src/app/book/confirmation/page.tsx create mode 100644 apps/public-site/src/app/book/page.tsx create mode 100644 apps/public-site/src/app/contact/page.tsx create mode 100644 apps/public-site/src/app/globals.css create mode 100644 apps/public-site/src/app/layout.tsx create mode 100644 apps/public-site/src/app/offers/page.tsx create mode 100644 apps/public-site/src/app/page.tsx create mode 100644 apps/public-site/src/app/pricing/page.tsx create mode 100644 apps/public-site/src/app/vehicles/VehicleFilters.tsx create mode 100644 apps/public-site/src/app/vehicles/[id]/page.tsx create mode 100644 apps/public-site/src/app/vehicles/page.tsx create mode 100644 apps/public-site/src/components/PublicLanguageSwitcher.tsx create mode 100644 apps/public-site/src/lib/api.ts create mode 100644 apps/public-site/src/lib/i18n.server.ts create mode 100644 apps/public-site/src/lib/i18n.ts create mode 100644 apps/public-site/src/middleware.ts create mode 100644 command_to_run_dev.txt create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.production.yml create mode 100644 docker-compose.test.yml create mode 100644 docker/pgadmin/servers.json create mode 100644 docker/scripts/dev-bootstrap.sh create mode 100644 docs/project-design/AUDIT.md create mode 100644 docs/project-design/FEATURES.md create mode 100644 docs/project-design/INTEGRATION.md create mode 100644 docs/project-design/PAGES.md create mode 100644 docs/project-design/README.md create mode 100644 docs/project-design/advanced-features.md create mode 100644 docs/project-design/api-routes.md create mode 100644 docs/project-design/schema.md create mode 100644 docs/project-design/stubs/EditMemberModal.tsx create mode 100644 docs/project-design/stubs/InviteModal.tsx create mode 100644 docs/project-design/stubs/PermissionsMatrix.tsx create mode 100644 docs/project-design/stubs/requireRole.ts create mode 100644 docs/project-design/stubs/team.ts create mode 100644 docs/project-design/stubs/team.tsx create mode 100644 docs/project-design/stubs/teamService.ts create mode 100644 docs/project-design/stubs/useTeam.ts create mode 100644 docs/project-design/stubs/webhooks.ts create mode 100644 package-lock.json create mode 100644 packages/database/src/index.js create mode 100644 packages/database/src/index.ts create mode 100644 packages/types/tsconfig.json create mode 100644 project_design/rentalcardrive.png diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ba0d71e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +.turbo +.git +.gitignore +.codex +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.next +dist +coverage +tmp +.env.local +.env.*.local + diff --git a/.env.docker.dev b/.env.docker.dev new file mode 100644 index 0000000..a730102 --- /dev/null +++ b/.env.docker.dev @@ -0,0 +1,23 @@ +DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego +REDIS_URL=redis://redis:6379 +API_PORT=4000 +API_URL=http://localhost:4000 +API_INTERNAL_URL=http://api:4000/api/v1 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_MARKETING_URL=http://localhost:3000 +NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore +NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001 +NEXT_PUBLIC_ADMIN_URL=http://localhost:3002 +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 +DASHBOARD_URL=http://localhost:3001 +JWT_SECRET=dev-secret +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +ADMIN_SEED_EMAIL=admin@rentaldrivego.com +ADMIN_SEED_PASSWORD=changeme123 +ADMIN_SEED_FIRST_NAME=Platform +ADMIN_SEED_LAST_NAME=Admin +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= +NODE_ENV=development +CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003 diff --git a/.env.docker.production.example b/.env.docker.production.example new file mode 100644 index 0000000..f8d01e1 --- /dev/null +++ b/.env.docker.production.example @@ -0,0 +1,17 @@ +DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego +REDIS_URL=redis://redis:6379 +API_PORT=4000 +API_URL=http://localhost:4000 +API_INTERNAL_URL=http://api:4000/api/v1 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_MARKETING_URL=http://localhost:3000 +NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore +NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001 +NEXT_PUBLIC_ADMIN_URL=http://localhost:3002 +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 +DASHBOARD_URL=http://localhost:3001 +JWT_SECRET=replace-with-a-long-random-secret +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +POSTGRES_PASSWORD=replace-with-a-real-password +NODE_ENV=production diff --git a/.env.docker.test b/.env.docker.test new file mode 100644 index 0000000..5a91bd4 --- /dev/null +++ b/.env.docker.test @@ -0,0 +1,10 @@ +DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test +REDIS_URL=redis://redis:6379 +API_PORT=4000 +API_URL=http://localhost:4000 +API_INTERNAL_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +JWT_SECRET=test-secret +JWT_EXPIRY=8h +RENTER_JWT_EXPIRY=7d +NODE_ENV=test diff --git a/.env.example b/.env.example index f2d3764..704be9d 100644 --- a/.env.example +++ b/.env.example @@ -26,14 +26,14 @@ NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding # ─── AmanPay (Primary payment provider) ─────────────────────── -# RentFlow's own AmanPay account (for collecting subscription fees) +# RentalDriveGo's own AmanPay account (for collecting subscription fees) AMANPAY_MERCHANT_ID=your-amanpay-merchant-id AMANPAY_SECRET_KEY=your-amanpay-secret-key AMANPAY_BASE_URL=https://api.amanpay.net AMANPAY_WEBHOOK_SECRET=your-amanpay-webhook-secret # ─── PayPal (Secondary payment provider) ────────────────────── -# RentFlow's own PayPal account (for collecting subscription fees) +# RentalDriveGo's own PayPal account (for collecting subscription fees) PAYPAL_CLIENT_ID=your-paypal-client-id PAYPAL_CLIENT_SECRET=your-paypal-client-secret PAYPAL_BASE_URL=https://api-m.paypal.com @@ -67,6 +67,9 @@ NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789 NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123 +# The Node API uses Resend for email delivery. +# MAIL_* SMTP variables are intentionally omitted here. + # ─── Redis (Real-time / Socket.io) ──────────────────────────── REDIS_URL=redis://localhost:6379 diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..aead9ca --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,131 @@ +## Docker Environments + +Three Docker environments are available: + +- `Dockerfile.dev` with `docker-compose.dev.yml` +- `Dockerfile.test` with `docker-compose.test.yml` +- `Dockerfile.production` with `docker-compose.production.yml` + +### Development + +Use the full dev stack for local work with hot reload and bundled Postgres and Redis: + +```bash +docker compose -f docker-compose.dev.yml --profile full up --build +``` + +Services: + +- marketplace: `http://localhost:3000` +- dashboard: `http://localhost:3001` +- admin: `http://localhost:3002` +- public-site: `http://localhost:3003` +- api: `http://localhost:4000` +- pgAdmin: `http://localhost:5050` + +Each dev app now runs in its own container and can be started independently with a profile tag: + +```bash +docker compose -f docker-compose.dev.yml --profile api up --build +docker compose -f docker-compose.dev.yml --profile marketplace up --build +docker compose -f docker-compose.dev.yml --profile dashboard up --build +docker compose -f docker-compose.dev.yml --profile admin up --build +docker compose -f docker-compose.dev.yml --profile public-site up --build +docker compose -f docker-compose.dev.yml --profile tools up --build +``` + +Notes: + +- `api` starts `postgres`, `redis`, and `migrate` automatically through dependencies. +- frontend profiles also start `api` and its dependencies automatically. +- `tools` starts only `pgadmin` plus its required `postgres` dependency. + +On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts. + +Default dev platform administrator: + +- email: `admin@rentaldrivego.com` +- password: `changeme123` + +If you intentionally want a fresh dev bootstrap: + +```bash +docker compose -f docker-compose.dev.yml down -v +``` + +If you want to keep the database and only apply new schema changes manually: + +```bash +docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy" +``` + +pgAdmin dev login: + +- email: `admin@rentaldrivego.local` +- email: `admin@rentaldrivego.dev` +- password: `admin` + +pgAdmin opens with the dev Postgres server pre-registered as `RentalDriveGo Dev DB`. + +pgAdmin Postgres connection: + +- host: `postgres` +- port: `5432` +- database: `rentaldrivego` +- username: `postgres` +- password: `password` + +### Test + +Use the test stack to run repeatable containerized verification: + +```bash +docker compose -f docker-compose.test.yml up --build --abort-on-container-exit +``` + +The test container runs: + +- `npm run db:deploy` +- `npm run db:generate` +- `npm run type-check` +- `npm run build` + +### Production + +1. Copy `.env.docker.production.example` to `.env.docker.production` +2. Fill in real secrets and domain values +3. Start the stack: + +```bash +docker compose -f docker-compose.production.yml up --build -d +``` + +Production compose starts separate containers for: + +- postgres +- redis +- api +- marketplace +- dashboard +- admin +- public-site + +### Notes + +- The production image builds the whole monorepo once, then each service overrides its runtime command. +- The dev compose file bind-mounts the repo and keeps `node_modules` in a named volume. +- `API_INTERNAL_URL` is used for server-side container-to-container calls, while `NEXT_PUBLIC_API_URL` is used by the browser. +- The Dockerfiles activate the repo's pinned `npm@10.5.0` with `corepack` before install so container builds do not depend on the npm version bundled with the base image. +- The dev compose stack stores Postgres data in `postgres_dev_data` and the bootstrap marker in `postgres_bootstrap_state`, so `up --build` does not reseed an existing local database. +- If you need database schema updates inside Docker, run: + +```bash +docker compose -f docker-compose.dev.yml run --rm migrate +``` + +If a cached base image still fails during `npm ci`, refresh it and rebuild without cache: + +```bash +docker pull node:20-bookworm +docker compose -f docker-compose.dev.yml build --no-cache dashboard +``` diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..287ad82 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM node:20-bookworm + +WORKDIR /app + +RUN npm install -g npm@10.5.0 --no-fund --no-audit + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm ci --no-fund --no-audit + +EXPOSE 3000 3001 3002 3003 4000 + +CMD ["sh", "-c", "npm run db:generate && npm run dev"] diff --git a/Dockerfile.production b/Dockerfile.production new file mode 100644 index 0000000..4b1bee3 --- /dev/null +++ b/Dockerfile.production @@ -0,0 +1,30 @@ +FROM node:20-bookworm AS builder + +WORKDIR /app + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm install +RUN npm run db:generate +RUN npm run build + +FROM node:20-bookworm AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY --from=builder /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/apps ./apps +COPY --from=builder /app/packages ./packages + +EXPOSE 3000 3001 3002 3003 4000 + +CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"] diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..2b313a6 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,15 @@ +FROM node:20-bookworm + +WORKDIR /app + +RUN corepack enable && corepack prepare npm@10.5.0 --activate + +COPY package.json package-lock.json turbo.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages + +RUN npm install + +ENV NODE_ENV=test + +CMD ["sh", "-c", "npm run db:generate && npm run type-check && npm run build"] diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index 4f89111..ff15bca 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -1,9 +1,19 @@ /** @type {import('next').NextConfig} */ +const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') + const nextConfig = { images: { domains: ['res.cloudinary.com'], }, transpilePackages: ['@rentaldrivego/types'], + async rewrites() { + return [ + { + source: '/api/:path*', + destination: `${apiOrigin}/api/:path*`, + }, + ] + }, } module.exports = nextConfig diff --git a/apps/admin/src/app/dashboard/admin-users/page.tsx b/apps/admin/src/app/dashboard/admin-users/page.tsx new file mode 100644 index 0000000..389cc54 --- /dev/null +++ b/apps/admin/src/app/dashboard/admin-users/page.tsx @@ -0,0 +1,211 @@ +'use client' + +import { useEffect, useState } from 'react' + +const API_BASE = '/api/v1' + +interface AdminUser { + id: string + firstName: string + lastName: string + email: string + role: string + isActive: boolean + createdAt: string + permissions?: { id: string; resource: string; actions: string[] }[] +} + +const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'] + +export default function AdminUsersPage() { + const [admins, setAdmins] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showModal, setShowModal] = useState(false) + const [form, setForm] = useState({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' }) + const [creating, setCreating] = useState(false) + + function getToken() { return localStorage.getItem('admin_token') ?? '' } + + async function fetchAdmins() { + try { + const res = await fetch(`${API_BASE}/admin/admins`, { + headers: { Authorization: `Bearer ${getToken()}` }, + cache: 'no-store', + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Failed') + setAdmins(json.data ?? []) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchAdmins() }, []) + + async function createAdmin(e: React.FormEvent) { + e.preventDefault() + setCreating(true) + setError(null) + try { + const res = await fetch(`${API_BASE}/admin/admins`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify(form), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Failed to create') + setShowModal(false) + setForm({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' }) + await fetchAdmins() + } catch (err: any) { + setError(err.message) + } finally { + setCreating(false) + } + } + + const ROLE_COLORS: Record = { + SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40', + ADMIN: 'text-sky-400 bg-sky-950/40', + SUPPORT: 'text-amber-400 bg-amber-950/40', + FINANCE: 'text-violet-400 bg-violet-950/40', + VIEWER: 'text-zinc-400 bg-zinc-800', + } + + return ( +
+
+
+

Platform

+

Admin Users

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

New admin user

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

Platform

+

Audit Logs

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

{company.name}

+

{company.slug}

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

{kpi.label}

+

{kpi.value}

+
+ ))} +
+ +
+
+

Details

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

Actions

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

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

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

Recent audit events

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

{dict.platform}

+

{copy.title}

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

{c.name}

+

{c.email}

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

{dict.admin}

+

{copy.platformOverview}

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

{kpi.label}

+

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

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

{title}

+

{body}

+

{cta}

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

{dict.platform}

+

{copy.title}

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

{dict.brand}

+

{dict.access}

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

{dict.enterCode}

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

+ {dict.preferences} +

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

RentalDriveGo API

+

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

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

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

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

${opts.body}

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

Billing

+

Manage your plan, payment provider, and invoice history.

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

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

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

Current plan

+
+

{subscription.plan}

+ + {subscription.status} + +
+

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

+ {subscription.cancelAtPeriodEnd && ( +

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

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

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

+

Select a plan and payment provider to proceed.

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

Payment provider

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

Total

+

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

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

Invoice history

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

Customers

+

Company-scoped CRM with license validation and risk flags.

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

{row.email}

+

{row.phone ?? 'No phone'}

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

{vehicle.make} {vehicle.model}

+

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

+
+
+
+

Photos

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

Vehicle details

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

Notifications

+

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

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

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

+

{notification.title}

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

{notification.body}

+

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

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

Offers

+

Promotions shown on your site and optionally on the marketplace.

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

{offer.title}

+

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

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

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

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

Reports

+

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

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

Bookings

{report.summary.totalReservations}

+

Rental

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

+

Insurance

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

+

Drivers

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

+

Discounts

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

+

Collected

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

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

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

+

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

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

Customer

+
+

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

+

{reservation.customer.email}

+

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

+

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

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

Vehicle

+
+

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

+

{reservation.vehicle.licensePlate}

+

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

+
+
+
+ +
+
+
+

Charges

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

Applied insurance

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

Pricing rules applied

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

Additional drivers

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

{driver.firstName} {driver.lastName}

+

License: {driver.driverLicense}

+

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

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

{driver.approvalNote}

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

Inspection summary

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

Reservations

+

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

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

{row.customer.email}

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

Advanced settings

+

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

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

Brand and public profile

+

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

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

Rental payment methods

+

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

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

Custom domain

+

Point your own domain to the branded booking site.

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

Contract and driver policies

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