diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a94146d7..c853cfc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,8 @@ name: CI on: push: - # TEMP: run CI on this branch while it bakes. Revert before merging. - branches: [master, main, LASAGNA-090706/soft] + # TEMP: run CI on these branches while they bake. Revert before merging. + branches: [master, main, LASAGNA-090706/soft, LASAGNA-140726/recover-crypto] pull_request: branches: [master, main] @@ -53,8 +53,8 @@ jobs: - name: Build (core) run: npm run build - - name: Build satellite packages (sso, billing, admin, backup, websockets, reporting, ai) - run: npm run build:sso && npm run build:billing && npm run build:admin && npm run build:backup && npm run build:websockets && npm run build:reporting && npm run build:ai + - name: Build satellite packages (crypto, sso, billing, admin, backup, websockets, reporting, ai) + run: npm run build:crypto && npm run build:sso && npm run build:billing && npm run build:admin && npm run build:backup && npm run build:websockets && npm run build:reporting && npm run build:ai # The dev-only satellite-test-kit is imported by core's bin/test.integration.ts # (and each satellite's), which core's tsconfig typechecks. Build it before the @@ -201,6 +201,7 @@ jobs: npm run test:coverage --workspace @adonisjs-lasagna/websockets npm run test:coverage --workspace @adonisjs-lasagna/reporting npm run test:coverage --workspace @adonisjs-lasagna/ai + npm run test:coverage --workspace @adonisjs-lasagna/crypto # Satellite ABI compatibility (B5): the reference third-party satellite # is built + tested against the freshly-built core above. Its typecheck @@ -302,6 +303,14 @@ jobs: path: coverage/.v8/ai-unit if-no-files-found: warn + - name: Upload raw satellite unit coverage (V8) — crypto + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: c8-raw-crypto-unit + path: coverage/.v8/crypto-unit + if-no-files-found: warn + # Full report (unused exports / orphaned files / deps) stays informational. - name: Knip (unused-code report) run: npm run knip @@ -330,7 +339,7 @@ jobs: - name: Package contracts (publint + types resolution) run: | set -e - for pkg in core sso billing admin backup websockets reporting ai; do + for pkg in core sso billing admin backup websockets reporting ai crypto; do echo "== @adonisjs-lasagna/$pkg ==" ( cd "packages/$pkg" \ && npx -y publint@0.3.21 \ @@ -475,11 +484,11 @@ jobs: # with `default_transaction_read_only = on` so a write is denied by Postgres. PLUGIN_RO_DB_USER: plugin_ro PLUGIN_RO_DB_PASSWORD: plugin_ro - # This job HAS a real Postgres service, so the AI real-PG proofs are + # This job HAS a real Postgres service, so the crypto/AI real-PG proofs are # mandatory here: they must EXECUTE, never self-skip. The satellite real-PG # helpers read this flag and turn a would-be self-skip (PG unreachable, role # lacks CREATEDB) into a hard failure — the fail-loud twin of RLS_DB_USER, - # so a broken/hardened runner can't ship those proofs green. + # so a broken/hardened runner can't ship the crypto crown-jewel proofs green. REQUIRE_REAL_PG: '1' steps: @@ -605,7 +614,9 @@ jobs: - name: Test (fault injection) — chaos tier if: contains(github.event.head_commit.message, '[chaos]') || contains(github.event.pull_request.title, '[chaos]') continue-on-error: true - run: npm run test:fault:run --workspace @adonisjs-lasagna/saas-tenancy + run: | + npm run test:fault:run --workspace @adonisjs-lasagna/saas-tenancy + npm run test:fault:run --workspace @adonisjs-lasagna/crypto # Satellite integration tiers boot through the shared satellite-test-kit, # proving the harness end to end on a real satellite. The step above ran @@ -653,6 +664,14 @@ jobs: - name: Test (satellite integration) + coverage — ai run: npm run test:integration:coverage --workspace @adonisjs-lasagna/ai + # crypto's integration tier: the wrapped-DEK store + shred + WORM ledger + the + # @encrypted/@searchable decorators against real Postgres via the kit, across + # every placement (schema-pg, a real second database for database-pg, and the + # shared rowscope table with its RLS stub). The database-pg + rowscope specs + # self-skip if the CI role cannot CREATEDB / set the RLS GUC. + - name: Test (satellite integration) + coverage — crypto + run: npm run test:integration:coverage --workspace @adonisjs-lasagna/crypto + # Consumer canary: boot the shared harness from a fresh satellite (the # reference template) against core's fixture. A kit change that breaks a # real consumer fails here on an isolated, fast signal instead of buried in @@ -743,6 +762,18 @@ jobs: path: coverage/.v8/ai-integration if-no-files-found: warn + # crypto integration V8 (written by test:integration:coverage to + # coverage/.v8/crypto-integration in the "— crypto" step above). The + # coverage-report job merges this with crypto's unit V8 so + # check-satellite-coverage.mjs gates a real per-satellite MERGED number. + - name: Upload raw satellite integration coverage (V8) — crypto + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: c8-raw-crypto-integration + path: coverage/.v8/crypto-integration + if-no-files-found: warn + test-e2e-demo: name: E2E (demo app) runs-on: ubuntu-latest @@ -1223,6 +1254,12 @@ jobs: name: c8-raw-ai-integration path: coverage/.v8/all + - name: Download raw satellite integration coverage (V8) — crypto + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: c8-raw-crypto-integration + path: coverage/.v8/all + # websockets integration V8 is produced by the test-e2e-websockets job (a # different job than test-integration above), now run under c8. This is why # test-e2e-websockets is in this job's needs:. @@ -1278,6 +1315,12 @@ jobs: name: c8-raw-ai-unit path: coverage/.v8/all + - name: Download raw satellite unit coverage (V8) — crypto + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: c8-raw-crypto-unit + path: coverage/.v8/all + - name: Aggregate coverage report (unit + integration, remapped to src) run: npm run coverage:report diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 53746c9e..b7d6e495 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -73,15 +73,16 @@ jobs: fi } - # Core first so satellites resolve a published peer. Only the six + # Core first so satellites resolve a published peer. Only the # publishable workspaces belong here: `admin` and `websockets` are # `private: true` (npm refuses them with EPRIVATE, which under - # `set -euo pipefail` would abort this script mid-release), and the - # crypto satellite was removed from the repo. `check-publish-coverage.mjs` - # asserts every non-private package appears below. + # `set -euo pipefail` would abort this script mid-release). + # `check-publish-coverage.mjs` asserts every non-private package + # appears below. publish_pkg "packages/core" publish_pkg "packages/sso" publish_pkg "packages/billing" publish_pkg "packages/backup" publish_pkg "packages/reporting" publish_pkg "packages/ai" + publish_pkg "packages/crypto" diff --git a/README.md b/README.md index d4f8ec5f..8121d60b 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ and runs the full e2e suite against it. | **Dependency resilience** | Per-dependency fail-open/fail-closed degradation policy via `ResilienceService`. Emits `DependencyDegraded` for alerting and returns a typed 503 (`DependencyUnavailableException`) when fail-closed. | | **Lifecycle hooks + 30 typed events** | Declarative `before` / `after` hooks wired into commands and jobs. 20 core (tenant / quota / maintenance / resilience / metrics / data-change / guard-audit lifecycle) + 10 billing. | | **Contextual logging** | `tenantId` rides along through HTTP and queue jobs via `AsyncLocalStorage`. | -| **`tenant:doctor`** | Ten built-in checks (plus `backup_recency` and `backup_encryption` when the backup satellite is installed), `--fix` for auto-recovery, `--json` for CI, `--watch` for a live TUI. | +| **`tenant:doctor`** | Thirteen built-in checks (plus `backup_recency` and `backup_encryption` when the backup satellite is installed), `--fix` for auto-recovery (heals unmigrated/behind/failed tenants), `--json` for CI, `--watch` for a live TUI. | | **Plans and quotas** | Declarative plans, rolling counters, snapshot usage, an `enforceQuota()` middleware that returns 429 and emits `TenantQuotaExceeded`. | | **Scheduled backups + retention** | Tier-based intervals and `keepLast`, S3 mirror with purge awareness, idempotent cron command. | | **Health probes + Prometheus** | `/livez`, `/readyz`, `/healthz`, `/metrics`. No `prom-client` peer dep. | diff --git a/apps/rental/.env.example b/apps/rental/.env.example new file mode 100644 index 00000000..acd60b9e --- /dev/null +++ b/apps/rental/.env.example @@ -0,0 +1,56 @@ +# ─── App ────────────────────────────────────────────────────────── +NODE_ENV=development +PORT=3333 +HOST=127.0.0.1 +LOG_LEVEL=info +APP_KEY=karimoto-dev-app-key-please-change-32ch + +# ─── Multitenancy ──────────────────────────────────────────────── +# Companies are addressed as .localhost (stored as custom_domain) and +# resolved via domain-or-subdomain, with x-tenant-id (UUID) as the API fallback. +TENANT_HEADER_KEY=x-tenant-id +APP_DOMAIN=localhost +IMPERSONATION_SECRET=karimoto-dev-impersonation-secret-change-me-0123456789 + +# Webhook-delivery tests post to an in-process listener on loopback, so the SSRF +# guard must exempt loopback for the suite. Keep OFF in any real deployment. +WEBHOOKS_ALLOW_LOOPBACK_TARGETS=true + +# ─── PostgreSQL (matches docker-compose.yml) ────────────────────── +DB_HOST=127.0.0.1 +DB_PORT=55433 +DB_USER=karimoto +DB_PASSWORD=karimoto +DB_DATABASE=karimoto + +# ─── Redis (matches docker-compose.yml) ─────────────────────────── +REDIS_HOST=127.0.0.1 +REDIS_PORT=56380 + +QUEUE_REDIS_HOST=127.0.0.1 +QUEUE_REDIS_PORT=56380 +QUEUE_REDIS_DB=1 + +CACHE_REDIS_HOST=127.0.0.1 +CACHE_REDIS_PORT=56380 +CACHE_REDIS_DB=2 + +# ─── Seed a demo owner into every company schema at migrate time ── +DEMO_SEED_TENANT_USERS=true + +# ─── Backups (optional) ────────────────────────────────────────── +BACKUP_STORAGE_PATH=./storage/backups + +# ─── Billing (Stripe) — leave unset to run the offline mock ────── +# BILLING_DRIVER=stripe +# STRIPE_API_KEY=sk_test_... +# STRIPE_WEBHOOK_SECRET=whsec_... + +# ─── AI (Anthropic) — leave unset to run the offline mock ──────── +# ANTHROPIC_API_KEY=sk-ant-... + +# ─── Mail (MailCatcher in dev, real SMTP in prod) ──────────────── +MAILCATCHER_HOST=127.0.0.1 +MAILCATCHER_PORT=1025 +MAIL_FROM_ADDRESS=noreply@karimoto.test +MAIL_FROM_NAME=Karimoto diff --git a/apps/rental/.gitignore b/apps/rental/.gitignore new file mode 100644 index 00000000..7367b095 --- /dev/null +++ b/apps/rental/.gitignore @@ -0,0 +1,7 @@ +node_modules +build +coverage +.env +storage +tmp +*.log diff --git a/apps/rental/README.md b/apps/rental/README.md new file mode 100644 index 00000000..e7bdab85 --- /dev/null +++ b/apps/rental/README.md @@ -0,0 +1,87 @@ +# Karimoto + +A real car-rental SaaS built on `@adonisjs-lasagna/*`, exercising the whole +platform: two auth realms, schema-per-tenant isolation, the nine satellites +(admin, billing, ai, crypto, sso, backup, websockets, reporting) plus the core +feature set, a `telematics` plugin, and two Inertia + React consoles (the +platform operator and the rental company). + +- **Operator** lives on the apex host `localhost:3333`. +- **Companies** live on a vanity host `.localhost:3333` (e.g. + `acme.localhost:3333`), stored as the tenant's `custom_domain`. + +## Runtime processes + +A real deployment runs three processes. In dev: + +- `npm run dev` — the HTTP server (Vite is auto-started, no `--hmr` needed). +- `npm run dev:worker` — the queue worker (`queue:work`). **Required** for + tenant provisioning: company creation dispatches an `InstallTenant` job, and + the schema only exists once the worker has run it. + +Infrastructure (Postgres `pgvector/pgvector:pg16` + Redis + MailCatcher) comes +up with `npm run infra:up`. Ports are 55433 / 56380 / 1025+1080, distinct from +the core demo so both run side by side. + +## Setup from a clean database + +Provisioning is asynchronous, so setup is two passes with the worker running in +between. From `apps/rental`: + +```bash +npm run infra:up # Postgres + Redis + MailCatcher + +# 1. Control plane: operator account, central car catalog, and the two demo +# companies (each dispatches InstallTenant). +npm run setup # backoffice:setup + central migrate + rental:seed + +# 2. Materialise the schemas: start the worker (leave it running) so it drains +# the InstallTenant jobs. The AI provider's after('provision') hook installs +# pgvector into the `extensions` schema as each company is provisioned. +npm run dev:worker # in a second terminal; wait for the jobs to drain + +# 3. Data plane: migrate each tenant schema, then fill it with demo data. +npm run setup:demo # tenant:vector:provision + migration:tenant:run + rental:seed:demo +``` + +`setup:demo` runs `tenant:vector:provision` first as a belt-and-suspenders step: +it is idempotent, and it guarantees the `vector` extension exists on a +pre-existing database (or one whose schemas were provisioned before the AI +provider's hook was in place) before the `ai_embeddings vector(N)` migration runs. + +Then start the server: + +```bash +npm run dev # http://localhost:3333 +``` + +### Logins (dev only, refused in production) + +| Realm | Host | Email | Password | +|---|---|---|---| +| Operator | `localhost:3333` | `operator@karimoto.test` | `operator-demo-password` | +| Company staff | `acme.localhost:3333` | `owner@karimoto.test` | `owner-demo-password` | +| Company staff | `sahara-cars.localhost:3333` | `owner@karimoto.test` | `owner-demo-password` | + +## The two seed commands + +- **`rental:seed`** (control plane) — the operator account, the shared central + car catalog, and the demo company rows (dispatching provisioning). Idempotent. +- **`rental:seed:demo`** (data plane) — fills each already-migrated company with + branches, a rate card, a fleet drawn from the catalog, renters with encrypted + PII, bookings across the lifecycle (invoices + payments for completed ones), + and a small RAG corpus of policy docs whose bodies are embedded into the tenant + vector store. Idempotent; safe to re-run to top up missing rows. Sizing follows + the company plan (`fleet`/`enterprise` get a full fleet, `starter` a smaller one + that stays under its `vehiclesPerTenant` quota). + +## The fleet assistant (RAG) + +The assistant streams over SSE at `POST /ai/chat`. Retrieval is opt-in +(`retrieve: true`): it embeds the query and searches the tenant's `ai_embeddings` +store, which `rental:seed:demo` populates from the policy docs. Offline (no +`ANTHROPIC_API_KEY`) the chat and embeddings run on the in-process mocks, so +retrieval returns real matches but the ranking is a deterministic hash, not +semantic relevance. Set `ANTHROPIC_API_KEY` (and a real embedding backend) in +`.env` and the same path uses the real model with no code change — re-run +`rental:seed:demo` so the corpus is re-embedded into the real vector space. diff --git a/apps/rental/ace.ts b/apps/rental/ace.ts new file mode 100644 index 00000000..2c2ecb8b --- /dev/null +++ b/apps/rental/ace.ts @@ -0,0 +1,29 @@ +/** + * AdonisJS ace entrypoint. Boots the Ignitor in a console environment so + * commands can be discovered and executed via `npm run ace -- `. + */ +import 'reflect-metadata' +import { Ignitor, prettyPrintError } from '@adonisjs/core' + +const APP_ROOT = new URL('./', import.meta.url) +const IMPORTER = (filePath: string) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, APP_ROOT).href) + } + return import(filePath) +} + +new Ignitor(APP_ROOT, { importer: IMPORTER }) + .tap((app) => { + app.booting(async () => { + await import('#start/env') + }) + app.listen('SIGTERM', () => app.terminate()) + app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate()) + }) + .ace() + .handle(process.argv.splice(2)) + .catch((error) => { + process.exitCode = 1 + prettyPrintError(error) + }) diff --git a/apps/rental/adonisrc.ts b/apps/rental/adonisrc.ts new file mode 100644 index 00000000..87ce20ad --- /dev/null +++ b/apps/rental/adonisrc.ts @@ -0,0 +1,81 @@ +import { defineConfig } from '@adonisjs/core/app' + +/** + * Karimoto — the car-rental SaaS reference application. + * + * Providers are added in the same order the platform expects: framework + * providers first, then the multitenancy kernel, then the satellites (wired in + * later phases), then this app's own provider last so its boot() can see every + * registry the satellites bound. + */ +export default defineConfig({ + commands: [ + () => import('@adonisjs/core/commands'), + () => import('@adonisjs/lucid/commands'), + () => import('@adonisjs/queue/commands'), + () => import('@adonisjs-lasagna/saas-tenancy/commands'), + () => import('@adonisjs-lasagna/backup/commands'), + () => import('@adonisjs-lasagna/billing/commands'), + () => import('@adonisjs-lasagna/reporting/commands'), + () => import('@adonisjs-lasagna/ai/commands'), + () => import('@adonisjs-lasagna/crypto/commands'), + ], + + providers: [ + () => import('@adonisjs/core/providers/app_provider'), + () => import('@adonisjs/core/providers/hash_provider'), + { + file: () => import('@adonisjs/core/providers/repl_provider'), + environment: ['repl', 'test'], + }, + () => import('@adonisjs/lucid/database_provider'), + () => import('@adonisjs/redis/redis_provider'), + () => import('@adonisjs/queue/queue_provider'), + () => import('@adonisjs/mail/mail_provider'), + () => import('@adonisjs/core/providers/vinejs_provider'), + () => import('@adonisjs/auth/auth_provider'), + // Browser-console stack: sessions back the `web-*` guards, Vite serves the + // React bundle, Edge renders the Inertia shell, and Inertia bridges the two. + // Vite must register before Inertia (the Inertia manager resolves `vite` from + // the container), and Edge before Inertia renders the root view via ctx.view. + () => import('@adonisjs/session/session_provider'), + () => import('@adonisjs/vite/vite_provider'), + () => import('@adonisjs/core/providers/edge_provider'), + () => import('@adonisjs/inertia/inertia_provider'), + () => import('@adonisjs-lasagna/saas-tenancy/providers/multitenancy_provider'), + () => import('@adonisjs-lasagna/backup/provider'), + () => import('@adonisjs-lasagna/billing/provider'), + () => import('@adonisjs-lasagna/websockets/provider'), + () => import('@adonisjs-lasagna/reporting/provider'), + () => import('@adonisjs-lasagna/ai/provider'), + () => import('@adonisjs-lasagna/crypto/provider'), + () => import('#app/plugins/telematics_plugin'), + () => import('#app/providers/app_provider'), + ], + + preloads: [ + () => import('#start/env'), + () => import('#start/kernel'), + () => import('#start/routes'), + () => import('#start/socket'), + ], + + // Copied verbatim into ./build on `node ace build` so the compiled server can + // still render the Edge shell and serve the compiled Vite assets. The Vite dev + // server is auto-detected from vite.config.ts and started by `node ace serve`. + metaFiles: [ + { pattern: 'resources/views/**/*.edge', reloadServer: false }, + { pattern: 'public/**', reloadServer: false }, + ], + + tests: { + suites: [ + { + name: 'e2e', + files: ['tests/@integration/e2e/**/*.spec.ts'], + timeout: 30_000, + }, + ], + forceExit: true, + }, +}) diff --git a/apps/rental/app/ai/fleet_tools.ts b/apps/rental/app/ai/fleet_tools.ts new file mode 100644 index 00000000..5e09306d --- /dev/null +++ b/apps/rental/app/ai/fleet_tools.ts @@ -0,0 +1,455 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { TenantModelContract } from '@adonisjs-lasagna/saas-tenancy/types' +import { DateTime } from 'luxon' +import type { BookingStatus } from '#app/models/tenant_scoped/booking' +import type { VehicleStatus } from '#app/models/tenant_scoped/vehicle' + +/** + * The fleet assistant's read-only tools (WS-AI-11). + * + * The AI satellite is a RAG-over-documents gateway: on its own it answers "what is + * our fuel policy?" from the knowledge base but cannot answer "how many bookings do + * I have?", which needs a query against this company's own tables. The old + * `/assistant/context` snapshot folded fixed aggregates into every turn — it answered + * "how many / how much" but never a drill-down. These tools replace it outright: the + * model chooses what to look up, with arguments, per question, and the snapshot is + * gone. + * + * Every handler is a plain Lucid query on a `TenantBaseModel`, which the adapter + * already routes to the resolved company's schema — and the satellite's executor + * runs it inside `tenancy.run(tenant)` and re-asserts the scope first, so a tool can + * only ever read the company that asked. Models are imported INSIDE the handlers: + * this module is reached from `config/multitenancy.ts`, which loads before the + * provider boots, and a top-level model import would pull the base models in too + * early (the same reason the `compliance.anonymize` hook imports dynamically). + * + * All of them are `mode: 'read'`. Nothing here mutates, so none needs the action + * kill-switch or a confirmation round-trip. + * + * Arguments are validated by the satellite's shipped JSON-Schema subset checker via + * each tool's `inputSchema`, NOT by a host `parseInput`. Two reasons: the checker's + * whitelist reconstruction is stricter (it rebuilds the object from the declared + * properties, so an undeclared or prototype-polluting key cannot reach the handler + * at all), and `parseInput` is synchronous while vine validates asynchronously, so + * vine cannot satisfy that seam anyway. These inputs — an enum, two date strings, a + * bounded integer — are fully expressible in the subset. + */ + +const BOOKING_STATUSES: BookingStatus[] = [ + 'quote', + 'confirmed', + 'active', + 'completed', + 'cancelled', + 'no_show', +] + +/** Statuses that represent a real rental (a quote / cancellation / no-show is not one). */ +const RENTAL_STATUSES: BookingStatus[] = ['confirmed', 'active', 'completed'] + +const VEHICLE_STATUSES: VehicleStatus[] = ['available', 'rented', 'maintenance', 'retired'] + +/** Money is stored in santimat (minor units); the model should never have to divide. */ +const toMajorUnits = (santimat: number): number => Math.round(santimat / 100) + +/** + * A tool result that tells the model its own arguments were unusable. + * + * Returned, not thrown. A throw degrades to the executor's generic bounded + * `tool_execution_failed`, which the model cannot learn anything from; a returned + * `{ error, hint }` lets it fix the argument and retry within the same loop. This is + * for arguments the schema cannot express (a syntactically fine date string that is + * not a real date) — never for an internal failure, which SHOULD degrade. + */ +const argError = (error: string, hint: string) => ({ error, hint }) + +/** + * `current_date()` — the company's current date, so the model can resolve a relative + * window ("next weekend", "this month") instead of guessing it. + * + * The `/assistant/context` snapshot used to carry `generatedAt`, which silently handed + * the model "now". With the snapshot gone, a date-relative question like "which cars are + * free next weekend?" left the model to hallucinate today's date — and it guessed the + * wrong year. This restores that one fact as an explicit, on-demand tool: no arguments, + * no DB, no PII. The model calls it first when a question is relative to now, then feeds + * the resolved dates to `list_available_vehicles`. + */ +export const currentDate = { + name: 'current_date', + description: + "Get the company's current date. Call this FIRST whenever a question is relative to " + + "now — 'today', 'this week', 'next weekend', 'this month' — then use the returned date " + + 'to compute the exact window for the other tools. Takes no arguments.', + inputSchema: { type: 'object', properties: {} }, + mode: 'read' as const, + handler: async () => { + const now = DateTime.now() + return { + today: now.toISODate() ?? '', + weekday: now.weekdayLong ?? '', + timezone: now.zoneName ?? '', + } + }, +} + +/** + * `count_bookings({ status? })` — booking volume, optionally narrowed to one + * lifecycle status. With no status it returns the full per-status breakdown, so the + * model can answer "how many bookings?" and "how many are active?" from one call. + */ +export const countBookings = { + name: 'count_bookings', + description: + "Count this company's bookings. Optionally narrow to a single lifecycle status " + + '(quote, confirmed, active, completed, cancelled, no_show). With no status, returns ' + + 'the total plus a per-status breakdown.', + inputSchema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: BOOKING_STATUSES, + description: 'Optional lifecycle status to count.', + }, + }, + }, + mode: 'read' as const, + handler: async (args: Record) => { + const { default: Booking } = await import('#app/models/tenant_scoped/booking') + const status = args.status as BookingStatus | undefined + + if (status) { + const rows = await Booking.query() + .where('status', status) + .count('* as count') + .pojo<{ count: number | string }>() + return { status, count: Number(rows[0]?.count ?? 0) } + } + + const rows = await Booking.query() + .select('status') + .count('* as count') + .groupBy('status') + .pojo<{ status: string; count: number | string }>() + const byStatus = Object.fromEntries(BOOKING_STATUSES.map((s) => [s, 0])) as Record< + BookingStatus, + number + > + for (const row of rows) { + if (row.status in byStatus) byStatus[row.status as BookingStatus] = Number(row.count) + } + return { total: Object.values(byStatus).reduce((a, b) => a + b, 0), byStatus } + }, +} + +/** + * `count_vehicles({ status? })` — fleet size, optionally narrowed to one status. + * + * The twin of `count_bookings` for the vehicle table. With no status it returns the + * total fleet size plus a per-status breakdown (available / rented / maintenance / + * retired), so the model answers "how many cars do I have?" and "how many are in + * maintenance?" from a single call. This is the count the `/assistant/context` + * snapshot used to carry as `fleet.total` / `fleet.byStatus`; no tool covered it + * before, so a bare "how big is my fleet?" had the model looping on + * `list_available_vehicles` (which needs a date window and only lists free cars) + * until it exhausted the round budget. + * + * Note this is a status count, not real availability: a car marked `available` may + * still be booked for a given window. For "free between these dates" the model wants + * `list_available_vehicles`, which does the overlap test. + */ +export const countVehicles = { + name: 'count_vehicles', + description: + "Count this company's vehicles. Optionally narrow to a single status (available, " + + 'rented, maintenance, retired). With no status, returns the total fleet size plus a ' + + 'per-status breakdown. This is a status count, not date-window availability — for ' + + 'cars free between specific dates use list_available_vehicles.', + inputSchema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: VEHICLE_STATUSES, + description: 'Optional vehicle status to count.', + }, + }, + }, + mode: 'read' as const, + handler: async (args: Record) => { + const { default: Vehicle } = await import('#app/models/tenant_scoped/vehicle') + const status = args.status as VehicleStatus | undefined + + if (status) { + const rows = await Vehicle.query() + .where('status', status) + .count('* as count') + .pojo<{ count: number | string }>() + return { status, count: Number(rows[0]?.count ?? 0) } + } + + const rows = await Vehicle.query() + .select('status') + .count('* as count') + .groupBy('status') + .pojo<{ status: string; count: number | string }>() + const byStatus = Object.fromEntries(VEHICLE_STATUSES.map((s) => [s, 0])) as Record< + VehicleStatus, + number + > + for (const row of rows) { + if (row.status in byStatus) byStatus[row.status as VehicleStatus] = Number(row.count) + } + return { total: Object.values(byStatus).reduce((a, b) => a + b, 0), byStatus } + }, +} + +/** + * `list_available_vehicles({ from?, to? })` — the fleet actually free for a window. + * + * Availability is not just `status = 'available'`: a car already reserved for those + * dates is not free. So it excludes any vehicle holding a confirmed/active booking + * that OVERLAPS the window — the standard half-open test (`pickup < to AND dropoff > + * from`), which correctly treats a booking ending exactly at `from` as no conflict. + * + * Both bounds are optional: omit them for what is free right now and the window defaults + * to the next 24 hours (`from` = now, `to` = now + 1 day), so a bare "what can I rent out + * today?" needs no date at all. Pass explicit dates for a specific window; for a RELATIVE + * one ("this weekend") call `current_date` first to anchor it. + */ +export const listAvailableVehicles = { + name: 'list_available_vehicles', + description: + 'List the vehicles free to rent across a date window. Omit `from`/`to` for what is free ' + + 'right now (defaults to the next 24 hours). Pass explicit ISO-8601 dates (YYYY-MM-DD or a ' + + 'full timestamp) for a specific window; for a relative window like "this weekend" call ' + + 'current_date FIRST to anchor it — do not guess today. Excludes vehicles out of service ' + + 'and those already booked for any part of the window.', + inputSchema: { + type: 'object', + properties: { + from: { + type: 'string', + maxLength: 40, + description: 'Window start, ISO-8601. Optional; defaults to now.', + }, + to: { + type: 'string', + maxLength: 40, + description: 'Window end, ISO-8601. Optional; defaults to one day after `from`.', + }, + }, + }, + mode: 'read' as const, + handler: async (args: Record) => { + const now = DateTime.now() + const from = args.from !== undefined ? DateTime.fromISO(String(args.from)) : now + const to = args.to !== undefined ? DateTime.fromISO(String(args.to)) : from.plus({ days: 1 }) + if (!from.isValid || !to.isValid) { + return argError( + 'invalid_date', + 'Provide `from`/`to` as ISO-8601, e.g. 2026-07-20 — or omit them for right now.' + ) + } + if (to <= from) { + return argError('empty_window', '`to` must be after `from`.') + } + + const { default: Booking } = await import('#app/models/tenant_scoped/booking') + const { default: Vehicle } = await import('#app/models/tenant_scoped/vehicle') + + // Vehicles busy for any part of the window: half-open overlap. + const busy = await Booking.query() + .whereIn('status', ['confirmed', 'active']) + .where('pickup_at', '<', to.toSQL({ includeOffset: false })!) + .where('dropoff_at', '>', from.toSQL({ includeOffset: false })!) + .distinct('vehicle_id') + .pojo<{ vehicle_id: string }>() + const busyIds = busy.map((row) => row.vehicle_id) + + const query = Vehicle.query().where('status', 'available') + if (busyIds.length > 0) query.whereNotIn('id', busyIds) + const vehicles = await query.orderBy('make_name').limit(25) + + return { + window: { from: from.toISODate(), to: to.toISODate() }, + available: vehicles.length, + vehicles: vehicles.map((v) => ({ + plate: v.plate, + vehicle: `${v.makeName} ${v.modelName}`, + year: v.year, + transmission: v.transmission, + fuel: v.fuel, + })), + } + }, +} + +/** + * `revenue_summary({ period })` — booked revenue over a named period. + * + * Counts only bookings that became real rentals (active/completed), so a quote or a + * cancellation never inflates the figure. Periods are an enum rather than free dates: + * the model asks for a business period, the app owns what that means. + */ +export const revenueSummary = { + name: 'revenue_summary', + description: + 'Total booked revenue for a named period: month_to_date, last_month, or ' + + 'year_to_date. Counts only bookings that became real rentals (active or completed).', + inputSchema: { + type: 'object', + properties: { + period: { + type: 'string', + enum: ['month_to_date', 'last_month', 'year_to_date'], + description: 'The business period to total.', + }, + }, + required: ['period'], + }, + mode: 'read' as const, + handler: async (args: Record) => { + const period = args.period as 'month_to_date' | 'last_month' | 'year_to_date' + const now = DateTime.now() + const range = + period === 'last_month' + ? { start: now.minus({ months: 1 }).startOf('month'), end: now.startOf('month') } + : period === 'year_to_date' + ? { start: now.startOf('year'), end: null } + : { start: now.startOf('month'), end: null } + + const { default: Booking } = await import('#app/models/tenant_scoped/booking') + const query = Booking.query() + .whereIn('status', ['active', 'completed']) + .where('created_at', '>=', range.start.toSQL({ includeOffset: false })!) + if (range.end) { + query.where('created_at', '<', range.end.toSQL({ includeOffset: false })!) + } + const rows = await query.sum('total_amount as total').pojo<{ total: string | null }>() + const currencyRow = await Booking.query().select('currency').first() + + return { + period, + from: range.start.toISODate(), + amount: toMajorUnits(Number(rows[0]?.total ?? 0)), + currency: currencyRow?.currency ?? 'MAD', + } + }, +} + +/** + * `top_rented_vehicles({ limit? })` — the fleet ranked by real rentals. + * + * Resolves the ranked ids to human labels (make/model/plate are fleet assets, not + * PII). A historical booking may point at a since-removed vehicle, so a missing row + * falls back to its id rather than dropping the rank. + */ +export const topRentedVehicles = { + name: 'top_rented_vehicles', + description: + "Rank this company's vehicles by how many real rentals they have had, most first. " + + '`limit` defaults to 5 and is capped at 10.', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 10, + description: 'How many vehicles to return (1-10, default 5).', + }, + }, + }, + mode: 'read' as const, + handler: async (args: Record) => { + const limit = typeof args.limit === 'number' ? args.limit : 5 + const { default: Booking } = await import('#app/models/tenant_scoped/booking') + const { default: Vehicle } = await import('#app/models/tenant_scoped/vehicle') + + const ranked = await Booking.query() + .whereIn('status', RENTAL_STATUSES) + .select('vehicle_id') + .count('* as rentals') + .groupBy('vehicle_id') + .orderBy('rentals', 'desc') + .limit(limit) + .pojo<{ vehicle_id: string; rentals: number | string }>() + + const ids = ranked.map((r) => r.vehicle_id) + const byId = new Map( + ids.length ? (await Vehicle.query().whereIn('id', ids)).map((v) => [v.id, v]) : [] + ) + return { + ranked: ranked.map((r) => { + const v = byId.get(r.vehicle_id) + return { + vehicle: v ? `${v.makeName} ${v.modelName} (${v.plate})` : r.vehicle_id, + rentals: Number(r.rentals), + } + }), + } + }, +} + +/** The read-only tools offered to the fleet assistant. */ +export const fleetTools = [ + currentDate, + countBookings, + countVehicles, + listAvailableVehicles, + revenueSummary, + topRentedVehicles, +] + +/** Tools an agent may call. The owner may call everything. */ +const AGENT_TOOLS = new Set([ + 'current_date', + 'count_bookings', + 'count_vehicles', + 'list_available_vehicles', + 'top_rented_vehicles', +]) + +/** + * Resolve the staff member behind this request, whichever realm they came through. + * + * TenantGuardMiddleware has already run `authorizeTenantAccess` by the time a tool is + * called, and that gate authenticates one of the two guards: `tenant` for a bearer + * token, `web-tenant` for a pinned browser session. Both point at the same + * `TenantUser` model in the resolved company's schema, so either one's `.user` is + * this company's staff. Returns null when neither authenticated, which the caller + * treats as a deny. + */ +function resolveStaff(ctx: HttpContext): { role?: string; email?: string } | null { + try { + const auth = ctx.auth as unknown as { + use: (name: string) => { user?: { role?: string; email?: string } } + } + return auth?.use('web-tenant')?.user ?? auth?.use('tenant')?.user ?? null + } catch { + // A guard that was never initialised is not an authorization: fail closed. + return null + } +} + +/** + * `config.ai.tools.authorizeTool` — the per-tool gate (WS-AI-11). + * + * Wiring this is what keeps the company off `acknowledgeUnauthorizedTools`, the + * escape hatch that runs tools with no authorization at all. Membership is already + * proven upstream by the tenant guard, so this is not "is the caller staff of this + * company?" — it is "may THIS staff member run THIS tool?". + * + * Revenue is the owner's business: an agent runs the counter (bookings, fleet, + * availability) but is not shown the company's takings. Read tools are otherwise + * open to both roles. Anything unrecognised — an unknown role, no resolvable staff — + * denies, so the gate stays fail-closed as the satellite expects. + */ +export function authorizeFleetTool(ctx: HttpContext, _tenant: TenantModelContract, tool: string) { + const staff = resolveStaff(ctx) + if (!staff) return { kind: 'deny' as const } + if (staff.role === 'owner') return { kind: 'allow' as const } + if (staff.role === 'agent' && AGENT_TOOLS.has(tool)) return { kind: 'allow' as const } + return { kind: 'deny' as const } +} diff --git a/apps/rental/app/controllers/admin/tenants_controller.ts b/apps/rental/app/controllers/admin/tenants_controller.ts new file mode 100644 index 00000000..b9c8fd6b --- /dev/null +++ b/apps/rental/app/controllers/admin/tenants_controller.ts @@ -0,0 +1,77 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { inject } from '@adonisjs/core' +import TenantsService from '#app/services/tenants_service' +import { + createTenantValidator, + destroyTenantQueryValidator, +} from '#app/validators/tenants_validator' +import { currentTenant } from '#app/helpers/current_tenant' + +/** + * A thin operator-facing façade over the company lifecycle. It shares the + * package's jobs and lifecycle methods with `multitenancyAdminRoutes()` (mounted + * at `/admin` in the satellite phase) but exposes simpler shapes for the seed + * and the smoke tests. + */ +@inject() +export default class TenantsController { + constructor(private readonly tenants: TenantsService) {} + + async list({ response }: HttpContext) { + return response.ok({ tenants: await this.tenants.list() }) + } + + async show({ params, response }: HttpContext) { + const tenant = await this.tenants.show(params.id) + if (!tenant) return response.notFound({ error: { message: 'company not found' } }) + return response.ok({ tenant }) + } + + async create({ request, response }: HttpContext) { + const payload = await request.validateUsing(createTenantValidator) + const tenant = await this.tenants.create(payload) + return response.accepted({ + tenantId: tenant.id, + status: tenant.status, + customDomain: tenant.customDomain, + hint: 'Run `node ace queue:work` to materialise the schema', + }) + } + + async activate({ params, response }: HttpContext) { + const tenant = await this.tenants.activate(params.id) + return response.ok({ id: tenant.id, status: tenant.status }) + } + + async suspend({ params, response }: HttpContext) { + const tenant = await this.tenants.suspend(params.id) + return response.ok({ id: tenant.id, status: tenant.status }) + } + + /** + * `?keepSchema=true` soft-deletes (preserves the schema for the retention + * window). Default queues UninstallTenant, which drops it. + */ + async destroy({ params, request, response }: HttpContext) { + const { keepSchema } = await request.validateUsing(destroyTenantQueryValidator) + if (keepSchema) { + const tenant = await this.tenants.softDelete(params.id) + return response.ok({ + id: tenant.id, + softDeleted: true, + hint: 'Schema preserved — `tenant:purge-expired` drops it after retentionDays', + }) + } + const tenant = await this.tenants.destroy(params.id) + return response.accepted({ id: tenant.id, scheduledFor: 'tear-down' }) + } + + /** Schema-isolation probe: returns the resolved company's named connection. */ + async connection({ request, response }: HttpContext) { + const tenant = await currentTenant(request) + return response.ok({ + tenantId: tenant.id, + connectionName: tenant.getConnection().connectionName, + }) + } +} diff --git a/apps/rental/app/controllers/auth/backoffice_auth_controller.ts b/apps/rental/app/controllers/auth/backoffice_auth_controller.ts new file mode 100644 index 00000000..5b2491f6 --- /dev/null +++ b/apps/rental/app/controllers/auth/backoffice_auth_controller.ts @@ -0,0 +1,36 @@ +import type { HttpContext } from '@adonisjs/core/http' +import BackofficeUser from '#app/models/backoffice/backoffice_user' +import { loginValidator } from '#app/validators/auth_validator' + +/** + * The operator realm's login surface. Mounted under `router.central()`: + * operators authenticate on the apex, never inside a company context. The + * minted `bko_` token is what the admin API, `/metrics` and the reporting + * dashboard expect as a bearer. + */ +export default class BackofficeAuthController { + async login({ request, response }: HttpContext) { + const { email, password } = await request.validateUsing(loginValidator) + const user = await BackofficeUser.verifyCredentials(email, password) + const token = await BackofficeUser.accessTokens.create(user) + if (!token.value) { + throw new Error('unreachable: accessTokens.create() always returns a token value') + } + return response.ok({ + type: 'bearer', + token: token.value.release(), + expiresAt: token.expiresAt, + }) + } + + async me({ auth, response }: HttpContext) { + const user = auth.use('backoffice').getUserOrFail() + return response.ok({ id: user.id, email: user.email, fullName: user.fullName }) + } + + async logout({ auth, response }: HttpContext) { + const user = auth.use('backoffice').getUserOrFail() + await BackofficeUser.accessTokens.delete(user, user.currentAccessToken.identifier) + return response.ok({ revoked: true }) + } +} diff --git a/apps/rental/app/controllers/auth/tenant_auth_controller.ts b/apps/rental/app/controllers/auth/tenant_auth_controller.ts new file mode 100644 index 00000000..796349a5 --- /dev/null +++ b/apps/rental/app/controllers/auth/tenant_auth_controller.ts @@ -0,0 +1,44 @@ +import type { HttpContext } from '@adonisjs/core/http' +import TenantUser from '#app/models/tenant_scoped/tenant_user' +import { loginValidator } from '#app/validators/auth_validator' + +/** + * The tenant realm's login surface. These routes live inside the tenant-guarded + * group, so the company is already resolved (from `.localhost` or the + * `x-tenant-id` header) when `verifyCredentials` runs and the lookup hits + * `tenant_.users`. The minted `tnt_` token is stored in that company's own + * `auth_access_tokens`, so it is worthless against any other company. + */ +export default class TenantAuthController { + async login({ request, response }: HttpContext) { + const { email, password } = await request.validateUsing(loginValidator) + const user = await TenantUser.verifyCredentials(email, password) + const token = await TenantUser.accessTokens.create(user) + if (!token.value) { + throw new Error('unreachable: accessTokens.create() always returns a token value') + } + return response.ok({ + type: 'bearer', + token: token.value.release(), + expiresAt: token.expiresAt, + }) + } + + async me({ auth, request, response }: HttpContext) { + const user = auth.use('tenant').getUserOrFail() + const tenant = await request.tenant() + return response.ok({ + id: user.id, + email: user.email, + fullName: user.fullName, + role: user.role, + tenantId: tenant.id, + }) + } + + async logout({ auth, response }: HttpContext) { + const user = auth.use('tenant').getUserOrFail() + await TenantUser.accessTokens.delete(user, user.currentAccessToken.identifier) + return response.ok({ revoked: true }) + } +} diff --git a/apps/rental/app/controllers/console/console_auth_controller.ts b/apps/rental/app/controllers/console/console_auth_controller.ts new file mode 100644 index 00000000..0f5ab322 --- /dev/null +++ b/apps/rental/app/controllers/console/console_auth_controller.ts @@ -0,0 +1,75 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { errors as authErrors } from '@adonisjs/auth' +import BackofficeUser from '#app/models/backoffice/backoffice_user' +import TenantUser from '#app/models/tenant_scoped/tenant_user' +import { loginValidator } from '#app/validators/auth_validator' +import { isAuthorizedStaff, WEB_TENANT_COMPANY_KEY } from '#app/security/session_realm' + +/** + * Session login for both browser consoles. The routes are `universal()`, so one + * controller serves both realms and the resolved host decides which: + * + * - apex `localhost` → no tenant → operator realm (`web-backoffice`) + * - `.localhost` → a tenant → company realm (`web-tenant`) + * + * `verifyCredentials` for the tenant realm hits the resolved company's own + * schema (through the tenant adapter), so an operator can never log into a + * company console and vice-versa — the credential lookup lives in a different + * schema entirely. + */ +export default class ConsoleAuthController { + async show(ctx: HttpContext) { + const tenant = await this.#tenantOrNull(ctx) + if (tenant) { + if (await isAuthorizedStaff(ctx, tenant)) return ctx.response.redirect('/') + // `company` (incl. its name) rides in shared props (see InertiaMiddleware). + return ctx.inertia.render('tenant/login', {}) + } + if (await ctx.auth.use('web-backoffice').check()) return ctx.response.redirect('/') + return ctx.inertia.render('operator/login', {}) + } + + async store(ctx: HttpContext) { + const { email, password } = await ctx.request.validateUsing(loginValidator) + const tenant = await this.#tenantOrNull(ctx) + + try { + if (tenant) { + const user = await TenantUser.verifyCredentials(email, password) + await ctx.auth.use('web-tenant').login(user) + // Pin the session to the company it was issued for (see session_realm). + ctx.session.put(WEB_TENANT_COMPANY_KEY, tenant.id) + } else { + const user = await BackofficeUser.verifyCredentials(email, password) + await ctx.auth.use('web-backoffice').login(user) + } + } catch (error) { + if (error instanceof authErrors.E_INVALID_CREDENTIALS) { + ctx.session.flash('error', 'Those credentials do not match our records.') + return ctx.response.redirect().back() + } + throw error + } + + return ctx.response.redirect('/') + } + + async destroy(ctx: HttpContext) { + const tenant = await this.#tenantOrNull(ctx) + await ctx.auth.use(tenant ? 'web-tenant' : 'web-backoffice').logout() + return ctx.response.redirect('/login') + } + + /** + * The resolved company on this request, or null on the apex. `request.tenant()` + * returns the value UniversalMiddleware already memoized on a company host, and + * throws (no DB hit) on the apex — which we read as "operator realm". + */ + async #tenantOrNull(ctx: HttpContext) { + try { + return await ctx.request.tenant() + } catch { + return null + } + } +} diff --git a/apps/rental/app/controllers/console/console_home_controller.ts b/apps/rental/app/controllers/console/console_home_controller.ts new file mode 100644 index 00000000..62c78202 --- /dev/null +++ b/apps/rental/app/controllers/console/console_home_controller.ts @@ -0,0 +1,37 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { isAuthorizedStaff } from '#app/security/session_realm' + +/** + * The console home (`GET /`). Like the auth controller it is host-aware: the + * apex renders the operator dashboard, a company host renders that company's + * staff dashboard. Anonymous visitors are bounced to `/login` (the same + * universal login, which itself renders the right realm). + * + * The pages are thin shells: they hydrate their data client-side from the REST + * surfaces that already exist and enforce their own auth — the operator console + * calls the admin satellite under `/admin`, the company console calls the + * tenant-guarded domain API. This keeps the browser consoles a 1:1 view over the + * same endpoints the programmatic API and e2e suite drive. + */ +export default class ConsoleHomeController { + async index(ctx: HttpContext) { + const tenant = await this.#tenantOrNull(ctx) + + if (tenant) { + if (!(await isAuthorizedStaff(ctx, tenant))) return ctx.response.redirect('/login') + // `company` rides in shared props (see InertiaMiddleware); nothing to pass. + return ctx.inertia.render('tenant/dashboard', {}) + } + + if (!(await ctx.auth.use('web-backoffice').check())) return ctx.response.redirect('/login') + return ctx.inertia.render('operator/dashboard', {}) + } + + async #tenantOrNull(ctx: HttpContext) { + try { + return await ctx.request.tenant() + } catch { + return null + } + } +} diff --git a/apps/rental/app/controllers/console/pages_controller.ts b/apps/rental/app/controllers/console/pages_controller.ts new file mode 100644 index 00000000..43800fa2 --- /dev/null +++ b/apps/rental/app/controllers/console/pages_controller.ts @@ -0,0 +1,53 @@ +import type { HttpContext } from '@adonisjs/core/http' + +/** + * Thin Inertia shells for the console pages beyond the home dashboard. Each just + * names its React page; the data is hydrated client-side from the REST surfaces + * (the tenant domain API for company pages, the admin satellite for the operator + * company view). Auth + realm are enforced by the `webAuth` route middleware, + * and `company` rides in shared props — so these methods carry no logic. + */ +export default class PagesController { + /* ─── Company staff pages (realm: tenant) ──────────────────────────── */ + async fleet({ inertia }: HttpContext) { + return inertia.render('tenant/fleet', {}) + } + async customers({ inertia }: HttpContext) { + return inertia.render('tenant/customers', {}) + } + async bookings({ inertia }: HttpContext) { + return inertia.render('tenant/bookings', {}) + } + async billing({ inertia }: HttpContext) { + return inertia.render('tenant/billing', {}) + } + async assistant({ inertia }: HttpContext) { + return inertia.render('tenant/assistant', {}) + } + async knowledge({ inertia }: HttpContext) { + return inertia.render('tenant/knowledge', {}) + } + + // Company self-service: branding, feature flags and SSO scoped to the caller's + // own company. The data is hydrated from the tenant-scoped `/settings/*` routes. + async settings({ inertia }: HttpContext) { + return inertia.render('tenant/settings', {}) + } + + /* ─── Operator pages (realm: operator) ─────────────────────────────── */ + // The per-company control panel (satellite tabs). The id addresses the tenant + // for the admin satellite's `/admin/tenants/:id/*` endpoints. + async company({ inertia, params }: HttpContext) { + return inertia.render('operator/company', { tenantId: params.id }) + } + + // Cross-tenant reporting dashboard (reporting satellite under /admin/reporting). + async reporting({ inertia }: HttpContext) { + return inertia.render('operator/reporting', {}) + } + + // Platform health + per-company doctor + queue depth (admin satellite /admin). + async health({ inertia }: HttpContext) { + return inertia.render('operator/health', {}) + } +} diff --git a/apps/rental/app/controllers/tenant/billing_controller.ts b/apps/rental/app/controllers/tenant/billing_controller.ts new file mode 100644 index 00000000..e5425e69 --- /dev/null +++ b/apps/rental/app/controllers/tenant/billing_controller.ts @@ -0,0 +1,57 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { inject } from '@adonisjs/core' +import { BillingService, BillingCustomer } from '@adonisjs-lasagna/billing' +import { currentTenant } from '#app/helpers/current_tenant' + +/** + * The company's SaaS subscription surface (the company paying Karimoto, NOT the + * renter paying the company). The client sends a PLAN name; the server resolves + * it to a price id from this allowlist, so a caller can never pass a raw price. + * Offline in dev via the injected MockStripe; a real Stripe key makes checkout + * return a live URL with no code change. + */ +const PRICE_BY_PLAN: Record = { + starter: 'price_starter_monthly', + fleet: 'price_fleet_monthly', + enterprise: 'price_enterprise_monthly', +} + +@inject() +export default class BillingController { + constructor(private readonly billing: BillingService) {} + + async show({ request, response }: HttpContext) { + const tenant = await currentTenant(request) + const customer = await BillingCustomer.find(tenant.id) + return response.ok({ + plan: tenant.metadata.plan, + hasCustomer: customer !== null, + providerCustomerId: customer?.providerCustomerId ?? null, + }) + } + + async checkout({ request, response }: HttpContext) { + const tenant = await currentTenant(request) + const plan = String(request.input('plan') ?? '') + const priceId = PRICE_BY_PLAN[plan] + if (!priceId) { + return response.badRequest({ + error: { code: 'unknown_plan', message: `Unknown plan "${plan}".` }, + }) + } + const session = await this.billing.createCheckoutSession(tenant, { + priceId, + successUrl: `http://${tenant.customDomain ?? 'localhost'}:3333/subscription?checkout=success`, + cancelUrl: `http://${tenant.customDomain ?? 'localhost'}:3333/subscription?checkout=cancel`, + }) + return response.ok({ url: session.url, id: session.id }) + } + + async portal({ request, response }: HttpContext) { + const tenant = await currentTenant(request) + const session = await this.billing.createBillingPortalSession(tenant, { + returnUrl: `http://${tenant.customDomain ?? 'localhost'}:3333/subscription`, + }) + return response.ok({ url: session.url }) + } +} diff --git a/apps/rental/app/controllers/tenant/bookings_controller.ts b/apps/rental/app/controllers/tenant/bookings_controller.ts new file mode 100644 index 00000000..0c9a8134 --- /dev/null +++ b/apps/rental/app/controllers/tenant/bookings_controller.ts @@ -0,0 +1,104 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { inject } from '@adonisjs/core' +import { DateTime } from 'luxon' +import Booking from '#app/models/tenant_scoped/booking' +import BookingService, { BookingError } from '#app/services/booking_service' +import InvoicingService from '#app/services/invoicing_service' +import { createBookingValidator } from '#app/validators/booking_validator' + +/** + * The booking lifecycle surface. Creation runs the overlap check + pricing in + * BookingService; the transition endpoints drive quote → confirmed → active → + * completed and flip the vehicle's status as a side effect. In the satellite + * phase, `create` also emits `TenantDataChanged` (live board) and fires the + * `booking.created` webhook, and gains an `enforceQuota('bookingsPerMonth')` + * gate on its route. + */ +@inject() +export default class BookingsController { + constructor( + private readonly bookings: BookingService, + private readonly invoicing: InvoicingService + ) {} + + async list({ response }: HttpContext) { + const rows = await Booking.query() + .orderBy('created_at', 'desc') + .preload('customer') + .preload('vehicle') + return response.ok({ bookings: rows }) + } + + async show({ params, response }: HttpContext) { + const booking = await Booking.query() + .where('id', params.id) + .preload('customer') + .preload('vehicle') + .first() + if (!booking) return response.notFound({ error: { code: 'not_found' } }) + return response.ok({ booking }) + } + + async create({ request, response }: HttpContext) { + const payload = await request.validateUsing(createBookingValidator) + const pickupAt = DateTime.fromISO(payload.pickupAt) + const dropoffAt = DateTime.fromISO(payload.dropoffAt) + if (!pickupAt.isValid || !dropoffAt.isValid) { + return response.badRequest({ + error: { code: 'invalid_dates', message: 'pickupAt/dropoffAt must be ISO 8601.' }, + }) + } + try { + const booking = await this.bookings.create({ + customerId: payload.customerId, + vehicleId: payload.vehicleId, + pickupAt, + dropoffAt, + pickupLocationId: payload.pickupLocationId ?? null, + dropoffLocationId: payload.dropoffLocationId ?? null, + extras: payload.extras ?? [], + confirm: payload.confirm ?? false, + }) + return response.created({ booking }) + } catch (error) { + if (error instanceof BookingError) { + return response.unprocessableEntity({ error: { code: error.code, message: error.message } }) + } + throw error + } + } + + async confirm(ctx: HttpContext) { + return this.transition(ctx, (id) => this.bookings.confirm(id)) + } + async activate(ctx: HttpContext) { + return this.transition(ctx, (id) => this.bookings.activate(id)) + } + async complete(ctx: HttpContext) { + return this.transition(ctx, (id) => this.bookings.complete(id)) + } + async cancel(ctx: HttpContext) { + return this.transition(ctx, (id) => this.bookings.cancel(id)) + } + + /** Issue (or return the existing) VAT invoice for a booking. */ + async invoice({ params, response }: HttpContext) { + const invoice = await this.invoicing.generateForBooking(params.id) + return response.ok({ invoice }) + } + + private async transition( + { params, response }: HttpContext, + run: (id: string) => Promise + ) { + try { + const booking = await run(params.id) + return response.ok({ id: booking.id, status: booking.status }) + } catch (error) { + if (error instanceof BookingError) { + return response.unprocessableEntity({ error: { code: error.code, message: error.message } }) + } + throw error + } + } +} diff --git a/apps/rental/app/controllers/tenant/customers_controller.ts b/apps/rental/app/controllers/tenant/customers_controller.ts new file mode 100644 index 00000000..99c26ae6 --- /dev/null +++ b/apps/rental/app/controllers/tenant/customers_controller.ts @@ -0,0 +1,63 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { inject } from '@adonisjs/core' +import { CryptoException } from '@adonisjs-lasagna/crypto' +import CustomerService from '#app/services/customer_service' +import { + createCustomerValidator, + searchCustomerValidator, +} from '#app/validators/customer_validator' + +/** + * Renter management over crypto-protected PII. Reading a shredded renter fails + * closed (410 Gone) rather than surfacing inert ciphertext; the "exercise + * erasure right" button calls `shred`. Refused shreds (governance said the + * category is not erasable) come back as 403. + */ +@inject() +export default class CustomersController { + constructor(private readonly customers: CustomerService) {} + + async list({ response }: HttpContext) { + return response.ok({ customers: await this.customers.list() }) + } + + async create({ request, response }: HttpContext) { + const payload = await request.validateUsing(createCustomerValidator) + const customer = await this.customers.create(payload) + return response.created({ customer }) + } + + async show({ params, response }: HttpContext) { + try { + const customer = await this.customers.find(params.id) + if (!customer) return response.notFound({ error: { code: 'not_found' } }) + return response.ok({ customer }) + } catch (error) { + if (error instanceof CryptoException) { + return response.status(410).send({ error: { code: 'unrecoverable', detail: error.code } }) + } + throw error + } + } + + async search({ request, response }: HttpContext) { + const { cin } = await request.validateUsing(searchCustomerValidator) + const matches = await this.customers.searchByCin(cin) + return response.ok({ matches }) + } + + /** Exercise a renter's erasure right (Law 09-08 Art. equivalent): crypto-shred. */ + async shred({ params, response }: HttpContext) { + try { + const result = await this.customers.shred(params.id) + return response.ok(result) + } catch (error) { + if (error instanceof CryptoException && error.code === 'shred_refused') { + return response + .status(403) + .send({ error: { code: 'shred_refused', message: error.message } }) + } + throw error + } + } +} diff --git a/apps/rental/app/controllers/tenant/fleet_controller.ts b/apps/rental/app/controllers/tenant/fleet_controller.ts new file mode 100644 index 00000000..a683c391 --- /dev/null +++ b/apps/rental/app/controllers/tenant/fleet_controller.ts @@ -0,0 +1,148 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { inject } from '@adonisjs/core' +import { randomUUID } from 'node:crypto' +import { DateTime } from 'luxon' +import RentalLocation from '#app/models/tenant_scoped/rental_location' +import VehicleCategory from '#app/models/tenant_scoped/vehicle_category' +import Vehicle from '#app/models/tenant_scoped/vehicle' +import CarMake from '#app/models/central/car_make' +import CarModel from '#app/models/central/car_model' +import FleetService from '#app/services/fleet_service' +import { currentTenant } from '#app/helpers/current_tenant' +import { + createLocationValidator, + createCategoryValidator, + createVehicleValidator, + vehicleStatusValidator, +} from '#app/validators/fleet_validator' + +/** + * The company's fleet surface: branches, categories and vehicles. Vehicle + * listing reads through the `_read` replica connection; the make/model come + * from the shared central catalog. Vehicle creation resolves the catalog names + * so a listing renders without crossing the connection. + */ +@inject() +export default class FleetController { + constructor(private readonly fleet: FleetService) {} + + /** The shared central catalog (make → models) a company picks vehicles from. */ + async catalog({ response }: HttpContext) { + const makes = await CarMake.query().preload('models').orderBy('name') + return response.ok({ + makes: makes.map((m) => ({ + id: m.id, + name: m.name, + models: m.models.map((mo) => ({ id: mo.id, name: mo.name, bodyType: mo.bodyType })), + })), + }) + } + + // ─── Locations ─────────────────────────────────────────────────── + async listLocations({ response }: HttpContext) { + return response.ok({ locations: await RentalLocation.query().orderBy('name') }) + } + + async createLocation({ request, response }: HttpContext) { + const payload = await request.validateUsing(createLocationValidator) + const location = await RentalLocation.create({ + id: randomUUID(), + name: payload.name, + type: payload.type ?? 'city', + address: payload.address ?? null, + city: payload.city, + timezone: payload.timezone ?? 'Africa/Casablanca', + phone: payload.phone ?? null, + openHour: payload.openHour ?? 8, + closeHour: payload.closeHour ?? 20, + }) + return response.created({ location }) + } + + // ─── Categories ────────────────────────────────────────────────── + async listCategories({ response }: HttpContext) { + return response.ok({ categories: await VehicleCategory.query().orderBy('daily_rate') }) + } + + async createCategory({ request, response }: HttpContext) { + const payload = await request.validateUsing(createCategoryValidator) + const category = await VehicleCategory.create({ + id: randomUUID(), + ...payload, + extras: payload.extras ?? [], + }) + return response.created({ category }) + } + + // ─── Vehicles ──────────────────────────────────────────────────── + async listVehicles({ request, response }: HttpContext) { + const tenant = await currentTenant(request) + // Route the listing read to the `_read` replica connection to exercise + // replica routing (falls back to the primary when none is configured). + const read = await tenant.getReadConnection() + const rows = await read.from('vehicles').select('*').orderBy('plate') + return response.ok({ vehicles: rows }) + } + + async createVehicle({ request, response }: HttpContext) { + const payload = await request.validateUsing(createVehicleValidator) + const model = await CarModel.query().where('id', payload.modelId).preload('make').first() + if (!model || model.makeId !== payload.makeId) { + return response.unprocessableEntity({ + error: { code: 'catalog_mismatch', message: 'Unknown make/model.' }, + }) + } + const vehicle = await Vehicle.create({ + id: randomUUID(), + plate: payload.plate, + makeId: payload.makeId, + modelId: payload.modelId, + makeName: model.make.name, + modelName: model.name, + year: payload.year, + categoryId: payload.categoryId, + locationId: payload.locationId ?? null, + status: 'available', + mileage: payload.mileage ?? 0, + fuel: payload.fuel ?? 'petrol', + transmission: payload.transmission ?? 'manual', + color: payload.color ?? null, + }) + return response.created({ vehicle }) + } + + async showVehicle({ params, response }: HttpContext) { + const vehicle = await Vehicle.query() + .where('id', params.id) + .preload('category') + .preload('location') + .first() + if (!vehicle) return response.notFound({ error: { code: 'not_found' } }) + return response.ok({ vehicle }) + } + + async setVehicleStatus({ params, request, response }: HttpContext) { + const { status } = await request.validateUsing(vehicleStatusValidator) + const vehicle = await this.fleet.setStatus(params.id, status) + return response.ok({ id: vehicle.id, status: vehicle.status }) + } + + async availability({ request, response }: HttpContext) { + const pickup = DateTime.fromISO(String(request.qs().pickupAt ?? '')) + const dropoff = DateTime.fromISO(String(request.qs().dropoffAt ?? '')) + if (!pickup.isValid || !dropoff.isValid) { + return response.badRequest({ + error: { code: 'invalid_dates', message: 'pickupAt/dropoffAt must be ISO 8601.' }, + }) + } + const vehicles = await this.fleet.availableVehicles(pickup, dropoff) + return response.ok({ + available: vehicles.map((v) => ({ + id: v.id, + plate: v.plate, + makeName: v.makeName, + modelName: v.modelName, + })), + }) + } +} diff --git a/apps/rental/app/controllers/tenant/fleet_docs_controller.ts b/apps/rental/app/controllers/tenant/fleet_docs_controller.ts new file mode 100644 index 00000000..93d40dd0 --- /dev/null +++ b/apps/rental/app/controllers/tenant/fleet_docs_controller.ts @@ -0,0 +1,30 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { randomUUID } from 'node:crypto' +import FleetDoc from '#app/models/tenant_scoped/fleet_doc' + +/** + * Policy/FAQ documents that feed the fleet assistant. Creating one stores the + * domain row; the body is ingested into the per-tenant vector store separately + * through `POST /ai/embed` (source = the doc's `source` key), so the AI + * satellite owns the embedding lifecycle. + */ +export default class FleetDocsController { + async list({ response }: HttpContext) { + return response.ok({ docs: await FleetDoc.query().orderBy('created_at', 'desc') }) + } + + async create({ request, response }: HttpContext) { + const title = String(request.input('title') ?? '').slice(0, 200) + const body = String(request.input('body') ?? '').slice(0, 8000) + const source = String(request.input('source') ?? `doc-${randomUUID().slice(0, 8)}`) + const existing = await FleetDoc.query().where('source', source).first() + if (existing) { + existing.title = title + existing.body = body + await existing.save() + return response.ok({ doc: existing }) + } + const doc = await FleetDoc.create({ id: randomUUID(), title, body, source }) + return response.created({ doc }) + } +} diff --git a/apps/rental/app/controllers/tenant/settings_controller.ts b/apps/rental/app/controllers/tenant/settings_controller.ts new file mode 100644 index 00000000..febaa137 --- /dev/null +++ b/apps/rental/app/controllers/tenant/settings_controller.ts @@ -0,0 +1,208 @@ +import app from '@adonisjs/core/services/app' +import type { HttpContext } from '@adonisjs/core/http' +import { BrandingService, FeatureFlagService } from '@adonisjs-lasagna/saas-tenancy/services' +import { currentTenant } from '#app/helpers/current_tenant' + +/** + * Company self-service settings: branding, feature flags and SSO, each scoped to + * the CALLER'S OWN company. It mirrors the admin satellite's per-tenant + * controllers but resolves the tenant from the request context (never a `:id` + * path param), so a company manages only itself — the tenant guard + membership + * gate already proved it belongs here. Lives under the tenant-guarded route group. + * + * The same underlying core services back both surfaces (BrandingService, + * FeatureFlagService, and the optional SsoService peer), so the operator console + * and the company console never drift. + */ + +/** The flags a company may flip itself. Everything else stays operator-only. */ +const SELF_SERVICE_FLAGS = ['online_checkin', 'dynamic_pricing', 'ai_assistant'] as const + +type SsoModule = typeof import('@adonisjs-lasagna/sso') + +export default class SettingsController { + /* ─── Branding ─────────────────────────────────────────────────────── */ + async brandingShow({ request, response }: HttpContext) { + const tenant = await currentTenant(request) + const svc = await app.container.make(BrandingService) + return response.ok({ data: await svc.getForTenant(tenant.id) }) + } + + async brandingUpdate({ request, response }: HttpContext) { + const tenant = await currentTenant(request) + + const fromName = request.input('fromName') + const fromEmail = request.input('fromEmail') + const logoUrl = request.input('logoUrl') + const primaryColor = request.input('primaryColor') + const supportUrl = request.input('supportUrl') + + if (isPresent(fromEmail) && !String(fromEmail).includes('@')) { + return response.badRequest({ error: 'invalid_fromEmail' }) + } + if (isPresent(logoUrl) && !looksLikeUrl(logoUrl)) { + return response.badRequest({ error: 'invalid_logoUrl' }) + } + if (isPresent(supportUrl) && !looksLikeUrl(supportUrl)) { + return response.badRequest({ error: 'invalid_supportUrl' }) + } + if (isPresent(primaryColor) && !/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(String(primaryColor))) { + return response.badRequest({ error: 'invalid_primaryColor' }) + } + + const svc = await app.container.make(BrandingService) + // An empty field clears the value (null); an absent field is treated the same + // way here since the form submits every field. + const branding = await svc.upsert(tenant.id, { + fromName: emptyToNull(fromName), + fromEmail: emptyToNull(fromEmail), + logoUrl: emptyToNull(logoUrl), + primaryColor: emptyToNull(primaryColor), + supportUrl: emptyToNull(supportUrl), + }) + return response.ok({ data: branding }) + } + + /* ─── Feature flags ────────────────────────────────────────────────── */ + async flagsList({ request, response }: HttpContext) { + const tenant = await currentTenant(request) + const svc = await app.container.make(FeatureFlagService) + return response.ok({ + data: await svc.listForTenant(tenant.id), + selfServiceable: SELF_SERVICE_FLAGS, + }) + } + + async flagSet({ request, response, params }: HttpContext) { + const tenant = await currentTenant(request) + const flag = String(params.flag) + if (!SELF_SERVICE_FLAGS.includes(flag as (typeof SELF_SERVICE_FLAGS)[number])) { + return response.forbidden({ error: 'flag_not_self_serviceable' }) + } + const enabled = request.input('enabled') + if (typeof enabled !== 'boolean') { + return response.badRequest({ error: 'enabled_must_be_boolean' }) + } + const svc = await app.container.make(FeatureFlagService) + return response.ok({ data: await svc.set(tenant.id, flag, enabled) }) + } + + /* ─── SSO (optional @adonisjs-lasagna/sso peer) ────────────────────── */ + async ssoShow({ request, response }: HttpContext) { + const tenant = await currentTenant(request) + const sso = await loadSso() + if (!sso) return ssoNotInstalled(response) + // Query the model directly (not SsoService.getConfig, which hides disabled + // configs) so a company can still see + re-enable one it turned off. + const config = await sso.TenantSsoConfig.query().where('tenant_id', tenant.id).first() + return response.ok({ data: serializeSso(config) }) + } + + async ssoUpdate({ request, response }: HttpContext) { + const tenant = await currentTenant(request) + const sso = await loadSso() + if (!sso) return ssoNotInstalled(response) + + const clientId = request.input('clientId') + const clientSecret = request.input('clientSecret') + const issuerUrl = request.input('issuerUrl') + const redirectUri = request.input('redirectUri') + const scopes = request.input('scopes') + + if (!isPresent(clientId)) return response.badRequest({ error: 'clientId_required' }) + if (!isPresent(clientSecret)) return response.badRequest({ error: 'clientSecret_required' }) + if (!isHttpsUrl(redirectUri)) return response.badRequest({ error: 'redirectUri_invalid' }) + if (scopes !== undefined && (!Array.isArray(scopes) || !scopes.every(isPresent))) { + return response.badRequest({ error: 'scopes_must_be_string_array' }) + } + + const svc = await app.container.make(sso.SsoService) + try { + // upsertConfig fetches issuerUrl server-side (OIDC discovery + JWKS), so it + // SSRF-guards the URL and rejects a private/metadata/non-https host. It also + // AES-encrypts the clientSecret at rest. + const config = await svc.upsertConfig(tenant.id, { + clientId, + clientSecret, + issuerUrl, + redirectUri, + ...(Array.isArray(scopes) ? { scopes } : {}), + }) + return response.ok({ data: serializeSso(config) }) + } catch (error) { + return response.badRequest({ + error: 'sso_config_rejected', + message: (error as Error).message, + }) + } + } + + async ssoDisable({ request, response }: HttpContext) { + const tenant = await currentTenant(request) + const sso = await loadSso() + if (!sso) return ssoNotInstalled(response) + const config = await sso.TenantSsoConfig.query().where('tenant_id', tenant.id).first() + if (!config) return response.notFound({ error: 'sso_config_not_found' }) + if (!config.enabled) return response.ok({ data: serializeSso(config), unchanged: true }) + config.enabled = false + await config.save() + return response.ok({ data: serializeSso(config) }) + } +} + +/* ─── helpers ──────────────────────────────────────────────────────────── */ + +function isPresent(value: unknown): boolean { + return typeof value === 'string' && value.trim().length > 0 +} + +function emptyToNull(value: unknown): string | null { + return isPresent(value) ? String(value).trim() : null +} + +function looksLikeUrl(value: unknown): boolean { + try { + const u = new URL(String(value)) + return u.protocol === 'http:' || u.protocol === 'https:' + } catch { + return false + } +} + +function isHttpsUrl(value: unknown): boolean { + try { + return new URL(String(value)).protocol === 'https:' + } catch { + return false + } +} + +async function loadSso(): Promise { + try { + return await import('@adonisjs-lasagna/sso') + } catch { + return null + } +} + +function ssoNotInstalled(response: HttpContext['response']) { + return response.status(501).send({ error: 'sso_not_installed' }) +} + +/** Never serialize the encrypted clientSecret; expose only whether one is set. */ +function serializeSso(c: InstanceType | null) { + if (!c) return null + return { + id: c.id, + tenantId: c.tenantId, + provider: c.provider, + clientId: c.clientId, + issuerUrl: c.issuerUrl, + redirectUri: c.redirectUri, + scopes: c.scopes, + enabled: c.enabled, + hasClientSecret: !!c.clientSecret, + createdAt: c.createdAt?.toISO?.() ?? null, + updatedAt: c.updatedAt?.toISO?.() ?? null, + } +} diff --git a/apps/rental/app/exceptions/handler.ts b/apps/rental/app/exceptions/handler.ts new file mode 100644 index 00000000..b8215034 --- /dev/null +++ b/apps/rental/app/exceptions/handler.ts @@ -0,0 +1,74 @@ +import app from '@adonisjs/core/services/app' +import { ExceptionHandler, type HttpContext } from '@adonisjs/core/http' +import { + MissingTenantHeaderException, + TenantNotFoundException, + TenantSuspendedException, + TenantAccessForbiddenException, + TenantNotReadyException, + CircuitOpenException, + QuotaExceededException, +} from '@adonisjs-lasagna/saas-tenancy/exceptions' + +/** + * Maps every typed exception the package can raise to a friendly JSON response. + * The `{ error: { code, message, details? } }` shape is consistent across the + * whole API surface. Inertia responses render through the framework's own + * handler (this only shapes the JSON/API and typed-503 paths). + */ +export default class HttpExceptionHandler extends ExceptionHandler { + protected debug = !app.inProduction + + async handle(error: unknown, ctx: HttpContext) { + if (error instanceof MissingTenantHeaderException) { + return ctx.response.status(400).send({ + error: { code: 'MISSING_TENANT_HEADER', message: 'No tenant identifier in request' }, + }) + } + if (error instanceof TenantNotFoundException) { + return ctx.response.status(404).send({ + error: { code: 'TENANT_NOT_FOUND', message: 'Company does not exist' }, + }) + } + if (error instanceof TenantSuspendedException) { + return ctx.response.status(403).send({ + error: { code: 'TENANT_SUSPENDED', message: 'Company is suspended' }, + }) + } + if (error instanceof TenantAccessForbiddenException) { + return ctx.response.status(403).send({ + error: { code: 'TENANT_ACCESS_FORBIDDEN', message: 'Not authorized for this company' }, + }) + } + if (error instanceof TenantNotReadyException) { + return ctx.response.status(503).send({ + error: { code: 'TENANT_NOT_READY', message: 'Company is still provisioning' }, + }) + } + if (error instanceof CircuitOpenException) { + return ctx.response.status(503).send({ + error: { code: 'CIRCUIT_OPEN', message: 'Company circuit breaker is open — try later' }, + }) + } + if (error instanceof QuotaExceededException) { + ctx.response.header('Retry-After', '60') + return ctx.response.status(429).send({ + error: { + code: 'QUOTA_EXCEEDED', + message: error.message, + details: { + quota: error.quota, + limit: error.limit, + current: error.current, + attempted: error.attempted, + }, + }, + }) + } + return super.handle(error, ctx) + } + + async report(error: unknown, ctx: HttpContext) { + return super.report(error, ctx) + } +} diff --git a/apps/rental/app/helpers/current_tenant.ts b/apps/rental/app/helpers/current_tenant.ts new file mode 100644 index 00000000..1f91ef30 --- /dev/null +++ b/apps/rental/app/helpers/current_tenant.ts @@ -0,0 +1,13 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type Tenant from '#app/models/backoffice/tenant' + +/** + * Narrow `request.tenant()` to this app's concrete Tenant model, in exactly one + * place. The repository bound to TENANT_REPOSITORY only ever returns this model, + * so the narrowing is sound. Call it from tenant-guarded routes only: + * `request.tenant()` itself throws when no tenant is resolved, so an unresolved + * tenant fails fast rather than surfacing later as an undefined-method crash. + */ +export async function currentTenant(request: HttpContext['request']): Promise { + return (await request.tenant()) as Tenant +} diff --git a/apps/rental/app/helpers/rental_credentials.ts b/apps/rental/app/helpers/rental_credentials.ts new file mode 100644 index 00000000..3f83fb8e --- /dev/null +++ b/apps/rental/app/helpers/rental_credentials.ts @@ -0,0 +1,19 @@ +/** + * Single source for the demo's well-known credentials. `rental:seed`, the + * afterMigrate seeding hook and the e2e helpers all import from here, so the + * values cannot drift. Deliberately not read from env: `rental:seed` refuses to + * run in production and per-tenant seeding is off unless DEMO_SEED_TENANT_USERS + * is set, so there is no secret to externalize. + */ +export const DEMO_OPERATOR = { + email: 'operator@karimoto.test', + password: 'operator-demo-password', + fullName: 'Karimoto Operator', +} as const + +/** Owner staff account seeded into each demo company's schema. */ +export const DEMO_TENANT_OWNER = { + email: 'owner@karimoto.test', + password: 'owner-demo-password', + fullName: 'Company Owner', +} as const diff --git a/apps/rental/app/listeners/booking_board_listener.ts b/apps/rental/app/listeners/booking_board_listener.ts new file mode 100644 index 00000000..07b8e88a --- /dev/null +++ b/apps/rental/app/listeners/booking_board_listener.ts @@ -0,0 +1,32 @@ +import type { Emitter } from '@adonisjs/core/events' +import app from '@adonisjs/core/services/app' +import { TenantDataChanged } from '@adonisjs-lasagna/saas-tenancy/mixins' +import { TenantSocketServer } from '@adonisjs-lasagna/websockets' + +/** + * Bridges committed booking writes to the company's live reservations board. + * `TenantDataChanged` is PII-free (`{ tenantId, table, operation, keys }`), so + * forwarding it to the tenant room leaks nothing; a client re-reads if it needs + * detail. Isolation is structural: `emitToTenant` targets only `tenant:`. + * + * WebSockets are an optional peer — if socket.io is not installed the server is + * inert and `emitToTenant` is a safe no-op. + */ +export default class BookingBoardListener { + static register(emitter: Emitter) { + emitter.on(TenantDataChanged, async (event: any) => { + const change = event.change ?? event + if (change?.table !== 'bookings') return + try { + const sockets = await app.container.make(TenantSocketServer) + sockets.emitToTenant(change.tenantId, 'booking:changed', { + table: change.table, + operation: change.operation, + keys: change.keys, + }) + } catch { + /* websockets disabled (no socket.io) — nothing to broadcast */ + } + }) + } +} diff --git a/apps/rental/app/middleware/auth_middleware.ts b/apps/rental/app/middleware/auth_middleware.ts new file mode 100644 index 00000000..5e21407c --- /dev/null +++ b/apps/rental/app/middleware/auth_middleware.ts @@ -0,0 +1,23 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import type { Authenticators } from '@adonisjs/auth/types' + +/** + * Authenticates the request against the given guards (`backoffice` for operator + * routes, `tenant` for tenant-staff routes) before the handler runs. An + * unauthenticated request raises E_UNAUTHORIZED_ACCESS, which renders as 401. + * + * On tenant routes this shares the per-request guard instance with the + * membership gate's `auth.use('tenant').check()`, so a request pays exactly one + * token lookup even though both layers run. + */ +export default class AuthMiddleware { + async handle( + ctx: HttpContext, + next: NextFn, + options: { guards?: (keyof Authenticators)[] } = {} + ) { + await ctx.auth.authenticateUsing(options.guards) + return next() + } +} diff --git a/apps/rental/app/middleware/inertia_middleware.ts b/apps/rental/app/middleware/inertia_middleware.ts new file mode 100644 index 00000000..a27f44ac --- /dev/null +++ b/apps/rental/app/middleware/inertia_middleware.ts @@ -0,0 +1,90 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import BaseInertiaMiddleware from '@adonisjs/inertia/inertia_middleware' +import { isAuthorizedStaff } from '#app/security/session_realm' + +/** + * Concrete Inertia middleware. The base class handles the Inertia request + * protocol (init/dispose, version negotiation, redirect-status upgrades); we add + * the `handle()` hook the router calls and the per-request `share()` payload that + * every page receives as props. + * + * Shared props are deliberately thin: flash messages and validation errors (so a + * failed login re-renders with its error), plus the signed-in identity for + * whichever browser realm authenticated this request. The React layouts read + * `auth` to decide which shell (operator vs company) to paint. + */ +export default class InertiaMiddleware extends BaseInertiaMiddleware { + async share(ctx: HttpContext) { + return { + flash: (ctx.session?.flashMessages.all() ?? {}) as Record, + errors: this.getValidationErrors(ctx), + auth: { + operator: await this.#currentOperator(ctx), + staff: await this.#currentStaff(ctx), + }, + // The resolved company on a tenant host, so every company page + the sign-in + // shell can name it without each controller threading it through. Null on + // the apex (operator realm). + company: await this.#currentCompany(ctx), + } + } + + async handle(ctx: HttpContext, next: NextFn) { + await this.init(ctx) + const output = await next() + this.dispose(ctx) + return output + } + + /** + * The operator behind a `web-backoffice` session, or null. `check()` is + * side-effect free and returns false when no session cookie is present, so + * this is safe to run on every request including anonymous ones. + */ + async #currentOperator(ctx: HttpContext) { + if (!(await ctx.auth.use('web-backoffice').check())) return null + const user = ctx.auth.use('web-backoffice').user + return user ? { id: user.id, email: user.email, fullName: user.fullName } : null + } + + /** + * The company staff member behind a `web-tenant` session, or null. Uses the + * company-pinned check so shared props never surface a session that belongs to + * a different company (or the apex, where no tenant resolves). + */ + async #currentStaff(ctx: HttpContext) { + let tenant + try { + tenant = await ctx.request.tenant() + } catch { + return null + } + if (!(await isAuthorizedStaff(ctx, tenant))) return null + const user = ctx.auth.use('web-tenant').user + return user + ? { id: user.id, email: user.email, fullName: user.fullName, role: user.role } + : null + } + + /** + * The resolved company for this request (id, name, plan), or null on the apex. + * Rendered into shared props so company pages read `company` without a + * per-controller prop. + */ + async #currentCompany(ctx: HttpContext) { + let tenant + try { + tenant = await ctx.request.tenant() + } catch { + return null + } + return { + id: tenant.id, + name: tenant.name, + plan: String(tenant.metadata?.plan ?? 'starter'), + tier: String(tenant.metadata?.tier ?? 'standard'), + maintenance: Boolean(tenant.isMaintenance), + } + } +} diff --git a/apps/rental/app/middleware/web_auth_middleware.ts b/apps/rental/app/middleware/web_auth_middleware.ts new file mode 100644 index 00000000..019f9521 --- /dev/null +++ b/apps/rental/app/middleware/web_auth_middleware.ts @@ -0,0 +1,42 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import { isAuthorizedStaff } from '#app/security/session_realm' + +/** + * Gate for the browser console page shells. The pages live on `universal()` + * routes (so the same paths resolve on both the apex and a company host), so + * each page declares which realm it belongs to and this middleware enforces it: + * + * - `realm: 'operator'` — must be the apex with a `web-backoffice` session. + * On a company host it bounces to `/` (that host's tenant home). + * - `realm: 'tenant'` — must be a company host with a company-pinned + * `web-tenant` session. On the apex it bounces to `/` (the operator home). + * + * An unauthenticated visitor on the right host is sent to `/login`, which itself + * renders the correct realm's sign-in. Data endpoints keep their own guards + * (the admin satellite's auth, the tenant membership gate); this only guards the + * shells so a page never renders for the wrong realm. + */ +export default class WebAuthMiddleware { + async handle(ctx: HttpContext, next: NextFn, options: { realm: 'operator' | 'tenant' }) { + const tenant = await this.#tenantOrNull(ctx) + + if (options.realm === 'tenant') { + if (!tenant) return ctx.response.redirect('/') + if (!(await isAuthorizedStaff(ctx, tenant))) return ctx.response.redirect('/login') + } else { + if (tenant) return ctx.response.redirect('/') + if (!(await ctx.auth.use('web-backoffice').check())) return ctx.response.redirect('/login') + } + + return next() + } + + async #tenantOrNull(ctx: HttpContext) { + try { + return await ctx.request.tenant() + } catch { + return null + } + } +} diff --git a/apps/rental/app/models/backoffice/backoffice_user.ts b/apps/rental/app/models/backoffice/backoffice_user.ts new file mode 100644 index 00000000..ed23c171 --- /dev/null +++ b/apps/rental/app/models/backoffice/backoffice_user.ts @@ -0,0 +1,49 @@ +import { DateTime } from 'luxon' +import hash from '@adonisjs/core/services/hash' +import { compose } from '@adonisjs/core/helpers' +import { column } from '@adonisjs/lucid/orm' +import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' +import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens' +import { BackofficeBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' + +const AuthFinder = withAuthFinder(() => hash.use('scrypt'), { + uids: ['email'], + passwordColumnName: 'password', +}) + +/** + * The operator realm's identity — Karimoto platform staff. Lives in + * `backoffice.backoffice_users`, one fleet-wide table, because + * BackofficeBaseModel pins every query to the backoffice connection. Access + * tokens follow the model's adapter, so they land in + * `backoffice.auth_access_tokens`, never inside a tenant schema. + * + * The `bko_` prefix is diagnostic only (a leaked token names its realm at a + * glance). No security decision branches on it. + */ +export default class BackofficeUser extends compose(BackofficeBaseModel, AuthFinder) { + static table = 'backoffice_users' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare email: string + + @column({ serializeAs: null }) + declare password: string + + @column() + declare fullName: string | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + static accessTokens = DbAccessTokensProvider.forModel(BackofficeUser, { + prefix: 'bko_', + expiresIn: '1 day', + }) +} diff --git a/apps/rental/app/models/backoffice/tenant.ts b/apps/rental/app/models/backoffice/tenant.ts new file mode 100644 index 00000000..6f5aca45 --- /dev/null +++ b/apps/rental/app/models/backoffice/tenant.ts @@ -0,0 +1,216 @@ +import { BackofficeBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { + ReadReplicaService, + PGVECTOR_EXTENSION_SCHEMA, +} from '@adonisjs-lasagna/saas-tenancy/services' +import { column, scope } from '@adonisjs/lucid/orm' +import db from '@adonisjs/lucid/services/db' +import app from '@adonisjs/core/services/app' +import { MigrationRunner } from '@adonisjs/lucid/migration' +import type { PostgreConfig } from '@adonisjs/lucid/types/database' +import type { MigratorOptions } from '@adonisjs/lucid/types/migrator' +import { DateTime } from 'luxon' +import assert from 'node:assert' +import multitenancyConfig from '#config/multitenancy' +import type { TenantStatus } from '@adonisjs-lasagna/saas-tenancy/types' + +/** + * The shape of `tenant.metadata` — one rental company's SaaS subscription + * facts. Drives plan resolution (`config.plans.getPlan`), backup retention tier + * (`config.backup.retention.getTier`), and the localised money/tax defaults the + * domain uses (Morocco: MAD, 20% VAT). + * + * A type alias (not an interface) on purpose: aliases get an implicit index + * signature, so `RentalMeta` stays assignable to the contract's + * `TenantMetadata` without a cast, while object literals still get + * excess-property checking (a typo like `plann:` stays a compile error). + */ +export type RentalMeta = { + plan: 'starter' | 'fleet' | 'enterprise' + tier: 'standard' | 'premium' + country: string + currency: string + industry?: string +} + +const MAX_TENANT_CONNECTIONS = 50 +const connectionLru = new Map() +const replicaService = new ReadReplicaService() + +export default class Tenant extends BackofficeBaseModel { + static table = 'tenants' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare name: string + + @column() + declare email: string + + @column() + declare status: TenantStatus + + @column() + declare customDomain: string | null + + @column({ + prepare: (value: RentalMeta | null) => (value ? JSON.stringify(value) : null), + consume: (value: string | RentalMeta | null) => + typeof value === 'string' ? (JSON.parse(value) as RentalMeta) : value, + }) + declare metadata: RentalMeta + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + @column.dateTime() + declare deletedAt: DateTime | null + + @column() + declare maintenance: boolean + + @column() + declare maintenanceMessage: string | null + + static active = scope((query) => { + query.where('status', 'active').whereNull('deleted_at') + }) + + static notDeleted = scope((query) => { + query.whereNull('deleted_at') + }) + + get isActive() { + return this.status === 'active' && this.deletedAt === null + } + get isSuspended() { + return this.status === 'suspended' + } + get isProvisioning() { + return this.status === 'provisioning' + } + get isFailed() { + return this.status === 'failed' + } + get isDeleted() { + return this.deletedAt !== null + } + get isMaintenance() { + return this.maintenance === true + } + + async enterMaintenance(message?: string | null) { + this.maintenance = true + this.maintenanceMessage = message ?? null + await this.save() + } + + async exitMaintenance() { + this.maintenance = false + this.maintenanceMessage = null + await this.save() + } + + private get connectionName() { + return `${multitenancyConfig.tenantConnectionNamePrefix}${this.id}` + } + + get schemaName() { + return `${multitenancyConfig.tenantSchemaPrefix}${this.id}` + } + + async closeConnection() { + connectionLru.delete(this.connectionName) + if (db.manager.has(this.connectionName)) { + await db.manager.close(this.connectionName) + } + } + + async migrate(options: Omit) { + const migrator = new MigrationRunner(db, app, { + ...options, + connectionName: this.connectionName, + }) + await migrator.run() + if (migrator.error) throw migrator.error + return migrator + } + + getConnection() { + if (db.manager.has(this.connectionName)) { + connectionLru.delete(this.connectionName) + connectionLru.set(this.connectionName, Date.now()) + return db.connection(this.connectionName) + } + + const config = db.manager.get('tenant')?.config + assert(config, 'Unable to get tenant template connection config') + + db.manager.add(this.connectionName, { + ...config, + // The tenant schema stays FIRST (all tenant objects resolve there); the + // shared pgvector `extensions` schema is appended so the + // `ai_embeddings vector(N)` column + operators resolve. `public` (central + // catalog) is deliberately kept off the tenant path. + searchPath: [this.schemaName, PGVECTOR_EXTENSION_SCHEMA], + } as PostgreConfig) + + connectionLru.set(this.connectionName, Date.now()) + if (connectionLru.size > MAX_TENANT_CONNECTIONS) { + const oldest = connectionLru.keys().next().value! + connectionLru.delete(oldest) + db.manager.close(oldest).catch(() => {}) + } + + return db.connection(this.connectionName) + } + + // When a replica is configured, route reads to it via the package's + // ReadReplicaService. Falls back to the primary when none is registered. + async getReadConnection() { + return (await replicaService.resolve(this)) ?? this.getConnection() + } + + async install() { + try { + this.status = 'provisioning' + await this.save() + await db.rawQuery(`CREATE SCHEMA IF NOT EXISTS "${this.schemaName}"`) + this.getConnection() + this.status = 'active' + await this.save() + } catch (error) { + this.status = 'failed' + await this.save() + throw error + } + } + + async uninstall() { + await this.closeConnection() + await db.rawQuery(`DROP SCHEMA IF EXISTS "${this.schemaName}" CASCADE`) + // Invariant A: deletedAt and status move together. + this.deletedAt = DateTime.now() + this.status = 'deleted' + await this.save() + } + + async dropSchemaIfExists() { + await db.rawQuery(`DROP SCHEMA IF EXISTS "${this.schemaName}" CASCADE`) + } + + async suspend() { + this.status = 'suspended' + await this.save() + } + + async activate() { + this.status = 'active' + await this.save() + } +} diff --git a/apps/rental/app/models/central/car_make.ts b/apps/rental/app/models/central/car_make.ts new file mode 100644 index 00000000..a2de3624 --- /dev/null +++ b/apps/rental/app/models/central/car_make.ts @@ -0,0 +1,36 @@ +import { CentralBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { column, hasMany } from '@adonisjs/lucid/orm' +import type { HasMany } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import CarModel from '#app/models/central/car_model' + +/** + * A car manufacturer in the shared, cross-company catalog. CentralBaseModel + * routes every query to the central (`public`) connection, so this table is a + * single global list every company's fleet selects from — the one place the + * demo uses the central realm that the reference API leaves idle. + */ +export default class CarMake extends CentralBaseModel { + static table = 'car_makes' + + @column({ isPrimary: true }) + declare id: number + + @column() + declare name: string + + @column() + declare slug: string + + @column() + declare country: string | null + + @hasMany(() => CarModel, { foreignKey: 'makeId' }) + declare models: HasMany + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/models/central/car_model.ts b/apps/rental/app/models/central/car_model.ts new file mode 100644 index 00000000..e4b7efcf --- /dev/null +++ b/apps/rental/app/models/central/car_model.ts @@ -0,0 +1,36 @@ +import { CentralBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { column, belongsTo } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import CarMake from '#app/models/central/car_make' + +/** + * A model within a manufacturer, in the shared central catalog. A company's + * Vehicle references `makeId`/`modelId` here so two companies picking "Dacia + * Logan" point at the same catalog row, while their vehicles stay isolated in + * their own schemas. + */ +export default class CarModel extends CentralBaseModel { + static table = 'car_models' + + @column({ isPrimary: true }) + declare id: number + + @column() + declare makeId: number + + @column() + declare name: string + + @column() + declare bodyType: string | null + + @belongsTo(() => CarMake, { foreignKey: 'makeId' }) + declare make: BelongsTo + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/models/tenant_scoped/booking.ts b/apps/rental/app/models/tenant_scoped/booking.ts new file mode 100644 index 00000000..d15bc8ef --- /dev/null +++ b/apps/rental/app/models/tenant_scoped/booking.ts @@ -0,0 +1,95 @@ +import { TenantBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { TracksDataChanges } from '@adonisjs-lasagna/saas-tenancy/mixins' +import { column, belongsTo } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import Customer from '#app/models/tenant_scoped/customer' +import Vehicle from '#app/models/tenant_scoped/vehicle' + +export type BookingStatus = 'quote' | 'confirmed' | 'active' | 'completed' | 'cancelled' | 'no_show' + +export interface PriceBreakdown { + days: number + dailyRate: number + base: number + extras: number + vat: number + total: number + currency: string +} + +/** + * A rental from pickup to dropoff. `reference` is a short human code + * (`KRM-XXXXXX`). Money fields are santimat. `priceBreakdown` records how the + * total was computed so a receipt is reproducible. The status is the booking + * lifecycle BookingService drives (quote → confirmed → active → completed). + * + * Wrapped in `TracksDataChanges` so every committed write emits a PII-free + * `TenantDataChanged` event; BookingBoardListener forwards it to the company's + * live socket room so the reservations board updates in real time. + */ +export default class Booking extends TracksDataChanges(TenantBaseModel) { + static table = 'bookings' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare reference: string + + @column() + declare customerId: string + + @column() + declare vehicleId: string + + @column() + declare pickupLocationId: string | null + + @column.dateTime() + declare pickupAt: DateTime + + @column() + declare dropoffLocationId: string | null + + @column.dateTime() + declare dropoffAt: DateTime + + @column() + declare status: BookingStatus + + @column({ + prepare: (value: PriceBreakdown | null) => (value ? JSON.stringify(value) : null), + consume: (value: string | PriceBreakdown | null) => + typeof value === 'string' ? (JSON.parse(value) as PriceBreakdown) : value, + }) + declare priceBreakdown: PriceBreakdown | null + + @column() + declare depositHeld: number + + @column({ + prepare: (value: string[] | null) => (value ? JSON.stringify(value) : '[]'), + consume: (value: string | string[] | null) => + typeof value === 'string' ? (JSON.parse(value) as string[]) : (value ?? []), + }) + declare extras: string[] + + @column() + declare totalAmount: number + + @column() + declare currency: string + + @belongsTo(() => Customer, { foreignKey: 'customerId' }) + declare customer: BelongsTo + + @belongsTo(() => Vehicle, { foreignKey: 'vehicleId' }) + declare vehicle: BelongsTo + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/models/tenant_scoped/customer.ts b/apps/rental/app/models/tenant_scoped/customer.ts new file mode 100644 index 00000000..2fe3ac08 --- /dev/null +++ b/apps/rental/app/models/tenant_scoped/customer.ts @@ -0,0 +1,72 @@ +import { TenantBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { column } from '@adonisjs/lucid/orm' +import { compose } from '@adonisjs/core/helpers' +import { DateTime } from 'luxon' +import { encrypted, searchable, withEncryptedFields } from '@adonisjs-lasagna/crypto' + +/** All three identity documents share one per-renter DEK, so a single shred + * erases the renter's whole identity set at once. */ +const CATEGORY = 'renter-id' + +/** + * A renter. Tenant-scoped, so a person who rents from two companies is two + * independent rows in two schemas. + * + * The identity fields — `cin` (Moroccan national ID), `driverLicense`, + * `passport` — are PII under Law 09-08 / the CNDP, stored as crypto + * `@encrypted` fields (enc_v2 ciphertext at rest, plaintext in memory) keyed by + * the `(customer.id × renter-id)` DEK. Each has a `@searchable` blind-index + * sibling for equality lookup that survives a shred. Crypto-shredding the DEK + * makes all three fields unreadable at once (re-reading fails closed → 410), + * which is how a renter's erasure right is honoured irreversibly. + */ +export default class Customer extends compose(TenantBaseModel, withEncryptedFields) { + static table = 'customers' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare fullName: string + + @column() + declare email: string | null + + @column() + declare phone: string | null + + // ─── Encrypted PII (per-renter DEK) ────────────────────────────── + @encrypted({ category: CATEGORY, subject: (row: Customer) => row.id }) + declare cin: string | null + + @encrypted({ category: CATEGORY, subject: (row: Customer) => row.id }) + declare driverLicense: string | null + + @encrypted({ category: CATEGORY, subject: (row: Customer) => row.id }) + declare passport: string | null + + // ─── Blind indexes (keyed-HMAC, survive a shred) ───────────────── + @searchable({ category: CATEGORY, from: (row: Customer) => row.cin }) + declare cinIndex: string | null + + @searchable({ category: CATEGORY, from: (row: Customer) => row.driverLicense }) + declare driverLicenseIndex: string | null + + @searchable({ category: CATEGORY, from: (row: Customer) => row.passport }) + declare passportIndex: string | null + + @column() + declare address: string | null + + @column.date() + declare dateOfBirth: DateTime | null + + @column() + declare nationality: string | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/models/tenant_scoped/fleet_doc.ts b/apps/rental/app/models/tenant_scoped/fleet_doc.ts new file mode 100644 index 00000000..d9c4b34d --- /dev/null +++ b/apps/rental/app/models/tenant_scoped/fleet_doc.ts @@ -0,0 +1,34 @@ +import { TenantBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +/** + * A company policy / FAQ document — the source corpus for the fleet assistant's + * RAG. Its `body` is embedded into the per-tenant `ai_embeddings` store (folded + * into each tenant schema by the AI satellite); `source` is the dedup key used + * when ingesting via `POST /ai/embed`. + */ +export default class FleetDoc extends TenantBaseModel { + static table = 'fleet_docs' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare title: string + + @column() + declare body: string + + @column() + declare source: string + + @column() + declare embeddedAt: DateTime | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/models/tenant_scoped/invoice.ts b/apps/rental/app/models/tenant_scoped/invoice.ts new file mode 100644 index 00000000..036f97ce --- /dev/null +++ b/apps/rental/app/models/tenant_scoped/invoice.ts @@ -0,0 +1,55 @@ +import { TenantBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +export interface InvoiceLine { + description: string + quantity: number + unitAmount: number + amount: number +} + +/** + * A VAT invoice for a booking. `number` is a per-company sequence + * (`INV-YYYY-NNNN`). All money is santimat; `vat` is the 20% Moroccan TVA. + */ +export default class Invoice extends TenantBaseModel { + static table = 'invoices' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare bookingId: string + + @column() + declare number: string + + @column({ + prepare: (value: InvoiceLine[] | null) => (value ? JSON.stringify(value) : '[]'), + consume: (value: string | InvoiceLine[] | null) => + typeof value === 'string' ? (JSON.parse(value) as InvoiceLine[]) : (value ?? []), + }) + declare lines: InvoiceLine[] + + @column() + declare subtotal: number + + @column() + declare vat: number + + @column() + declare total: number + + @column() + declare currency: string + + @column.dateTime() + declare issuedAt: DateTime + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/models/tenant_scoped/maintenance_record.ts b/apps/rental/app/models/tenant_scoped/maintenance_record.ts new file mode 100644 index 00000000..829f9379 --- /dev/null +++ b/apps/rental/app/models/tenant_scoped/maintenance_record.ts @@ -0,0 +1,45 @@ +import { TenantBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { column, belongsTo } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import Vehicle from '#app/models/tenant_scoped/vehicle' + +export type MaintenanceType = 'service' | 'repair' | 'inspection' | 'cleaning' + +/** + * A maintenance event on a vehicle. `cost` is santimat; `odometer` is the + * reading at the time of service. + */ +export default class MaintenanceRecord extends TenantBaseModel { + static table = 'maintenance_records' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare vehicleId: string + + @column() + declare type: MaintenanceType + + @column() + declare cost: number + + @column() + declare odometer: number + + @column.dateTime() + declare performedAt: DateTime + + @column() + declare notes: string | null + + @belongsTo(() => Vehicle, { foreignKey: 'vehicleId' }) + declare vehicle: BelongsTo + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/models/tenant_scoped/payment.ts b/apps/rental/app/models/tenant_scoped/payment.ts new file mode 100644 index 00000000..ebd9c14c --- /dev/null +++ b/apps/rental/app/models/tenant_scoped/payment.ts @@ -0,0 +1,45 @@ +import { TenantBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +export type PaymentMethod = 'cash' | 'card' | 'transfer' +export type PaymentStatus = 'pending' | 'paid' | 'refunded' | 'failed' + +/** + * A payment a renter makes against a booking (the DOMAIN money flow — distinct + * from the billing satellite, which is the company paying Karimoto for the SaaS + * subscription). `amount` is santimat. + */ +export default class Payment extends TenantBaseModel { + static table = 'payments' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare bookingId: string + + @column() + declare amount: number + + @column() + declare currency: string + + @column() + declare method: PaymentMethod + + @column() + declare status: PaymentStatus + + @column() + declare reference: string | null + + @column.dateTime() + declare paidAt: DateTime | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/models/tenant_scoped/rental_agreement.ts b/apps/rental/app/models/tenant_scoped/rental_agreement.ts new file mode 100644 index 00000000..16cee0a6 --- /dev/null +++ b/apps/rental/app/models/tenant_scoped/rental_agreement.ts @@ -0,0 +1,35 @@ +import { TenantBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +/** + * The signed contract for a booking. `signedAt` stays null until the renter + * signs; `pdfRef`/`signatureRef` point at externally stored artefacts. + */ +export default class RentalAgreement extends TenantBaseModel { + static table = 'rental_agreements' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare bookingId: string + + @column() + declare terms: string | null + + @column.dateTime() + declare signedAt: DateTime | null + + @column() + declare signatureRef: string | null + + @column() + declare pdfRef: string | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/models/tenant_scoped/rental_location.ts b/apps/rental/app/models/tenant_scoped/rental_location.ts new file mode 100644 index 00000000..03c1f57a --- /dev/null +++ b/apps/rental/app/models/tenant_scoped/rental_location.ts @@ -0,0 +1,46 @@ +import { TenantBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +export type LocationType = 'airport' | 'city' | 'depot' + +/** + * A branch where a company hands over and takes back vehicles. Tenant-scoped: + * rows live in `tenant_.rental_locations`. + */ +export default class RentalLocation extends TenantBaseModel { + static table = 'rental_locations' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare name: string + + @column() + declare type: LocationType + + @column() + declare address: string | null + + @column() + declare city: string + + @column() + declare timezone: string + + @column() + declare phone: string | null + + @column() + declare openHour: number + + @column() + declare closeHour: number + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/models/tenant_scoped/tenant_user.ts b/apps/rental/app/models/tenant_scoped/tenant_user.ts new file mode 100644 index 00000000..f3ba536b --- /dev/null +++ b/apps/rental/app/models/tenant_scoped/tenant_user.ts @@ -0,0 +1,55 @@ +import { DateTime } from 'luxon' +import hash from '@adonisjs/core/services/hash' +import { compose } from '@adonisjs/core/helpers' +import { column } from '@adonisjs/lucid/orm' +import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' +import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens' +import { TenantBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' + +const AuthFinder = withAuthFinder(() => hash.use('scrypt'), { + uids: ['email'], + passwordColumnName: 'password', +}) + +/** A company staff member's role: the owner administers the account, agents + * run the counter (bookings, customers, fleet). */ +export type TenantUserRole = 'owner' | 'agent' + +/** + * The tenant realm's identity — a rental company's staff. TenantBaseModel + * routes every query to the resolved tenant's schema, so `users` and its + * `auth_access_tokens` exist once per company: a login only ever sees the + * resolved company's rows, and the same email can exist independently in two + * companies. That per-schema storage is the isolation guarantee. + * + * The `tnt_` prefix is diagnostic only, same as `bko_` on the operator side. + */ +export default class TenantUser extends compose(TenantBaseModel, AuthFinder) { + static table = 'users' + + @column({ isPrimary: true }) + declare id: number + + @column() + declare email: string + + @column({ serializeAs: null }) + declare password: string + + @column() + declare fullName: string | null + + @column() + declare role: TenantUserRole + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + static accessTokens = DbAccessTokensProvider.forModel(TenantUser, { + prefix: 'tnt_', + expiresIn: '1 day', + }) +} diff --git a/apps/rental/app/models/tenant_scoped/vehicle.ts b/apps/rental/app/models/tenant_scoped/vehicle.ts new file mode 100644 index 00000000..38fd20ac --- /dev/null +++ b/apps/rental/app/models/tenant_scoped/vehicle.ts @@ -0,0 +1,75 @@ +import { TenantBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { column, belongsTo } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import VehicleCategory from '#app/models/tenant_scoped/vehicle_category' +import RentalLocation from '#app/models/tenant_scoped/rental_location' + +export type VehicleStatus = 'available' | 'rented' | 'maintenance' | 'retired' +export type FuelType = 'petrol' | 'diesel' | 'hybrid' | 'electric' +export type Transmission = 'manual' | 'automatic' + +/** + * A vehicle in a company's fleet. `makeId`/`modelId` reference the shared + * central catalog (`car_makes`/`car_models`); their names are denormalised onto + * `makeName`/`modelName` so a fleet listing never has to cross the connection + * boundary to render. `status` is the availability state machine FleetService + * transitions. + */ +export default class Vehicle extends TenantBaseModel { + static table = 'vehicles' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare plate: string + + @column() + declare makeId: number + + @column() + declare modelId: number + + @column() + declare makeName: string + + @column() + declare modelName: string + + @column() + declare year: number + + @column() + declare categoryId: string + + @column() + declare locationId: string | null + + @column() + declare status: VehicleStatus + + @column() + declare mileage: number + + @column() + declare fuel: FuelType + + @column() + declare transmission: Transmission + + @column() + declare color: string | null + + @belongsTo(() => VehicleCategory, { foreignKey: 'categoryId' }) + declare category: BelongsTo + + @belongsTo(() => RentalLocation, { foreignKey: 'locationId' }) + declare location: BelongsTo + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/models/tenant_scoped/vehicle_category.ts b/apps/rental/app/models/tenant_scoped/vehicle_category.ts new file mode 100644 index 00000000..dbe4976d --- /dev/null +++ b/apps/rental/app/models/tenant_scoped/vehicle_category.ts @@ -0,0 +1,44 @@ +import { TenantBaseModel } from '@adonisjs-lasagna/saas-tenancy/base-models' +import { column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +export type CategoryCode = 'economy' | 'compact' | 'suv' | 'luxury' | 'van' + +/** + * A pricing tier for vehicles (economy, SUV, …). `dailyRate` and + * `depositAmount` are stored in integer santimat (1 MAD = 100 santimat) to keep + * money exact. `extras` is the list of add-ons this category offers. + */ +export default class VehicleCategory extends TenantBaseModel { + static table = 'vehicle_categories' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare name: string + + @column() + declare code: CategoryCode + + /** Daily rate in santimat (MAD × 100). */ + @column() + declare dailyRate: number + + /** Refundable deposit in santimat. */ + @column() + declare depositAmount: number + + @column({ + prepare: (value: string[] | null) => (value ? JSON.stringify(value) : '[]'), + consume: (value: string | string[] | null) => + typeof value === 'string' ? (JSON.parse(value) as string[]) : (value ?? []), + }) + declare extras: string[] + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/app/plugins/telematics_plugin.ts b/apps/rental/app/plugins/telematics_plugin.ts new file mode 100644 index 00000000..0695e9e4 --- /dev/null +++ b/apps/rental/app/plugins/telematics_plugin.ts @@ -0,0 +1,60 @@ +import { + definePlugin, + LASAGNA_PLUGIN_API_VERSION, + requestMacro, +} from '@adonisjs-lasagna/saas-tenancy/plugin' +import { safeFetch, SafeFetchError } from '@adonisjs-lasagna/saas-tenancy/safe-fetch' + +/** + * An app-owned plugin built on the SAME `definePlugin` facade the nine + * satellites use — proof the plugin platform is open to host code. It adds a + * `request.vehicleTelematics(vehicleId)` macro that queries an external + * telematics provider through SSRF-pinned `safeFetch`: a provider URL that + * resolves to a private/loopback/metadata address is refused fail-closed + * (SafeFetchError), so a poisoned telematics endpoint can't be used to reach the + * internal network. + * + * Registered in adonisrc.ts#providers (definePlugin returns a provider class). + */ +const TELEMATICS_BASE = process.env.TELEMATICS_BASE_URL ?? 'https://telematics.invalid' + +export default definePlugin({ + name: 'telematics', + packageName: 'karimoto-telematics', + satelliteApi: 1, + pluginApiVersion: LASAGNA_PLUGIN_API_VERSION, + + requestMacros: () => [ + requestMacro({ + name: 'vehicleTelematics', + requireTenant: true, + resolve: () => { + return async ( + vehicleId: string + ): Promise<{ vehicleId: string; ok: boolean; detail?: unknown }> => { + try { + const res = await safeFetch( + `${TELEMATICS_BASE}/vehicles/${encodeURIComponent(vehicleId)}/position`, + { + method: 'GET', + timeoutMs: 3000, + } + ) + return { vehicleId, ok: res.ok, detail: await res.json().catch(() => null) } + } catch (error) { + if (error instanceof SafeFetchError) { + return { + vehicleId, + ok: false, + detail: { blocked: 'ssrf_guard', code: error.message }, + } + } + // Provider unreachable (the placeholder host never resolves) — degrade, + // don't crash the request path. + return { vehicleId, ok: false, detail: { unreachable: true } } + } + } + }, + }), + ], +}) diff --git a/apps/rental/app/providers/app_provider.ts b/apps/rental/app/providers/app_provider.ts new file mode 100644 index 00000000..33cf67a8 --- /dev/null +++ b/apps/rental/app/providers/app_provider.ts @@ -0,0 +1,216 @@ +import type { ApplicationService } from '@adonisjs/core/types' +import { TENANT_REPOSITORY } from '@adonisjs-lasagna/saas-tenancy/types' +import { + CircuitBreakerService, + DoctorService, + builtInChecks, + mapTenants, +} from '@adonisjs-lasagna/saas-tenancy/services' +import type { DiagnosisIssue } from '@adonisjs-lasagna/saas-tenancy/services' +import { + ReportExtensionRegistry, + ReportingService, + REPORTING_CONTRACT_VERSION, +} from '@adonisjs-lasagna/reporting' +import type { ReportExtensionFilters } from '@adonisjs-lasagna/reporting' +import { adminActionRegistry, ADMIN_CONTRACT_VERSION } from '@adonisjs-lasagna/admin' +import { + AIProviderRegistry, + DeepSeekProvider, + EmbeddingProviderRegistry, +} from '@adonisjs-lasagna/ai' +import { MockAIProvider, MockEmbeddingProvider } from '@adonisjs-lasagna/ai/testing' +import { BillingService, MockStripe } from '@adonisjs-lasagna/billing' +import env from '#start/env' +import TenantRepository from '#app/repositories/tenant_repository' + +export default class AppProvider { + constructor(protected app: ApplicationService) {} + + async boot() { + this.bindContainerServices() + await this.registerReportExtensions() + this.registerAdminActions() + await this.registerAiMockProviders() + await this.injectMockStripeIfOffline() + } + + async ready() { + await this.registerListeners() + } + + /** + * Repository contract + cross-request singletons the package resolves at + * runtime. The DoctorService carries the built-in checks plus a `fleet_health` + * domain check that proves custom checks are pluggable. + */ + private bindContainerServices() { + this.app.container.bind(TENANT_REPOSITORY as any, () => new TenantRepository()) + this.app.container.singleton(CircuitBreakerService, () => new CircuitBreakerService()) + this.app.container.singleton(DoctorService, () => { + const svc = new DoctorService() + for (const check of builtInChecks) svc.register(check) + svc.register({ + name: 'fleet_health', + description: 'Domain check: confirms the fleet subsystem is reachable.', + async run(): Promise { + return [{ code: 'fleet_ok', severity: 'info', message: 'Fleet subsystem healthy.' }] + }, + }) + return svc + }) + } + + /** + * AI providers. The mock chat + embedding providers are always registered so + * `/ai/chat`, `/ai/embed` and `/ai/retrieve` work offline; the chat mock emits a + * CIN-shaped token so the `config.ai.redactOutput` DLP hook has something to + * strip end to end. When `DEEPSEEK_API_KEY` is set (and not under the test + * runner), the real DeepSeek chat provider is registered and becomes the + * config default — the gateway then streams `deepseek-chat` over the same path. + * Embeddings stay on the mock either way, so RAG retrieves over the seeded + * mock-embedding space and feeds the matched docs to whichever model is active. + * + * The chat mock declares contract v2 + `capabilities.tools` because `config.ai.tools` + * now offers the fleet tools (WS-AI-11): the gateway refuses a tool-carrying request + * to a provider that does not advertise tool support rather than silently dropping + * the tools, so an unversioned mock would 403 every offline chat. Declaring the + * capability is honest — the mock tolerates `tools` on the request and `role: 'tool'` + * turns in the history. Its script simply never calls a tool, exactly as a real model + * may decline to; the loop is exercised for real against DeepSeek. + */ + private async registerAiMockProviders() { + const chat = await this.app.container.make(AIProviderRegistry) + if (!chat.has('mock')) { + chat.register( + new MockAIProvider({ + name: 'mock', + contractVersion: 2, + tools: true, + fragments: [ + { data: 'Your fleet has vehicles available. Ref ', tokens: 1 }, + { data: 'AB123456', tokens: 1 }, + ], + }), + { activate: true } + ) + } + + // Bind the real DeepSeek chat provider when its key is present. The mirror of + // the `config/multitenancy.ts` predicate keeps the offline test path on the + // deterministic mock. The key is read from the environment, never hardcoded. + const deepSeekKey = env.get('DEEPSEEK_API_KEY') + if (deepSeekKey && env.get('NODE_ENV') !== 'test' && !chat.has('deepseek')) { + chat.register(new DeepSeekProvider({ apiKey: deepSeekKey }), { activate: true }) + } + + const embedding = await this.app.container.make(EmbeddingProviderRegistry) + if (!embedding.has()) embedding.register(new MockEmbeddingProvider({ dimension: 8 })) + } + + /** + * Offline billing: with no real Stripe key, inject MockStripe into the stripe + * driver so checkout/portal/webhook run in memory. A real `sk_test_…`/`sk_live_…` + * key skips this and the driver dials Stripe for real — no code change. + */ + private async injectMockStripeIfOffline() { + const apiKey = env.get('STRIPE_API_KEY') + const isPlaceholder = !apiKey || apiKey.includes('placeholder') + if (!isPlaceholder) return + const billing = await this.app.container.make(BillingService) + const secret = env.get('STRIPE_WEBHOOK_SECRET', 'whsec_karimoto_placeholder_secret') + await billing.__setStripeForTests(new MockStripe(secret)) + } + + /** + * A demo admin action (`fleet_snapshot`) and a cross-tenant report extension + * (`fleet_utilization`). Both walk the busiest tenants and read a per-tenant + * figure with bounded concurrency + error isolation via `mapTenants`. + */ + private registerAdminActions() { + if (adminActionRegistry.has('fleet_snapshot')) return + const app = this.app + adminActionRegistry.register({ + name: 'fleet_snapshot', + description: 'Count vehicles across the busiest companies (bounded, error-isolated).', + contractVersion: ADMIN_CONTRACT_VERSION, + async execute() { + const reporting = await app.container.make(ReportingService) + const tenants = [] + for await (const { tenant } of reporting.iterateTenantsByUsage({})) { + tenants.push(tenant) + if (tenants.length >= 10) break + } + const { results, errors } = await mapTenants( + tenants, + async () => { + // mapTenants runs this inside tenancy.run(tenant), so a TenantBaseModel + // query routes to that company's schema. A bare db.connection() would + // hit the template connection (search_path 'public'), where the + // per-tenant `vehicles` table does not exist. + const { default: Vehicle } = await import('#app/models/tenant_scoped/vehicle') + const rows = await Vehicle.query().count('* as n') + return Number(rows[0]?.$extras.n ?? 0) + }, + { concurrency: 3 } + ) + return { + scanned: results.length, + failed: errors.length, + totalVehicles: results.reduce((a, r) => a + (r.value ?? 0), 0), + } + }, + }) + } + + private async registerReportExtensions() { + const registry = await this.app.container.make(ReportExtensionRegistry) + if (registry.has('fleet_utilization')) return + const app = this.app + registry.register({ + name: 'fleet_utilization', + description: 'Per-company fleet size + active rentals across the busiest tenants.', + contractVersion: REPORTING_CONTRACT_VERSION, + async execute(filters: ReportExtensionFilters) { + const reporting = await app.container.make(ReportingService) + const tenants = [] + for await (const { tenant } of reporting.iterateTenantsByUsage({ + since: filters.since, + until: filters.until, + })) { + tenants.push(tenant) + if (tenants.length >= 20) break + } + const { results } = await mapTenants( + tenants, + async (tenant) => { + // Inside tenancy.run(tenant): the tenant-scoped models route to this + // company's schema (a bare db.connection() would hit the 'public' + // template, where these tables do not live). + const { default: Vehicle } = await import('#app/models/tenant_scoped/vehicle') + const { default: Booking } = await import('#app/models/tenant_scoped/booking') + const fleet = await Vehicle.query().count('* as n') + const active = await Booking.query().where('status', 'active').count('* as n') + const vehicles = Number(fleet[0]?.$extras.n ?? 0) + const rented = Number(active[0]?.$extras.n ?? 0) + return { + tenant: tenant.id, + vehicles, + activeRentals: rented, + utilization: vehicles > 0 ? Math.round((rented / vehicles) * 100) : 0, + } + }, + { concurrency: 3 } + ) + return { companies: results.map((r) => r.value) } + }, + }) + } + + /** Domain event listeners (booking board / metrics feed) are wired here. */ + private async registerListeners() { + const emitter = await this.app.container.make('emitter') + const { default: BookingBoardListener } = await import('#app/listeners/booking_board_listener') + BookingBoardListener.register(emitter) + } +} diff --git a/apps/rental/app/repositories/tenant_repository.ts b/apps/rental/app/repositories/tenant_repository.ts new file mode 100644 index 00000000..1b099ef8 --- /dev/null +++ b/apps/rental/app/repositories/tenant_repository.ts @@ -0,0 +1,94 @@ +import Tenant from '#app/models/backoffice/tenant' +import type { + TenantRepositoryContract, + TenantModelContract, + TenantStatus, + EachOptions, +} from '@adonisjs-lasagna/saas-tenancy/types' + +/** + * Implements the contract the package looks up via the TENANT_REPOSITORY symbol. + * Bound in app/providers/app_provider.ts. `findByDomain` is what the + * `domain-or-subdomain` resolver calls to turn `acme.localhost` into a tenant. + */ +export default class TenantRepository implements TenantRepositoryContract { + async findById(id: string, includeDeleted = false): Promise { + const query = Tenant.query().where('id', id) + if (!includeDeleted) query.whereNull('deleted_at') + return query.first() + } + + async findByIdOrFail(id: string, includeDeleted = false): Promise { + const tenant = await this.findById(id, includeDeleted) + if (!tenant) throw new Error(`Tenant ${id} not found`) + return tenant + } + + async findByDomain(domain: string): Promise { + return Tenant.query().where('custom_domain', domain).whereNull('deleted_at').first() + } + + async all( + options: { includeDeleted?: boolean; statuses?: TenantStatus[] } = {} + ): Promise { + const query = Tenant.query().orderBy('created_at', 'desc') + if (!options.includeDeleted) query.whereNull('deleted_at') + if (options.statuses?.length) query.whereIn('status', options.statuses) + return query + } + + async whereIn(ids: string[], includeDeleted = false): Promise { + const query = Tenant.query().whereIn('id', ids) + if (!includeDeleted) query.whereNull('deleted_at') + return query + } + + /** + * Counts grouped by status, computed in the database. The package's `/metrics` + * collector prefers this over `all()` so a Prometheus scrape stays O(1) + * regardless of how many companies exist. + */ + async countByStatus( + options: { includeDeleted?: boolean } = {} + ): Promise>> { + const query = Tenant.query().select('status').count('* as total').groupBy('status') + if (!options.includeDeleted) query.whereNull('deleted_at') + const rows = await query + const result: Partial> = {} + for (const row of rows) { + result[row.status as TenantStatus] = Number((row.$extras as { total?: unknown }).total ?? 0) + } + return result + } + + async each( + callback: (tenant: TenantModelContract) => Promise | void, + options: EachOptions = {} + ): Promise { + const batchSize = Math.max(1, options.batchSize ?? 100) + // Keyset cursor on the primary key, not OFFSET pagination: a callback that + // mutates the rows being iterated would shift an offset window and silently + // skip rows, while an id cursor stays stable under any mutation. + let lastId: string | null = null + while (true) { + const query = Tenant.query().orderBy('id', 'asc').limit(batchSize) + if (lastId !== null) query.where('id', '>', lastId) + if (!options.includeDeleted) query.whereNull('deleted_at') + if (options.statuses?.length) query.whereIn('status', options.statuses) + const batch = await query + for (const tenant of batch) { + await callback(tenant) + } + if (batch.length < batchSize) break + lastId = batch[batch.length - 1]!.id + } + } + + async create(data: { + name: string + email: string + status: TenantStatus + }): Promise { + return new Tenant().merge(data).save() + } +} diff --git a/apps/rental/app/security/membership_authorizer.ts b/apps/rental/app/security/membership_authorizer.ts new file mode 100644 index 00000000..0431e47a --- /dev/null +++ b/apps/rental/app/security/membership_authorizer.ts @@ -0,0 +1,57 @@ +import type { TenantAccessAuthorizer } from '@adonisjs-lasagna/saas-tenancy/types' +import { isAuthorizedStaff } from '#app/security/session_realm' + +/** + * Anonymous requests are refused by default; only these public entry points on + * a tenant host may be reached without a session or token. Everything else + * requires proof of membership in the resolved company. + */ +const PUBLIC_TENANT_PATHS: RegExp[] = [ + /^\/login$/, // tenant staff login page (Inertia GET) + POST + /^\/auth\/login$/, // programmatic tenant login (API/e2e) + /^\/sso\//, // OIDC login start + callback + /^\/branding\/public/, // public branding used to theme the login page +] + +/** + * The membership gate (`config.authorizeTenantAccess`), run by + * TenantGuardMiddleware after the lifecycle checks. Returning `false` (or + * throwing) makes the guard answer 403 before any controller runs. + * + * Deny-by-default, unlike the reference demo which lets anonymous traffic + * through for curl exploration. Karimoto is a real app: the caller must prove + * they belong to the resolved company. + * + * The gate is prefix-agnostic on purpose — no branch inspects the `bko_` / + * `tnt_` token prefixes. An operator token on a tenant route fails here for the + * same structural reason a company-B token does: it is not a valid token of the + * RESOLVED company (its `auth_access_tokens` row lives in another schema). + */ +export function createMembershipAuthorizer(): TenantAccessAuthorizer { + return async (ctx, tenant) => { + // Programmatic realm: any bearer on a tenant route must be a valid token of + // the RESOLVED company. The tenant guard looks the token up inside that + // company's own schema, so operator tokens, garbage, and tokens minted by + // another company all fail. check() returns false on unauthorized and + // re-throws infra errors, which the authorizer registry converts to deny, + // so every path stays fail-closed. API/e2e callers address the company by + // the `x-tenant-id` header, which the adapter's sync resolver reads directly. + if (ctx.request.header('authorization')) { + return ctx.auth.use('tenant').check() + } + + // Browser realm: a valid `web-tenant` session that was issued FOR this + // company proves membership. isAuthorizedStaff() pins the session to its + // origin company and runs the staff lookup in the tenant's schema; a session + // stolen from another company is refused even if that company happens to + // have a user with the same per-schema id. (In a browser it never arrives at + // all — the session cookie is host-only to `.localhost`.) + if (await isAuthorizedStaff(ctx, tenant)) { + return true + } + + // Anonymous: allowed only at the public entry points, denied everywhere else. + const path = ctx.request.url() + return PUBLIC_TENANT_PATHS.some((re) => re.test(path)) + } +} diff --git a/apps/rental/app/security/session_realm.ts b/apps/rental/app/security/session_realm.ts new file mode 100644 index 00000000..f7b23dd6 --- /dev/null +++ b/apps/rental/app/security/session_realm.ts @@ -0,0 +1,35 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { TenantModelContract } from '@adonisjs-lasagna/saas-tenancy/types' + +/** + * Session key holding the id of the company a `web-tenant` session was issued + * for. Set once at login; read on every authorization. + */ +export const WEB_TENANT_COMPANY_KEY = 'web_tenant_company' + +/** + * True iff the request carries a valid `web-tenant` session that was issued for + * THIS exact company. + * + * The company binding is a real isolation control, not a nicety. Staff user ids + * are per-schema auto-increment integers, so two companies routinely have a + * user with the same id (e.g. each owner is `id = 1` in its own schema). A + * session that only carried the bare user id would therefore authenticate as + * company B's `id = 1` if company A's cookie were replayed against B's host. + * Host-only cookies stop that in a browser, but a stolen-cookie replay would + * slip through — so we also pin the session to the globally-unique company id + * captured at login. The cookie is encrypted with APP_KEY, so the pin cannot be + * forged. + * + * The user lookup runs inside `tenancy.run(tenant, …)` so the session guard's + * `TenantUser` query routes to this company's schema even on host-addressed + * browser requests, which carry no `x-tenant-id` header for the sync resolver. + */ +export async function isAuthorizedStaff( + ctx: HttpContext, + tenant: TenantModelContract +): Promise { + if (ctx.session?.get(WEB_TENANT_COMPANY_KEY) !== tenant.id) return false + const { tenancy } = await import('@adonisjs-lasagna/saas-tenancy') + return tenancy.run(tenant, () => ctx.auth.use('web-tenant').check()) +} diff --git a/apps/rental/app/services/booking_service.ts b/apps/rental/app/services/booking_service.ts new file mode 100644 index 00000000..72f4bbc1 --- /dev/null +++ b/apps/rental/app/services/booking_service.ts @@ -0,0 +1,144 @@ +import { inject } from '@adonisjs/core' +import { DateTime } from 'luxon' +import { randomUUID } from 'node:crypto' +import Booking, { type BookingStatus } from '#app/models/tenant_scoped/booking' +import Vehicle from '#app/models/tenant_scoped/vehicle' +import Customer from '#app/models/tenant_scoped/customer' +import VehicleCategory from '#app/models/tenant_scoped/vehicle_category' +import FleetService from '#app/services/fleet_service' +import PricingService from '#app/services/pricing_service' + +export class BookingError extends Error { + constructor( + public readonly code: string, + message: string + ) { + super(message) + } +} + +export interface CreateBookingInput { + customerId: string + vehicleId: string + pickupAt: DateTime + dropoffAt: DateTime + pickupLocationId?: string | null + dropoffLocationId?: string | null + extras?: string[] + confirm?: boolean +} + +/** `KRM-A1B2C3` */ +function bookingReference(): string { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + let code = '' + const bytes = randomUUID().replace(/-/g, '') + for (let i = 0; i < 6; i++) code += alphabet[Number.parseInt(bytes[i]!, 16) % alphabet.length] + return `KRM-${code}` +} + +/** + * The heart of the domain. Creating a booking validates the vehicle is free for + * the window (no double-book), prices it, and persists the breakdown. + * + * The satellite phase layers on: `enforceQuota('bookingsPerMonth')`, a + * `TenantDataChanged` emit (→ live dashboard over websockets + metrics), and a + * `booking.created` webhook. The pure domain rules live here. + */ +@inject() +export default class BookingService { + constructor( + private readonly fleet: FleetService, + private readonly pricing: PricingService + ) {} + + async create(input: CreateBookingInput): Promise { + if (input.dropoffAt <= input.pickupAt) { + throw new BookingError('invalid_dates', 'Dropoff must be after pickup.') + } + + const customer = await Customer.find(input.customerId) + if (!customer) throw new BookingError('customer_not_found', 'Customer does not exist.') + + const vehicle = await Vehicle.find(input.vehicleId) + if (!vehicle) throw new BookingError('vehicle_not_found', 'Vehicle does not exist.') + if (vehicle.status === 'retired' || vehicle.status === 'maintenance') { + throw new BookingError('vehicle_unavailable', `Vehicle is ${vehicle.status}.`) + } + + const free = await this.fleet.isAvailable(vehicle.id, input.pickupAt, input.dropoffAt) + if (!free) throw new BookingError('overlap', 'Vehicle is already booked for that window.') + + const category = await VehicleCategory.findOrFail(vehicle.categoryId) + const breakdown = this.pricing.quote( + category, + input.pickupAt, + input.dropoffAt, + input.extras ?? [] + ) + + const booking = new Booking() + booking.id = randomUUID() + booking.reference = bookingReference() + booking.customerId = customer.id + booking.vehicleId = vehicle.id + booking.pickupLocationId = input.pickupLocationId ?? vehicle.locationId ?? null + booking.pickupAt = input.pickupAt + booking.dropoffLocationId = input.dropoffLocationId ?? vehicle.locationId ?? null + booking.dropoffAt = input.dropoffAt + booking.status = input.confirm ? 'confirmed' : 'quote' + booking.priceBreakdown = breakdown + booking.depositHeld = input.confirm ? category.depositAmount : 0 + booking.extras = input.extras ?? [] + booking.totalAmount = breakdown.total + booking.currency = breakdown.currency + await booking.save() + return booking + } + + /** quote → confirmed: hold the deposit. */ + async confirm(id: string): Promise { + const booking = await Booking.findOrFail(id) + this.assertTransition(booking.status, 'confirmed', ['quote']) + const vehicle = await Vehicle.findOrFail(booking.vehicleId) + const category = await VehicleCategory.findOrFail(vehicle.categoryId) + booking.status = 'confirmed' + booking.depositHeld = category.depositAmount + await booking.save() + return booking + } + + /** confirmed → active: hand the keys over, mark the vehicle rented. */ + async activate(id: string): Promise { + const booking = await Booking.findOrFail(id) + this.assertTransition(booking.status, 'active', ['confirmed']) + booking.status = 'active' + await booking.save() + await this.fleet.setStatus(booking.vehicleId, 'rented') + return booking + } + + /** active → completed: take the vehicle back, free it. */ + async complete(id: string): Promise { + const booking = await Booking.findOrFail(id) + this.assertTransition(booking.status, 'completed', ['active']) + booking.status = 'completed' + await booking.save() + await this.fleet.setStatus(booking.vehicleId, 'available') + return booking + } + + async cancel(id: string): Promise { + const booking = await Booking.findOrFail(id) + this.assertTransition(booking.status, 'cancelled', ['quote', 'confirmed']) + booking.status = 'cancelled' + await booking.save() + return booking + } + + private assertTransition(from: BookingStatus, to: BookingStatus, allowedFrom: BookingStatus[]) { + if (!allowedFrom.includes(from)) { + throw new BookingError('invalid_transition', `Cannot move a ${from} booking to ${to}.`) + } + } +} diff --git a/apps/rental/app/services/customer_service.ts b/apps/rental/app/services/customer_service.ts new file mode 100644 index 00000000..b71aa033 --- /dev/null +++ b/apps/rental/app/services/customer_service.ts @@ -0,0 +1,122 @@ +import { inject } from '@adonisjs/core' +import { DateTime } from 'luxon' +import { randomUUID } from 'node:crypto' +import { EncryptedRepository } from '@adonisjs-lasagna/crypto' +import Customer from '#app/models/tenant_scoped/customer' + +/** Matches the category on Customer's encrypted fields. */ +const CATEGORY = 'renter-id' + +export interface CreateCustomerInput { + fullName: string + email?: string | null | undefined + phone?: string | null | undefined + cin?: string | null | undefined + driverLicense?: string | null | undefined + passport?: string | null | undefined + address?: string | null | undefined + dateOfBirth?: string | null | undefined + nationality?: string | null | undefined +} + +/** + * Renter records with crypto-protected PII. Writes go through the model + * instance so the `@encrypted`/`@searchable` hooks encrypt + index transparently + * (a raw write would be rejected by the DB CHECK). CIN search resolves the + * plaintext to its blind index first, so equality lookup never decrypts and + * still works after a shred. A shred destroys the renter's DEK, making every + * identity field unreadable at once. + */ +@inject() +export default class CustomerService { + constructor(private readonly crypto: EncryptedRepository) {} + + /** + * The list view. Shows masked PII (data minimisation) and, critically, must + * survive shredded renters: their DEK is gone, so the model's decrypt hook + * would throw `dek_missing` and 500 the whole list the moment one renter has + * exercised erasure. We read only the plaintext columns as POJOs (no model + * hydration → no decrypt hook, and the encrypted CIN / licence / passport + * ciphertext is never even selected). Full identity fields are decrypted only + * on the detail view, which fails closed to 410 once shredded. + */ + async list(): Promise[]> { + const rows = await Customer.query() + .select( + 'id', + 'full_name', + 'email', + 'phone', + 'address', + 'nationality', + 'date_of_birth', + 'created_at' + ) + .orderBy('created_at', 'desc') + .pojo<{ + id: string + full_name: string + email: string | null + phone: string | null + address: string | null + nationality: string | null + date_of_birth: string | null + created_at: string + }>() + + return rows.map((r) => ({ + id: r.id, + fullName: r.full_name, + email: r.email, + phone: r.phone, + address: r.address, + nationality: r.nationality, + dateOfBirth: r.date_of_birth, + createdAt: r.created_at, + // Encrypted identity fields are surfaced only on the detail view. + cin: null, + driverLicense: null, + passport: null, + })) + } + + find(id: string) { + return Customer.find(id) + } + + async create(input: CreateCustomerInput): Promise { + const customer = new Customer() + customer.id = randomUUID() + customer.fullName = input.fullName + customer.email = input.email ?? null + customer.phone = input.phone ?? null + customer.cin = input.cin ?? null + customer.driverLicense = input.driverLicense ?? null + customer.passport = input.passport ?? null + customer.address = input.address ?? null + customer.dateOfBirth = input.dateOfBirth ? DateTime.fromISO(input.dateOfBirth) : null + customer.nationality = input.nationality ?? 'MA' + await customer.save() + return customer + } + + /** Equality search by CIN via the keyed-HMAC blind index (never decrypts). */ + async searchByCin(cin: string): Promise { + const index = await this.crypto.blindIndex(CATEGORY, cin) + return Customer.query().where('cin_index', index) + } + + /** + * Crypto-shred a renter's DEK (gated by the erasabilityResolver + WORM + * ledger). Afterwards every identity field is irrecoverable; the index + * columns are nulled through a hook-free write so the equality/frequency leak + * is closed too. + */ + async shred(customerId: string) { + const result = await this.crypto.shred(customerId, CATEGORY) + await Customer.query() + .where('id', customerId) + .update({ cin_index: null, driver_license_index: null, passport_index: null }) + return result + } +} diff --git a/apps/rental/app/services/fleet_service.ts b/apps/rental/app/services/fleet_service.ts new file mode 100644 index 00000000..0fb3c444 --- /dev/null +++ b/apps/rental/app/services/fleet_service.ts @@ -0,0 +1,49 @@ +import { type DateTime } from 'luxon' +import Vehicle, { type VehicleStatus } from '#app/models/tenant_scoped/vehicle' +import Booking from '#app/models/tenant_scoped/booking' + +/** Bookings in these states occupy a vehicle for their date window. */ +const BLOCKING_STATUSES = ['confirmed', 'active'] as const + +/** + * Fleet availability + the vehicle status machine. Availability is derived from + * overlapping bookings, not a flag, so a double-book is impossible even if a + * status flag drifts. Read paths use the `_read` replica connection to + * demonstrate replica routing. + */ +export default class FleetService { + /** Is the vehicle free for [pickupAt, dropoffAt), ignoring `exceptBookingId`? */ + async isAvailable( + vehicleId: string, + pickupAt: DateTime, + dropoffAt: DateTime, + exceptBookingId?: string + ): Promise { + const query = Booking.query() + .where('vehicle_id', vehicleId) + .whereIn('status', [...BLOCKING_STATUSES]) + // Two ranges overlap when each starts before the other ends. + .where('pickup_at', '<', dropoffAt.toSQL()!) + .where('dropoff_at', '>', pickupAt.toSQL()!) + if (exceptBookingId) query.whereNot('id', exceptBookingId) + const clash = await query.first() + return clash === null + } + + /** Vehicles marked available AND with no blocking booking in the window. */ + async availableVehicles(pickupAt: DateTime, dropoffAt: DateTime): Promise { + const vehicles = await Vehicle.query().where('status', 'available').orderBy('plate', 'asc') + const free: Vehicle[] = [] + for (const v of vehicles) { + if (await this.isAvailable(v.id, pickupAt, dropoffAt)) free.push(v) + } + return free + } + + async setStatus(vehicleId: string, status: VehicleStatus): Promise { + const vehicle = await Vehicle.findOrFail(vehicleId) + vehicle.status = status + await vehicle.save() + return vehicle + } +} diff --git a/apps/rental/app/services/invoicing_service.ts b/apps/rental/app/services/invoicing_service.ts new file mode 100644 index 00000000..e6a97d83 --- /dev/null +++ b/apps/rental/app/services/invoicing_service.ts @@ -0,0 +1,60 @@ +import { DateTime } from 'luxon' +import { randomUUID } from 'node:crypto' +import Booking from '#app/models/tenant_scoped/booking' +import Invoice, { type InvoiceLine } from '#app/models/tenant_scoped/invoice' + +/** + * Issues VAT invoices from a completed/confirmed booking's price breakdown. + * The invoice number is a per-company yearly sequence (`INV-YYYY-NNNN`); since + * each company is its own schema, the counter never collides across tenants. + */ +export default class InvoicingService { + async generateForBooking(bookingId: string): Promise { + const booking = await Booking.findOrFail(bookingId) + const existing = await Invoice.query().where('booking_id', booking.id).first() + if (existing) return existing + + const breakdown = booking.priceBreakdown + const lines: InvoiceLine[] = [] + if (breakdown) { + lines.push({ + description: `Rental — ${breakdown.days} day(s)`, + quantity: breakdown.days, + unitAmount: breakdown.dailyRate, + amount: breakdown.base, + }) + if (breakdown.extras > 0) { + lines.push({ + description: 'Extras', + quantity: 1, + unitAmount: breakdown.extras, + amount: breakdown.extras, + }) + } + } + const subtotal = breakdown ? breakdown.base + breakdown.extras : booking.totalAmount + const vat = breakdown ? breakdown.vat : 0 + + const invoice = new Invoice() + invoice.id = randomUUID() + invoice.bookingId = booking.id + invoice.number = await this.nextNumber() + invoice.lines = lines + invoice.subtotal = subtotal + invoice.vat = vat + invoice.total = subtotal + vat + invoice.currency = booking.currency + invoice.issuedAt = DateTime.now() + await invoice.save() + return invoice + } + + private async nextNumber(): Promise { + const year = DateTime.now().year + const count = await Invoice.query() + .whereRaw('number like ?', [`INV-${year}-%`]) + .count('* as total') + const n = Number((count[0]?.$extras as { total?: unknown })?.total ?? 0) + 1 + return `INV-${year}-${String(n).padStart(4, '0')}` + } +} diff --git a/apps/rental/app/services/pricing_service.ts b/apps/rental/app/services/pricing_service.ts new file mode 100644 index 00000000..bb47cea2 --- /dev/null +++ b/apps/rental/app/services/pricing_service.ts @@ -0,0 +1,43 @@ +import { type DateTime } from 'luxon' +import type VehicleCategory from '#app/models/tenant_scoped/vehicle_category' +import type { PriceBreakdown } from '#app/models/tenant_scoped/booking' + +/** Moroccan TVA. */ +const VAT_RATE = 0.2 +/** Flat per-extra, per-day charge in santimat (50 MAD/day). */ +const EXTRA_DAILY_SANTIMAT = 5_000 + +/** + * Turns a category + date range + extras into a reproducible price breakdown. + * All amounts are integer santimat. Kept pure (no DB) so it is trivial to test + * and the same call powers a quote and the final invoice. + */ +export default class PricingService { + /** Whole rental days, minimum one, rounding a partial day up. */ + rentalDays(pickupAt: DateTime, dropoffAt: DateTime): number { + const hours = dropoffAt.diff(pickupAt, 'hours').hours + return Math.max(1, Math.ceil(hours / 24)) + } + + quote( + category: VehicleCategory, + pickupAt: DateTime, + dropoffAt: DateTime, + extras: string[] = [] + ): PriceBreakdown { + const days = this.rentalDays(pickupAt, dropoffAt) + const base = days * category.dailyRate + const extrasTotal = extras.length * EXTRA_DAILY_SANTIMAT * days + const subtotal = base + extrasTotal + const vat = Math.round(subtotal * VAT_RATE) + return { + days, + dailyRate: category.dailyRate, + base, + extras: extrasTotal, + vat, + total: subtotal + vat, + currency: 'MAD', + } + } +} diff --git a/apps/rental/app/services/tenants_service.ts b/apps/rental/app/services/tenants_service.ts new file mode 100644 index 00000000..aa4c8cef --- /dev/null +++ b/apps/rental/app/services/tenants_service.ts @@ -0,0 +1,95 @@ +import { DateTime } from 'luxon' +import { InstallTenant, UninstallTenant } from '@adonisjs-lasagna/saas-tenancy/jobs' +import Tenant, { type RentalMeta } from '#app/models/backoffice/tenant' + +export interface CreateCompanyInput { + name: string + email: string + // `| undefined` (not just `?`) so the validator's optional output passes under + // exactOptionalPropertyTypes; each defaults in `create()` below. + slug?: string | undefined + plan?: RentalMeta['plan'] | undefined + tier?: RentalMeta['tier'] | undefined + country?: string | undefined + currency?: string | undefined +} + +/** `Acme Cars` → `acme-cars`; the vanity host is `.localhost`. */ +function slugify(value: string): string { + return ( + value + .toLowerCase() + .normalize('NFKD') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) || 'company' + ) +} + +/** + * Company lifecycle operations the controllers delegate to. Keeps the model + * write + queue dispatch out of the request handler. The `beforeProvision` hook + * in `config/multitenancy.ts` runs inside InstallTenant and may abort by + * throwing (the company then flips to status=failed). + */ +export default class TenantsService { + list() { + return Tenant.query().orderBy('created_at', 'desc') + } + + show(id: string) { + return Tenant.query().where('id', id).first() + } + + async create(input: CreateCompanyInput) { + const slug = input.slug ?? slugify(input.name) + const baseDomain = (await import('#config/multitenancy')).default.baseDomain + const tenant = await new Tenant() + .merge({ + name: input.name, + email: input.email, + status: 'provisioning', + customDomain: `${slug}.${baseDomain}`, + metadata: { + plan: input.plan ?? 'starter', + tier: input.tier ?? 'standard', + country: input.country ?? 'MA', + currency: input.currency ?? 'MAD', + }, + }) + .save() + + await InstallTenant.dispatch({ tenantId: tenant.id }) + return tenant + } + + async activate(id: string) { + const tenant = await Tenant.findOrFail(id) + await tenant.activate() + return tenant + } + + async suspend(id: string) { + const tenant = await Tenant.findOrFail(id) + await tenant.suspend() + return tenant + } + + /** Marks the company deleted but preserves its `tenant_` schema. */ + async softDelete(id: string) { + const tenant = await Tenant.findOrFail(id) + // Invariant A: deletedAt and status move together (the schema is preserved for + // the retention window, but the lifecycle status must read 'deleted'). + tenant.deletedAt = DateTime.now() + tenant.status = 'deleted' + await tenant.save() + return tenant + } + + /** Queues UninstallTenant. The job drops the schema. */ + async destroy(id: string) { + const tenant = await Tenant.findOrFail(id) + await UninstallTenant.dispatch({ tenantId: tenant.id }) + return tenant + } +} diff --git a/apps/rental/app/validators/auth_validator.ts b/apps/rental/app/validators/auth_validator.ts new file mode 100644 index 00000000..768a0433 --- /dev/null +++ b/apps/rental/app/validators/auth_validator.ts @@ -0,0 +1,12 @@ +import vine from '@vinejs/vine' + +/** + * Shared by both realms' login endpoints. Shape only; the credential check + * itself is `verifyCredentials` in the controllers. + */ +export const loginValidator = vine.compile( + vine.object({ + email: vine.string().email(), + password: vine.string(), + }) +) diff --git a/apps/rental/app/validators/booking_validator.ts b/apps/rental/app/validators/booking_validator.ts new file mode 100644 index 00000000..a8ef515c --- /dev/null +++ b/apps/rental/app/validators/booking_validator.ts @@ -0,0 +1,17 @@ +import vine from '@vinejs/vine' +import type { ExactOptionalProps } from './exact_optional.js' + +const createBookingSchema = { + customerId: vine.string().uuid(), + vehicleId: vine.string().uuid(), + // ISO 8601 datetimes; parsed with DateTime.fromISO in the controller. + pickupAt: vine.string().trim(), + dropoffAt: vine.string().trim(), + pickupLocationId: vine.string().uuid().optional(), + dropoffLocationId: vine.string().uuid().optional(), + extras: vine.array(vine.string().trim().maxLength(60)).optional(), + confirm: vine.boolean().optional(), +} +export const createBookingValidator = vine.compile( + vine.object(createBookingSchema as ExactOptionalProps) +) diff --git a/apps/rental/app/validators/customer_validator.ts b/apps/rental/app/validators/customer_validator.ts new file mode 100644 index 00000000..6830c044 --- /dev/null +++ b/apps/rental/app/validators/customer_validator.ts @@ -0,0 +1,21 @@ +import vine from '@vinejs/vine' +import type { ExactOptionalProps } from './exact_optional.js' + +const createCustomerSchema = { + fullName: vine.string().trim().minLength(2).maxLength(160), + email: vine.string().trim().email().optional(), + phone: vine.string().trim().maxLength(40).optional(), + cin: vine.string().trim().maxLength(40).optional(), + driverLicense: vine.string().trim().maxLength(40).optional(), + passport: vine.string().trim().maxLength(40).optional(), + address: vine.string().trim().maxLength(240).optional(), + dateOfBirth: vine.string().trim().maxLength(40).optional(), + nationality: vine.string().trim().maxLength(60).optional(), +} +export const createCustomerValidator = vine.compile( + vine.object(createCustomerSchema as ExactOptionalProps) +) + +export const searchCustomerValidator = vine.compile( + vine.object({ cin: vine.string().trim().minLength(1).maxLength(40) }) +) diff --git a/apps/rental/app/validators/exact_optional.ts b/apps/rental/app/validators/exact_optional.ts new file mode 100644 index 00000000..1f0588fb --- /dev/null +++ b/apps/rental/app/validators/exact_optional.ts @@ -0,0 +1,29 @@ +/** + * Bridges VineJS schemas onto TypeScript's `exactOptionalPropertyTypes: true`. + * + * VineJS 4.x models `.optional()` / `.nullable()` fields with modifiers whose + * `allowNull` / `isOptional` getters are typed `boolean | undefined`, while + * VineJS's own `ConstructableSchema` declares them `boolean?`. Under + * `exactOptionalPropertyTypes` an optional property may be absent but not + * `undefined`, so every optional field trips TS2375. These aliases re-type only + * those three members back to what the runtime actually guarantees; the + * `[OTYPE]` inference marker is preserved, so `Infer<...>` stays exact. + * + * Usage: + * ```ts + * const schema = { title: vine.string(), body: vine.string().optional() } + * export const createFooValidator = vine.compile( + * vine.object(schema as ExactOptionalProps) + * ) + * ``` + */ +export type ExactOptionalSchema = Omit & { + allowNull?: boolean + isOptional?: boolean + clone(): ExactOptionalSchema +} + +/** Applies {@link ExactOptionalSchema} across every property of a `vine.object` map. */ +export type ExactOptionalProps

= { + [K in keyof P]: ExactOptionalSchema +} diff --git a/apps/rental/app/validators/fleet_validator.ts b/apps/rental/app/validators/fleet_validator.ts new file mode 100644 index 00000000..b764eb3d --- /dev/null +++ b/apps/rental/app/validators/fleet_validator.ts @@ -0,0 +1,49 @@ +import vine from '@vinejs/vine' +import type { ExactOptionalProps } from './exact_optional.js' + +const createLocationSchema = { + name: vine.string().trim().minLength(2).maxLength(120), + type: vine.enum(['airport', 'city', 'depot'] as const).optional(), + address: vine.string().trim().maxLength(240).optional(), + city: vine.string().trim().minLength(2).maxLength(120), + timezone: vine.string().trim().maxLength(60).optional(), + phone: vine.string().trim().maxLength(40).optional(), + openHour: vine.number().min(0).max(23).optional(), + closeHour: vine.number().min(0).max(23).optional(), +} +export const createLocationValidator = vine.compile( + vine.object(createLocationSchema as ExactOptionalProps) +) + +const createCategorySchema = { + name: vine.string().trim().minLength(2).maxLength(120), + code: vine.enum(['economy', 'compact', 'suv', 'luxury', 'van'] as const), + dailyRate: vine.number().min(0), + depositAmount: vine.number().min(0), + extras: vine.array(vine.string().trim().maxLength(60)).optional(), +} +export const createCategoryValidator = vine.compile( + vine.object(createCategorySchema as ExactOptionalProps) +) + +const createVehicleSchema = { + plate: vine.string().trim().minLength(2).maxLength(20), + makeId: vine.number().positive(), + modelId: vine.number().positive(), + year: vine.number().min(1990).max(2100), + categoryId: vine.string().uuid(), + locationId: vine.string().uuid().optional(), + fuel: vine.enum(['petrol', 'diesel', 'hybrid', 'electric'] as const).optional(), + transmission: vine.enum(['manual', 'automatic'] as const).optional(), + color: vine.string().trim().maxLength(40).optional(), + mileage: vine.number().min(0).optional(), +} +export const createVehicleValidator = vine.compile( + vine.object(createVehicleSchema as ExactOptionalProps) +) + +export const vehicleStatusValidator = vine.compile( + vine.object({ + status: vine.enum(['available', 'rented', 'maintenance', 'retired'] as const), + }) +) diff --git a/apps/rental/app/validators/tenants_validator.ts b/apps/rental/app/validators/tenants_validator.ts new file mode 100644 index 00000000..57bedb0d --- /dev/null +++ b/apps/rental/app/validators/tenants_validator.ts @@ -0,0 +1,38 @@ +import vine from '@vinejs/vine' +import type { ExactOptionalProps } from './exact_optional.js' + +/** + * Validates the body of the operator's "create company" form. `slug` becomes + * the vanity host `.localhost` (stored as `custom_domain`); plan/tier/ + * country/currency default in the service. The `@email` business rule stays in + * the `beforeProvision` hook so the hook-abort path is exercised. + */ +const createTenantSchema = { + name: vine.string().trim().minLength(2).maxLength(100), + email: vine.string().trim().email(), + slug: vine + .string() + .trim() + .toLowerCase() + .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/) + .minLength(2) + .maxLength(40) + .optional(), + plan: vine.enum(['starter', 'fleet', 'enterprise'] as const).optional(), + tier: vine.enum(['standard', 'premium'] as const).optional(), + country: vine.string().trim().fixedLength(2).toUpperCase().optional(), + currency: vine.string().trim().fixedLength(3).toUpperCase().optional(), +} + +export const createTenantValidator = vine.compile( + vine.object(createTenantSchema as ExactOptionalProps) +) + +/** ?keepSchema=true on DELETE /admin/tenants/:id */ +const destroyTenantQuerySchema = { + keepSchema: vine.boolean().optional(), +} + +export const destroyTenantQueryValidator = vine.compile( + vine.object(destroyTenantQuerySchema as ExactOptionalProps) +) diff --git a/apps/rental/bin/console.ts b/apps/rental/bin/console.ts new file mode 100644 index 00000000..e50e2b07 --- /dev/null +++ b/apps/rental/bin/console.ts @@ -0,0 +1,25 @@ +import 'reflect-metadata' +import { Ignitor, prettyPrintError } from '@adonisjs/core' + +const APP_ROOT = new URL('../', import.meta.url) +const IMPORTER = (filePath: string) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, APP_ROOT).href) + } + return import(filePath) +} + +new Ignitor(APP_ROOT, { importer: IMPORTER }) + .tap((app) => { + app.booting(async () => { + await import('#start/env') + }) + app.listen('SIGTERM', () => app.terminate()) + app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate()) + }) + .ace() + .handle(process.argv.splice(2)) + .catch((error) => { + process.exitCode = 1 + prettyPrintError(error) + }) diff --git a/apps/rental/bin/server.ts b/apps/rental/bin/server.ts new file mode 100644 index 00000000..819c3ed9 --- /dev/null +++ b/apps/rental/bin/server.ts @@ -0,0 +1,29 @@ +/** + * HTTP server entrypoint. `npm run dev` invokes `node ace serve`, which calls + * this file via the Ignitor's `httpServer()` runner. + */ +import 'reflect-metadata' +import { Ignitor, prettyPrintError } from '@adonisjs/core' + +const APP_ROOT = new URL('../', import.meta.url) +const IMPORTER = (filePath: string) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, APP_ROOT).href) + } + return import(filePath) +} + +new Ignitor(APP_ROOT, { importer: IMPORTER }) + .tap((app) => { + app.booting(async () => { + await import('#start/env') + }) + app.listen('SIGTERM', () => app.terminate()) + app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate()) + }) + .httpServer() + .start() + .catch((error) => { + process.exitCode = 1 + prettyPrintError(error) + }) diff --git a/apps/rental/bin/test.ts b/apps/rental/bin/test.ts new file mode 100644 index 00000000..02651d08 --- /dev/null +++ b/apps/rental/bin/test.ts @@ -0,0 +1,37 @@ +process.env.NODE_ENV = 'test' + +import 'reflect-metadata' +import { Ignitor, prettyPrintError } from '@adonisjs/core' +import { configure, processCLIArgs, run } from '@japa/runner' + +const APP_ROOT = new URL('../', import.meta.url) +const IMPORTER = (filePath: string) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, APP_ROOT).href) + } + return import(filePath) +} + +new Ignitor(APP_ROOT, { importer: IMPORTER }) + .tap((app) => { + app.booting(async () => { + await import('#start/env') + }) + app.listen('SIGTERM', () => app.terminate()) + app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate()) + }) + .testRunner() + .configure(async (app) => { + processCLIArgs(process.argv.splice(2)) + const { plugins, configureSuite } = await import('#tests/bootstrap') + configure({ + ...app.rcFile.tests, + ...(plugins !== undefined ? { plugins } : {}), + ...(configureSuite !== undefined ? { configureSuite } : {}), + }) + }) + .run(() => run()) + .catch(async (error) => { + process.exitCode = 1 + await prettyPrintError(error) + }) diff --git a/apps/rental/commands/rental_seed.ts b/apps/rental/commands/rental_seed.ts new file mode 100644 index 00000000..fa6c22d7 --- /dev/null +++ b/apps/rental/commands/rental_seed.ts @@ -0,0 +1,141 @@ +import { BaseCommand } from '@adonisjs/core/ace' +import type { CommandOptions } from '@adonisjs/core/types/ace' + +/** + * Seeds the platform operator and two demo rental companies. Idempotent, and + * refuses to run in production (it creates well-known credentials). Chained + * after `backoffice:setup` by `npm run setup`. + * + * The companies are created through the normal lifecycle: each dispatches an + * InstallTenant job, so `node ace queue:work` must be running to materialise the + * `tenant_` schemas (and the afterMigrate hook seeds an owner in each). + * + * Later phases grow this into a full fleet/customer/booking seed. + */ +export default class RentalSeed extends BaseCommand { + static readonly commandName = 'rental:seed' + static readonly description = 'Seed the operator account and two demo rental companies' + static readonly options: CommandOptions = { startApp: true } + + private readonly companies = [ + { name: 'Acme Cars', slug: 'acme', email: 'ops@acme.test', plan: 'fleet' as const }, + { + name: 'Sahara Cars', + slug: 'sahara-cars', + email: 'ops@sahara.test', + plan: 'starter' as const, + }, + ] + + async run() { + if (this.app.inProduction) { + this.logger.error( + 'rental:seed creates well-known credentials and refuses to run in production.' + ) + this.exitCode = 1 + return + } + + const { default: BackofficeUser } = await import('#app/models/backoffice/backoffice_user') + const { DEMO_OPERATOR, DEMO_TENANT_OWNER } = await import('#app/helpers/rental_credentials') + + await BackofficeUser.updateOrCreate( + { email: DEMO_OPERATOR.email }, + { password: DEMO_OPERATOR.password, fullName: DEMO_OPERATOR.fullName } + ) + this.logger.success(`Operator ready: ${DEMO_OPERATOR.email} / ${DEMO_OPERATOR.password}`) + + await this.seedCatalog() + + const { default: Tenant } = await import('#app/models/backoffice/tenant') + const { default: TenantsService } = await import('#app/services/tenants_service') + const service = new TenantsService() + + for (const c of this.companies) { + const existing = await Tenant.query().where('email', c.email).first() + if (existing) { + this.logger.info(`Company already present: ${c.name} (${existing.customDomain})`) + continue + } + const tenant = await service.create({ + name: c.name, + email: c.email, + slug: c.slug, + plan: c.plan, + }) + this.logger.success(`Queued company: ${c.name} → ${tenant.customDomain} (${tenant.id})`) + } + + this.logger.info('Run `node ace queue:work` to materialise the company schemas.') + this.logger.info( + `Then log in as staff on e.g. http://acme.localhost:3333 with ` + + `${DEMO_TENANT_OWNER.email} / ${DEMO_TENANT_OWNER.password}.` + ) + } + + /** Seed the shared central car catalog (Moroccan-market makes/models). */ + private async seedCatalog() { + const { default: CarMake } = await import('#app/models/central/car_make') + const { default: CarModel } = await import('#app/models/central/car_model') + + const catalog: Array<{ name: string; slug: string; models: Array<[string, string]> }> = [ + { + name: 'Dacia', + slug: 'dacia', + models: [ + ['Logan', 'sedan'], + ['Sandero', 'hatchback'], + ['Duster', 'suv'], + ], + }, + { + name: 'Renault', + slug: 'renault', + models: [ + ['Clio', 'hatchback'], + ['Mégane', 'sedan'], + ['Kangoo', 'van'], + ], + }, + { + name: 'Peugeot', + slug: 'peugeot', + models: [ + ['208', 'hatchback'], + ['301', 'sedan'], + ['3008', 'suv'], + ], + }, + { + name: 'Toyota', + slug: 'toyota', + models: [ + ['Yaris', 'hatchback'], + ['Corolla', 'sedan'], + ['RAV4', 'suv'], + ], + }, + { + name: 'Hyundai', + slug: 'hyundai', + models: [ + ['i10', 'hatchback'], + ['Accent', 'sedan'], + ['Tucson', 'suv'], + ], + }, + ] + + let makes = 0 + let models = 0 + for (const m of catalog) { + const make = await CarMake.updateOrCreate({ slug: m.slug }, { name: m.name, country: 'MA' }) + makes++ + for (const [name, bodyType] of m.models) { + await CarModel.updateOrCreate({ makeId: make.id, name }, { bodyType }) + models++ + } + } + this.logger.success(`Central catalog: ${makes} makes, ${models} models.`) + } +} diff --git a/apps/rental/commands/rental_seed_demo.ts b/apps/rental/commands/rental_seed_demo.ts new file mode 100644 index 00000000..6350e03f --- /dev/null +++ b/apps/rental/commands/rental_seed_demo.ts @@ -0,0 +1,856 @@ +import { BaseCommand } from '@adonisjs/core/ace' +import type { CommandOptions } from '@adonisjs/core/types/ace' +import { randomUUID, createHash } from 'node:crypto' +import { DateTime } from 'luxon' +// Type-only: erased at compile, so it is safe at command-discovery time (no +// runtime import of the model before the app has booted). +import type Tenant from '#app/models/backoffice/tenant' + +/** + * Fills each provisioned demo company with a believable working dataset: + * branches, a rate card, a fleet drawn from the shared catalog, renters with + * encrypted PII, bookings spread across the lifecycle (with invoices and + * payments for the completed ones), and a small RAG corpus of policy docs whose + * bodies are embedded into the tenant vector store. + * + * This is the data-plane companion to `rental:seed`. That command creates the + * company rows and dispatches provisioning; the schemas only exist once the + * queue worker has run InstallTenant and `migration:tenant:run` has migrated + * them. So this seed runs as a SEPARATE, later pass over the already-migrated + * companies. It is fully idempotent: every row is keyed on a natural identifier + * (location name, category code, plate, renter email, doc source), bookings are + * seeded only when a company has none yet, and the embedding insert dedups on + * `(source, content_hash)`. Re-running tops up anything missing and never + * duplicates. + * + * Refuses to run in production (it writes well-known demo data). + */ +export default class RentalSeedDemo extends BaseCommand { + static readonly commandName = 'rental:seed:demo' + static readonly description = + 'Fill the provisioned demo companies with fleet, renters, bookings and a RAG corpus' + static readonly options: CommandOptions = { startApp: true } + + async run() { + if (this.app.inProduction) { + this.logger.error('rental:seed:demo writes demo data and refuses to run in production.') + this.exitCode = 1 + return + } + + const { default: Tenant } = await import('#app/models/backoffice/tenant') + + // Addressable, live companies only: a company with no vanity host (e.g. one + // created ad hoc from the operator console) has no staff console to browse + // the data from, so there is nothing to demo there. + const companies = await Tenant.query() + .where('status', 'active') + .whereNotNull('custom_domain') + .orderBy('created_at') + + if (companies.length === 0) { + this.logger.warning( + 'No addressable active companies found. Run `rental:seed`, then the queue worker ' + + 'and `migration:tenant:run`, before seeding demo data.' + ) + return + } + + let seeded = 0 + for (const company of companies) { + try { + await this.#seedCompany(company) + seeded++ + } catch (error) { + // One company failing (e.g. a schema not migrated yet) must not abort the + // rest of the fleet — surface it and move on. + this.logger.error( + `Failed to seed ${company.name} (${company.customDomain}): ${(error as Error).message}` + ) + } + } + this.logger.success(`Demo data ready for ${seeded}/${companies.length} companies.`) + } + + /** Seed one company's schema. Runs inside its tenancy scope so every tenant + * model + the vector-store insert land in `tenant_`. */ + async #seedCompany(company: Tenant) { + const { tenancy } = await import('@adonisjs-lasagna/saas-tenancy') + const plan = company.metadata?.plan ?? 'starter' + const profile = plan === 'starter' ? PROFILES.starter : PROFILES.full + + await tenancy.run(company, async () => { + const locations = await this.#seedLocations(profile) + const categories = await this.#seedCategories() + const vehicles = await this.#seedVehicles(profile, categories, locations) + const customers = await this.#seedCustomers(profile) + await this.#seedBookings(vehicles, customers) + const embedded = await this.#seedKnowledge(company) + this.logger.info( + ` ${company.name}: ${locations.length} branches, ${vehicles.length} vehicles, ` + + `${customers.length} renters, ${embedded} policy docs embedded.` + ) + }) + } + + // ─── Branches ──────────────────────────────────────────────────── + async #seedLocations(profile: SeedProfile) { + const { default: RentalLocation } = await import('#app/models/tenant_scoped/rental_location') + const out: InstanceType[] = [] + for (const spec of profile.locations) { + let loc = await RentalLocation.query().where('name', spec.name).first() + if (!loc) { + loc = await RentalLocation.create({ + id: randomUUID(), + name: spec.name, + type: spec.type, + address: spec.address, + city: spec.city, + timezone: 'Africa/Casablanca', + phone: spec.phone ?? null, + openHour: spec.openHour ?? 8, + closeHour: spec.closeHour ?? 20, + }) + } + out.push(loc) + } + return out + } + + // ─── Rate card ─────────────────────────────────────────────────── + // Returns a code → category-id lookup (that is all the fleet seed needs). + async #seedCategories(): Promise> { + const { default: VehicleCategory } = await import('#app/models/tenant_scoped/vehicle_category') + const byCode = new Map() + for (const spec of CATEGORIES) { + let cat = await VehicleCategory.query().where('code', spec.code).first() + if (!cat) { + cat = await VehicleCategory.create({ + id: randomUUID(), + name: spec.name, + code: spec.code, + dailyRate: spec.dailyRate, + depositAmount: spec.depositAmount, + extras: spec.extras, + }) + } + byCode.set(spec.code, cat.id) + } + return byCode + } + + // ─── Fleet ─────────────────────────────────────────────────────── + async #seedVehicles( + profile: SeedProfile, + categories: Map, + locations: { id: string }[] + ) { + const { default: Vehicle } = await import('#app/models/tenant_scoped/vehicle') + const { default: CarModel } = await import('#app/models/central/car_model') + + // Resolve the shared catalog once: (makeSlug::modelName) → { makeId, modelId, makeName, modelName }. + const models = await CarModel.query().preload('make') + const catalog = new Map< + string, + { makeId: number; modelId: number; makeName: string; modelName: string } + >() + for (const m of models) { + catalog.set(`${m.make.slug}::${m.name}`, { + makeId: m.makeId, + modelId: m.id, + makeName: m.make.name, + modelName: m.name, + }) + } + + const out: InstanceType[] = [] + for (const spec of profile.vehicles) { + let vehicle = await Vehicle.query().where('plate', spec.plate).first() + if (!vehicle) { + const ref = catalog.get(`${spec.makeSlug}::${spec.modelName}`) + if (!ref) { + this.logger.warning( + ` skipping ${spec.plate}: catalog has no ${spec.makeSlug} ${spec.modelName}` + ) + continue + } + const categoryId = categories.get(spec.categoryCode) + if (!categoryId) continue + const location = locations[spec.locationIndex % locations.length] + vehicle = await Vehicle.create({ + id: randomUUID(), + plate: spec.plate, + makeId: ref.makeId, + modelId: ref.modelId, + makeName: ref.makeName, + modelName: ref.modelName, + year: spec.year, + categoryId, + locationId: location?.id ?? null, + status: 'available', + mileage: spec.mileage, + fuel: spec.fuel, + transmission: spec.transmission, + color: spec.color, + }) + } + out.push(vehicle) + } + return out + } + + // ─── Renters (encrypted PII) ───────────────────────────────────── + async #seedCustomers(profile: SeedProfile) { + const { default: Customer } = await import('#app/models/tenant_scoped/customer') + + const out: InstanceType[] = [] + for (const spec of profile.customers) { + let customer = await Customer.query().where('email', spec.email).first() + if (!customer) { + // Set the identity fields on the model instance and save, exactly as + // CustomerService.create does: the `@encrypted`/`@searchable` hooks encrypt + // cin/driverLicense/passport and write their blind indexes transparently (a + // raw insert of plaintext is rejected by the DB CHECK). We build the model + // directly rather than resolving CustomerService, whose EncryptedRepository + // dependency is not constructable outside an HTTP request; the encryption is + // the model's job either way. + customer = new Customer() + customer.id = randomUUID() + customer.fullName = spec.fullName + customer.email = spec.email + customer.phone = spec.phone + customer.cin = spec.cin ?? null + customer.driverLicense = spec.driverLicense ?? null + customer.passport = spec.passport ?? null + customer.address = spec.address + customer.dateOfBirth = DateTime.fromISO(spec.dateOfBirth) + customer.nationality = spec.nationality + await customer.save() + } + out.push(customer) + } + return out + } + + // ─── Bookings + invoices + payments ────────────────────────────── + async #seedBookings(vehicles: { id: string }[], customers: { id: string }[]) { + const { default: Booking } = await import('#app/models/tenant_scoped/booking') + const { default: Payment } = await import('#app/models/tenant_scoped/payment') + const { default: BookingService } = await import('#app/services/booking_service') + const { default: FleetService } = await import('#app/services/fleet_service') + const { default: PricingService } = await import('#app/services/pricing_service') + const { default: InvoicingService } = await import('#app/services/invoicing_service') + + // Bookings have no stable natural key, so seed them only once per company. + const existing = await Booking.query().limit(1) + if (existing.length > 0) return + if (vehicles.length < 4 || customers.length < 3) return + + // Construct BookingService with its (dependency-free) collaborators directly. + // Container resolution of @inject classes relies on decorator metadata that + // esbuild (tsx) does not emit, so it fails in this command context; the HTTP + // server uses a metadata-emitting loader and is unaffected. + const bookings = new BookingService(new FleetService(), new PricingService()) + const invoicing = new InvoicingService() + const now = DateTime.now() + + // A completed rental in the recent past → carries an invoice + a settled payment. + const completed = await bookings.create({ + customerId: customers[0]!.id, + vehicleId: vehicles[0]!.id, + pickupAt: now.minus({ days: 20 }), + dropoffAt: now.minus({ days: 17 }), + confirm: true, + }) + await bookings.activate(completed.id) + await bookings.complete(completed.id) + const invoice = await invoicing.generateForBooking(completed.id) + await Payment.create({ + id: randomUUID(), + bookingId: completed.id, + amount: invoice.total, + currency: invoice.currency, + method: 'card', + status: 'paid', + reference: `PAY-${invoice.number}`, + paidAt: now.minus({ days: 20 }), + }) + + // An active rental spanning today → the picked-up vehicle shows as rented. + const active = await bookings.create({ + customerId: customers[1]!.id, + vehicleId: vehicles[1]!.id, + pickupAt: now.minus({ days: 1 }), + dropoffAt: now.plus({ days: 3 }), + confirm: true, + }) + await bookings.activate(active.id) + + // A confirmed upcoming rental with paid extras. + await bookings.create({ + customerId: customers[2]!.id, + vehicleId: vehicles[2]!.id, + pickupAt: now.plus({ days: 5 }), + dropoffAt: now.plus({ days: 9 }), + extras: ['gps', 'child_seat'], + confirm: true, + }) + + // An open quote a renter has not committed to yet. + await bookings.create({ + customerId: customers[customers.length - 1]!.id, + vehicleId: vehicles[3]!.id, + pickupAt: now.plus({ days: 14 }), + dropoffAt: now.plus({ days: 16 }), + confirm: false, + }) + } + + // ─── RAG corpus: policy docs + their embeddings ────────────────── + /** + * Create the fleet-assistant knowledge docs and embed their bodies into the + * per-tenant `ai_embeddings` store so `retrieve:true` returns grounded matches. + * + * The AI satellite's ingestion service is internal (not a public export) and, + * more to the point, it meters `aiTokens` quota — which would make a company + * show AI usage before anyone has chatted. So this uses the two PUBLIC seams + * the app already owns: the registered embedding provider (mock offline, the + * real backend when a key is configured, so docs and queries always share one + * vector space) to produce the vectors, and a direct insert into the app-owned + * `ai_embeddings` table (the same table the app's own tenant migration 0014 + * declares), mirroring the vector store's idempotent `ON CONFLICT` insert. + */ + async #seedKnowledge(company: Tenant): Promise { + const { default: FleetDoc } = await import('#app/models/tenant_scoped/fleet_doc') + const { EmbeddingProviderRegistry } = await import('@adonisjs-lasagna/ai') + const { default: db } = await import('@adonisjs/lucid/services/db') + const { default: multitenancyConfig } = await import('#config/multitenancy') + + const registry = await this.app.container.make(EmbeddingProviderRegistry) + const provider = registry.resolve(multitenancyConfig.ai.embedding) + const client = db.connection(`${multitenancyConfig.tenantConnectionNamePrefix}${company.id}`) + + let embedded = 0 + for (const doc of FLEET_DOCS) { + let row = await FleetDoc.query().where('source', doc.source).first() + if (!row) { + row = await FleetDoc.create({ + id: randomUUID(), + title: doc.title, + body: doc.body, + source: doc.source, + }) + } + + const result = await provider.embed({ input: [doc.body] }, AbortSignal.timeout(30_000)) + const vector = result.embeddings[0] ?? [] + const contentHash = dedupHash(result.model, doc.body) + // safe-sql: `ai_embeddings` is a fixed table this app owns; every value is a + // bind. Mirrors VectorStoreService.insert so a later /ai/embed of the same + // doc dedups against this row rather than duplicating it. `actor` is omitted + // (it defaults to NULL): these rows are system-seeded, not user-attributed. + await client.rawQuery( + `INSERT INTO ai_embeddings (source, content_hash, content, metadata, model, dim, embedding) ` + + `VALUES (?, ?, ?, ?::jsonb, ?, ?, ?::vector) ON CONFLICT (source, content_hash) DO NOTHING`, + [ + doc.source, + contentHash, + doc.body, + JSON.stringify({ title: doc.title, kind: 'fleet-doc' }), + result.model, + result.dimension, + `[${vector.join(',')}]`, + ] + ) + if (!row.embeddedAt) { + row.embeddedAt = DateTime.now() + await row.save() + } + embedded++ + } + return embedded + } +} + +/** + * The row dedup key the vector store uses: SHA-256 over (SHA-256(model), content). + * Replicated so a seeded row and one later ingested through `/ai/embed` collide + * on the `UNIQUE (source, content_hash)` constraint instead of double-storing. + */ +function dedupHash(model: string, content: string): string { + const modelKey = createHash('sha256').update(model).digest('hex') + return createHash('sha256').update(modelKey).update(content).digest('hex') +} + +// ─── Seed data ───────────────────────────────────────────────────── +// Money is integer santimat (1 MAD = 100 santimat) throughout. + +type LocationType = 'airport' | 'city' | 'depot' +type FuelType = 'petrol' | 'diesel' | 'hybrid' | 'electric' +type Transmission = 'manual' | 'automatic' +type CategoryCode = 'economy' | 'compact' | 'suv' | 'luxury' | 'van' + +interface LocationSpec { + name: string + type: LocationType + city: string + address: string + phone?: string + openHour?: number + closeHour?: number +} + +interface VehicleSpec { + plate: string + makeSlug: string + modelName: string + categoryCode: CategoryCode + year: number + fuel: FuelType + transmission: Transmission + color: string + mileage: number + locationIndex: number +} + +interface CustomerSpec { + fullName: string + email: string + phone: string + cin?: string + driverLicense?: string + passport?: string + address: string + dateOfBirth: string + nationality: string +} + +interface SeedProfile { + locations: LocationSpec[] + vehicles: VehicleSpec[] + customers: CustomerSpec[] +} + +const CATEGORIES: Array<{ + name: string + code: CategoryCode + dailyRate: number + depositAmount: number + extras: string[] +}> = [ + { name: 'Economy', code: 'economy', dailyRate: 20_000, depositAmount: 300_000, extras: ['gps'] }, + { + name: 'Compact', + code: 'compact', + dailyRate: 28_000, + depositAmount: 400_000, + extras: ['gps', 'additional_driver'], + }, + { + name: 'SUV', + code: 'suv', + dailyRate: 45_000, + depositAmount: 700_000, + extras: ['gps', 'child_seat'], + }, + { + name: 'Luxury', + code: 'luxury', + dailyRate: 90_000, + depositAmount: 1_500_000, + extras: ['gps', 'child_seat', 'chauffeur'], + }, + { + name: 'Van', + code: 'van', + dailyRate: 55_000, + depositAmount: 800_000, + extras: ['gps', 'additional_driver'], + }, +] + +const FLEET_DOCS: Array<{ source: string; title: string; body: string }> = [ + { + source: 'policy-rental-terms', + title: 'Rental Terms & Conditions', + body: + 'Renters must be at least 21 years old and have held a valid driving licence for one year or ' + + 'more. A security deposit is pre-authorised on the renter card at pickup and released after the ' + + 'car is returned undamaged. Economy and compact categories include unlimited mileage; SUV and ' + + 'luxury categories are capped at 250 km per day with an excess-kilometre charge. Cross-border ' + + 'travel outside Morocco requires prior written authorisation and a supplementary insurance rider.', + }, + { + source: 'policy-insurance', + title: 'Insurance & Damage Waiver', + body: + 'Every vehicle includes third-party liability cover as required by Moroccan law. The optional ' + + 'Collision Damage Waiver reduces the renter liability to the stated deductible. Tyres, the ' + + 'windscreen, the underbody and lost keys are excluded from the standard waiver unless the ' + + 'premium protection package is purchased at booking. Damage must be reported within 24 hours and ' + + 'a police report is required for any theft or third-party accident.', + }, + { + source: 'policy-fuel', + title: 'Fuel Policy', + body: + 'Vehicles are supplied full-to-full: the tank is full at pickup and must be returned full. A ' + + 'car returned with less fuel is charged for the missing litres plus a refuelling service fee. ' + + 'Diesel vehicles are labelled on the key fob and the fuel filler cap; using the wrong fuel is ' + + 'billed to the renter. Electric and hybrid vehicles are returned charged above 50 percent.', + }, + { + source: 'faq-pickup', + title: 'Pickup & Return FAQ', + body: + 'Airport pickups include a meet-and-greet at the arrivals hall; bring the booking reference, your ' + + 'passport or CIN and your driving licence. City-branch pickups open from 8am. Late returns beyond ' + + 'the 59-minute grace period are charged one additional rental day. A different drop-off branch is ' + + 'possible for a one-way fee quoted at booking. Child seats and additional drivers are added at the desk.', + }, +] + +const PROFILES: Record<'starter' | 'full', SeedProfile> = { + // Fleet / enterprise plans: three branches, a dozen cars, four renters. + full: { + locations: [ + { + name: 'Casablanca Mohammed V Airport', + type: 'airport', + city: 'Casablanca', + address: 'Nouaceur, Casablanca 20240', + openHour: 6, + closeHour: 23, + }, + { + name: 'Casablanca Downtown', + type: 'city', + city: 'Casablanca', + address: 'Bd Mohammed V, Casablanca 20250', + }, + { + name: 'Marrakech Menara Airport', + type: 'airport', + city: 'Marrakech', + address: 'Menara, Marrakech 40000', + openHour: 6, + closeHour: 23, + }, + ], + vehicles: [ + { + plate: '10001-A-6', + makeSlug: 'dacia', + modelName: 'Sandero', + categoryCode: 'economy', + year: 2023, + fuel: 'diesel', + transmission: 'manual', + color: 'White', + mileage: 24_500, + locationIndex: 0, + }, + { + plate: '10002-A-6', + makeSlug: 'hyundai', + modelName: 'i10', + categoryCode: 'economy', + year: 2022, + fuel: 'petrol', + transmission: 'manual', + color: 'Grey', + mileage: 41_200, + locationIndex: 1, + }, + { + plate: '10003-A-6', + makeSlug: 'toyota', + modelName: 'Yaris', + categoryCode: 'economy', + year: 2023, + fuel: 'hybrid', + transmission: 'automatic', + color: 'Red', + mileage: 18_900, + locationIndex: 0, + }, + { + plate: '10004-A-6', + makeSlug: 'renault', + modelName: 'Clio', + categoryCode: 'compact', + year: 2023, + fuel: 'diesel', + transmission: 'manual', + color: 'Blue', + mileage: 30_100, + locationIndex: 1, + }, + { + plate: '10005-A-6', + makeSlug: 'peugeot', + modelName: '208', + categoryCode: 'compact', + year: 2022, + fuel: 'petrol', + transmission: 'manual', + color: 'Black', + mileage: 52_300, + locationIndex: 0, + }, + { + plate: '10006-A-6', + makeSlug: 'dacia', + modelName: 'Logan', + categoryCode: 'compact', + year: 2023, + fuel: 'diesel', + transmission: 'manual', + color: 'Silver', + mileage: 27_800, + locationIndex: 2, + }, + { + plate: '10007-A-6', + makeSlug: 'toyota', + modelName: 'Corolla', + categoryCode: 'compact', + year: 2024, + fuel: 'hybrid', + transmission: 'automatic', + color: 'White', + mileage: 9_400, + locationIndex: 1, + }, + { + plate: '10008-A-6', + makeSlug: 'dacia', + modelName: 'Duster', + categoryCode: 'suv', + year: 2023, + fuel: 'diesel', + transmission: 'manual', + color: 'Beige', + mileage: 33_600, + locationIndex: 0, + }, + { + plate: '10009-A-6', + makeSlug: 'peugeot', + modelName: '3008', + categoryCode: 'suv', + year: 2024, + fuel: 'diesel', + transmission: 'automatic', + color: 'Grey', + mileage: 12_050, + locationIndex: 2, + }, + { + plate: '10010-A-6', + makeSlug: 'toyota', + modelName: 'RAV4', + categoryCode: 'suv', + year: 2024, + fuel: 'hybrid', + transmission: 'automatic', + color: 'Blue', + mileage: 7_800, + locationIndex: 0, + }, + { + plate: '10011-A-6', + makeSlug: 'hyundai', + modelName: 'Tucson', + categoryCode: 'suv', + year: 2023, + fuel: 'diesel', + transmission: 'automatic', + color: 'Black', + mileage: 21_400, + locationIndex: 1, + }, + { + plate: '10012-A-6', + makeSlug: 'renault', + modelName: 'Kangoo', + categoryCode: 'van', + year: 2022, + fuel: 'diesel', + transmission: 'manual', + color: 'White', + mileage: 61_700, + locationIndex: 2, + }, + ], + customers: [ + { + fullName: 'Youssef El Amrani', + email: 'youssef.elamrani@example.ma', + phone: '+212611000001', + cin: 'BE102938', + driverLicense: 'DL445566', + address: '12 Rue des Fleurs, Casablanca', + dateOfBirth: '1988-03-12', + nationality: 'MA', + }, + { + fullName: 'Fatima Zahra Bennani', + email: 'fatimazahra.bennani@example.ma', + phone: '+212611000002', + cin: 'BK884412', + driverLicense: 'DL992133', + address: '44 Av Hassan II, Rabat', + dateOfBirth: '1992-07-25', + nationality: 'MA', + }, + { + fullName: 'Karim Idrissi', + email: 'karim.idrissi@example.ma', + phone: '+212611000003', + cin: 'AB556677', + driverLicense: 'DL330099', + passport: 'MA1234567', + address: '8 Rue Atlas, Marrakech', + dateOfBirth: '1985-11-02', + nationality: 'MA', + }, + { + fullName: 'Sophie Laurent', + email: 'sophie.laurent@example.fr', + phone: '+33600000004', + driverLicense: 'FR778812', + passport: 'FR9988776', + address: 'Rue de Rivoli, Paris', + dateOfBirth: '1990-01-19', + nationality: 'FR', + }, + ], + }, + // Starter plan: two branches, a handful of cars, three renters (stays under the + // starter vehiclesPerTenant=10 quota). + starter: { + locations: [ + { + name: 'Agadir Al Massira Airport', + type: 'airport', + city: 'Agadir', + address: 'Al Massira, Agadir 80000', + openHour: 6, + closeHour: 22, + }, + { + name: 'Agadir City Center', + type: 'city', + city: 'Agadir', + address: 'Av Hassan II, Agadir 80000', + }, + ], + vehicles: [ + { + plate: '20001-S-6', + makeSlug: 'dacia', + modelName: 'Sandero', + categoryCode: 'economy', + year: 2022, + fuel: 'diesel', + transmission: 'manual', + color: 'White', + mileage: 48_200, + locationIndex: 0, + }, + { + plate: '20002-S-6', + makeSlug: 'hyundai', + modelName: 'i10', + categoryCode: 'economy', + year: 2023, + fuel: 'petrol', + transmission: 'manual', + color: 'Blue', + mileage: 22_600, + locationIndex: 1, + }, + { + plate: '20003-S-6', + makeSlug: 'renault', + modelName: 'Clio', + categoryCode: 'compact', + year: 2022, + fuel: 'diesel', + transmission: 'manual', + color: 'Grey', + mileage: 39_900, + locationIndex: 0, + }, + { + plate: '20004-S-6', + makeSlug: 'peugeot', + modelName: '301', + categoryCode: 'compact', + year: 2023, + fuel: 'diesel', + transmission: 'manual', + color: 'Silver', + mileage: 28_300, + locationIndex: 1, + }, + { + plate: '20005-S-6', + makeSlug: 'dacia', + modelName: 'Duster', + categoryCode: 'suv', + year: 2023, + fuel: 'diesel', + transmission: 'manual', + color: 'Beige', + mileage: 19_100, + locationIndex: 0, + }, + { + plate: '20006-S-6', + makeSlug: 'renault', + modelName: 'Kangoo', + categoryCode: 'van', + year: 2021, + fuel: 'diesel', + transmission: 'manual', + color: 'White', + mileage: 72_400, + locationIndex: 1, + }, + ], + customers: [ + { + fullName: 'Hassan Ouahbi', + email: 'hassan.ouahbi@example.ma', + phone: '+212612000001', + cin: 'JD223344', + driverLicense: 'DL112255', + address: '3 Av Hassan II, Agadir', + dateOfBirth: '1979-06-30', + nationality: 'MA', + }, + { + fullName: 'Nadia Chraibi', + email: 'nadia.chraibi@example.ma', + phone: '+212612000002', + cin: 'JC778899', + driverLicense: 'DL665544', + address: '21 Rue Souss, Agadir', + dateOfBirth: '1995-09-14', + nationality: 'MA', + }, + { + fullName: 'Omar Tazi', + email: 'omar.tazi@example.ma', + phone: '+212612000003', + cin: 'JE445566', + driverLicense: 'DL887766', + address: 'Taroudant Centre', + dateOfBirth: '1983-12-05', + nationality: 'MA', + }, + ], + }, +} diff --git a/apps/rental/config/app.ts b/apps/rental/config/app.ts new file mode 100644 index 00000000..c142d8c6 --- /dev/null +++ b/apps/rental/config/app.ts @@ -0,0 +1,18 @@ +import env from '#start/env' +import { defineConfig } from '@adonisjs/core/http' + +export const appKey = env.get('APP_KEY') + +export const http = defineConfig({ + generateRequestId: true, + allowMethodSpoofing: false, + useAsyncLocalStorage: true, + cookie: { + domain: '', + path: '/', + maxAge: '2h', + httpOnly: true, + secure: false, + sameSite: 'lax', + }, +}) diff --git a/apps/rental/config/auth.ts b/apps/rental/config/auth.ts new file mode 100644 index 00000000..cb9db7f5 --- /dev/null +++ b/apps/rental/config/auth.ts @@ -0,0 +1,61 @@ +import { defineConfig } from '@adonisjs/auth' +import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens' +import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session' +import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types' + +/** + * Two fully separate auth realms, one token guard each. They share nothing: the + * `backoffice` guard reads operators from `backoffice.backoffice_users` and + * stores tokens in `backoffice.auth_access_tokens`; the `tenant` guard reads + * staff from the resolved tenant's own schema, so its tokens live in + * `tenant_.auth_access_tokens`. The schema routing is not configured + * here — it falls out of the model each provider points at (token storage + * resolves through `model.$adapter`, and the package installs the right adapter + * on each base model at boot). + * + * The browser consoles (Inertia) authenticate with the `web-*` session guards; + * the token guards stay for the programmatic API and the e2e suite. Each session + * guard points at the SAME model as its token twin, so schema routing is + * identical — a `web-tenant` session still loads staff from the resolved + * company's own schema. Remember-me tokens are off, so no extra table is needed: + * the encrypted cookie store holds the whole session. + */ +const authConfig = defineConfig({ + default: 'tenant', + guards: { + 'backoffice': tokensGuard({ + provider: tokensUserProvider({ + tokens: 'accessTokens', + model: () => import('#app/models/backoffice/backoffice_user'), + }), + }), + 'tenant': tokensGuard({ + provider: tokensUserProvider({ + tokens: 'accessTokens', + model: () => import('#app/models/tenant_scoped/tenant_user'), + }), + }), + 'web-backoffice': sessionGuard({ + useRememberMeTokens: false, + provider: sessionUserProvider({ + model: () => import('#app/models/backoffice/backoffice_user'), + }), + }), + 'web-tenant': sessionGuard({ + useRememberMeTokens: false, + provider: sessionUserProvider({ + model: () => import('#app/models/tenant_scoped/tenant_user'), + }), + }), + }, +}) + +export default authConfig + +declare module '@adonisjs/auth/types' { + export interface Authenticators extends InferAuthenticators {} +} + +declare module '@adonisjs/core/types' { + interface EventsList extends InferAuthEvents {} +} diff --git a/apps/rental/config/bodyparser.ts b/apps/rental/config/bodyparser.ts new file mode 100644 index 00000000..5f1ea257 --- /dev/null +++ b/apps/rental/config/bodyparser.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@adonisjs/core/bodyparser' + +export default defineConfig({ + allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'], + form: { convertEmptyStringsToNull: true, types: ['application/x-www-form-urlencoded'] }, + json: { convertEmptyStringsToNull: true, types: ['application/json'] }, + multipart: { + autoProcess: true, + convertEmptyStringsToNull: true, + processManually: [], + types: ['multipart/form-data'], + }, +}) diff --git a/apps/rental/config/database.ts b/apps/rental/config/database.ts new file mode 100644 index 00000000..7797a615 --- /dev/null +++ b/apps/rental/config/database.ts @@ -0,0 +1,70 @@ +import env from '#start/env' +import multitenancyConfig from '#config/multitenancy' +import { defineConfig } from '@adonisjs/lucid' + +// No `as const` here: it would freeze `migrations.paths` into a readonly tuple, +// which Lucid's config type rejects (it wants a mutable string[]). DB_PASSWORD +// is optional (local Postgres often trusts the socket); omit the key when unset +// rather than passing `undefined`, which exactOptionalPropertyTypes rejects. +const dbPassword = env.get('DB_PASSWORD') + +const baseConnection = { + client: 'pg' as const, + connection: { + host: env.get('DB_HOST'), + port: env.get('DB_PORT'), + user: env.get('DB_USER'), + ...(dbPassword !== undefined ? { password: dbPassword } : {}), + database: env.get('DB_DATABASE'), + }, + migrations: { + naturalSort: true, + paths: ['./database/migrations/backoffice'], + }, +} + +// Backoffice/central are shared connections used by every request → generous +// pool. The `tenant` template is cloned per tenant → small pool, aggressive +// idle-close so a live fleet of tenants never exhausts PG's max_connections. +const sharedPool = { pool: { min: 0, max: 20, idleTimeoutMillis: 10_000 } } as const +const tenantTemplatePool = { pool: { min: 0, max: 3, idleTimeoutMillis: 5_000 } } as const + +export default defineConfig({ + connection: 'tenant', + connections: { + // Central (`public`): the shared, cross-tenant car catalog (car_makes / + // car_models). Migrations run with `node ace migration:run --connection=public`. + [multitenancyConfig.centralConnectionName]: { + ...baseConnection, + ...sharedPool, + searchPath: [multitenancyConfig.centralSchemaName], + migrations: { + naturalSort: true, + paths: ['./database/migrations/central'], + }, + }, + + // Backoffice: tenants registry + satellite backoffice tables. + [multitenancyConfig.backofficeConnectionName]: { + ...baseConnection, + ...sharedPool, + searchPath: [multitenancyConfig.backofficeSchemaName], + migrations: { + naturalSort: true, + paths: ['./database/migrations/backoffice'], + }, + }, + + // Template: the package clones this when materialising each tenant_ + // connection. Its migrations run once per tenant schema. + tenant: { + ...baseConnection, + ...tenantTemplatePool, + searchPath: ['public'], + migrations: { + naturalSort: true, + paths: ['./database/migrations/tenant'], + }, + }, + }, +}) diff --git a/apps/rental/config/encryption.ts b/apps/rental/config/encryption.ts new file mode 100644 index 00000000..2929eded --- /dev/null +++ b/apps/rental/config/encryption.ts @@ -0,0 +1,9 @@ +import env from '#start/env' +import { defineConfig, drivers } from '@adonisjs/core/encryption' + +export default defineConfig({ + default: 'app', + list: { + app: drivers.aes256gcm({ id: 'v1', keys: [env.get('APP_KEY')] }), + }, +}) diff --git a/apps/rental/config/hash.ts b/apps/rental/config/hash.ts new file mode 100644 index 00000000..456cef1d --- /dev/null +++ b/apps/rental/config/hash.ts @@ -0,0 +1,19 @@ +import { defineConfig, drivers } from '@adonisjs/core/hash' +import type { InferHashers } from '@adonisjs/core/types' + +/** + * Password hashing for both auth realms (backoffice operators and tenant + * staff). scrypt ships with Node, so no native dependency is needed. + */ +const hashConfig = defineConfig({ + default: 'scrypt', + list: { + scrypt: drivers.scrypt({}), + }, +}) + +export default hashConfig + +declare module '@adonisjs/core/types' { + export interface HashersList extends InferHashers {} +} diff --git a/apps/rental/config/inertia.ts b/apps/rental/config/inertia.ts new file mode 100644 index 00000000..10b65a0b --- /dev/null +++ b/apps/rental/config/inertia.ts @@ -0,0 +1,31 @@ +import { defineConfig } from '@adonisjs/inertia' +import type { InferSharedProps } from '@adonisjs/inertia/types' +import type InertiaMiddleware from '#app/middleware/inertia_middleware' + +/** + * Inertia server config. The React SPA is served through the `inertia_layout` + * Edge shell (resources/views/inertia_layout.edge); SSR stays off — these are + * authenticated back-office consoles, not SEO surfaces, so a client-rendered + * SPA keeps the runtime (and the deploy) simpler. + * + * Per-request shared props (flash, validation errors, the signed-in user) are + * produced by app/middleware/inertia_middleware.ts, not here — v4 moved sharing + * onto the middleware's `share()` method. + */ +const inertiaConfig = defineConfig({ + rootView: 'inertia_layout', + ssr: { enabled: false }, +}) + +export default inertiaConfig + +declare module '@adonisjs/inertia/types' { + export interface SharedProps extends InferSharedProps {} + + // Page props are validated on the React side (inertia/pages/**). A permissive + // index keeps `inertia.render('operator/dashboard', props)` callable for any + // page without a per-page server-side prop declaration. + export interface InertiaPages { + [page: string]: Record + } +} diff --git a/apps/rental/config/logger.ts b/apps/rental/config/logger.ts new file mode 100644 index 00000000..44661281 --- /dev/null +++ b/apps/rental/config/logger.ts @@ -0,0 +1,15 @@ +import env from '#start/env' +import { defineConfig } from '@adonisjs/core/logger' + +const loggerConfig = defineConfig({ + default: 'app', + loggers: { + app: { + enabled: true, + name: env.get('NODE_ENV') === 'test' ? 'test' : 'karimoto', + level: env.get('LOG_LEVEL'), + }, + }, +}) + +export default loggerConfig diff --git a/apps/rental/config/mail.ts b/apps/rental/config/mail.ts new file mode 100644 index 00000000..23ac25a2 --- /dev/null +++ b/apps/rental/config/mail.ts @@ -0,0 +1,23 @@ +import env from '#start/env' +import { defineConfig, transports } from '@adonisjs/mail' + +/** + * Mail config. Points at MailCatcher in dev/test (`MAILCATCHER_HOST:1025`); + * captured messages are at http://localhost:1080. Swap the SMTP transport for a + * real provider (Postmark/SES/Resend) in production. Powers the tenant-welcome + * mail fired when a company is activated. + */ +export default defineConfig({ + default: 'smtp', + from: { + address: env.get('MAIL_FROM_ADDRESS', 'noreply@karimoto.test'), + name: env.get('MAIL_FROM_NAME', 'Karimoto'), + }, + mailers: { + smtp: transports.smtp({ + host: env.get('MAILCATCHER_HOST', '127.0.0.1'), + port: env.get('MAILCATCHER_PORT', 1025), + secure: false, + }), + }, +}) diff --git a/apps/rental/config/multitenancy.ts b/apps/rental/config/multitenancy.ts new file mode 100644 index 00000000..b0a10f3f --- /dev/null +++ b/apps/rental/config/multitenancy.ts @@ -0,0 +1,357 @@ +import env from '#start/env' +import type { TenantResolverStrategy } from '@adonisjs-lasagna/saas-tenancy/types' +import type { DeclarativeHooks } from '@adonisjs-lasagna/saas-tenancy/services' +import { createMembershipAuthorizer } from '#app/security/membership_authorizer' +import { authorizeFleetTool, fleetTools } from '#app/ai/fleet_tools' + +// Stream the real DeepSeek model when its key is present, EXCEPT under the test +// runner, where the deterministic mock keeps the e2e assertions stable. AppProvider +// reads the same predicate to decide which chat provider to register. +const aiUsesDeepSeek = !!env.get('DEEPSEEK_API_KEY') && env.get('NODE_ENV') !== 'test' + +/** + * The multitenancy kernel configuration for Karimoto. + * + * Satellite blocks (backup, billing, ai, crypto, reporting, websockets, + * compliance) are layered on in the satellite-wiring phase; this file holds the + * core spine: schema/connection names, tenant resolution, the membership gate, + * lifecycle hooks, plans + quotas, the circuit breaker and per-tenant queues. + */ +export default { + // ─── Schema and connection names ───────────────────────────────── + backofficeSchemaName: 'backoffice', + backofficeConnectionName: 'backoffice', + centralSchemaName: 'public', + centralConnectionName: 'public', + tenantConnectionNamePrefix: 'tenant_', + tenantSchemaPrefix: 'tenant_', + + // ─── Isolation ─────────────────────────────────────────────────── + // Migrate + seed each new company to head as part of provisioning, so a + // UI-created company is born fully migrated (never active-but-unmigrated, the + // class of bug that returned a 503 on "Run doctor"). `tenant:migrate` stays + // available and idempotent for the existing fleet. + isolation: { + migrateOnProvision: true, + }, + + // ─── Resolution ────────────────────────────────────────────────── + // Each rental company gets a vanity host `.localhost` stored as the + // tenant's `custom_domain`. The chain tries `domain-or-subdomain` first (the + // browser hits `acme.localhost:3333`, resolved via `findByDomain`), then falls + // back to the `x-tenant-id` UUID header for the programmatic API and the e2e + // suite (which also keeps the synchronous routing path fed). The operator + // console lives on the apex `localhost` (no tenant → central plane). + resolverStrategy: 'domain-or-subdomain' as TenantResolverStrategy, + resolverChain: ['domain-or-subdomain', 'header'], + // Only hosts under `.localhost` may pick a tenant, so a spoofed + // X-Forwarded-Host can never hop into another company's schema. + resolver: { expectedHostSuffix: ['localhost'] }, + tenantHeaderKey: env.get('TENANT_HEADER_KEY'), + baseDomain: env.get('APP_DOMAIN'), + + // ─── Membership gate (cross-tenant IDOR firewall) ──────────────── + // Deny-by-default: an authenticated caller's credentials must belong to the + // resolved tenant, and anonymous traffic is refused except at the handful of + // public entry points (login, SSO). See app/security/membership_authorizer.ts. + authorizeTenantAccess: createMembershipAuthorizer(), + + // Health, the operator console and the billing webhook carry no tenant, so + // they bypass resolution. The webhook resolves its tenant from the event later. + ignorePaths: ['/livez', '/readyz', '/healthz', '/metrics', '/admin', '/webhooks/billing'], + + schemaCacheTtl: 300, + maintenanceSchedule: { backupHour: 2, migrateAllHour: 3 }, + + // ─── Admin impersonation ───────────────────────────────────────── + impersonation: { + secret: env.get( + 'IMPERSONATION_SECRET', + 'karimoto-dev-impersonation-secret-change-me-0123456789abcdef' + ), + }, + + // ─── Circuit breaker ───────────────────────────────────────────── + circuitBreaker: { + threshold: 50, + resetTimeout: 30_000, + rollingCountTimeout: 10_000, + volumeThreshold: 10, + }, + + // ─── Per-tenant queues ─────────────────────────────────────────── + queue: { + tenantQueuePrefix: 'tenant_queue_', + defaultConcurrency: 1, + attempts: 3, + redis: { + host: env.get('QUEUE_REDIS_HOST'), + port: env.get('QUEUE_REDIS_PORT'), + password: env.get('REDIS_PASSWORD'), + db: env.get('QUEUE_REDIS_DB'), + }, + }, + + // ─── Cache (BentoCache) ────────────────────────────────────────── + cache: { + ttl: 300, + redis: { + host: env.get('CACHE_REDIS_HOST'), + port: env.get('CACHE_REDIS_PORT'), + password: env.get('REDIS_PASSWORD'), + db: env.get('CACHE_REDIS_DB'), + }, + }, + + // ─── Lifecycle hooks (declarative form) ────────────────────────── + hooks: { + // Runs inside the InstallTenant job; throwing aborts provisioning and the + // tenant flips to status=failed. A light shape check here proves the seam + // without blocking real onboarding data. + beforeProvision: async ({ tenant }) => { + if (!tenant.email.includes('@')) { + throw new Error( + `Refusing to provision "${tenant.name}": "${tenant.email}" is not an email.` + ) + } + }, + + // Seed a demo staff user inside each freshly migrated tenant schema so the + // tenant realm has someone to log in as. Gated on DEMO_SEED_TENANT_USERS and + // refused in production; idempotent via updateOrCreate. + afterMigrate: async ({ tenant, direction }) => { + if (direction !== 'up') return + if (!env.get('DEMO_SEED_TENANT_USERS')) return + const { default: app } = await import('@adonisjs/core/services/app') + if (app.inProduction) return + const { tenancy } = await import('@adonisjs-lasagna/saas-tenancy') + const { default: TenantUser } = await import('#app/models/tenant_scoped/tenant_user') + const { DEMO_TENANT_OWNER } = await import('#app/helpers/rental_credentials') + await tenancy.run(tenant, async () => { + await TenantUser.updateOrCreate( + { email: DEMO_TENANT_OWNER.email }, + { + password: DEMO_TENANT_OWNER.password, + fullName: DEMO_TENANT_OWNER.fullName, + role: 'owner', + } + ) + }) + }, + } satisfies DeclarativeHooks, + + // ─── Soft-delete TTL ───────────────────────────────────────────── + // tenant:purge-expired drops schemas older than this many days. + softDelete: { + retentionDays: 30, + }, + + // ─── Plans + quotas ────────────────────────────────────────────── + // The company (tenant) subscribes to one of these; the billing satellite + // maps a Stripe subscription to the plan and QuotaService enforces the limits. + // enforceQuota('vehiclesPerTenant' | 'bookingsPerMonth') is wired on the + // domain routes; apiCallsPerDay guards the general tenant surface. + plans: { + defaultPlan: 'starter', + definitions: { + starter: { + limits: { + vehiclesPerTenant: 10, + bookingsPerMonth: 100, + apiCallsPerDay: 2_000, + aiTokens: 50_000, + }, + }, + fleet: { + limits: { + vehiclesPerTenant: 100, + bookingsPerMonth: 2_000, + apiCallsPerDay: 20_000, + aiTokens: 500_000, + }, + }, + enterprise: { + limits: { + vehiclesPerTenant: 100_000, + bookingsPerMonth: 100_000, + apiCallsPerDay: 1_000_000, + aiTokens: 5_000_000, + }, + }, + }, + getPlan: (tenant: any) => tenant.metadata?.plan ?? 'starter', + }, + + // ─── Read replica routing ──────────────────────────────────────── + // Wire a replica ONLY when DB_REPLICA_HOST is set. Local dev runs a single + // Postgres with no standby, so the block stays absent and the replica_lag doctor + // check returns [] rather than probing the primary and raising a false + // points-at-primary signal (WS-6 catches that case too, as belt-and-suspenders). + // Set DB_REPLICA_HOST to a real standby to exercise read routing; vehicle listings + // read through the `_read` connection. + ...(env.get('DB_REPLICA_HOST') + ? { + tenantReadReplicas: { + hosts: [{ host: env.get('DB_REPLICA_HOST')!, name: 'karimoto-replica-1' }], + strategy: 'sticky' as const, + connectionSuffix: '_read', + }, + } + : {}), + + // ─── Compliance (GDPR / Law 09-08 erasure seam) ────────────────── + // Backs `tenant:gdpr:anonymize`. Runs inside tenancy.run(tenant), so Customer + // queries hit the company's own schema. Masks renter PII while keeping the + // booking history intact. + compliance: { + anonymize: async ({ dryRun }: { dryRun: boolean }) => { + const { default: Customer } = await import('#app/models/tenant_scoped/customer') + const customers = await Customer.all() + if (dryRun) return { affected: customers.length } + for (const c of customers) { + c.fullName = 'Redacted' + c.email = null + c.phone = null + c.cin = null + c.driverLicense = null + c.passport = null + c.address = null + await c.save() + } + return { affected: customers.length } + }, + }, + + // ─── Backups (@adonisjs-lasagna/backup) ────────────────────────── + backup: { + storagePath: env.get('BACKUP_STORAGE_PATH', './storage/backups'), + metadataTtl: 86_400, + pgConnection: { + host: env.get('DB_HOST'), + port: env.get('DB_PORT'), + user: env.get('DB_USER'), + password: env.get('DB_PASSWORD', ''), + database: env.get('DB_DATABASE'), + }, + // Two retention tiers keyed off tenant.metadata.tier. + retention: { + defaultTier: 'standard', + tiers: { + standard: { intervalHours: 24, keepLast: 7 }, + premium: { intervalHours: 6, keepLast: 30 }, + }, + getTier: (tenant: any) => tenant.metadata?.tier ?? 'standard', + }, + }, + + // ─── Reporting (@adonisjs-lasagna/reporting) ───────────────────── + reporting: { + rollups: { enabled: true }, + cache: { invalidateOnFlush: true }, + }, + + // ─── Billing (@adonisjs-lasagna/billing) ───────────────────────── + // The COMPANY (tenant) subscribes to Karimoto. Fully offline in dev: with no + // STRIPE_API_KEY, AppProvider injects MockStripe into the stripe driver, so + // checkout/portal/webhook run in-memory. Set STRIPE_API_KEY (sk_test_…) to go + // live with zero code change. `products` maps price ids to the SaaS plans. + billing: { + driver: env.get('BILLING_DRIVER', 'stripe'), + stripe: { + apiKey: env.get('STRIPE_API_KEY', 'sk_test_karimoto_placeholder_key'), + webhookSecret: env.get('STRIPE_WEBHOOK_SECRET', 'whsec_karimoto_placeholder_secret'), + }, + products: { + price_starter_monthly: 'starter', + price_fleet_monthly: 'fleet', + price_enterprise_monthly: 'enterprise', + }, + defaultPlan: 'starter', + }, + + // ─── Multi-tenant WebSockets (@adonisjs-lasagna/websockets) ────── + // The provider attaches socket.io to the HTTP server and isolates connections + // per company (resolved from io(url, { auth: { tenantId } })). start/socket.ts + // registers the live booking-board handlers. + websockets: { + cors: { origin: true, credentials: true }, + handshake: { authKey: 'tenantId' }, + authorize: async () => true, + }, + + // ─── AI satellite (@adonisjs-lasagna/ai) ───────────────────────── + // The fleet assistant. Offline by default via MockAIProvider + MockEmbedding + // (registered in AppProvider). Set DEEPSEEK_API_KEY to stream the real DeepSeek + // model (`deepseek-chat`, OpenAI-compatible) instead — the same code path, + // provider swapped by config. The test env stays on the mock regardless, so the + // e2e suite's deterministic assertions (the CIN-shaped DLP token) keep holding. + // Chat provider only; embeddings stay on the mock (the `dimension: 8` matches + // the per-tenant `ai_embeddings vector(8)` column the satellite folds into each + // tenant migration), so RAG retrieves over the seeded mock-embedding space and + // hands the matched fleet docs to whichever chat model is active. + ai: { + allowedProviders: aiUsesDeepSeek ? ['deepseek', 'mock'] : ['mock'], + defaultProvider: aiUsesDeepSeek ? 'deepseek' : 'mock', + ...(aiUsesDeepSeek + ? { deepseek: { apiKey: env.get('DEEPSEEK_API_KEY')!, defaultModel: 'deepseek-chat' } } + : {}), + authorizeAIAccess: () => true, + resolvePrincipal: (ctx: any) => ctx.request.header('x-ai-user') ?? null, + rateLimit: { limit: 10, windowSeconds: 60 }, + audit: { enabled: true }, + embedding: { + provider: 'mock-embedding', + apiKey: 'demo-embeddings-key', + baseUrl: 'https://embeddings.invalid', + dimension: 8, + authorizeIngestion: () => true, + }, + retrieval: { retrievalFilter: () => ({ kind: 'all' as const }) }, + // Output DLP: strip anything shaped like a Moroccan CIN from streamed output. + // Defense-in-depth, never the isolation control. + redactOutput: (_ctx: any, _tenant: any, chunk: string) => + chunk.replace(/\b[A-Z]{1,2}\d{5,6}\b/g, '[redacted]'), + // ─── Tool calling (WS-AI-11) ─────────────────────────────────── + // Read-only tools over this company's own tables, so the assistant can answer + // live drill-downs ("which of my cars is free next weekend?") the RAG corpus + // can't. These replace the old `/assistant/context` snapshot outright: the model + // chooses what to look up, with arguments, per question — no fixed aggregate + // folded into every turn. + // Each handler is a plain Lucid query the tenant adapter already scopes; the + // satellite runs it inside tenancy.run(tenant) and re-asserts the scope first. + // authorizeTool is wired (never the acknowledgeUnauthorizedTools escape hatch) + // and action tools stay off — nothing here mutates. + tools: { + registry: fleetTools, + authorizeTool: authorizeFleetTool, + actionTools: { enabled: false }, + // A simple question needs a lookup and an answer, but a "give me a report" pulls + // several metrics at once (revenue + fleet + bookings + a ranking). 4 rounds × + // 3 tools (12 calls, under the hard 16 cap) covers a multi-metric report; the + // system prompt's one-call-per-tool discipline keeps the loop from wandering + // there rather than a tight ceiling tripping tool_budget_exhausted mid-report. + maxRounds: 4, + maxToolsPerRound: 3, + }, + }, + + // ─── crypto satellite (@adonisjs-lasagna/crypto) ───────────────── + // Field-level encryption for renter PII (CIN / driver licence / passport), + // each blind-index searchable. The dev `env` KeyProvider derives the KEK from + // APP_KEY. The erasabilityResolver is the governance gate crypto CONSULTS + // before a shred: the `renter-id` category is erasable on request (a renter + // exercising their Law 09-08 erasure right); every other category is refused + // fail-closed. + crypto: { + keyProvider: 'env', + fields: { + 'customer.cin': { category: 'renter-id', searchable: true }, + 'customer.driverLicense': { category: 'renter-id', searchable: true }, + 'customer.passport': { category: 'renter-id', searchable: true }, + }, + erasabilityResolver: (_tenant: any, _subject: string, category: string) => + category === 'renter-id' + ? { erasable: true, reason: 'consent' } + : { erasable: false, reason: `category '${category}' is not erasable on request` }, + }, +} as const diff --git a/apps/rental/config/queue.ts b/apps/rental/config/queue.ts new file mode 100644 index 00000000..2d3011ab --- /dev/null +++ b/apps/rental/config/queue.ts @@ -0,0 +1,20 @@ +import { defineConfig, drivers } from '@adonisjs/queue' + +export default defineConfig({ + default: 'redis', + + adapters: { + redis: drivers.redis({ + connectionName: 'queue', + }), + }, + + worker: { + concurrency: 2, + idleDelay: '1s', + }, + + defaultJobOptions: { + maxRetries: 3, + }, +}) diff --git a/apps/rental/config/redis.ts b/apps/rental/config/redis.ts new file mode 100644 index 00000000..68736f94 --- /dev/null +++ b/apps/rental/config/redis.ts @@ -0,0 +1,51 @@ +import env from '#start/env' +import { defineConfig } from '@adonisjs/redis' +import type { InferConnections } from '@adonisjs/redis/types' + +// REDIS_PASSWORD is optional (local Redis runs open). Spread the key in only +// when set, rather than passing `undefined`, which exactOptionalPropertyTypes +// rejects against ioredis' `password?: string`. +const redisPassword = env.get('REDIS_PASSWORD') +const redisAuth = redisPassword !== undefined ? { password: redisPassword } : {} + +const redisConfig = defineConfig({ + connection: 'default', + connections: { + default: { + host: env.get('REDIS_HOST'), + port: env.get('REDIS_PORT'), + ...redisAuth, + db: 0, + keyPrefix: '', + retryStrategy(times) { + return times > 10 ? null : times * 50 + }, + }, + queue: { + host: env.get('QUEUE_REDIS_HOST'), + port: env.get('QUEUE_REDIS_PORT'), + ...redisAuth, + db: env.get('QUEUE_REDIS_DB'), + keyPrefix: '', + retryStrategy(times) { + return times > 10 ? null : times * 50 + }, + }, + cache: { + host: env.get('CACHE_REDIS_HOST'), + port: env.get('CACHE_REDIS_PORT'), + ...redisAuth, + db: env.get('CACHE_REDIS_DB'), + keyPrefix: '', + retryStrategy(times) { + return times > 10 ? null : times * 50 + }, + }, + }, +}) + +export default redisConfig + +declare module '@adonisjs/redis/types' { + export interface RedisConnections extends InferConnections {} +} diff --git a/apps/rental/config/session.ts b/apps/rental/config/session.ts new file mode 100644 index 00000000..ecb60532 --- /dev/null +++ b/apps/rental/config/session.ts @@ -0,0 +1,40 @@ +import app from '@adonisjs/core/services/app' +import { defineConfig, stores } from '@adonisjs/session' + +/** + * Session config for the Inertia browser consoles. + * + * The store is the encrypted **cookie** store: the whole session payload rides + * in a signed cookie, so there is no server-side session table to migrate and no + * shared store for two companies to contend over. That choice also tightens + * isolation — the cookie is host-only, so a session minted on `acme.localhost` + * is never even transmitted to `sahara.localhost`, on top of the membership gate + * that already refuses a foreign session server-side. + * + * Both realms (operator + tenant staff) hang their `web-*` session guards off + * this one store; each guard namespaces its user id under its own key, so a + * browser can hold an operator session on the apex and a staff session on a + * company host without collision. + */ +const sessionConfig = defineConfig({ + enabled: true, + cookieName: 'karimoto-session', + + // Keep the session alive across browser restarts; expire after inactivity. + clearWithBrowser: false, + age: '8h', + + cookie: { + path: '/', + httpOnly: true, + secure: app.inProduction, + sameSite: 'lax', + }, + + store: 'cookie', + stores: { + cookie: stores.cookie(), + }, +}) + +export default sessionConfig diff --git a/apps/rental/config/vite.ts b/apps/rental/config/vite.ts new file mode 100644 index 00000000..76927925 --- /dev/null +++ b/apps/rental/config/vite.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@adonisjs/vite' + +/** + * Backend half of the Vite integration: where `vite build` writes the bundle and + * the manifest the server reads to resolve `@vite([...])` tags to hashed asset + * URLs. The frontend half (plugins, entrypoints) lives in the root vite.config.ts. + */ +const viteBackendConfig = defineConfig({ + buildDirectory: 'public/assets', + manifestFile: 'public/assets/.vite/manifest.json', + assetsUrl: '/assets', +}) + +export default viteBackendConfig diff --git a/apps/rental/database/migrations/backoffice/0001_create_tenants_table.ts b/apps/rental/database/migrations/backoffice/0001_create_tenants_table.ts new file mode 100644 index 00000000..ea9dfe51 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0001_create_tenants_table.ts @@ -0,0 +1,37 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * The companies registry — one row per rental company (tenant). The package's + * commands and admin routes look these up by id; the `custom_domain` column is + * what the `domain-or-subdomain` resolver matches `.localhost` against. + * + * `metadata` is JSONB matching `RentalMeta` in app/models/backoffice/tenant.ts. + * The package never reads it directly — it flows through the resolvers in + * config/multitenancy.ts (`plans.getPlan`, `backup.retention.getTier`). + * + * The `maintenance` flag + message ship in the create table (not a later alter) + * so `tenant:maintenance` and TenantGuardMiddleware's 503 gate work from day one. + */ +export default class extends BaseSchema { + protected tableName = 'tenants' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.string('name').notNullable() + table.string('email').notNullable().unique() + table.string('status').notNullable().defaultTo('provisioning') + table.string('custom_domain').nullable().unique() + table.jsonb('metadata').nullable() + table.boolean('maintenance').notNullable().defaultTo(false) + table.text('maintenance_message').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('deleted_at', { useTz: true }).nullable().index() + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0002_create_backoffice_users_table.ts b/apps/rental/database/migrations/backoffice/0002_create_backoffice_users_table.ts new file mode 100644 index 00000000..5ad51014 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0002_create_backoffice_users_table.ts @@ -0,0 +1,25 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Operator accounts for the backoffice auth realm. One fleet-wide table in the + * backoffice schema; company staff never live here (they get their own `users` + * table inside each company schema, see database/migrations/tenant/0001). + */ +export default class extends BaseSchema { + protected tableName = 'backoffice_users' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.string('email').notNullable().unique() + table.string('password').notNullable() + table.string('full_name').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0003_create_backoffice_auth_access_tokens_table.ts b/apps/rental/database/migrations/backoffice/0003_create_backoffice_auth_access_tokens_table.ts new file mode 100644 index 00000000..54d51e77 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0003_create_backoffice_auth_access_tokens_table.ts @@ -0,0 +1,35 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Token storage for the backoffice (operator) guard. Tenant-realm tokens do NOT + * share this table: they live in each company schema's own `auth_access_tokens` + * (database/migrations/tenant/0002), which is what keeps the realms separate at + * rest. + */ +export default class extends BaseSchema { + protected tableName = 'auth_access_tokens' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.increments('id').primary() + table + .uuid('tokenable_id') + .notNullable() + .references('id') + .inTable('backoffice.backoffice_users') + .onDelete('CASCADE') + table.string('type').notNullable() + table.string('name').nullable() + table.string('hash').notNullable() + table.text('abilities').notNullable() + table.timestamp('created_at', { useTz: true }).notNullable() + table.timestamp('updated_at', { useTz: true }).notNullable() + table.timestamp('last_used_at', { useTz: true }).nullable() + table.timestamp('expires_at', { useTz: true }).nullable() + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0004_create_tenant_audit_logs_table.ts b/apps/rental/database/migrations/backoffice/0004_create_tenant_audit_logs_table.ts new file mode 100644 index 00000000..567e8a58 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0004_create_tenant_audit_logs_table.ts @@ -0,0 +1,79 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'tenant_audit_logs' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.uuid('tenant_id').nullable() + table.string('actor_type').notNullable() + // Free-form operator identity (uuid, int-as-string, email), not a tenant id. + // Text, not uuid, so a non-uuid admin id is recorded instead of dropped. + table.string('actor_id').nullable() + table.string('action').notNullable() + table.jsonb('metadata').nullable() + table.string('ip_address').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + + // Composite index matching AuditLogService.listForTenant (filter tenant_id, + // order by created_at desc). It serves both the filter and the sort, and a + // tenant-prefixed lookup still uses it. + table.index(['tenant_id', 'created_at'], 'tenant_audit_logs_tenant_created_idx') + }) + + // Audit logs are append-only. Enforce it at the database level so a + // compromised tenant role, or a buggy controller, cannot rewrite or erase + // evidence. This mirrors the package's canonical migration stub + // (packages/core/stubs/migrations/create_tenant_audit_logs_table.stub); the + // demo originally shipped the table without these guards. The triggers fire + // regardless of role, unlike a REVOKE the table owner can bypass. + this.defer(async (db) => { + await db.rawQuery(` + CREATE OR REPLACE FUNCTION backoffice.tenant_audit_logs_no_mutate() + RETURNS TRIGGER AS $$ + BEGIN + RAISE EXCEPTION 'tenant_audit_logs is append-only; UPDATE/DELETE is forbidden' + USING ERRCODE = 'insufficient_privilege'; + END; + $$ LANGUAGE plpgsql; + `) + await db.rawQuery(` + DROP TRIGGER IF EXISTS tenant_audit_logs_no_update ON backoffice.tenant_audit_logs; + CREATE TRIGGER tenant_audit_logs_no_update + BEFORE UPDATE ON backoffice.tenant_audit_logs + FOR EACH ROW EXECUTE FUNCTION backoffice.tenant_audit_logs_no_mutate(); + `) + await db.rawQuery(` + DROP TRIGGER IF EXISTS tenant_audit_logs_no_delete ON backoffice.tenant_audit_logs; + CREATE TRIGGER tenant_audit_logs_no_delete + BEFORE DELETE ON backoffice.tenant_audit_logs + FOR EACH ROW EXECUTE FUNCTION backoffice.tenant_audit_logs_no_mutate(); + `) + // TRUNCATE bypasses per-row triggers, so it needs its own statement-level + // guard. Without it a single TRUNCATE would erase the whole history. + await db.rawQuery(` + DROP TRIGGER IF EXISTS tenant_audit_logs_no_truncate ON backoffice.tenant_audit_logs; + CREATE TRIGGER tenant_audit_logs_no_truncate + BEFORE TRUNCATE ON backoffice.tenant_audit_logs + FOR EACH STATEMENT EXECUTE FUNCTION backoffice.tenant_audit_logs_no_mutate(); + `) + }) + } + + async down() { + this.defer(async (db) => { + await db.rawQuery( + 'DROP TRIGGER IF EXISTS tenant_audit_logs_no_update ON backoffice.tenant_audit_logs' + ) + await db.rawQuery( + 'DROP TRIGGER IF EXISTS tenant_audit_logs_no_delete ON backoffice.tenant_audit_logs' + ) + await db.rawQuery( + 'DROP TRIGGER IF EXISTS tenant_audit_logs_no_truncate ON backoffice.tenant_audit_logs' + ) + await db.rawQuery('DROP FUNCTION IF EXISTS backoffice.tenant_audit_logs_no_mutate()') + }) + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0005_create_tenant_feature_flags_table.ts b/apps/rental/database/migrations/backoffice/0005_create_tenant_feature_flags_table.ts new file mode 100644 index 00000000..534405f0 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0005_create_tenant_feature_flags_table.ts @@ -0,0 +1,23 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'tenant_feature_flags' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.uuid('tenant_id').notNullable().index() + table.string('flag').notNullable() + table.boolean('enabled').notNullable().defaultTo(false) + table.jsonb('config').nullable() + table.timestamp('expires_at', { useTz: true }).nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.unique(['tenant_id', 'flag']) + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0006_create_tenant_webhooks_table.ts b/apps/rental/database/migrations/backoffice/0006_create_tenant_webhooks_table.ts new file mode 100644 index 00000000..13e48125 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0006_create_tenant_webhooks_table.ts @@ -0,0 +1,22 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'tenant_webhooks' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.uuid('tenant_id').notNullable().index() + table.string('url').notNullable() + table.specificType('events', 'text[]').notNullable().defaultTo('{}') + table.text('secret').nullable() + table.boolean('enabled').notNullable().defaultTo(true) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0007_create_tenant_webhook_deliveries_table.ts b/apps/rental/database/migrations/backoffice/0007_create_tenant_webhook_deliveries_table.ts new file mode 100644 index 00000000..34a17a68 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0007_create_tenant_webhook_deliveries_table.ts @@ -0,0 +1,33 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'tenant_webhook_deliveries' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table + .uuid('webhook_id') + .notNullable() + .references('id') + .inTable('backoffice.tenant_webhooks') + .onDelete('CASCADE') + table.string('event').notNullable() + table.jsonb('payload').notNullable() + table.integer('status_code').nullable() + table.text('response_body').nullable() + table.integer('attempt').notNullable().defaultTo(1) + table + .enum('status', ['pending', 'success', 'failed', 'retrying']) + .notNullable() + .defaultTo('pending') + table.timestamp('next_retry_at', { useTz: true }).nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.index(['status', 'next_retry_at']) + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0008_create_tenant_brandings_table.ts b/apps/rental/database/migrations/backoffice/0008_create_tenant_brandings_table.ts new file mode 100644 index 00000000..903ba572 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0008_create_tenant_brandings_table.ts @@ -0,0 +1,24 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'tenant_brandings' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.uuid('tenant_id').notNullable().unique() + table.string('from_name').nullable() + table.string('from_email').nullable() + table.text('logo_url').nullable() + table.string('primary_color', 7).nullable() + table.text('support_url').nullable() + table.jsonb('email_footer').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0009_create_tenant_sso_configs_table.ts b/apps/rental/database/migrations/backoffice/0009_create_tenant_sso_configs_table.ts new file mode 100644 index 00000000..0d7500f5 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0009_create_tenant_sso_configs_table.ts @@ -0,0 +1,25 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'tenant_sso_configs' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.uuid('tenant_id').notNullable().unique() + table.string('provider').notNullable() + table.string('client_id').notNullable() + table.text('client_secret').notNullable() + table.text('issuer_url').notNullable() + table.text('redirect_uri').notNullable() + table.specificType('scopes', 'text[]').notNullable().defaultTo('{}') + table.boolean('enabled').notNullable().defaultTo(true) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0010_create_tenant_metrics_table.ts b/apps/rental/database/migrations/backoffice/0010_create_tenant_metrics_table.ts new file mode 100644 index 00000000..7b86e47e --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0010_create_tenant_metrics_table.ts @@ -0,0 +1,23 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'tenant_metrics' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.uuid('tenant_id').notNullable() + table.date('period').notNullable() + table.bigInteger('request_count').notNullable().defaultTo(0) + table.bigInteger('error_count').notNullable().defaultTo(0) + table.bigInteger('bandwidth_bytes').notNullable().defaultTo(0) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.unique(['tenant_id', 'period']) + table.index('period') + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0011_create_tenant_plans_table.ts b/apps/rental/database/migrations/backoffice/0011_create_tenant_plans_table.ts new file mode 100644 index 00000000..d2e1a9f3 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0011_create_tenant_plans_table.ts @@ -0,0 +1,26 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Source-of-truth for tenant-to-plan assignments. Read by + * `QuotaService.getAssignedPlan` (with BentoCache 60s) when + * `config.plans.getPlan` is undefined, and written by `assignPlan` + * (manual assignment, or `source='stripe'` from the billing satellite). + */ +export default class extends BaseSchema { + protected tableName = 'tenant_plans' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('tenant_id').primary() + table.string('plan_name').notNullable() + table.string('source').notNullable().defaultTo('manual') + table.timestamp('assigned_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('expires_at', { useTz: true }).nullable() + table.index(['expires_at']) + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0012_create_billing_customers_table.ts b/apps/rental/database/migrations/backoffice/0012_create_billing_customers_table.ts new file mode 100644 index 00000000..423e3ea1 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0012_create_billing_customers_table.ts @@ -0,0 +1,28 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * One row per tenant. The mapping `tenant_id ↔ provider_customer_id` is the + * keystone of every webhook lookup: the package never stores the provider + * customer id on the host's Tenant model. `provider` records which driver owns + * the id. + */ +export default class extends BaseSchema { + protected tableName = 'billing_customers' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('tenant_id').primary() + table.string('provider').notNullable() + table.string('provider_customer_id').notNullable() + table.string('default_payment_method').nullable() + table.string('currency').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('deleted_at', { useTz: true }).nullable() + table.unique(['provider', 'provider_customer_id']) + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0013_create_billing_subscriptions_table.ts b/apps/rental/database/migrations/backoffice/0013_create_billing_subscriptions_table.ts new file mode 100644 index 00000000..8520f9ad --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0013_create_billing_subscriptions_table.ts @@ -0,0 +1,50 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Provider-agnostic mirror of subscriptions. Reconciled by `tenant:billing:sync`. + * `last_event_at` is the ordering guard against out-of-order webhook delivery; + * `raw` jsonb preserves the full provider payload. + */ +export default class extends BaseSchema { + protected tableName = 'billing_subscriptions' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.string('provider_subscription_id').primary() + table.string('provider').notNullable() + table + .uuid('tenant_id') + .nullable() + .references('tenant_id') + .inTable('backoffice.billing_customers') + .onDelete('SET NULL') + table + .enum('status', [ + 'incomplete', + 'incomplete_expired', + 'trialing', + 'active', + 'past_due', + 'canceled', + 'unpaid', + 'paused', + ]) + .notNullable() + table.timestamp('current_period_start', { useTz: true }).notNullable() + table.timestamp('current_period_end', { useTz: true }).notNullable() + table.boolean('cancel_at_period_end').notNullable().defaultTo(false) + table.timestamp('cancel_at', { useTz: true }).nullable() + table.timestamp('canceled_at', { useTz: true }).nullable() + table.timestamp('trial_end', { useTz: true }).nullable() + table.string('plan_name').notNullable() + table.timestamp('last_event_at', { useTz: true }).notNullable() + table.jsonb('raw').notNullable() + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.index(['tenant_id', 'status']) + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0014_create_billing_processed_events_table.ts b/apps/rental/database/migrations/backoffice/0014_create_billing_processed_events_table.ts new file mode 100644 index 00000000..e558908a --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0014_create_billing_processed_events_table.ts @@ -0,0 +1,35 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Webhook idempotency ledger. The controller does `INSERT ... ON CONFLICT + * (event_id) DO NOTHING`; a 0-row result means the event is a duplicate and is + * acked without dispatching the job. `provider` records the source driver; + * `payload` is the replay fallback for events the provider can no longer return. + */ +export default class extends BaseSchema { + protected tableName = 'billing_processed_events' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.string('event_id').primary() + table.string('provider').notNullable() + table.string('event_type').notNullable() + table.timestamp('processed_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('completed_at', { useTz: true }).nullable() + table.uuid('tenant_id').nullable() + table.integer('attempts').notNullable().defaultTo(0) + table.text('last_error').nullable() + table + .enum('status', ['pending', 'processing', 'completed', 'failed']) + .notNullable() + .defaultTo('pending') + table.jsonb('payload').nullable() + table.index(['status', 'processed_at']) + table.index(['event_type']) + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0015_create_billing_usage_events_table.ts b/apps/rental/database/migrations/backoffice/0015_create_billing_usage_events_table.ts new file mode 100644 index 00000000..a9359c68 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0015_create_billing_usage_events_table.ts @@ -0,0 +1,34 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Audit ledger for usage-based / metered billing reports. Each row maps to one + * report through the active driver's metering API. `provider` records which + * driver it was sent through; `idempotency_key` is unique PER TENANT at the DB + * layer (defense in depth) and sent to the provider. + */ +export default class extends BaseSchema { + protected tableName = 'billing_usage_events' + + async up() { + this.schema.raw('CREATE EXTENSION IF NOT EXISTS pgcrypto') + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.string('provider').notNullable() + table.uuid('tenant_id').notNullable().index() + table.string('meter_event_name').notNullable() + table.bigInteger('quantity').notNullable() + table.string('idempotency_key').notNullable() + table.timestamp('reported_at', { useTz: true }).nullable() + table.enum('status', ['pending', 'sent', 'failed']).notNullable().defaultTo('pending') + table.text('last_error').nullable() + table.integer('attempts').notNullable().defaultTo(0) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.unique(['tenant_id', 'idempotency_key']) + table.index(['tenant_id', 'meter_event_name', 'status']) + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0016_fix_billing_usage_events_unique_per_tenant.ts b/apps/rental/database/migrations/backoffice/0016_fix_billing_usage_events_unique_per_tenant.ts new file mode 100644 index 00000000..c0ffea7f --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0016_fix_billing_usage_events_unique_per_tenant.ts @@ -0,0 +1,63 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Re-scope the usage-event idempotency uniqueness from GLOBAL to PER TENANT, + * matching the shipped `fix_billing_usage_events_unique_per_tenant` stub. 0014 + * already creates the composite for a fresh demo database; this migration exists + * so a demo database created before 0014 carried the composite (i.e. with the old + * global `UNIQUE(idempotency_key)`) converges. Idempotent and order-independent: + * it no-ops when the table is absent and only adds the composite when missing. + */ +export default class extends BaseSchema { + protected tableName = 'billing_usage_events' + + async up() { + this.schema.raw(` + DO $$ + BEGIN + IF to_regclass('backoffice.billing_usage_events') IS NULL THEN + RETURN; + END IF; + ALTER TABLE backoffice.billing_usage_events + DROP CONSTRAINT IF EXISTS billing_usage_events_idempotency_key_unique; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'backoffice' + AND t.relname = 'billing_usage_events' + AND c.conname = 'billing_usage_events_tenant_id_idempotency_key_unique' + ) THEN + ALTER TABLE backoffice.billing_usage_events + ADD CONSTRAINT billing_usage_events_tenant_id_idempotency_key_unique + UNIQUE (tenant_id, idempotency_key); + END IF; + END $$; + `) + } + + async down() { + this.schema.raw(` + DO $$ + BEGIN + IF to_regclass('backoffice.billing_usage_events') IS NULL THEN + RETURN; + END IF; + ALTER TABLE backoffice.billing_usage_events + DROP CONSTRAINT IF EXISTS billing_usage_events_tenant_id_idempotency_key_unique; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'backoffice' + AND t.relname = 'billing_usage_events' + AND c.conname = 'billing_usage_events_idempotency_key_unique' + ) THEN + ALTER TABLE backoffice.billing_usage_events + ADD CONSTRAINT billing_usage_events_idempotency_key_unique + UNIQUE (idempotency_key); + END IF; + END $$; + `) + } +} diff --git a/apps/rental/database/migrations/backoffice/0017_create_tenant_custom_metrics_table.ts b/apps/rental/database/migrations/backoffice/0017_create_tenant_custom_metrics_table.ts new file mode 100644 index 00000000..6790ee6b --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0017_create_tenant_custom_metrics_table.ts @@ -0,0 +1,22 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'tenant_custom_metrics' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.uuid('tenant_id').notNullable() + table.date('period').notNullable() + table.string('name', 63).notNullable() + table.bigInteger('value').notNullable().defaultTo(0) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.unique(['tenant_id', 'period', 'name']) + table.index('period') + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0018_create_tenant_metrics_monthly_table.ts b/apps/rental/database/migrations/backoffice/0018_create_tenant_metrics_monthly_table.ts new file mode 100644 index 00000000..acd321e7 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0018_create_tenant_metrics_monthly_table.ts @@ -0,0 +1,23 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'tenant_metrics_monthly' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.uuid('tenant_id').notNullable() + table.date('month').notNullable() + table.bigInteger('request_count').notNullable().defaultTo(0) + table.bigInteger('error_count').notNullable().defaultTo(0) + table.bigInteger('bandwidth_bytes').notNullable().defaultTo(0) + table.timestamp('computed_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.unique(['tenant_id', 'month']) + table.index('month') + }) + } + + async down() { + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0019_create_ai_audit_logs_table.ts b/apps/rental/database/migrations/backoffice/0019_create_ai_audit_logs_table.ts new file mode 100644 index 00000000..0571b5d3 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0019_create_ai_audit_logs_table.ts @@ -0,0 +1,103 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * The dedicated AI audit table (WS-AI-7). The demo enables `config.ai.audit`, so + * the AI gateway writes a non-PII, append-only, hash-chained row per chat / + * embedding / retrieval action; `/ai/embed` and `/ai/retrieve` fail CLOSED when + * this write cannot land, so the table must exist. This mirrors the satellite's + * `create_ai_audit_logs_table.stub` (the source of truth) that `configure` copies + * into a host app; the demo carries it as a real backoffice migration so the AI + * e2e run against a provisioned audit chain. + */ +export default class extends BaseSchema { + protected tableName = 'ai_audit_logs' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.uuid('tenant_id').notNullable() + + // Per-tenant monotonic sequence + hash chain. `checksum` is computed in the + // writer as sha256(canonical(row, seq) + '\n' + prev_checksum), so a + // deletion, reorder, or in-place rewrite that slips past the triggers breaks + // the chain and `tenant:ai:audit:verify` reports it. `UNIQUE(tenant_id, seq)` + // backs the advisory-locked writer. + table.bigInteger('seq').notNullable() + table.specificType('checksum', 'char(64)').notNullable() + table.specificType('prev_checksum', 'char(64)').nullable() + + // Non-PII attribution only (I5): principal and source are one-way SHA-256 + // hashes; no prompt, response, query, or document text is ever stored. `op` + // discriminates the three choke points (chat / embedding / retrieval) whose + // frozen events map onto this shared row. + table.string('op').notNullable() + table.string('outcome').notNullable() + table.string('reason').nullable() + table.specificType('principal_hash', 'char(64)').nullable() + table.specificType('source_hash', 'char(64)').nullable() + table.string('provider').nullable() + table.string('model').nullable() + table.integer('tokens').notNullable().defaultTo(0) + table.integer('fragments').notNullable().defaultTo(0) + table.integer('embeddings_count').notNullable().defaultTo(0) + table.integer('dimension').notNullable().defaultTo(0) + table.integer('match_count').notNullable().defaultTo(0) + table.boolean('idempotent_replay').notNullable().defaultTo(false) + + table.timestamp('occurred_at', { useTz: true }).notNullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + + table.unique(['tenant_id', 'seq'], 'ai_audit_logs_tenant_seq_uq') + table.index(['tenant_id', 'created_at'], 'ai_audit_logs_tenant_created_idx') + }) + + // Append-only, enforced at the database level so a compromised tenant role or a + // buggy controller cannot rewrite or erase evidence. The triggers fire on every + // UPDATE/DELETE regardless of role; TRUNCATE needs its own statement-level guard. + this.defer(async (db) => { + await db.rawQuery(` + CREATE OR REPLACE FUNCTION backoffice.ai_audit_logs_no_mutate() + RETURNS TRIGGER AS $$ + BEGIN + RAISE EXCEPTION 'ai_audit_logs is append-only; UPDATE/DELETE is forbidden' + USING ERRCODE = 'insufficient_privilege'; + END; + $$ LANGUAGE plpgsql; + `) + await db.rawQuery(` + DROP TRIGGER IF EXISTS ai_audit_logs_no_update ON backoffice.ai_audit_logs; + CREATE TRIGGER ai_audit_logs_no_update + BEFORE UPDATE ON backoffice.ai_audit_logs + FOR EACH ROW EXECUTE FUNCTION backoffice.ai_audit_logs_no_mutate(); + `) + await db.rawQuery(` + DROP TRIGGER IF EXISTS ai_audit_logs_no_delete ON backoffice.ai_audit_logs; + CREATE TRIGGER ai_audit_logs_no_delete + BEFORE DELETE ON backoffice.ai_audit_logs + FOR EACH ROW EXECUTE FUNCTION backoffice.ai_audit_logs_no_mutate(); + `) + await db.rawQuery(` + DROP TRIGGER IF EXISTS ai_audit_logs_no_truncate ON backoffice.ai_audit_logs; + CREATE TRIGGER ai_audit_logs_no_truncate + BEFORE TRUNCATE ON backoffice.ai_audit_logs + FOR EACH STATEMENT EXECUTE FUNCTION backoffice.ai_audit_logs_no_mutate(); + `) + }) + } + + async down() { + this.defer(async (db) => { + await db.rawQuery( + 'DROP TRIGGER IF EXISTS ai_audit_logs_no_update ON backoffice.ai_audit_logs' + ) + await db.rawQuery( + 'DROP TRIGGER IF EXISTS ai_audit_logs_no_delete ON backoffice.ai_audit_logs' + ) + await db.rawQuery( + 'DROP TRIGGER IF EXISTS ai_audit_logs_no_truncate ON backoffice.ai_audit_logs' + ) + await db.rawQuery('DROP FUNCTION IF EXISTS backoffice.ai_audit_logs_no_mutate()') + }) + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/backoffice/0020_create_worm_ledger_table.ts b/apps/rental/database/migrations/backoffice/0020_create_worm_ledger_table.ts new file mode 100644 index 00000000..41aa8d25 --- /dev/null +++ b/apps/rental/database/migrations/backoffice/0020_create_worm_ledger_table.ts @@ -0,0 +1,89 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * The shared WORM (write-once, read-many) shred ledger, in the backoffice schema. + * The crypto satellite's two-phase crypto-shred writes an append-only, per-tenant + * hash-chained audit row here BEFORE it destroys a DEK and confirms it AFTER, so an + * erasure is never left silently unaudited. Materialized from core's + * `stubs/migrations/create_worm_ledger_table.stub` (the configure hook copies it + * into a host app; the demo pins it here so `backoffice:setup` provisions it and the + * crypto e2e can assert the ledger is append-only). + */ +export default class extends BaseSchema { + protected tableName = 'worm_ledger' + + async up() { + this.schema.withSchema('backoffice').createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.uuid('tenant_id').notNullable() + + // Per-tenant monotonic sequence + hash chain: `checksum` is + // sha256(canonical(row, seq) + '\n' + prev_checksum), computed in the writer, + // so a deletion/reorder/in-place rewrite that slips past the triggers breaks + // the chain and verify() reports it. UNIQUE(tenant_id, seq) backs the writer. + table.bigInteger('seq').notNullable() + table.specificType('checksum', 'char(64)').notNullable() + table.specificType('prev_checksum', 'char(64)').nullable() + + // Non-PII payload only: `subject_hash` is a one-way digest of the data subject + // (never the raw id), `action` namespaces the event, `metadata` holds non-PII + // structured extras. Keeping the ledger forever therefore leaks nothing. + table.string('action').notNullable() + table.specificType('subject_hash', 'char(64)').nullable() + table.string('category').nullable() + table.string('reason').nullable() + table.jsonb('metadata').notNullable().defaultTo('{}') + + table.timestamp('occurred_at', { useTz: true }).notNullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + + table.unique(['tenant_id', 'seq'], 'worm_ledger_tenant_seq_uq') + table.index(['tenant_id', 'created_at'], 'worm_ledger_tenant_created_idx') + }) + + // Append-only, enforced at the DB level so a compromised tenant role or a buggy + // controller cannot rewrite or erase evidence. The triggers fire on every + // UPDATE/DELETE/TRUNCATE regardless of role (unlike REVOKE, which the owner + // bypasses). This is why the two-phase shred marks COMMITTED by appending a + // second row, never by UPDATE-ing the PENDING one. + this.defer(async (db) => { + await db.rawQuery(` + CREATE OR REPLACE FUNCTION backoffice.worm_ledger_no_mutate() + RETURNS TRIGGER AS $$ + BEGIN + RAISE EXCEPTION 'worm_ledger is append-only; UPDATE/DELETE is forbidden' + USING ERRCODE = 'insufficient_privilege'; + END; + $$ LANGUAGE plpgsql; + `) + await db.rawQuery(` + DROP TRIGGER IF EXISTS worm_ledger_no_update ON backoffice.worm_ledger; + CREATE TRIGGER worm_ledger_no_update + BEFORE UPDATE ON backoffice.worm_ledger + FOR EACH ROW EXECUTE FUNCTION backoffice.worm_ledger_no_mutate(); + `) + await db.rawQuery(` + DROP TRIGGER IF EXISTS worm_ledger_no_delete ON backoffice.worm_ledger; + CREATE TRIGGER worm_ledger_no_delete + BEFORE DELETE ON backoffice.worm_ledger + FOR EACH ROW EXECUTE FUNCTION backoffice.worm_ledger_no_mutate(); + `) + await db.rawQuery(` + DROP TRIGGER IF EXISTS worm_ledger_no_truncate ON backoffice.worm_ledger; + CREATE TRIGGER worm_ledger_no_truncate + BEFORE TRUNCATE ON backoffice.worm_ledger + FOR EACH STATEMENT EXECUTE FUNCTION backoffice.worm_ledger_no_mutate(); + `) + }) + } + + async down() { + this.defer(async (db) => { + await db.rawQuery('DROP TRIGGER IF EXISTS worm_ledger_no_update ON backoffice.worm_ledger') + await db.rawQuery('DROP TRIGGER IF EXISTS worm_ledger_no_delete ON backoffice.worm_ledger') + await db.rawQuery('DROP TRIGGER IF EXISTS worm_ledger_no_truncate ON backoffice.worm_ledger') + await db.rawQuery('DROP FUNCTION IF EXISTS backoffice.worm_ledger_no_mutate()') + }) + this.schema.withSchema('backoffice').dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/central/.gitkeep b/apps/rental/database/migrations/central/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/rental/database/migrations/central/0001_create_car_makes_table.ts b/apps/rental/database/migrations/central/0001_create_car_makes_table.ts new file mode 100644 index 00000000..ed4c6982 --- /dev/null +++ b/apps/rental/database/migrations/central/0001_create_car_makes_table.ts @@ -0,0 +1,25 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * The shared car-make catalog, in the central `public` schema. Runs with + * `node ace migration:run --connection=public`. Cross-company: every company's + * fleet selects makes from this one table. + */ +export default class extends BaseSchema { + protected tableName = 'car_makes' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary() + table.string('name').notNullable() + table.string('slug').notNullable().unique() + table.string('country').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/central/0002_create_car_models_table.ts b/apps/rental/database/migrations/central/0002_create_car_models_table.ts new file mode 100644 index 00000000..2267ebd6 --- /dev/null +++ b/apps/rental/database/migrations/central/0002_create_car_models_table.ts @@ -0,0 +1,31 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * The shared car-model catalog, in the central `public` schema. `make_id` + * references `car_makes` in the same central connection. + */ +export default class extends BaseSchema { + protected tableName = 'car_models' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary() + table + .integer('make_id') + .unsigned() + .notNullable() + .references('id') + .inTable('car_makes') + .onDelete('CASCADE') + table.string('name').notNullable() + table.string('body_type').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.unique(['make_id', 'name']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0001_create_users_table.ts b/apps/rental/database/migrations/tenant/0001_create_users_table.ts new file mode 100644 index 00000000..28331648 --- /dev/null +++ b/apps/rental/database/migrations/tenant/0001_create_users_table.ts @@ -0,0 +1,28 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Company staff for the tenant auth realm. Runs against the per-company + * connection, so the table is created once inside every `tenant_` schema + * and the same email can exist independently in two companies. `role` is + * `owner` (administers the account) or `agent` (runs the counter). Operators + * never live here; they have `backoffice.backoffice_users`. + */ +export default class extends BaseSchema { + protected tableName = 'users' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary() + table.string('email').notNullable().unique() + table.string('password').notNullable() + table.string('full_name').nullable() + table.string('role').notNullable().defaultTo('agent') + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0002_create_auth_access_tokens_table.ts b/apps/rental/database/migrations/tenant/0002_create_auth_access_tokens_table.ts new file mode 100644 index 00000000..b37743e8 --- /dev/null +++ b/apps/rental/database/migrations/tenant/0002_create_auth_access_tokens_table.ts @@ -0,0 +1,36 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Token storage for the tenant guard, one table per company schema. A token row + * minted in company A simply does not exist in company B, so cross-company + * token reuse dies on the lookup; even a row-id collision across schemas is + * rejected by the guard's timing-safe hash compare. + */ +export default class extends BaseSchema { + protected tableName = 'auth_access_tokens' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary() + table + .integer('tokenable_id') + .unsigned() + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table.string('type').notNullable() + table.string('name').nullable() + table.string('hash').notNullable() + table.text('abilities').notNullable() + table.timestamp('created_at', { useTz: true }).notNullable() + table.timestamp('updated_at', { useTz: true }).notNullable() + table.timestamp('last_used_at', { useTz: true }).nullable() + table.timestamp('expires_at', { useTz: true }).nullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0003_create_rental_locations_table.ts b/apps/rental/database/migrations/tenant/0003_create_rental_locations_table.ts new file mode 100644 index 00000000..47325833 --- /dev/null +++ b/apps/rental/database/migrations/tenant/0003_create_rental_locations_table.ts @@ -0,0 +1,26 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** Company branches. Runs once inside every `tenant_` schema. */ +export default class extends BaseSchema { + protected tableName = 'rental_locations' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.string('name').notNullable() + table.string('type').notNullable().defaultTo('city') + table.string('address').nullable() + table.string('city').notNullable() + table.string('timezone').notNullable().defaultTo('Africa/Casablanca') + table.string('phone').nullable() + table.integer('open_hour').notNullable().defaultTo(8) + table.integer('close_hour').notNullable().defaultTo(20) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0004_create_vehicle_categories_table.ts b/apps/rental/database/migrations/tenant/0004_create_vehicle_categories_table.ts new file mode 100644 index 00000000..3b72f737 --- /dev/null +++ b/apps/rental/database/migrations/tenant/0004_create_vehicle_categories_table.ts @@ -0,0 +1,24 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** Pricing tiers. Money columns are santimat (MAD × 100). */ +export default class extends BaseSchema { + protected tableName = 'vehicle_categories' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.string('name').notNullable() + table.string('code').notNullable() + table.integer('daily_rate').notNullable().defaultTo(0) + table.integer('deposit_amount').notNullable().defaultTo(0) + table.jsonb('extras').notNullable().defaultTo('[]') + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.unique(['code']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0005_create_vehicles_table.ts b/apps/rental/database/migrations/tenant/0005_create_vehicles_table.ts new file mode 100644 index 00000000..4aabb724 --- /dev/null +++ b/apps/rental/database/migrations/tenant/0005_create_vehicles_table.ts @@ -0,0 +1,46 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Fleet vehicles. `make_id`/`model_id` reference the central catalog and are + * plain integers (no cross-connection FK); `make_name`/`model_name` are + * denormalised for display. `category_id`/`location_id` are in-schema FKs. + */ +export default class extends BaseSchema { + protected tableName = 'vehicles' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.string('plate').notNullable().unique() + table.integer('make_id').notNullable() + table.integer('model_id').notNullable() + table.string('make_name').notNullable() + table.string('model_name').notNullable() + table.integer('year').notNullable() + table + .uuid('category_id') + .notNullable() + .references('id') + .inTable('vehicle_categories') + .onDelete('RESTRICT') + table + .uuid('location_id') + .nullable() + .references('id') + .inTable('rental_locations') + .onDelete('SET NULL') + table.string('status').notNullable().defaultTo('available') + table.integer('mileage').notNullable().defaultTo(0) + table.string('fuel').notNullable().defaultTo('petrol') + table.string('transmission').notNullable().defaultTo('manual') + table.string('color').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.index(['status']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0006_create_customers_table.ts b/apps/rental/database/migrations/tenant/0006_create_customers_table.ts new file mode 100644 index 00000000..8b470a3c --- /dev/null +++ b/apps/rental/database/migrations/tenant/0006_create_customers_table.ts @@ -0,0 +1,46 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' +import { encryptedColumnCheckSql } from '@adonisjs-lasagna/crypto' + +/** + * Renters. `cin`, `driver_license`, `passport` are crypto `@encrypted` fields: + * enc_v2 ciphertext at rest, guarded by the DB-level `encryptedColumnCheckSql` + * CHECK — the fail-closed backstop that rejects a raw / query-builder / + * `*Quietly` plaintext write the model hooks can't see. The `*_index` columns + * hold their `@searchable` blind-index HMACs. + */ +export default class extends BaseSchema { + protected tableName = 'customers' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.string('full_name').notNullable() + table.string('email').nullable() + table.string('phone').nullable() + + // enc_v2 ciphertext at rest, never plaintext (guarded by the CHECKs below). + table.text('cin').nullable() + table.text('driver_license').nullable() + table.text('passport').nullable() + + table.string('cin_index').nullable().index() + table.string('driver_license_index').nullable().index() + table.string('passport_index').nullable().index() + + table.string('address').nullable() + table.date('date_of_birth').nullable() + table.string('nationality').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + + // safe-sql: table/column are fixed literals; the helper validates identifiers. + this.schema.raw(encryptedColumnCheckSql(this.tableName, 'cin')) + this.schema.raw(encryptedColumnCheckSql(this.tableName, 'driver_license')) + this.schema.raw(encryptedColumnCheckSql(this.tableName, 'passport')) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0007_create_bookings_table.ts b/apps/rental/database/migrations/tenant/0007_create_bookings_table.ts new file mode 100644 index 00000000..e5dff902 --- /dev/null +++ b/apps/rental/database/migrations/tenant/0007_create_bookings_table.ts @@ -0,0 +1,53 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** Rentals. Money columns are santimat; price_breakdown records the calc. */ +export default class extends BaseSchema { + protected tableName = 'bookings' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.string('reference').notNullable().unique() + table + .uuid('customer_id') + .notNullable() + .references('id') + .inTable('customers') + .onDelete('RESTRICT') + table + .uuid('vehicle_id') + .notNullable() + .references('id') + .inTable('vehicles') + .onDelete('RESTRICT') + table + .uuid('pickup_location_id') + .nullable() + .references('id') + .inTable('rental_locations') + .onDelete('SET NULL') + table.timestamp('pickup_at', { useTz: true }).notNullable() + table + .uuid('dropoff_location_id') + .nullable() + .references('id') + .inTable('rental_locations') + .onDelete('SET NULL') + table.timestamp('dropoff_at', { useTz: true }).notNullable() + table.string('status').notNullable().defaultTo('quote') + table.jsonb('price_breakdown').nullable() + table.integer('deposit_held').notNullable().defaultTo(0) + table.jsonb('extras').notNullable().defaultTo('[]') + table.integer('total_amount').notNullable().defaultTo(0) + table.string('currency').notNullable().defaultTo('MAD') + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.index(['vehicle_id', 'status']) + table.index(['status']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0008_create_rental_agreements_table.ts b/apps/rental/database/migrations/tenant/0008_create_rental_agreements_table.ts new file mode 100644 index 00000000..e384e2a5 --- /dev/null +++ b/apps/rental/database/migrations/tenant/0008_create_rental_agreements_table.ts @@ -0,0 +1,28 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** Signed contracts, one per booking. */ +export default class extends BaseSchema { + protected tableName = 'rental_agreements' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table + .uuid('booking_id') + .notNullable() + .references('id') + .inTable('bookings') + .onDelete('CASCADE') + table.text('terms').nullable() + table.timestamp('signed_at', { useTz: true }).nullable() + table.string('signature_ref').nullable() + table.string('pdf_ref').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0009_create_payments_table.ts b/apps/rental/database/migrations/tenant/0009_create_payments_table.ts new file mode 100644 index 00000000..9936c6d4 --- /dev/null +++ b/apps/rental/database/migrations/tenant/0009_create_payments_table.ts @@ -0,0 +1,31 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** Renter payments against a booking (domain money, not the SaaS billing). */ +export default class extends BaseSchema { + protected tableName = 'payments' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table + .uuid('booking_id') + .notNullable() + .references('id') + .inTable('bookings') + .onDelete('CASCADE') + table.integer('amount').notNullable() + table.string('currency').notNullable().defaultTo('MAD') + table.string('method').notNullable().defaultTo('cash') + table.string('status').notNullable().defaultTo('pending') + table.string('reference').nullable() + table.timestamp('paid_at', { useTz: true }).nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.index(['booking_id']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0010_create_invoices_table.ts b/apps/rental/database/migrations/tenant/0010_create_invoices_table.ts new file mode 100644 index 00000000..654cdd99 --- /dev/null +++ b/apps/rental/database/migrations/tenant/0010_create_invoices_table.ts @@ -0,0 +1,31 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** VAT invoices, one per booking. Money is santimat; vat is 20% TVA. */ +export default class extends BaseSchema { + protected tableName = 'invoices' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table + .uuid('booking_id') + .notNullable() + .references('id') + .inTable('bookings') + .onDelete('CASCADE') + table.string('number').notNullable().unique() + table.jsonb('lines').notNullable().defaultTo('[]') + table.integer('subtotal').notNullable().defaultTo(0) + table.integer('vat').notNullable().defaultTo(0) + table.integer('total').notNullable().defaultTo(0) + table.string('currency').notNullable().defaultTo('MAD') + table.timestamp('issued_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0011_create_maintenance_records_table.ts b/apps/rental/database/migrations/tenant/0011_create_maintenance_records_table.ts new file mode 100644 index 00000000..776058ac --- /dev/null +++ b/apps/rental/database/migrations/tenant/0011_create_maintenance_records_table.ts @@ -0,0 +1,30 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** Per-vehicle maintenance history. `cost` is santimat. */ +export default class extends BaseSchema { + protected tableName = 'maintenance_records' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table + .uuid('vehicle_id') + .notNullable() + .references('id') + .inTable('vehicles') + .onDelete('CASCADE') + table.string('type').notNullable().defaultTo('service') + table.integer('cost').notNullable().defaultTo(0) + table.integer('odometer').notNullable().defaultTo(0) + table.timestamp('performed_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.text('notes').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.index(['vehicle_id']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/migrations/tenant/0012_create_fleet_docs_table.ts b/apps/rental/database/migrations/tenant/0012_create_fleet_docs_table.ts new file mode 100644 index 00000000..b50f5760 --- /dev/null +++ b/apps/rental/database/migrations/tenant/0012_create_fleet_docs_table.ts @@ -0,0 +1,22 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** Policy/FAQ documents — the RAG corpus for the fleet assistant. */ +export default class extends BaseSchema { + protected tableName = 'fleet_docs' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().defaultTo(this.db.rawQuery('gen_random_uuid()').knexQuery) + table.string('title').notNullable() + table.text('body').notNullable() + table.string('source').notNullable().unique() + table.timestamp('embedded_at', { useTz: true }).nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(this.now()) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/rental/database/schema.ts b/apps/rental/database/schema.ts new file mode 100644 index 00000000..5d2fa127 --- /dev/null +++ b/apps/rental/database/schema.ts @@ -0,0 +1,42 @@ +/** + * This file is automatically generated + * DO NOT EDIT manually + * Run "node ace migration:run" command to re-generate this file + */ + +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +export class CarMakeSchema extends BaseModel { + static $columns = ['country', 'createdAt', 'id', 'name', 'slug', 'updatedAt'] as const + $columns = CarMakeSchema.$columns + @column() + declare country: string | null + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column({ isPrimary: true }) + declare id: number + @column() + declare name: string + @column() + declare slug: string + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} + +export class CarModelSchema extends BaseModel { + static $columns = ['bodyType', 'createdAt', 'id', 'makeId', 'name', 'updatedAt'] as const + $columns = CarModelSchema.$columns + @column() + declare bodyType: string | null + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column({ isPrimary: true }) + declare id: number + @column() + declare makeId: number + @column() + declare name: string + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/apps/rental/docker-compose.yml b/apps/rental/docker-compose.yml new file mode 100644 index 00000000..78cc02f1 --- /dev/null +++ b/apps/rental/docker-compose.yml @@ -0,0 +1,48 @@ +# Local infrastructure for Karimoto. Distinct host ports from the core demo +# (55432/56379) so both can run side by side. One Postgres + one Redis is +# plenty — the package fits 3 schemas inside one PG instance, and Redis uses +# logical DBs 0/1/2 for default/queue/cache. +services: + # pgvector image (Postgres 16 + the `vector` extension) so the AI satellite's + # per-tenant embedding store works locally. The extension is created into a + # dedicated `extensions` schema at provision time (never `public`), and each + # tenant connection appends that schema to its search_path. + postgres: + image: pgvector/pgvector:pg16 + container_name: karimoto_postgres + environment: + POSTGRES_USER: karimoto + POSTGRES_PASSWORD: karimoto + POSTGRES_DB: karimoto + ports: + - "55433:5432" + volumes: + - karimoto_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U karimoto -d karimoto"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: karimoto_redis + ports: + - "56380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + # MailCatcher captures outgoing email (the tenant-welcome mailer). Web UI at + # http://localhost:1080, JSON API at http://localhost:1080/messages. + mailcatcher: + image: schickling/mailcatcher + container_name: karimoto_mailcatcher + ports: + - "1025:1025" + - "1080:1080" + +volumes: + karimoto_pgdata: diff --git a/apps/rental/inertia/app/app.tsx b/apps/rental/inertia/app/app.tsx new file mode 100644 index 00000000..c3a082f8 --- /dev/null +++ b/apps/rental/inertia/app/app.tsx @@ -0,0 +1,22 @@ +import '../css/app.css' +import { createInertiaApp } from '@inertiajs/react' +import { createRoot } from 'react-dom/client' +import { resolvePageComponent } from '@adonisjs/inertia/helpers' + +const appName = 'Karimoto' + +createInertiaApp({ + progress: { color: '#e2603b' }, + title: (title) => (title ? `${title} · ${appName}` : appName), + + resolve: (name) => { + return resolvePageComponent( + `../pages/${name}.tsx`, + import.meta.glob('../pages/**/*.tsx') + ) + }, + + setup({ el, App, props }) { + createRoot(el).render() + }, +}) diff --git a/apps/rental/inertia/components/assistant_message.tsx b/apps/rental/inertia/components/assistant_message.tsx new file mode 100644 index 00000000..95ecba18 --- /dev/null +++ b/apps/rental/inertia/components/assistant_message.tsx @@ -0,0 +1,539 @@ +import { createElement } from 'react' +import type { ReactNode } from 'react' + +/** + * Rich renderer for a fleet-assistant answer (WS-AI-11 UI). + * + * The assistant streams plain text over SSE, and that text is markdown. Dumping it + * raw (the old `white-space: pre-wrap` bubble) showed the reader the markdown SOURCE + * — literal `**`, `##`, `| … |`. This turns the same stream into real UI: headings, + * bold, lists and GFM tables render, and two host-defined fenced blocks the model is + * taught to emit (see `lib/assistant_prompt`) become live components: + * + * ```stat → a KPI row of stat tiles (JSON array of { label, value, sub? }) + * ```chart → a bar or line chart (JSON { type, title, unit?, data:[{label,value}] }) + * + * Everything is parsed from the SAME token stream — the satellite never ships tool + * RESULTS to the client (only `tool_call` notices), so there is nothing to wire on the + * server: the model narrates and emits the block, the client renders it. + * + * Streaming-safe by construction. The text arrives token by token, so a fenced block + * is routinely half-written: an unterminated ```chart / ```stat renders a pulsing + * placeholder (never raw JSON), and only pops into a chart/tiles once the closing + * fence lands and the JSON parses. Malformed-but-closed falls back to a code block. + * + * No markdown dependency: a compact block+inline parser handles the subset the model + * uses. It renders through React elements (never `dangerouslySetInnerHTML`), so the + * answer text cannot inject markup. + */ +export function AssistantMessage({ content }: { content: string }) { + const segments = splitSegments(content) + return ( +

+ {segments.map((seg, i) => + seg.kind === 'fence' ? ( + + ) : ( + + ) + )} +
+ ) +} + +// ─── Segmentation: fenced blocks vs markdown text ──────────────────────────── + +type Segment = + | { kind: 'fence'; lang: string; body: string; closed: boolean } + | { kind: 'text'; text: string } + +/** + * Split the answer into an ordered run of fenced code blocks and the markdown text + * between them. A fence opens on a line of ```` ``` ```` (+ optional info string) and + * closes on a bare ```` ``` ````; an unclosed trailing fence (mid-stream) is kept with + * `closed: false` so the caller can show a placeholder rather than the raw body. + */ +function splitSegments(src: string): Segment[] { + const lines = src.split('\n') + const out: Segment[] = [] + let buf: string[] = [] + const flush = () => { + if (buf.some((l) => l.trim() !== '')) out.push({ kind: 'text', text: buf.join('\n') }) + buf = [] + } + for (let i = 0; i < lines.length; ) { + const line = lines[i] ?? '' + const open = /^\s*```(.*)$/.exec(line) + if (open) { + flush() + const lang = (open[1] ?? '').trim().toLowerCase() + const body: string[] = [] + i += 1 + let closed = false + for (; i < lines.length; i += 1) { + if (/^\s*```\s*$/.test(lines[i] ?? '')) { + closed = true + i += 1 + break + } + body.push(lines[i] ?? '') + } + out.push({ kind: 'fence', lang, body: body.join('\n'), closed }) + continue + } + buf.push(line) + i += 1 + } + flush() + return out +} + +function FenceBlock({ seg }: { seg: Extract }) { + if (seg.lang === 'chart') { + const spec = seg.closed ? parseChart(seg.body) : null + if (spec) return + return seg.closed ? : + } + if (seg.lang === 'stat') { + const items = seg.closed ? parseStats(seg.body) : null + if (items) return + return seg.closed ? : + } + return +} + +// ─── Fenced payloads ───────────────────────────────────────────────────────── + +type ChartSpec = { + type: 'bar' | 'line' + title: string + unit: string + data: { label: string; value: number }[] +} + +/** Parse a ```chart payload, coercing/validating defensively; null if unusable. */ +function parseChart(body: string): ChartSpec | null { + try { + const raw = JSON.parse(body) as Record + const rows = Array.isArray(raw.data) ? raw.data : [] + const data = rows + .map((d) => { + const row = (d ?? {}) as Record + return { label: String(row.label ?? ''), value: Number(row.value) } + }) + .filter((d) => d.label !== '' && Number.isFinite(d.value)) + .slice(0, 12) + if (data.length === 0) return null + return { + type: raw.type === 'line' ? 'line' : 'bar', + title: String(raw.title ?? ''), + unit: String(raw.unit ?? ''), + data, + } + } catch { + return null + } +} + +type StatItem = { label: string; value: string; sub: string | null } + +/** Parse a ```stat payload (array, or `{ items: [...] }`); null if empty/unusable. */ +function parseStats(body: string): StatItem[] | null { + try { + const raw = JSON.parse(body) as unknown + const list = Array.isArray(raw) + ? raw + : Array.isArray((raw as Record)?.items) + ? ((raw as Record).items as unknown[]) + : [] + const items = list + .map((s) => { + const row = (s ?? {}) as Record + return { + label: String(row.label ?? ''), + value: String(row.value ?? ''), + sub: row.sub != null ? String(row.sub) : null, + } + }) + .filter((s) => s.label !== '' || s.value !== '') + .slice(0, 6) + return items.length ? items : null + } catch { + return null + } +} + +// ─── Figures ───────────────────────────────────────────────────────────────── + +function StatTiles({ items }: { items: StatItem[] }) { + return ( +
+ {items.map((s, i) => ( +
+
{s.label}
+
{s.value}
+ {s.sub &&
{s.sub}
} +
+ ))} +
+ ) +} + +function ChartFigure({ spec }: { spec: ChartSpec }) { + return ( +
+ {(spec.title || spec.unit) && ( +
+ {spec.title} + {spec.unit && {spec.unit}} +
+ )} + {spec.type === 'line' ? : } +
+ ) +} + +/** + * Horizontal bars, in plain HTML. Category comparisons and rankings often carry long + * labels (a vehicle's make/model/plate), which read far better down the left than + * rotated under columns. Single series, so one hue (`--brand`) carries magnitude — the + * sequential default — with the value at each bar's tip (a `title` gives the hover). + */ +function BarChart({ spec }: { spec: ChartSpec }) { + const max = Math.max(...spec.data.map((d) => d.value), 0) + return ( +
+ {spec.data.map((d, i) => { + const pct = max > 0 ? Math.max((d.value / max) * 100, 1.5) : 0 + return ( +
+
{d.label}
+
+
+
+
{fmt(d.value)}
+
+ ) + })} +
+ ) +} + +/** + * A single-series line over an ordered axis (a genuine time trend). 2px line, round + * caps, an end-marker ringed in the surface colour so it clears the line, two-to-three + * recessive gridlines, and the final value labelled directly. Scales with its + * container via the viewBox; text stays in ink tokens, never the data colour. + */ +function LineChart({ spec }: { spec: ChartSpec }) { + const W = 640 + const H = 200 + const padL = 6 + const padR = 44 + const padT = 14 + const padB = 26 + const data = spec.data + const top = niceTop(Math.max(...data.map((d) => d.value), 0)) + const plotW = W - padL - padR + const plotH = H - padT - padB + const x = (i: number) => + padL + (data.length <= 1 ? plotW / 2 : (i / (data.length - 1)) * plotW) + const y = (v: number) => padT + plotH - (top > 0 ? (v / top) * plotH : 0) + const points = data.map((d, i) => `${x(i)},${y(d.value)}`).join(' ') + const ticks = [0, top / 2, top] + const lastIndex = data.length - 1 + const last = data[lastIndex] + + return ( + + {ticks.map((t, i) => ( + + + + {fmt(Math.round(t))} + + + ))} + + {data.map((d, i) => ( + + + + {`${d.label}: ${fmt(d.value)}`} + + + ))} + {last && ( + + {fmt(last.value)} + + )} + {data.map((d, i) => ( + + {d.label} + + ))} + + ) +} + +// ─── Markdown (the non-fenced text) ────────────────────────────────────────── + +type Block = + | { t: 'h'; level: number; text: string } + | { t: 'hr' } + | { t: 'quote'; text: string } + | { t: 'ul'; items: string[] } + | { t: 'ol'; items: string[] } + | { t: 'table'; header: string[]; rows: string[][] } + | { t: 'p'; text: string } + +function Markdown({ text }: { text: string }) { + return ( + <> + {parseBlocks(text).map((block, i) => ( + + ))} + + ) +} + +const HEADING = /^\s{0,3}(#{1,4})\s+(.*)$/ +const HR = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/ +const UL = /^\s{0,3}[-*+]\s+/ +const OL = /^\s{0,3}\d+\.\s+/ +const QUOTE = /^\s{0,3}>\s?/ + +/** Group lines into block-level markdown nodes. Tolerant of partial input. */ +function parseBlocks(src: string): Block[] { + const lines = src.split('\n') + const blocks: Block[] = [] + const n = lines.length + let i = 0 + while (i < n) { + const line = lines[i] ?? '' + if (line.trim() === '') { + i += 1 + continue + } + const heading = HEADING.exec(line) + if (heading) { + blocks.push({ t: 'h', level: (heading[1] ?? '#').length, text: (heading[2] ?? '').trim() }) + i += 1 + continue + } + if (HR.test(line)) { + blocks.push({ t: 'hr' }) + i += 1 + continue + } + if (line.includes('|') && isTableSep(lines[i + 1] ?? '')) { + const header = splitRow(line) + i += 2 + const rows: string[][] = [] + while (i < n && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim() !== '') { + rows.push(splitRow(lines[i] ?? '')) + i += 1 + } + blocks.push({ t: 'table', header, rows }) + continue + } + if (QUOTE.test(line)) { + const q: string[] = [] + while (i < n && QUOTE.test(lines[i] ?? '')) { + q.push((lines[i] ?? '').replace(QUOTE, '')) + i += 1 + } + blocks.push({ t: 'quote', text: q.join(' ') }) + continue + } + if (UL.test(line)) { + const items: string[] = [] + while (i < n && UL.test(lines[i] ?? '')) { + items.push((lines[i] ?? '').replace(UL, '')) + i += 1 + } + blocks.push({ t: 'ul', items }) + continue + } + if (OL.test(line)) { + const items: string[] = [] + while (i < n && OL.test(lines[i] ?? '')) { + items.push((lines[i] ?? '').replace(OL, '')) + i += 1 + } + blocks.push({ t: 'ol', items }) + continue + } + const para: string[] = [] + while (i < n) { + const l = lines[i] ?? '' + if ( + l.trim() === '' || + HEADING.test(l) || + HR.test(l) || + UL.test(l) || + OL.test(l) || + QUOTE.test(l) || + (l.includes('|') && isTableSep(lines[i + 1] ?? '')) + ) { + break + } + para.push(l) + i += 1 + } + if (para.length) blocks.push({ t: 'p', text: para.join(' ') }) + else i += 1 + } + return blocks +} + +/** A GFM table separator row: only pipes, dashes, colons and spaces, with both a pipe and a dash. */ +function isTableSep(line: string): boolean { + const t = line.trim() + return t !== '' && /^[\s|:-]+$/.test(t) && t.includes('|') && t.includes('-') +} + +function splitRow(line: string): string[] { + let t = line.trim() + if (t.startsWith('|')) t = t.slice(1) + if (t.endsWith('|')) t = t.slice(0, -1) + return t.split('|').map((c) => c.trim()) +} + +function MdBlock({ block }: { block: Block }) { + switch (block.t) { + case 'h': + return createElement( + `h${Math.min(6, block.level + 1)}`, + { className: 'md-h' }, + inline(block.text) + ) + case 'hr': + return
+ case 'quote': + return
{inline(block.text)}
+ case 'ul': + return ( +
    + {block.items.map((it, i) => ( +
  • {inline(it)}
  • + ))} +
+ ) + case 'ol': + return ( +
    + {block.items.map((it, i) => ( +
  1. {inline(it)}
  2. + ))} +
+ ) + case 'table': + return ( +
+ + + + {block.header.map((h, i) => ( + + ))} + + + + {block.rows.map((row, ri) => ( + + {block.header.map((_, ci) => ( + + ))} + + ))} + +
{inline(h)}
{inline(row[ci] ?? '')}
+
+ ) + case 'p': + return

{inline(block.text)}

+ } +} + +// Inline: **bold** / __bold__, *em* / _em_, `code`, [text](url). Not nested — good +// enough for chat prose, and it never emits raw HTML. +const INLINE = + /(\*\*(.+?)\*\*)|(__(.+?)__)|(`([^`]+)`)|(\*(.+?)\*)|(_(.+?)_)|(\[([^\]]+)\]\(([^)\s]+)\))/g + +function inline(src: string): ReactNode { + const nodes: ReactNode[] = [] + let last = 0 + let key = 0 + let m: RegExpExecArray | null + INLINE.lastIndex = 0 + while ((m = INLINE.exec(src))) { + if (m.index > last) nodes.push(src.slice(last, m.index)) + if (m[2] != null) nodes.push({m[2]}) + else if (m[4] != null) nodes.push({m[4]}) + else if (m[6] != null) + nodes.push( + + {m[6]} + + ) + else if (m[8] != null) nodes.push({m[8]}) + else if (m[10] != null) nodes.push({m[10]}) + else if (m[12] != null) + nodes.push( + + {m[12]} + + ) + last = INLINE.lastIndex + } + if (last < src.length) nodes.push(src.slice(last)) + return nodes +} + +// ─── Bits ──────────────────────────────────────────────────────────────────── + +function CodeBlock({ body }: { body: string }) { + return ( +
+      {body}
+    
+ ) +} + +function Pending({ label }: { label: string }) { + return ( +
+ + {label} +
+ ) +} + +/** Round a max up to a clean axis top (1/2/5 × 10ⁿ). */ +function niceTop(max: number): number { + if (max <= 0) return 1 + const pow = Math.pow(10, Math.floor(Math.log10(max))) + const n = max / pow + const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10 + return step * pow +} + +/** Thousands-grouped; up to two decimals for a non-integer. */ +function fmt(n: number): string { + return Number.isInteger(n) + ? n.toLocaleString('en-US') + : n.toLocaleString('en-US', { maximumFractionDigits: 2 }) +} + +/** Only allow safe URL schemes; anything else (e.g. javascript:) collapses to '#'. */ +function safeHref(url: string): string { + return /^(https?:\/\/|mailto:|\/|#)/i.test(url) ? url : '#' +} diff --git a/apps/rental/inertia/components/login_form.tsx b/apps/rental/inertia/components/login_form.tsx new file mode 100644 index 00000000..88946746 --- /dev/null +++ b/apps/rental/inertia/components/login_form.tsx @@ -0,0 +1,87 @@ +import { useForm, usePage, Head } from '@inertiajs/react' +import type { FormEvent } from 'react' +import type { SharedProps } from '../types' + +type Props = { + heading: string + subtitle: string +} + +/** + * The shared session-login card. Both realms render it; the server route + * (`POST /login`, universal) picks the realm from the resolved host, so the form + * itself is realm-agnostic. Field errors come back through Inertia's validation + * bag; a bad-credentials failure arrives as a flash message. + */ +export default function LoginForm({ heading, subtitle }: Props) { + const { props } = usePage() + const form = useForm({ email: '', password: '' }) + + const submit = (e: FormEvent) => { + e.preventDefault() + form.post('/login', { onFinish: () => form.reset('password') }) + } + + const flashError = props.flash?.error + + return ( +
+ +
+
+ 🚗 Karimoto +
+

{subtitle}

+ + {flashError &&
{flashError}
} + +
+
+ + form.setData('email', e.target.value)} + /> + {form.errors.email && {form.errors.email}} +
+ +
+ + form.setData('password', e.target.value)} + /> + {form.errors.password && ( + {form.errors.password} + )} +
+ + +
+ +

+ {heading} +

+
+
+ ) +} diff --git a/apps/rental/inertia/components/shells.tsx b/apps/rental/inertia/components/shells.tsx new file mode 100644 index 00000000..c2c2649a --- /dev/null +++ b/apps/rental/inertia/components/shells.tsx @@ -0,0 +1,220 @@ +import type { ReactNode } from 'react' +import { router, usePage, Head } from '@inertiajs/react' +import type { SharedProps, TenantStatus } from '../types' + +/* ─── Small shared UI ────────────────────────────────────────────────────── */ + +export function Stat({ label, value, sub }: { label: string; value: ReactNode; sub?: ReactNode }) { + return ( +
+
{label}
+
{value}
+ {sub != null &&
{sub}
} +
+ ) +} + +const STATUS_TONE: Record = { + active: 'badge--green', + provisioning: 'badge--blue', + suspended: 'badge--amber', + failed: 'badge--red', + deleted: 'badge--slate', +} + +export function StatusBadge({ status }: { status: TenantStatus }) { + return ( + + + {status} + + ) +} + +function initials(name: string | null, email: string) { + const src = (name || email || '?').trim() + const parts = src.split(/\s+/) + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase() + return src.slice(0, 2).toUpperCase() +} + +/* ─── Nav model ──────────────────────────────────────────────────────────── */ + +export type NavItem = { label: string; href?: string; icon: string; soon?: boolean } + +/* ─── The shell (sidebar + topbar + content) ─────────────────────────────── */ + +function Shell({ + brandSub, + nav, + title, + activeHref, + identity, + children, +}: { + brandSub: string + nav: { section: string; items: NavItem[] }[] + title: string + activeHref: string + identity: { name: string | null; email: string } + children: ReactNode +}) { + return ( +
+ + + +
+
+
{title}
+
+
{children}
+
+
+ ) +} + +/* ─── Operator shell ─────────────────────────────────────────────────────── */ + +const OPERATOR_NAV: { section: string; items: NavItem[] }[] = [ + { + section: 'Platform', + items: [ + { label: 'Companies', href: '/', icon: '▤' }, + { label: 'Reporting', href: '/reporting', icon: '◷' }, + { label: 'Health & doctor', href: '/health', icon: '✚' }, + { label: 'Billing', icon: '❖', soon: true }, + ], + }, +] + +export function OperatorShell({ + title, + activeHref = '/', + children, +}: { + title: string + activeHref?: string + children: ReactNode +}) { + const { props } = usePage() + const op = props.auth.operator + return ( + + {children} + + ) +} + +/* ─── Tenant shell ───────────────────────────────────────────────────────── */ + +const TENANT_NAV: { section: string; items: NavItem[] }[] = [ + { + section: 'Operations', + items: [ + { label: 'Dashboard', href: '/', icon: '▤' }, + { label: 'Fleet', href: '/fleet', icon: '🚘' }, + { label: 'Bookings', href: '/reservations', icon: '📅' }, + { label: 'Customers', href: '/renters', icon: '👤' }, + ], + }, + { + section: 'Company', + items: [ + { label: 'Billing', href: '/subscription', icon: '❖' }, + { label: 'AI assistant', href: '/assistant', icon: '✦' }, + { label: 'Knowledge base', href: '/knowledge', icon: '📚' }, + { label: 'Settings', href: '/settings', icon: '⚙' }, + ], + }, +] + +export function TenantShell({ + title, + activeHref = '/', + children, +}: { + title: string + activeHref?: string + children: ReactNode +}) { + const { props } = usePage() + const staff = props.auth.staff + return ( + + {children} + + ) +} diff --git a/apps/rental/inertia/css/app.css b/apps/rental/inertia/css/app.css new file mode 100644 index 00000000..f469841e --- /dev/null +++ b/apps/rental/inertia/css/app.css @@ -0,0 +1,858 @@ +/* + * Karimoto design system. + * + * One warm, Moroccan-leaning palette (clay/terracotta on sand and deep slate) + * shared by both consoles. Everything is driven by custom properties so the two + * shells and the light/dark themes stay in sync. Pages compose the component + * classes below rather than shipping bespoke CSS. + */ + +:root { + --clay-50: #fdf3ee; + --clay-100: #fbe3d6; + --clay-200: #f6c3a9; + --clay-300: #ef9d75; + --clay-400: #e97a4c; + --clay-500: #e2603b; + --clay-600: #c74a2a; + --clay-700: #a43a22; + --clay-800: #7f2f1f; + --clay-900: #5f261b; + + --sand-50: #faf7f2; + --sand-100: #f2ece1; + --sand-200: #e6dccb; + + --slate-50: #f8fafc; + --slate-100: #f1f5f9; + --slate-200: #e2e8f0; + --slate-300: #cbd5e1; + --slate-400: #94a3b8; + --slate-500: #64748b; + --slate-600: #475569; + --slate-700: #334155; + --slate-800: #1e293b; + --slate-900: #0f172a; + --slate-950: #020617; + + --green-500: #10b981; + --amber-500: #f59e0b; + --red-500: #ef4444; + --blue-500: #3b82f6; + + /* Semantic tokens (light) */ + --bg: var(--sand-50); + --bg-elevated: #ffffff; + --bg-sunken: var(--sand-100); + --surface: #ffffff; + --surface-2: var(--slate-50); + --border: var(--slate-200); + --border-strong: var(--slate-300); + --ink: var(--slate-900); + --ink-2: var(--slate-600); + --ink-3: var(--slate-400); + --brand: var(--clay-500); + --brand-strong: var(--clay-600); + --brand-tint: var(--clay-50); + --ring: color-mix(in srgb, var(--brand) 40%, transparent); + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow: 0 4px 16px -4px rgba(15, 23, 42, 0.12); + --shadow-lg: 0 24px 48px -12px rgba(15, 23, 42, 0.25); + --radius: 12px; + --radius-sm: 8px; + --radius-lg: 18px; + --font: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, + Helvetica, Arial, sans-serif; + --mono: ui-monospace, 'SFMono-Regular', 'JetBrains Mono', Menlo, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: var(--slate-950); + --bg-elevated: var(--slate-900); + --bg-sunken: #000; + --surface: var(--slate-900); + --surface-2: var(--slate-800); + --border: color-mix(in srgb, var(--slate-700) 70%, transparent); + --border-strong: var(--slate-600); + --ink: var(--slate-50); + --ink-2: var(--slate-300); + --ink-3: var(--slate-500); + --brand: var(--clay-400); + --brand-strong: var(--clay-300); + --brand-tint: color-mix(in srgb, var(--clay-500) 16%, transparent); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow: 0 8px 24px -6px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 32px 64px -16px rgba(0, 0, 0, 0.7); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--ink); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + line-height: 1.5; +} + +a { + color: var(--brand-strong); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +h1, +h2, +h3, +h4 { + margin: 0; + line-height: 1.2; + letter-spacing: -0.02em; +} + +/* ─── Layout shells ──────────────────────────────────────────────── */ + +.app-shell { + display: grid; + grid-template-columns: 264px 1fr; + min-height: 100vh; +} + +.sidebar { + background: var(--bg-elevated); + border-right: 1px solid var(--border); + padding: 22px 16px; + display: flex; + flex-direction: column; + gap: 4px; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} + +.sidebar__brand { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px 20px; + font-weight: 800; + font-size: 18px; + letter-spacing: -0.03em; +} +.sidebar__brand .logo { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 10px; + background: linear-gradient(140deg, var(--clay-400), var(--clay-600)); + color: #fff; + font-size: 18px; + box-shadow: var(--shadow-sm); +} +.sidebar__section { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-3); + padding: 16px 12px 6px; + font-weight: 600; +} +.nav-link { + display: flex; + align-items: center; + gap: 11px; + padding: 9px 12px; + border-radius: var(--radius-sm); + color: var(--ink-2); + font-weight: 500; + font-size: 14px; + transition: background 0.12s, color 0.12s; +} +.nav-link:hover { + background: var(--surface-2); + color: var(--ink); + text-decoration: none; +} +.nav-link.is-active { + background: var(--brand-tint); + color: var(--brand-strong); +} +.nav-link .ico { + width: 18px; + text-align: center; + opacity: 0.85; +} +.sidebar__foot { + margin-top: auto; + padding-top: 16px; + border-top: 1px solid var(--border); + font-size: 13px; + color: var(--ink-2); +} + +.main { + min-width: 0; + display: flex; + flex-direction: column; +} +.topbar { + height: 64px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-elevated) 88%, transparent); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 28px; + position: sticky; + top: 0; + z-index: 10; +} +.topbar__title { + font-size: 15px; + font-weight: 600; +} +.content { + padding: 28px; + max-width: 1180px; + width: 100%; +} + +/* ─── Cards / surfaces ───────────────────────────────────────────── */ + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); +} +.card__head { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.card__title { + font-size: 15px; + font-weight: 650; +} +.card__body { + padding: 20px; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 16px; +} +.stat { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + box-shadow: var(--shadow-sm); +} +.stat__label { + font-size: 12px; + color: var(--ink-2); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} +.stat__value { + font-size: 30px; + font-weight: 750; + margin-top: 8px; + letter-spacing: -0.03em; +} +.stat__sub { + font-size: 13px; + color: var(--ink-3); + margin-top: 4px; +} + +/* ─── Buttons ────────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 9px 16px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, transform 0.05s, opacity 0.12s; + white-space: nowrap; +} +.btn:active { + transform: translateY(1px); +} +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.btn--primary { + background: var(--brand); + color: #fff; +} +.btn--primary:hover:not(:disabled) { + background: var(--brand-strong); + text-decoration: none; +} +.btn--ghost { + background: transparent; + border-color: var(--border-strong); + color: var(--ink); +} +.btn--ghost:hover:not(:disabled) { + background: var(--surface-2); + text-decoration: none; +} +.btn--subtle { + background: var(--surface-2); + color: var(--ink); +} +.btn--subtle:hover:not(:disabled) { + background: var(--border); + text-decoration: none; +} +.btn--danger { + background: transparent; + border-color: color-mix(in srgb, var(--red-500) 45%, transparent); + color: var(--red-500); +} +.btn--danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--red-500) 12%, transparent); + text-decoration: none; +} +.btn--sm { + padding: 6px 11px; + font-size: 13px; +} +.btn--block { + width: 100%; +} + +/* ─── Forms ──────────────────────────────────────────────────────── */ + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} +.field__label { + font-size: 13px; + font-weight: 600; + color: var(--ink-2); +} +.input, +.select, +.textarea { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--ink); + font-family: inherit; + font-size: 14px; + transition: border-color 0.12s, box-shadow 0.12s; +} +.input:focus, +.select:focus, +.textarea:focus { + outline: none; + border-color: var(--brand); + box-shadow: 0 0 0 3px var(--ring); +} +.field__error { + font-size: 12.5px; + color: var(--red-500); + font-weight: 500; +} + +/* ─── Table ──────────────────────────────────────────────────────── */ + +.table-wrap { + overflow-x: auto; +} +.table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.table th { + text-align: left; + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-3); + font-weight: 600; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.table td { + padding: 13px 16px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.table tr:last-child td { + border-bottom: none; +} +.table tbody tr { + transition: background 0.1s; +} +.table tbody tr:hover { + background: var(--surface-2); +} +.table--flush { + margin: -4px 0; +} + +/* ─── Badges ─────────────────────────────────────────────────────── */ + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + line-height: 1.5; + border: 1px solid transparent; +} +.badge .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +.badge--green { + color: #0a7a55; + background: color-mix(in srgb, var(--green-500) 15%, transparent); +} +.badge--amber { + color: #a16207; + background: color-mix(in srgb, var(--amber-500) 18%, transparent); +} +.badge--red { + color: #b91c1c; + background: color-mix(in srgb, var(--red-500) 14%, transparent); +} +.badge--blue { + color: #1d4ed8; + background: color-mix(in srgb, var(--blue-500) 14%, transparent); +} +.badge--slate { + color: var(--ink-2); + background: var(--surface-2); +} +@media (prefers-color-scheme: dark) { + .badge--green { + color: #34d399; + } + .badge--amber { + color: #fbbf24; + } + .badge--red { + color: #f87171; + } + .badge--blue { + color: #60a5fa; + } +} + +/* ─── Auth (centered) screens ────────────────────────────────────── */ + +.auth-screen { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: radial-gradient( + 1200px 600px at 20% -10%, + var(--brand-tint), + transparent 60% + ), + var(--bg); +} +.auth-card { + width: 100%; + max-width: 400px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: 32px; +} +.auth-card__brand { + display: flex; + align-items: center; + gap: 11px; + font-weight: 800; + font-size: 20px; + margin-bottom: 4px; +} +.auth-card__brand .logo { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 11px; + background: linear-gradient(140deg, var(--clay-400), var(--clay-600)); + color: #fff; + font-size: 20px; +} +.auth-card__sub { + color: var(--ink-2); + font-size: 14px; + margin: 4px 0 24px; +} + +/* ─── Helpers ────────────────────────────────────────────────────── */ + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 22px; + flex-wrap: wrap; +} +.page-head h1 { + font-size: 24px; + font-weight: 750; +} +.page-head p { + margin: 6px 0 0; + color: var(--ink-2); + font-size: 14px; +} +.stack { + display: flex; + flex-direction: column; + gap: 20px; +} +.row { + display: flex; + align-items: center; + gap: 10px; +} +.row--wrap { + flex-wrap: wrap; +} +.spacer { + flex: 1; +} +.muted { + color: var(--ink-2); +} +.mono { + font-family: var(--mono); + font-size: 12.5px; +} +.truncate { + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.empty { + text-align: center; + padding: 48px 20px; + color: var(--ink-3); +} +.alert { + padding: 12px 16px; + border-radius: var(--radius-sm); + font-size: 14px; + font-weight: 500; + margin-bottom: 16px; +} +.alert--error { + background: color-mix(in srgb, var(--red-500) 12%, transparent); + color: #b91c1c; +} +.alert--success { + background: color-mix(in srgb, var(--green-500) 14%, transparent); + color: #0a7a55; +} +@media (prefers-color-scheme: dark) { + .alert--error { + color: #f87171; + } + .alert--success { + color: #34d399; + } +} +.avatar { + width: 34px; + height: 34px; + border-radius: 50%; + display: grid; + place-items: center; + background: var(--brand-tint); + color: var(--brand-strong); + font-weight: 700; + font-size: 13px; +} + +@media (max-width: 820px) { + .app-shell { + grid-template-columns: 1fr; + } + .sidebar { + position: static; + height: auto; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + } +} + +/* ─── AI assistant — rich answers (markdown + stat tiles + charts) ─── */ + +.md { + font-size: 14.5px; + line-height: 1.6; +} +.md > *:first-child { + margin-top: 0; +} +.md > *:last-child { + margin-bottom: 0; +} +.md-p { + margin: 0 0 10px; +} +.md-h { + margin: 16px 0 8px; + line-height: 1.25; + font-weight: 700; + letter-spacing: -0.01em; +} +h2.md-h { + font-size: 18px; +} +h3.md-h { + font-size: 16px; +} +h4.md-h, +h5.md-h, +h6.md-h { + font-size: 14.5px; + color: var(--ink-2); +} +.md-ul, +.md-ol { + margin: 0 0 10px; + padding-left: 22px; +} +.md-ul { + list-style: disc; +} +.md-ol { + list-style: decimal; +} +.md-ul li, +.md-ol li { + margin: 3px 0; +} +.md-code { + font-family: var(--mono); + font-size: 0.86em; + background: var(--surface); + border: 1px solid var(--border); + padding: 1px 5px; + border-radius: 5px; +} +.md-pre { + margin: 0 0 12px; + padding: 12px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow-x: auto; +} +.md-pre code { + font-family: var(--mono); + font-size: 12.5px; + color: var(--ink); +} +.md-quote { + margin: 0 0 12px; + padding: 6px 0 6px 14px; + border-left: 3px solid var(--border-strong); + color: var(--ink-2); +} +.md-hr { + border: none; + border-top: 1px solid var(--border); + margin: 16px 0; +} +.md-table { + margin: 4px 0 14px; +} +.md-table td { + font-variant-numeric: tabular-nums; +} + +/* Stat tiles inside an answer: a touch more compact than the page KPIs. */ +.ai-stats { + margin: 6px 0 14px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} +.ai-stats .stat { + padding: 14px 16px; +} +.ai-stats .stat__value { + font-size: 24px; + margin-top: 6px; +} + +/* Charts. */ +.chart { + margin: 8px 0 16px; +} +.chart__title { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 10px; + font-size: 13px; + font-weight: 650; + color: var(--ink-2); +} +.chart__unit { + font-size: 11.5px; + font-weight: 500; + color: var(--ink-3); +} + +/* Horizontal bars (HTML): one hue carries magnitude, value at the tip. */ +.bars { + display: flex; + flex-direction: column; + gap: 8px; +} +.bars__row { + display: grid; + grid-template-columns: minmax(72px, 34%) 1fr auto; + align-items: center; + gap: 10px; +} +.bars__label { + font-size: 13px; + color: var(--ink-2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.bars__track { + height: 12px; + background: var(--surface); + border-radius: 4px; + overflow: hidden; +} +.bars__fill { + height: 100%; + min-width: 3px; + background: var(--brand); + border-radius: 0 4px 4px 0; + transition: width 0.3s ease; +} +.bars__value { + font-size: 13px; + font-weight: 650; + color: var(--ink); + font-variant-numeric: tabular-nums; +} + +/* Line chart (SVG): 2px line, surface-ringed markers, recessive grid. */ +.linechart { + display: block; + width: 100%; + height: auto; +} +.chart__grid { + stroke: var(--border); + stroke-width: 1; +} +.chart__axis { + fill: var(--ink-3); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.chart__line { + fill: none; + stroke: var(--brand); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} +.chart__dot { + fill: var(--brand); +} +.chart__dotring { + fill: var(--surface-2); +} +.chart__endval { + fill: var(--ink); + font-size: 11.5px; + font-weight: 650; +} + +/* Placeholder while a stat/chart block is still streaming in. */ +.chart-pending { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 8px 0 14px; + padding: 8px 12px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm); + color: var(--ink-3); + font-size: 13px; +} +.chart-pending__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--brand); + animation: chart-pulse 1s ease-in-out infinite; +} +@keyframes chart-pulse { + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 1; + } +} diff --git a/apps/rental/inertia/lib/api.ts b/apps/rental/inertia/lib/api.ts new file mode 100644 index 00000000..9a96c5ab --- /dev/null +++ b/apps/rental/inertia/lib/api.ts @@ -0,0 +1,60 @@ +/** + * Thin JSON fetch wrapper for the REST surfaces the consoles read. + * + * The browser consoles talk to the very same endpoints the programmatic API and + * e2e suite drive — the operator console to the admin satellite under `/admin`, + * the company console to the tenant-guarded domain API. Auth rides on the + * session cookie (same origin), so no bearer is attached here; the server + * accepts either a `web-*` session or a token on these routes. + */ + +export class ApiError extends Error { + constructor( + public status: number, + public code: string | null, + message: string + ) { + super(message) + this.name = 'ApiError' + } +} + +async function request(method: string, url: string, body?: unknown): Promise { + const res = await fetch(url, { + method, + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + + const text = await res.text() + const payload = text ? safeJson(text) : null + + if (!res.ok) { + const code = (payload && (payload.code || payload.error)) || null + const message = + (payload && (payload.message || payload.error)) || `Request failed (${res.status})` + throw new ApiError(res.status, code, message) + } + + return payload as T +} + +function safeJson(text: string): any { + try { + return JSON.parse(text) + } catch { + return { message: text } + } +} + +export const api = { + get: (url: string) => request('GET', url), + post: (url: string, body?: unknown) => request('POST', url, body), + put: (url: string, body?: unknown) => request('PUT', url, body), + del: (url: string) => request('DELETE', url), +} diff --git a/apps/rental/inertia/lib/assistant_prompt.ts b/apps/rental/inertia/lib/assistant_prompt.ts new file mode 100644 index 00000000..c5efb545 --- /dev/null +++ b/apps/rental/inertia/lib/assistant_prompt.ts @@ -0,0 +1,61 @@ +/** + * The fleet assistant's system prompt (WS-AI-11 UI). + * + * Sent as the leading `{ role: 'system' }` turn of every `/ai/chat` request. The + * satellite keeps a client system prompt at the head of the context (memory and the + * retrieval block slot in after it), so this is the sanctioned place to shape the + * assistant — no server change needed. + * + * It does two jobs. It gives the model a FORMATTING contract: answer in concise + * markdown, and emit two host-defined fenced blocks — ```stat for headline numbers and + * ```chart for comparisons/trends — which `components/assistant_message` renders as a + * KPI row and a chart. And it gives the model DATA DISCIPLINE: the read-only fleet + * tools return everything in one call each, so there is never a reason to call the same + * tool twice — which is exactly what used to send the tool loop past its round budget + * (`tool_budget_exhausted`) on an open-ended request like "make me a report". + * + * The block schemas here are the contract the renderer parses; keep them in sync. + */ +export const FLEET_SYSTEM_PROMPT = [ + 'You are the fleet assistant for a car-rental company using Karimoto. You help staff', + "with questions about their own fleet, bookings, availability and revenue. Answer in the", + "user's language. Be concise and direct — a manager reading on a busy day.", + '', + 'TOOLS. You have read-only tools over this company\'s live data: current_date,', + 'count_bookings, count_vehicles, list_available_vehicles, revenue_summary and', + 'top_rented_vehicles. Use them for anything numeric — never invent or estimate a figure.', + 'Each tool returns everything it has in a single call (a total plus its full breakdown),', + 'so call a given tool AT MOST ONCE per question and never repeat the same call. Gather the', + 'few facts you need, then write the answer. If a question is relative to now ("this month",', + '"next weekend"), call current_date first. Money is in MAD unless a tool says otherwise.', + '', + 'FORMAT. Reply in clean, compact markdown:', + '- Short paragraphs. Bold the key figure in a sentence (**1,240 MAD**).', + '- A bulleted or numbered list for a few points; a markdown table for a list of items', + ' (available vehicles, a ranking) — columns with headers.', + '- Do not show raw JSON in prose, and do not describe the blocks below — just emit them.', + '', + 'HEADLINE NUMBERS. When the answer leads with one to four key figures, emit a fenced', + '```stat block: a JSON array of tiles. `value` is a display string you format', + '(with unit); `sub` is an optional one-line note. Example:', + '```stat', + '[', + ' { "label": "Revenue this month", "value": "82,400 MAD", "sub": "active + completed" },', + ' { "label": "Fleet available", "value": "8", "sub": "of 20 vehicles" }', + ']', + '```', + '', + 'CHARTS. When you are comparing categories or ranking items, emit a fenced ```chart', + 'block with type "bar" (this is the common case: bookings by status, fleet by status,', + 'top vehicles, this-month vs last-month revenue). Use type "line" only for a genuine', + 'time series. `value` must be a plain number (no units, no separators); put the unit in', + '`unit` and a short `title`. Twelve data points at most. Example:', + '```chart', + '{ "type": "bar", "title": "Top vehicles by rentals", "unit": "rentals",', + ' "data": [ { "label": "Dacia Duster", "value": 42 }, { "label": "Renault Clio", "value": 27 } ] }', + '```', + '', + 'Put a stat or chart block between short lines of prose, not as the whole reply — a', + 'sentence of context before, a takeaway after. Only use a chart when it genuinely helps;', + 'a single number is a ```stat tile or just bold text, never a one-bar chart.', +].join('\n') diff --git a/apps/rental/inertia/lib/socket.ts b/apps/rental/inertia/lib/socket.ts new file mode 100644 index 00000000..934aeacc --- /dev/null +++ b/apps/rental/inertia/lib/socket.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react' +import { io, type Socket } from 'socket.io-client' + +/** + * Live per-company board over WebSockets. The server (see start/socket.ts + + * BookingBoardListener) attaches socket.io to the HTTP server, joins each + * connection to its `tenant:` room from the handshake `auth.tenantId`, and + * broadcasts PII-free `booking:changed` events on every committed booking write. + * + * The hook connects same-origin, authenticates with the company's own id (from + * shared props), and calls `onEvent` for each named event. Detail is never on the + * wire, so a client re-reads the REST list when notified — the classic + * "invalidate, then refetch" live pattern. Returns the connection status for a + * live/offline indicator. + */ +export type BoardStatus = 'connecting' | 'live' | 'offline' + +const LIVE_EVENTS = ['booking:changed', 'board:pong'] as const + +export function useLiveBoard( + tenantId: string | undefined | null, + onEvent: (name: string, payload: unknown) => void +): BoardStatus { + const [status, setStatus] = useState('connecting') + // Keep the latest callback without re-subscribing the socket on every render. + const cb = useRef(onEvent) + cb.current = onEvent + + useEffect(() => { + if (!tenantId) { + setStatus('offline') + return + } + const socket: Socket = io({ + auth: { tenantId }, + // Prefer a real socket; fall back to polling behind proxies that buffer. + transports: ['websocket', 'polling'], + reconnectionAttempts: 5, + }) + socket.on('connect', () => setStatus('live')) + socket.on('disconnect', () => setStatus('offline')) + socket.on('connect_error', () => setStatus('offline')) + for (const name of LIVE_EVENTS) { + socket.on(name, (payload: unknown) => cb.current(name, payload)) + } + return () => { + socket.close() + } + }, [tenantId]) + + return status +} diff --git a/apps/rental/inertia/pages/operator/company.tsx b/apps/rental/inertia/pages/operator/company.tsx new file mode 100644 index 00000000..9d4b9cf3 --- /dev/null +++ b/apps/rental/inertia/pages/operator/company.tsx @@ -0,0 +1,1086 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { usePage, router } from '@inertiajs/react' +import { OperatorShell, Stat, StatusBadge } from '../../components/shells' +import { api, ApiError } from '../../lib/api' +import type { AdminTenant } from '../../types' + +/* + * The per-company control panel. Everything here drives the admin satellite + * under `/admin/tenants/:id/...` — the operator session already authorizes those + * routes, so the session cookie is all the auth these calls carry. + * + * The panel is tabbed: each tab lazy-loads its own slice on first open (it only + * mounts once selected) and carries a Refresh button. Notice/error/busy are + * top-level and shared, exactly like operator/dashboard.tsx. + */ + +type Tab = 'overview' | 'flags' | 'webhooks' | 'audit' | 'metrics' | 'quotas' + +const TABS: { key: Tab; label: string }[] = [ + { key: 'overview', label: 'Overview' }, + { key: 'flags', label: 'Feature flags' }, + { key: 'webhooks', label: 'Webhooks' }, + { key: 'audit', label: 'Audit log' }, + { key: 'metrics', label: 'Metrics' }, + { key: 'quotas', label: 'Quotas' }, +] + +/** The shared action runner — same shape the fleet/dashboard pages copy. */ +type Run = ( + key: string, + fn: () => Promise, + message: string, + reload?: () => Promise +) => Promise + +type TabProps = { + tenantId: string + busy: string | null + run: Run + setError: (m: string | null) => void +} + +export default function OperatorCompany() { + const { tenantId } = usePage<{ tenantId: string }>().props + + const [tenant, setTenant] = useState(null) + const [tab, setTab] = useState('overview') + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [busy, setBusy] = useState(null) + + const loadTenant = useCallback(async () => { + try { + const res = await api.get<{ data: AdminTenant }>(`/admin/tenants/${tenantId}`) + setTenant(res.data) + } catch (e) { + setError(errMessage(e)) + } + }, [tenantId]) + + useEffect(() => { + loadTenant() + }, [loadTenant]) + + const run = useCallback(async (key, fn, message, reload) => { + setBusy(key) + setError(null) + setNotice(null) + try { + await fn() + setNotice(message) + if (reload) await reload() + } catch (e) { + // A cancelled confirm() rejects with this sentinel — leave the UI quiet. + if (!(e instanceof Error && e.message === 'cancelled')) { + setError(errMessage(e)) + } + } finally { + setBusy(null) + } + }, []) + + const selectTab = (next: Tab) => { + setTab(next) + setNotice(null) + setError(null) + } + + const shared: TabProps = { tenantId, busy, run, setError } + + return ( + +
+
+ { + e.preventDefault() + router.visit('/') + }} + > + ← Companies + +

{tenant?.name ?? 'Company'}

+

Feature flags, webhooks, audit trail, usage metrics and quotas for this company.

+
+ {tenant && } +
+ + {notice &&
{notice}
} + {error &&
{error}
} + +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === 'overview' && ( + + )} + {tab === 'flags' && } + {tab === 'webhooks' && } + {tab === 'audit' && } + {tab === 'metrics' && } + {tab === 'quotas' && } +
+ ) +} + +/* ─── Overview ───────────────────────────────────────────────────────────── */ + +function OverviewTab({ + tenantId, + tenant, + busy, + run, + reload, +}: { + tenantId: string + tenant: AdminTenant | null + busy: string | null + run: Run + reload: () => Promise +}) { + const [queue, setQueue] = useState | null>(null) + const [userId, setUserId] = useState('') + const [reason, setReason] = useState('') + const [token, setToken] = useState(null) + + const loadQueue = useCallback(async () => { + try { + const res = await api.get<{ data: unknown }>(`/admin/tenants/${tenantId}/queue/stats`) + setQueue(isObj(res.data) ? res.data : null) + } catch { + // Queue stats are best-effort — a missing driver shouldn't blank the tab. + setQueue(null) + } + }, [tenantId]) + + useEffect(() => { + loadQueue() + }, [loadQueue]) + + const lifecycle = buildLifecycle(tenant) + + const impersonate = () => + run( + 'impersonate', + async () => { + const res = await api.post<{ data?: { token?: string } }>( + `/admin/tenants/${tenantId}/impersonations`, + { userId: userId.trim(), ...(reason.trim() ? { reason: reason.trim() } : {}) } + ) + setToken(res?.data?.token ?? null) + }, + 'Impersonation token minted.' + ) + + return ( +
+
+
+
Identity & lifecycle
+ +
+
+ {tenant === null ? ( +
Loading company…
+ ) : ( + <> +
+ + + + + + {tenant.metadata?.plan ?? 'starter'} + + + {tenant.schemaName} + + + {tenant.id} + +
+ +
+ {lifecycle.length === 0 && ( + + No lifecycle actions available for this status. + + )} + {lifecycle.map((b) => ( + + ))} +
+ + )} +
+
+ +
+
+
Queue
+ +
+
+ {queue === null ? ( +
+ No queue stats available. +
+ ) : Object.keys(queue).length === 0 ? ( +
+ Queue is idle. +
+ ) : ( +
+ {Object.entries(queue).map(([k, v]) => ( + + {k} + + {text(v)} + + + ))} +
+ )} +
+
+ +
+
+
Impersonate
+
+
+

+ Mint a short-lived token to act as a company user. Needs an admin actor resolver + configured on the platform. +

+
+
+ + setUserId(e.target.value)} + placeholder="user uuid" + /> +
+
+ + setReason(e.target.value)} + placeholder="support ticket #123" + /> +
+
+
+ +
+ {token && ( +
+ Token:{' '} + + {token} + +
+ )} +
+
+
+ ) +} + +function buildLifecycle(t: AdminTenant | null) { + const out: { key: string; label: string; fn: () => Promise; msg: string; danger?: boolean }[] = [] + if (!t) return out + + const id = t.id + const isActive = t.status === 'active' + const isSuspended = t.status === 'suspended' + const isDeleted = t.status === 'deleted' + + if (isSuspended || t.status === 'provisioning' || t.status === 'failed') { + out.push({ + key: 'activate', + label: 'Activate', + fn: () => api.post(`/admin/tenants/${id}/activate`), + msg: `${t.name} activated.`, + }) + } + if (isActive) { + out.push({ + key: 'suspend', + label: 'Suspend', + fn: () => api.post(`/admin/tenants/${id}/suspend`), + msg: `${t.name} suspended.`, + }) + out.push({ + key: 'maintenance', + label: 'Maintenance', + fn: () => api.post(`/admin/tenants/${id}/maintenance`, { message: 'Scheduled maintenance' }), + msg: `${t.name} entered maintenance.`, + }) + } + if (isDeleted) { + out.push({ + key: 'restore', + label: 'Restore', + fn: () => api.post(`/admin/tenants/${id}/restore`), + msg: `${t.name} restored.`, + }) + } + if (isActive || isSuspended) { + out.push({ + key: 'destroy', + label: 'Destroy', + danger: true, + msg: `${t.name} destroyed.`, + fn: () => { + if (!confirm(`Destroy ${t.name}? Its schema will be dropped.`)) { + return Promise.reject(new Error('cancelled')) + } + return api.post(`/admin/tenants/${id}/destroy?keepSchema=false`) + }, + }) + } + return out +} + +/* ─── Feature flags ──────────────────────────────────────────────────────── */ + +type Flag = { flag: string; enabled: boolean; config?: any; expiresAt?: string | null } + +function FlagsTab({ tenantId, busy, run, setError }: TabProps) { + const [flags, setFlags] = useState(null) + const [name, setName] = useState('') + const [enabled, setEnabled] = useState(true) + + const load = useCallback(async () => { + try { + const res = await api.get<{ data: Flag[] }>(`/admin/tenants/${tenantId}/feature-flags`) + setFlags(Array.isArray(res.data) ? res.data : []) + } catch (e) { + setError(errMessage(e)) + setFlags([]) + } + }, [tenantId, setError]) + + useEffect(() => { + load() + }, [load]) + + const create = () => + run( + 'flag-create', + () => api.post(`/admin/tenants/${tenantId}/feature-flags`, { flag: name.trim(), enabled }), + `Flag ${name.trim()} saved.`, + load + ).then(() => setName('')) + + const toggle = (f: Flag) => + run( + `flag:${f.flag}`, + () => + api.put(`/admin/tenants/${tenantId}/feature-flags/${encodeURIComponent(f.flag)}`, { + enabled: !f.enabled, + }), + `Flag ${f.flag} ${!f.enabled ? 'enabled' : 'disabled'}.`, + load + ) + + const remove = (f: Flag) => + run( + `flag-del:${f.flag}`, + () => { + if (!confirm(`Delete flag ${f.flag}?`)) return Promise.reject(new Error('cancelled')) + return api.del(`/admin/tenants/${tenantId}/feature-flags/${encodeURIComponent(f.flag)}`) + }, + `Flag ${f.flag} deleted.`, + load + ) + + return ( +
+
+
Feature flags
+ +
+
+
+
+ + setName(e.target.value)} + placeholder="online_checkin" + /> +
+ + +
+
+ Common flags: online_checkin,{' '} + dynamic_pricing, ai_assistant. +
+ +
+ + + + + + + + + + + {flags === null && ( + + + + )} + {flags?.length === 0 && ( + + + + )} + {flags?.map((f) => ( + + + + + + + ))} + +
FlagStateExpiresActions
+ Loading flags… +
+ No feature flags set for this company. +
{f.flag} + + + {f.enabled ? 'on' : 'off'} + + + {f.expiresAt ? fmtDate(f.expiresAt) : '—'} + +
+ + +
+
+
+
+
+ ) +} + +/* ─── Webhooks ───────────────────────────────────────────────────────────── */ + +type Webhook = { id: string; url: string; events: string[]; enabled: boolean; hasSecret: boolean } + +function WebhooksTab({ tenantId, busy, run, setError }: TabProps) { + const [hooks, setHooks] = useState(null) + const [url, setUrl] = useState('') + const [events, setEvents] = useState('') + const [secret, setSecret] = useState(null) + + const load = useCallback(async () => { + try { + const res = await api.get<{ data: Webhook[] }>(`/admin/tenants/${tenantId}/webhooks`) + setHooks(Array.isArray(res.data) ? res.data : []) + } catch (e) { + setError(errMessage(e)) + setHooks([]) + } + }, [tenantId, setError]) + + useEffect(() => { + load() + }, [load]) + + const create = () => + run( + 'wh-create', + async () => { + const evs = events + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + const res = await api.post(`/admin/tenants/${tenantId}/webhooks`, { + url: url.trim(), + events: evs, + }) + // The signing secret is shown once, on creation only. + const s = res?.secret ?? res?.data?.secret + if (s) setSecret(String(s)) + }, + 'Webhook created.', + load + ).then(() => { + setUrl('') + setEvents('') + }) + + const toggle = (w: Webhook) => + run( + `wh:${w.id}`, + () => api.put(`/admin/tenants/${tenantId}/webhooks/${w.id}`, { enabled: !w.enabled }), + `Webhook ${!w.enabled ? 'enabled' : 'disabled'}.`, + load + ) + + const remove = (w: Webhook) => + run( + `wh-del:${w.id}`, + () => { + if (!confirm(`Delete webhook to ${w.url}?`)) return Promise.reject(new Error('cancelled')) + return api.del(`/admin/tenants/${tenantId}/webhooks/${w.id}`) + }, + 'Webhook deleted.', + load + ) + + return ( +
+
+
Webhooks
+ +
+
+ {secret && ( +
+ Signing secret (shown once — copy it now):{' '} + + {secret} + +
+ )} +
+
+ + setUrl(e.target.value)} + placeholder="https://hooks.acme.example/karimoto" + /> +
+
+ + setEvents(e.target.value)} + placeholder="booking.created, invoice.paid" + /> +
+ +
+ +
+ + + + + + + + + + + {hooks === null && ( + + + + )} + {hooks?.length === 0 && ( + + + + )} + {hooks?.map((w) => ( + + + + + + + ))} + +
EndpointEventsStateActions
+ Loading webhooks… +
+ No webhooks configured. +
+
{w.url}
+ {w.hasSecret && ( +
+ signed +
+ )} +
+
+ {(w.events ?? []).length === 0 && } + {(w.events ?? []).map((ev) => ( + + {ev} + + ))} +
+
+ + + {w.enabled ? 'enabled' : 'disabled'} + + +
+ + +
+
+
+
+
+ ) +} + +/* ─── Audit log ──────────────────────────────────────────────────────────── */ + +function AuditTab({ tenantId, setError }: TabProps) { + const [rows, setRows] = useState[] | null>(null) + + const load = useCallback(async () => { + try { + const res = await api.get<{ data: Record[] }>( + `/admin/tenants/${tenantId}/audit-logs?page=1&limit=50` + ) + setRows(Array.isArray(res.data) ? res.data : []) + } catch (e) { + setError(errMessage(e)) + setRows([]) + } + }, [tenantId, setError]) + + useEffect(() => { + load() + }, [load]) + + return ( +
+
+
Audit log
+ +
+
+ + + + + + + + + + + {rows === null && ( + + + + )} + {rows?.length === 0 && ( + + + + )} + {rows?.map((r, i) => { + const actor = r.actorId ?? r.actor_id + const actorType = r.actorType ?? r.actor_type + const ip = r.ipAddress ?? r.ip_address ?? r.ip + return ( + + + + + + + ) + })} + +
ActionActorIPWhen
+ Loading audit log… +
+ No audit entries recorded. +
{text(r.action ?? r.event)} + {actor ? ( + <> + {text(actor)} + {actorType && ( + + {text(actorType)} + + )} + + ) : ( + system + )} + + {text(ip)} + + {fmtDate(r.createdAt ?? r.created_at)} +
+
+
+ ) +} + +/* ─── Metrics ────────────────────────────────────────────────────────────── */ + +function MetricsTab({ tenantId, setError }: TabProps) { + const [rows, setRows] = useState[] | null>(null) + const [days, setDays] = useState(30) + + const load = useCallback(async () => { + try { + const res = await api.get<{ data: Record[]; days?: number }>( + `/admin/tenants/${tenantId}/metrics?days=${days}` + ) + setRows(Array.isArray(res.data) ? res.data : []) + } catch (e) { + setError(errMessage(e)) + setRows([]) + } + }, [tenantId, days, setError]) + + useEffect(() => { + load() + }, [load]) + + const totals = useMemo(() => { + let req = 0 + let err = 0 + let bw = 0 + for (const r of rows ?? []) { + req += num(r.requestCount ?? r.request_count) + err += num(r.errorCount ?? r.error_count) + bw += num(r.bandwidthBytes ?? r.bandwidth_bytes) + } + return { req, err, bw } + }, [rows]) + + return ( +
+
+ + + + +
+ +
+
+
Daily usage
+
+ + +
+
+
+ + + + + + + + + + {rows === null && ( + + + + )} + {rows?.length === 0 && ( + + + + )} + {rows?.map((r, i) => ( + + + + + + ))} + +
DateRequestsErrors
+ Loading metrics… +
+ No usage recorded in this window. +
{text(r.date ?? r.day)}{num(r.requestCount ?? r.request_count).toLocaleString()}{num(r.errorCount ?? r.error_count).toLocaleString()}
+
+
+
+ ) +} + +/* ─── Quotas ─────────────────────────────────────────────────────────────── */ + +function QuotasTab({ tenantId, busy, run, setError }: TabProps) { + const [data, setData] = useState | null>(null) + const [unavailable, setUnavailable] = useState(false) + + const load = useCallback(async () => { + setUnavailable(false) + try { + const res = await api.get<{ data: unknown }>(`/admin/tenants/${tenantId}/quotas`) + setData(isObj(res.data) ? res.data : {}) + } catch (e) { + // 503 quotas_unavailable = the quota service is simply not enabled here. + if (e instanceof ApiError && (e.status === 503 || e.code === 'quotas_unavailable')) { + setUnavailable(true) + setData(null) + } else { + setError(errMessage(e)) + setData({}) + } + } + }, [tenantId, setError]) + + useEffect(() => { + load() + }, [load]) + + const reset = () => + run('quota-reset', () => api.post(`/admin/tenants/${tenantId}/quotas/reset`, {}), 'Quotas reset.', load) + + const entries = data ? Object.entries(data) : [] + + return ( +
+
+
Quotas
+
+ + +
+
+ {unavailable ? ( +
Quota service is not enabled for this company.
+ ) : ( +
+ + + + + + + + + + {data === null && ( + + + + )} + {data !== null && entries.length === 0 && ( + + + + )} + {entries.map(([name, v]) => { + const used = isObj(v) ? v.used ?? v.current ?? v.count : v + const limit = isObj(v) ? v.limit ?? v.max ?? v.quota : null + return ( + + + + + + ) + })} + +
QuotaUsedLimit
+ Loading quotas… +
+ No quotas tracked. +
{name}{text(used)}{text(limit)}
+
+ )} +
+ ) +} + +/* ─── bits & helpers ─────────────────────────────────────────────────────── */ + +function Meta({ + label, + value, + children, + mono, +}: { + label: string + value?: ReactNode + children?: ReactNode + mono?: boolean +}) { + return ( +
+
{label}
+
+ {children ?? value} +
+
+ ) +} + +function errMessage(e: unknown): string { + return e instanceof Error ? e.message : 'Action failed' +} + +function isObj(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +function num(v: unknown): number { + const n = typeof v === 'number' ? v : Number(v) + return Number.isFinite(n) ? n : 0 +} + +function text(v: unknown): string { + if (v === null || v === undefined || v === '') return '—' + if (typeof v === 'object') return JSON.stringify(v) + return String(v) +} + +function errRate(req: number, err: number): string { + if (req <= 0) return 'no traffic' + return `${((err / req) * 100).toFixed(1)}% error rate` +} + +function fmtDate(iso: string | null | undefined): string { + if (!iso) return '—' + const d = new Date(iso) + return Number.isNaN(d.getTime()) ? String(iso) : d.toLocaleString() +} + +function fmtBytes(n: number): string { + if (!n || n < 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1) + const unit = units[i] ?? 'B' + return `${(n / Math.pow(1024, i)).toFixed(i ? 1 : 0)} ${unit}` +} diff --git a/apps/rental/inertia/pages/operator/dashboard.tsx b/apps/rental/inertia/pages/operator/dashboard.tsx new file mode 100644 index 00000000..04f52ed4 --- /dev/null +++ b/apps/rental/inertia/pages/operator/dashboard.tsx @@ -0,0 +1,378 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from '@inertiajs/react' +import { OperatorShell, Stat, StatusBadge } from '../../components/shells' +import { api } from '../../lib/api' +import type { AdminTenant, TenantStatus } from '../../types' + +type ListResponse = { data: AdminTenant[]; total: number } +type RunStatus = 'ok' | 'degraded' | 'fail' +type DoctorTotals = { + info: number + warn: number + error: number + fixable: number + platformError: number + tenantError: number +} +type DoctorReport = { status: RunStatus; totals: DoctorTotals } + +const PLANS = ['starter', 'fleet', 'enterprise'] + +export default function OperatorDashboard() { + const [tenants, setTenants] = useState(null) + const [health, setHealth] = useState<{ totals: DoctorTotals; status: RunStatus } | null>(null) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [busy, setBusy] = useState(null) + const [showCreate, setShowCreate] = useState(false) + + const loadTenants = useCallback(async () => { + try { + const res = await api.get('/admin/tenants?includeDeleted=true') + setTenants(res.data) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load companies') + setTenants([]) + } + }, []) + + const loadHealth = useCallback(async () => { + try { + // 200 (ok/degraded) or 503 (platform fail) — the doctor body is identical, so + // read it raw and trust the report's own tri-state `status` rather than the + // HTTP code, which only distinguishes fail from the rest. + const res = await fetch('/admin/health/report', { + credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }) + const body = (await res.json()) as DoctorReport + setHealth({ totals: body.totals, status: body.status }) + } catch { + setHealth(null) + } + }, []) + + useEffect(() => { + loadTenants() + loadHealth() + }, [loadTenants, loadHealth]) + + const act = useCallback( + async (key: string, run: () => Promise, message: string) => { + setBusy(key) + setNotice(null) + setError(null) + try { + await run() + setNotice(message) + await Promise.all([loadTenants(), loadHealth()]) + } catch (e) { + setError(e instanceof Error ? e.message : 'Action failed') + } finally { + setBusy(null) + } + }, + [loadTenants, loadHealth] + ) + + const counts = summarize(tenants) + + return ( + +
+
+

Companies

+

Provision and govern every rental company on the platform.

+
+ +
+ + {notice &&
{notice}
} + {error &&
{error}
} + +
+ + + + + + {health.status === 'fail' + ? 'Attention' + : health.status === 'degraded' + ? 'Degraded' + : 'Healthy'} + + ) : ( + '—' + ) + } + sub={health ? `${health.totals.error} err · ${health.totals.warn} warn` : 'doctor'} + /> +
+ + {showCreate && } + +
+
+
All companies
+ +
+
+ + + + + + + + + + + + + {tenants === null && ( + + + + )} + {tenants?.length === 0 && ( + + + + )} + {tenants?.map((t) => ( + + + + + + + + + ))} + +
CompanyStatusPlanSchemaCreatedLifecycle
+ Loading companies… +
+ No companies yet. Provision the first one. +
+ + {t.name} + +
+ {t.customDomain ?? t.email} +
+
+ + + {t.metadata?.plan ?? 'starter'} + {t.schemaName} + {formatDate(t.createdAt)} + + +
+
+
+
+ ) +} + +/* ─── Per-row lifecycle actions (drive the tenant state machine) ──────────── */ + +function LifecycleActions({ + tenant: t, + busy, + act, +}: { + tenant: AdminTenant + busy: string | null + act: (key: string, run: () => Promise, message: string) => Promise +}) { + const disabled = busy !== null + const btns: { key: string; label: string; run: () => Promise; msg: string; danger?: boolean }[] = + [] + + const isActive: boolean = t.status === 'active' + const isSuspended: boolean = t.status === 'suspended' + const isDeleted: boolean = t.status === 'deleted' + + if (isSuspended || t.status === 'provisioning' || t.status === 'failed') { + btns.push({ + key: `${t.id}:activate`, + label: 'Activate', + run: () => api.post(`/admin/tenants/${t.id}/activate`), + msg: `${t.name} activated.`, + }) + } + if (isActive) { + btns.push({ + key: `${t.id}:suspend`, + label: 'Suspend', + run: () => api.post(`/admin/tenants/${t.id}/suspend`), + msg: `${t.name} suspended.`, + }) + btns.push({ + key: `${t.id}:maintenance`, + label: 'Maintenance', + run: () => api.post(`/admin/tenants/${t.id}/maintenance`, { message: 'Scheduled maintenance' }), + msg: `${t.name} entered maintenance.`, + }) + } + if (isDeleted) { + btns.push({ + key: `${t.id}:restore`, + label: 'Restore', + run: () => api.post(`/admin/tenants/${t.id}/restore`), + msg: `${t.name} restored.`, + }) + } + if (isActive || isSuspended) { + btns.push({ + key: `${t.id}:destroy`, + label: 'Destroy', + danger: true, + run: () => { + if (!confirm(`Destroy ${t.name}? Its schema will be dropped.`)) { + return Promise.reject(new Error('cancelled')) + } + return api.post(`/admin/tenants/${t.id}/destroy?keepSchema=false`) + }, + msg: `${t.name} destroyed.`, + }) + } + + return ( +
+ {btns.map((b) => ( + + ))} +
+ ) +} + +/* ─── Create company form ────────────────────────────────────────────────── */ + +function CreateCompany({ + busy, + onCreate, +}: { + busy: boolean + onCreate: (key: string, run: () => Promise, message: string) => Promise +}) { + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [plan, setPlan] = useState('starter') + + const submit = () => { + onCreate( + 'create', + () => + api.post('/admin/tenants', { + name, + email, + metadata: { plan }, + }), + `${name} is provisioning — the install job is running.` + ).then(() => { + setName('') + setEmail('') + setPlan('starter') + }) + } + + return ( +
+
+
Provision a new company
+
+
+
+
+ + setName(e.target.value)} placeholder="Acme Cars" /> +
+
+ + setEmail(e.target.value)} + placeholder="owner@acme.example" + /> +
+
+ + +
+
+
+ + + Dispatches the async InstallTenant job — watch the status flip provisioning → active. + +
+
+
+ ) +} + +/* ─── helpers ────────────────────────────────────────────────────────────── */ + +function summarize(tenants: AdminTenant[] | null) { + const c = { total: 0, active: 0, suspended: 0, provisioning: 0, failed: 0, deleted: 0 } + if (!tenants) return c + for (const t of tenants) { + c.total += 1 + c[t.status as keyof typeof c] = (c[t.status as keyof typeof c] ?? 0) + 1 + } + return c +} + +function formatDate(iso: string | null) { + if (!iso) return '—' + const d = new Date(iso) + return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString() +} + +export type { TenantStatus } diff --git a/apps/rental/inertia/pages/operator/health.tsx b/apps/rental/inertia/pages/operator/health.tsx new file mode 100644 index 00000000..0d1b0682 --- /dev/null +++ b/apps/rental/inertia/pages/operator/health.tsx @@ -0,0 +1,284 @@ +import { useCallback, useEffect, useState } from 'react' +import { OperatorShell, Stat } from '../../components/shells' +import { api } from '../../lib/api' +import type { AdminTenant } from '../../types' + +/* Platform health, from the admin satellite. `GET /admin/health/report` runs the + * DoctorService (built-in checks + the app's `fleet_health` check) and answers + * 200 when there are no errors, 503 otherwise — with the SAME body either way, so + * this page reads it with a raw fetch rather than the throw-on-non-2xx helper. + * Per-company queue depth comes from `GET /admin/tenants/:id/queue/stats`. */ + +type Severity = 'info' | 'warn' | 'error' +type Scope = 'platform' | 'tenant' +type Issue = { code: string; severity: Severity; scope?: Scope; message: string; fixable?: boolean } +type CheckReport = { + check: string + description: string + durationMs: number + issues: Issue[] + error?: string +} +type RunStatus = 'ok' | 'degraded' | 'fail' +type Totals = { + info: number + warn: number + error: number + fixable: number + platformError: number + tenantError: number +} +type HealthReport = { reports: CheckReport[]; status: RunStatus; totals: Totals } + +const EMPTY_TOTALS: Totals = { + info: 0, + warn: 0, + error: 0, + fixable: 0, + platformError: 0, + tenantError: 0, +} + +type QueueStats = { + tenantId: string + queueName: string + waiting: number + active: number + completed: number + failed: number + delayed: number +} +type QueueRow = { name: string; stats: QueueStats } + +const SEVERITY_TONE: Record = { + info: 'badge--slate', + warn: 'badge--amber', + error: 'badge--red', +} + +export default function Health() { + const [report, setReport] = useState(null) + const [queues, setQueues] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + // 200 or 503 — the doctor body is identical, so read it raw. + const res = await fetch('/admin/health/report', { + credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }) + setReport((await res.json()) as HealthReport) + + // Queue depth for each active company (best-effort: a company without a + // provisioned queue just drops out). + const tenants = await api + .get<{ data: AdminTenant[] }>('/admin/tenants?includeDeleted=false') + .catch(() => ({ data: [] })) + const active = tenants.data.filter((t) => t.status === 'active') + const rows = await Promise.all( + active.map((t) => + api + .get<{ data: QueueStats }>(`/admin/tenants/${t.id}/queue/stats`) + .then((r) => ({ name: t.name, stats: r.data })) + .catch(() => null) + ) + ) + setQueues(rows.filter((r): r is QueueRow => r !== null)) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load health') + setReport({ reports: [], status: 'ok', totals: EMPTY_TOTALS }) + setQueues([]) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + const totals = report?.totals ?? EMPTY_TOTALS + // Tri-state verdict, mirroring the doctor: a platform error fails (Attention), a + // lone tenant error only degrades (200), else Healthy. This is why one broken + // company no longer paints the whole console red. + const status: RunStatus = report?.status ?? 'ok' + const verdict = + status === 'fail' + ? { label: 'Attention', tone: 'badge--red' } + : status === 'degraded' + ? { label: 'Degraded', tone: 'badge--amber' } + : { label: 'Healthy', tone: 'badge--green' } + + return ( + +
+
+

Health & doctor

+

Platform-wide diagnostics across every company, plus per-company queue depth.

+
+ +
+ + {error &&
{error}
} + +
+ + + {verdict.label} + + } + sub={`${report?.reports.length ?? 0} checks`} + /> + 0 + ? `${totals.platformError} platform · ${totals.tenantError} tenant` + : totals.fixable + ? `${totals.fixable} fixable` + : 'none' + } + /> + + +
+ +
+
+
Diagnostic checks
+
+
+ + + + + + + + + + {report === null && ( + + + + )} + {report?.reports.length === 0 && ( + + + + )} + {report?.reports.map((r) => ( + + + + + + ))} + +
CheckFindingsDuration
+ Running diagnostics… +
+ No checks reported. +
+
{r.check}
+
+ {r.description} +
+
+ {r.error ? ( + + + check threw: {r.error} + + ) : r.issues.length === 0 ? ( + + + ok + + ) : ( +
+ {r.issues.map((iss, i) => ( + + + {iss.message} + + ))} +
+ )} +
+ {r.durationMs} ms +
+
+
+ +
+
+
Queue depth by company
+
+
+ + + + + + + + + + + + + {queues === null && ( + + + + )} + {queues?.length === 0 && ( + + + + )} + {queues?.map((q) => ( + + + + + + + + + ))} + +
CompanyWaitingActiveCompletedFailedDelayed
+ Loading queues… +
+ No active queues. +
{q.name}{q.stats.waiting}{q.stats.active}{q.stats.completed} + {q.stats.failed > 0 ? ( + + + {q.stats.failed} + + ) : ( + 0 + )} + {q.stats.delayed}
+
+
+
+ ) +} diff --git a/apps/rental/inertia/pages/operator/login.tsx b/apps/rental/inertia/pages/operator/login.tsx new file mode 100644 index 00000000..7d7fa9c4 --- /dev/null +++ b/apps/rental/inertia/pages/operator/login.tsx @@ -0,0 +1,14 @@ +import LoginForm from '../../components/login_form' + +/** + * Operator sign-in (apex `localhost`). Rendered by ConsoleAuthController.show + * when no company host is resolved. + */ +export default function OperatorLogin() { + return ( + + ) +} diff --git a/apps/rental/inertia/pages/operator/reporting.tsx b/apps/rental/inertia/pages/operator/reporting.tsx new file mode 100644 index 00000000..c2afafc6 --- /dev/null +++ b/apps/rental/inertia/pages/operator/reporting.tsx @@ -0,0 +1,283 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { OperatorShell, Stat } from '../../components/shells' +import { api } from '../../lib/api' +import type { AdminTenant } from '../../types' + +/* Cross-tenant reporting, read from the reporting satellite under + * `/admin/reporting`. Two lenses: the traffic dashboard (requests / errors / + * bandwidth aggregated from tenant_metrics by the trackMetrics middleware) and + * the app's own `fleet_utilization` report extension (real domain data: fleet + * size, active rentals and utilization per company). */ + +type Period = 'day' | 'week' | 'month' + +type AggregateBucket = { + period: string + totalRequests: number + totalErrors: number + totalBandwidthBytes: number + activeTenants: number + errorRate: number +} +type TopTenant = { + tenantId: string + requests: number + errors: number + bandwidthBytes: number + errorRate: number +} +type CustomMetric = { name: string; total: number } +type Dashboard = { + aggregate: AggregateBucket[] + topTenants: TopTenant[] + customMetrics: CustomMetric[] + dataAsOf: string | null +} +type FleetRow = { tenant: string; vehicles: number; activeRentals: number; utilization: number } + +export default function Reporting() { + const [period, setPeriod] = useState('day') + const [dashboard, setDashboard] = useState(null) + const [fleet, setFleet] = useState(null) + const [names, setNames] = useState>({}) + const [error, setError] = useState(null) + + const load = useCallback(async (p: Period) => { + setError(null) + try { + const [dash, ext, tenants] = await Promise.all([ + api.get<{ data: Dashboard }>(`/admin/reporting/dashboard?period=${p}`), + api + .get<{ + data: { companies: FleetRow[] } + }>('/admin/reporting/reports/extension/fleet_utilization') + .catch(() => ({ data: { companies: [] } })), + api + .get<{ data: AdminTenant[] }>('/admin/tenants?includeDeleted=true') + .catch(() => ({ data: [] })), + ]) + setDashboard(dash.data) + setFleet(ext.data.companies) + const map: Record = {} + for (const t of tenants.data) map[t.id] = t.name + setNames(map) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load reporting') + setDashboard({ aggregate: [], topTenants: [], customMetrics: [], dataAsOf: null }) + setFleet([]) + } + }, []) + + useEffect(() => { + load(period) + }, [load, period]) + + const totals = useMemo(() => { + const buckets = dashboard?.aggregate ?? [] + const requests = buckets.reduce((a, b) => a + b.totalRequests, 0) + const errors = buckets.reduce((a, b) => a + b.totalErrors, 0) + const bandwidth = buckets.reduce((a, b) => a + b.totalBandwidthBytes, 0) + const activeTenants = buckets.reduce((a, b) => Math.max(a, b.activeTenants), 0) + return { + requests, + errors, + bandwidth, + activeTenants, + errorRate: requests ? errors / requests : 0, + } + }, [dashboard]) + + const nameOf = (id: string) => names[id] ?? `${id.slice(0, 8)}…` + + return ( + +
+
+

Reporting

+

Cross-tenant traffic and fleet utilization across every company on the platform.

+
+
+ {(['day', 'week', 'month'] as Period[]).map((p) => ( + + ))} +
+
+ + {error &&
{error}
} + +
+ + + + +
+ +
+
+
Fleet utilization
+ + fleet_utilization report extension + +
+
+ + + + + + + + + + + {fleet === null && ( + + + + )} + {fleet?.length === 0 && ( + + + + )} + {fleet?.map((r) => ( + + + + + + + ))} + +
CompanyFleetOn the roadUtilization
+ Loading… +
+ No fleet data yet. +
{nameOf(r.tenant)}{r.vehicles} vehicles{r.activeRentals} active + +
+
+
+ +
+
+
Top companies by traffic
+
+
+ + + + + + + + + + + {(dashboard?.topTenants ?? []).length === 0 && ( + + + + )} + {dashboard?.topTenants?.map((t) => ( + + + + + + + ))} + +
CompanyRequestsErrorsError rate
+ No traffic recorded for this window. +
{nameOf(t.tenantId)}{fmt(t.requests)}{fmt(t.errors)} + 0.05 ? 'badge--amber' : 'badge--green'}`} + > + + {(t.errorRate * 100).toFixed(1)}% + +
+
+
+ + {(dashboard?.customMetrics ?? []).length > 0 && ( +
+
+
Custom metrics
+
+
+ + + + + + + + + {dashboard?.customMetrics.map((m) => ( + + + + + ))} + +
MetricTotal
{m.name}{fmt(m.total)}
+
+
+ )} +
+ ) +} + +function UtilBar({ pct }: { pct: number }) { + const clamped = Math.max(0, Math.min(100, pct)) + const tone = + clamped >= 75 + ? 'var(--red, #d1495b)' + : clamped >= 40 + ? 'var(--brand, #e2603b)' + : 'var(--green, #2f9e69)' + return ( +
+
+
+
+ + {clamped}% + +
+ ) +} + +function fmt(n: number): string { + return new Intl.NumberFormat().format(n) +} + +function fmtBytes(n: number): string { + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` + return `${(n / 1024 / 1024).toFixed(1)} MB` +} diff --git a/apps/rental/inertia/pages/tenant/assistant.tsx b/apps/rental/inertia/pages/tenant/assistant.tsx new file mode 100644 index 00000000..c61b3998 --- /dev/null +++ b/apps/rental/inertia/pages/tenant/assistant.tsx @@ -0,0 +1,334 @@ +import { useRef, useState } from 'react' +import { usePage } from '@inertiajs/react' +import { TenantShell } from '../../components/shells' +import { AssistantMessage } from '../../components/assistant_message' +import { FLEET_SYSTEM_PROMPT } from '../../lib/assistant_prompt' +import type { SharedProps } from '../../types' + +/** `tools` records the lookups the assistant ran for this turn (WS-AI-11 notices). */ +type Turn = { role: 'user' | 'assistant'; content: string; tools?: string[] } + +const SUGGESTIONS = [ + 'How many bookings do I have right now?', + 'Which vehicles are free next weekend?', + 'Which vehicle is rented the most?', + 'What is our fuel policy?', +] + +/** + * Human labels for the fleet tools (config.ai.tools). The stream carries the tool's + * name; an unknown one still renders, humanised, so a newly registered tool never + * shows up blank. + */ +const TOOL_LABELS: Record = { + current_date: 'Checking the date', + count_bookings: 'Checking bookings', + count_vehicles: 'Checking the fleet', + list_available_vehicles: 'Checking availability', + revenue_summary: 'Checking revenue', + top_rented_vehicles: 'Ranking the fleet', +} + +const toolLabel = (name: string) => TOOL_LABELS[name] ?? name.replace(/_/g, ' ') + +export default function Assistant() { + const { props } = usePage() + const principal = props.auth.staff?.email ?? 'staff' + + const [turns, setTurns] = useState([]) + const [input, setInput] = useState('') + const [streaming, setStreaming] = useState(false) + const [error, setError] = useState(null) + const [note, setNote] = useState(null) + // RAG grounding is opt-in: retrieval needs the per-tenant vector store + // provisioned (pgvector) + embeddings ingested from the Knowledge base. When + // that isn't wired the gateway returns 400, so we default off and degrade + // gracefully rather than failing the chat. + const [useRag, setUseRag] = useState(false) + const scroller = useRef(null) + + const scrollDown = () => + requestAnimationFrame(() => { + scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: 'smooth' }) + }) + + const appendToken = (chunk: string) => + setTurns((prev) => { + const next = prev.slice() + const last = next[next.length - 1] + if (last?.role === 'assistant') + next[next.length - 1] = { ...last, content: last.content + chunk } + return next + }) + + /** Record a tool the model ran for this turn, so the answer shows what it consulted. */ + const appendToolCall = (name: string) => + setTurns((prev) => { + const next = prev.slice() + const last = next[next.length - 1] + if (last?.role === 'assistant') + next[next.length - 1] = { ...last, tools: [...(last.tools ?? []), name] } + return next + }) + + /** POST + stream one turn. Returns the HTTP status so the caller can retry. */ + async function streamChat(history: Turn[], retrieve: boolean): Promise { + // The gateway wants `retrieve` OMITTED for a plain answer, or an object + // { query } to ground the reply in the tenant's knowledge base — a bare + // boolean is a 400. Ground the retrieval on the latest user turn. + const lastUserTurn = [...history].reverse().find((t) => t.role === 'user')?.content ?? '' + // Operational questions ("how many bookings? which cars are free?") are answered + // by the fleet tools (config.ai.tools): the model calls them with arguments per + // question. No pre-folded snapshot — the turns go out as-is, led by the system + // prompt that sets the answer format (markdown + stat/chart blocks) and tells the + // model each tool returns everything in one call, so it never loops the budget out. + const messages = [ + { role: 'system' as const, content: FLEET_SYSTEM_PROMPT }, + ...history.map((t) => ({ role: t.role, content: t.content })), + ] + const res = await fetch('/ai/chat', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + 'X-Requested-With': 'XMLHttpRequest', + // config.ai.resolvePrincipal reads this to scope rate-limit + audit. + 'X-Ai-User': principal, + }, + body: JSON.stringify({ + messages, + ...(retrieve && lastUserTurn ? { retrieve: { query: lastUserTurn } } : {}), + }), + }) + if (!res.ok || !res.body) return res.status + await consumeSse(res.body, { + onToken: appendToken, + onToolCall: appendToolCall, + onError: (code) => setError(`Stream error: ${code}`), + }) + return 200 + } + + async function send(text: string) { + const question = text.trim() + if (!question || streaming) return + setError(null) + setNote(null) + setInput('') + + // Optimistically show the user turn and an empty assistant turn we stream into. + const history: Turn[] = [...turns, { role: 'user', content: question }] + setTurns([...history, { role: 'assistant', content: '' }]) + setStreaming(true) + scrollDown() + + try { + let status = await streamChat(history, useRag) + // Retrieval unavailable (vector store not provisioned) → 400. Degrade to a + // plain answer instead of failing, and tell the user why once. + if (status === 400 && useRag) { + setUseRag(false) + setNote('Knowledge base grounding is not available yet — answering without retrieval.') + status = await streamChat(history, false) + } + if (status !== 200) throw new Error(`Assistant unavailable (${status}).`) + scrollDown() + } catch (e) { + setError(e instanceof Error ? e.message : 'Assistant failed') + // Drop the empty assistant bubble on hard failure. + setTurns((prev) => (prev[prev.length - 1]?.content === '' ? prev.slice(0, -1) : prev)) + } finally { + setStreaming(false) + } + } + + return ( + +
+
+

Fleet assistant

+

+ Grounded on your fleet and knowledge base (RAG). Streamed over SSE; PII is redacted on + the way out. +

+
+
+ + {error &&
{error}
} + {note &&
{note}
} + +
+
+ {turns.length === 0 ? ( +
+
+

Ask about availability, pricing or your policies.

+
+ {SUGGESTIONS.map((s) => ( + + ))} +
+
+ ) : ( +
+ {turns.map((t, i) => ( + + ))} +
+ )} +
+ +
+ +
{ + e.preventDefault() + send(input) + }} + > + setInput(e.target.value)} + disabled={streaming} + /> + +
+
+
+
+ ) +} + +function Bubble({ turn, streaming }: { turn: Turn; streaming: boolean }) { + const isUser = turn.role === 'user' + const tools = turn.tools ?? [] + // While the answer is still empty the tool notice IS the progress indicator, so + // it replaces the "…" placeholder rather than sitting above it. + const awaitingTools = streaming && tools.length > 0 && turn.content === '' + return ( +
+
+ {tools.length > 0 && ( +
+ {tools.map((name, i) => ( + + 🔧 {toolLabel(name)} + {awaitingTools && i === tools.length - 1 ? '…' : ''} + + ))} +
+ )} + {(turn.content || (streaming && !awaitingTools)) && ( +
+ {isUser ? ( + turn.content + ) : turn.content ? ( + + ) : ( + '…' + )} +
+ )} +
+
+ ) +} + +/** + * Minimal SSE reader for the `/ai/chat` stream. Frames are `id: N\nevent: \n + * data: \n\n`; `event: token` fragments are the answer text, `event: done` + * ends it, `event: error` carries a classified code, and `event: tool_call` announces + * a tool the model is running (name + id only — the satellite never streams the + * arguments unless the host opts in). Heartbeats (`:` comments) are ignored. + */ +async function consumeSse( + body: ReadableStream, + handlers: { + onToken: (chunk: string) => void + onError: (code: string) => void + onToolCall: (name: string) => void + } +) { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + let sep: number + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, sep) + buffer = buffer.slice(sep + 2) + if (frame.startsWith(':')) continue // heartbeat + + let event = 'token' + const data: string[] = [] + for (const line of frame.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim() + else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')) + } + const payload = data.join('\n') + + if (event === 'done') return + if (event === 'error') { + handlers.onError(payload || 'unknown') + return + } + // A tool_call frame is a NOTICE that the model is looking something up, not + // answer text — its payload is `{name, id}` JSON. Route it to its own handler; + // appending it as a token would paint raw JSON into the bubble. + if (event === 'tool_call') { + try { + const call = JSON.parse(payload) as { name?: string } + if (call.name) handlers.onToolCall(call.name) + } catch { + /* a notice we cannot parse is not worth failing the stream over */ + } + continue + } + if (payload) handlers.onToken(payload) + } + } +} diff --git a/apps/rental/inertia/pages/tenant/billing.tsx b/apps/rental/inertia/pages/tenant/billing.tsx new file mode 100644 index 00000000..cb29eb77 --- /dev/null +++ b/apps/rental/inertia/pages/tenant/billing.tsx @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useState } from 'react' +import { usePage } from '@inertiajs/react' +import { TenantShell, Stat } from '../../components/shells' +import { api, ApiError } from '../../lib/api' +import type { SharedProps } from '../../types' + +type PlanId = 'starter' | 'fleet' | 'enterprise' + +type Billing = { + plan: string + hasCustomer: boolean + providerCustomerId: string | null +} + +/* + * The subscription tiers the rental company buys from Karimoto. The quota copy + * mirrors config/multitenancy.ts `plans.definitions`; the authoritative limits + * are enforced server-side by QuotaService, this is just the shopfront. + */ +const PLANS: { id: PlanId; name: string; blurb: string; limits: string[] }[] = [ + { + id: 'starter', + name: 'Starter', + blurb: 'For a single branch finding its feet.', + limits: ['10 vehicles', '100 bookings / month', '2,000 API calls / day'], + }, + { + id: 'fleet', + name: 'Fleet', + blurb: 'For a growing multi-branch operation.', + limits: ['100 vehicles', '2,000 bookings / month', '20,000 API calls / day'], + }, + { + id: 'enterprise', + name: 'Enterprise', + blurb: 'For nationwide fleets with no ceiling.', + limits: ['Unlimited vehicles', 'Unlimited bookings'], + }, +] + +export default function Billing() { + const { props } = usePage() + const company = props.company + + const [billing, setBilling] = useState(null) + const [loadError, setLoadError] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(null) + + useEffect(() => { + let alive = true + api + .get('/billing') + .then((b) => alive && setBilling(b)) + .catch((e) => alive && setLoadError(e instanceof Error ? e.message : 'Failed to load billing')) + return () => { + alive = false + } + }, []) + + // Checkout and portal both hand back a provider URL that we navigate straight + // to, so on success we keep `busy` set and the buttons stay disabled through + // the redirect. Only a failure clears it and surfaces the error. + const redirectAction = useCallback(async (key: string, fn: () => Promise) => { + setBusy(key) + setError(null) + try { + const url = await fn() + window.location.href = url + } catch (e) { + setError(e instanceof ApiError ? e.message : 'Action failed') + setBusy(null) + } + }, []) + + const checkout = (plan: PlanId) => + redirectAction(`checkout:${plan}`, async () => { + const res = await api.post<{ url: string; id: string }>('/billing/checkout', { plan }) + return res.url + }) + + const openPortal = () => + redirectAction('portal', async () => { + const res = await api.post<{ url: string }>('/billing/portal') + return res.url + }) + + const currentPlan = billing?.plan ?? company?.plan ?? 'starter' + const currentName = PLANS.find((p) => p.id === currentPlan)?.name ?? currentPlan + + return ( + +
+
+

Billing & subscription

+

+ {company?.name ?? 'Your company'} subscribes to Karimoto. Payments run through MockStripe + in development and switch to real Stripe the moment a Stripe key is set, using the exact + same code. +

+
+
+ {currentPlan} +
+
+ + {error &&
{error}
} + + {loadError && billing === null ? ( +
+
{loadError}
+
+ ) : billing === null ? ( +
+
Loading…
+
+ ) : ( +
+
+ + + +
+ +
+
+
Current plan
+ {billing.plan} +
+
+
+ + + {billing.hasCustomer ? 'Customer active on provider' : 'No billing customer yet'} + + {billing.providerCustomerId && ( + {billing.providerCustomerId} + )} + + +
+
+
+ +
+ {PLANS.map((p) => { + const isCurrent = p.id === currentPlan + const key = `checkout:${p.id}` + const label = isCurrent + ? 'Current plan' + : busy === key + ? 'Redirecting…' + : billing.hasCustomer + ? `Switch to ${p.name}` + : 'Subscribe' + return ( +
+
+
{p.name}
+ {isCurrent && Current} +
+
+
+ {p.blurb} +
+
+ {p.limits.map((l) => ( +
+ + {l} +
+ ))} +
+ +
+
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/apps/rental/inertia/pages/tenant/bookings.tsx b/apps/rental/inertia/pages/tenant/bookings.tsx new file mode 100644 index 00000000..7a80e63b --- /dev/null +++ b/apps/rental/inertia/pages/tenant/bookings.tsx @@ -0,0 +1,560 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { usePage } from '@inertiajs/react' +import { TenantShell, Stat } from '../../components/shells' +import { api, ApiError } from '../../lib/api' +import { useLiveBoard, type BoardStatus } from '../../lib/socket' +import type { SharedProps } from '../../types' + +/* A booking as returned by the tenant domain API (`GET /bookings`). Money is + * carried as integer *santimat* (MAD × 100) end to end, so divide by 100 to + * display: a `total` of 97200 renders as "972.00 MAD". */ +type BookingStatus = 'quote' | 'confirmed' | 'active' | 'completed' | 'cancelled' | 'no_show' + +type PriceBreakdown = { + total?: number + currency?: string + days?: number + lineItems?: any[] +} + +type Booking = { + id: string + status: BookingStatus + pickupAt: string + dropoffAt: string + priceBreakdown: PriceBreakdown | null + depositHeld: number | null + customer?: { id: string; fullName: string } | null + vehicle?: { id: string; plate: string; makeName: string; modelName: string } | null +} + +/* Customers come back tidy; vehicles come from the raw `_read` replica → snake_case. */ +type Customer = { id: string; fullName: string } +type VehicleRow = { + id: string + plate: string + make_name: string + model_name: string + status: string +} + +/* The invoice shape the VAT endpoint hands back varies; read it defensively. */ +type Invoice = { + id?: string + number?: string + invoiceNumber?: string + total?: number + currency?: string +} + +const BOOKING_TONE: Record = { + quote: 'badge--slate', + confirmed: 'badge--blue', + active: 'badge--green', + completed: 'badge--slate', + cancelled: 'badge--red', + no_show: 'badge--red', +} + +type RunMsg = string | ((result: any) => string) +type RunFn = ( + key: string, + fn: () => Promise, + msg: RunMsg, + reload: () => Promise +) => Promise +type ActFn = (key: string, fn: () => Promise, msg: RunMsg) => Promise + +export default function Bookings() { + const [bookings, setBookings] = useState(null) + const [customers, setCustomers] = useState([]) + const [vehicles, setVehicles] = useState([]) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [busy, setBusy] = useState(null) + const [showAdd, setShowAdd] = useState(false) + + const loadBookings = useCallback(async () => { + try { + const res = await api.get<{ bookings: Booking[] }>('/bookings') + setBookings(res.bookings) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load bookings') + setBookings([]) + } + }, []) + + const loadRefs = useCallback(async () => { + const [cus, veh] = await Promise.all([ + api.get<{ customers: Customer[] }>('/customers').catch(() => ({ customers: [] })), + api.get<{ vehicles: VehicleRow[] }>('/vehicles').catch(() => ({ vehicles: [] })), + ]) + setCustomers(cus.customers) + setVehicles(veh.vehicles) + }, []) + + useEffect(() => { + loadBookings() + loadRefs() + }, [loadBookings, loadRefs]) + + // Live board: when the server broadcasts a committed booking write to this + // company's room, refetch the list so a change made anywhere (another agent, + // another tab, the API) lands here without a manual refresh. + const { props } = usePage() + const boardStatus = useLiveBoard(props.company?.id, (name) => { + if (name === 'booking:changed') loadBookings() + }) + + const run = useCallback(async (key, fn, msg, reload) => { + setBusy(key) + setError(null) + setNotice(null) + try { + const result = await fn() + setNotice(typeof msg === 'function' ? msg(result) : msg) + await reload() + } catch (e) { + // 422 (overlap/pricing/bad transition) and 429 (quota) both carry a + // human message on the ApiError — surface it verbatim. + setError(e instanceof ApiError ? e.message : 'Action failed') + } finally { + setBusy(null) + } + }, []) + + const act = useCallback( + (key, fn, msg) => run(key, fn, msg, loadBookings), + [run, loadBookings] + ) + + const counts = useMemo(() => { + const c = { total: 0, active: 0, confirmed: 0, completed: 0 } + for (const b of bookings ?? []) { + c.total++ + if (b.status in c) (c as any)[b.status]++ + } + return c + }, [bookings]) + + return ( + +
+
+

Bookings

+

overlap-checked, priced with 20% VAT, counted against your monthly quota.

+
+ +
+ + {notice &&
{notice}
} + {error &&
{error}
} + +
+ + + + +
+ + {showAdd && ( + { + loadBookings() + loadRefs() + setShowAdd(false) + }} + /> + )} + +
+
+
All bookings
+
+ + +
+
+
+ + + + + + + + + + + + + {bookings === null && ( + + + + )} + {bookings?.length === 0 && ( + + + + )} + {bookings?.map((b) => ( + + + + + + + + + ))} + +
CustomerVehicleDatesStatusTotalLifecycle
+ Loading bookings… +
+ No bookings yet. Create the first one. +
+
{b.customer?.fullName ?? '—'}
+
+ {b.vehicle ? ( + <> +
{b.vehicle.plate}
+
+ {b.vehicle.makeName} {b.vehicle.modelName} +
+ + ) : ( + + )} +
+
+ {formatShort(b.pickupAt)} + + {formatShort(b.dropoffAt)} +
+ {b.priceBreakdown?.days != null && ( +
+ {b.priceBreakdown.days} day{b.priceBreakdown.days === 1 ? '' : 's'} +
+ )} +
+ + +
+ {formatMoney(b.priceBreakdown?.total, b.priceBreakdown?.currency)} +
+ {b.depositHeld != null && b.depositHeld > 0 && ( +
+ {formatMoney(b.depositHeld, b.priceBreakdown?.currency)} deposit +
+ )} +
+ +
+
+
+
+ ) +} + +/* ─── Per-row lifecycle actions (drive the booking state machine) ─────────── */ + +function BookingActions({ + booking: b, + busy, + act, +}: { + booking: Booking + busy: string | null + act: ActFn +}) { + const disabled = busy !== null + const btns: { + key: string + label: string + run: () => Promise + msg: RunMsg + danger?: boolean + }[] = [] + + if (b.status === 'quote') { + btns.push({ + key: `${b.id}:confirm`, + label: 'Confirm', + run: () => api.post(`/bookings/${b.id}/confirm`), + msg: 'Booking confirmed.', + }) + btns.push({ + key: `${b.id}:cancel`, + label: 'Cancel', + danger: true, + run: () => api.post(`/bookings/${b.id}/cancel`), + msg: 'Booking cancelled.', + }) + } + if (b.status === 'confirmed') { + btns.push({ + key: `${b.id}:activate`, + label: 'Activate', + run: () => api.post(`/bookings/${b.id}/activate`), + msg: 'Booking activated — vehicle is out.', + }) + btns.push({ + key: `${b.id}:cancel`, + label: 'Cancel', + danger: true, + run: () => api.post(`/bookings/${b.id}/cancel`), + msg: 'Booking cancelled.', + }) + } + if (b.status === 'active') { + btns.push({ + key: `${b.id}:complete`, + label: 'Complete', + run: () => api.post(`/bookings/${b.id}/complete`), + msg: 'Booking completed — vehicle returned.', + }) + } + if (b.status === 'completed') { + btns.push({ + key: `${b.id}:invoice`, + label: 'Invoice', + run: () => api.post<{ invoice?: Invoice }>(`/bookings/${b.id}/invoice`), + msg: (r: { invoice?: Invoice } | null) => invoiceNotice(r?.invoice), + }) + } + + if (btns.length === 0) { + return ( + + — + + ) + } + + return ( +
+ {btns.map((btn) => ( + + ))} +
+ ) +} + +/* ─── New booking form ───────────────────────────────────────────────────── */ + +function AddBooking({ + customers, + vehicles, + busy, + run, + onDone, +}: { + customers: Customer[] + vehicles: VehicleRow[] + busy: boolean + run: RunFn + onDone: () => void +}) { + const [customerId, setCustomerId] = useState('') + const [vehicleId, setVehicleId] = useState('') + const [pickupAt, setPickupAt] = useState('') + const [dropoffAt, setDropoffAt] = useState('') + const [extras, setExtras] = useState('') + const [confirm, setConfirm] = useState(false) + + const extrasList = extras + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + const canSubmit = customerId && vehicleId && pickupAt && dropoffAt + + const submit = () => + run( + 'add-booking', + () => + api.post('/bookings', { + customerId, + vehicleId, + // datetime-local yields a local wall-clock value; normalise to ISO 8601. + pickupAt: new Date(pickupAt).toISOString(), + dropoffAt: new Date(dropoffAt).toISOString(), + ...(extrasList.length ? { extras: extrasList } : {}), + confirm, + }), + confirm ? 'Booking created and confirmed.' : 'Booking created as a quote.', + async () => onDone() + ) + + return ( +
+
+
New booking
+
+
+ {(customers.length === 0 || vehicles.length === 0) && ( +
+ {customers.length === 0 && 'Add a customer first — a booking needs one. '} + {vehicles.length === 0 && 'Add a vehicle first (Fleet) — a booking needs one.'} +
+ )} +
+ + + + + + + + setPickupAt(e.target.value)} + /> + + + setDropoffAt(e.target.value)} + /> + +
+ +
+ + setExtras(e.target.value)} + placeholder="gps, child_seat, additional_driver" + /> + +
+ + + +
+ + + The server checks the vehicle is free for the window and prices it with 20% VAT. + +
+
+
+ ) +} + +/* ─── bits ───────────────────────────────────────────────────────────────── */ + +function BookingBadge({ status }: { status: BookingStatus }) { + return ( + + + {status.replace('_', ' ')} + + ) +} + +/** WebSocket connection indicator for the live board. */ +function LiveDot({ status }: { status: BoardStatus }) { + const tone = + status === 'live' ? 'badge--green' : status === 'connecting' ? 'badge--blue' : 'badge--slate' + const label = status === 'live' ? 'Live' : status === 'connecting' ? 'Connecting…' : 'Offline' + return ( + + + {label} + + ) +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +/* ─── helpers ────────────────────────────────────────────────────────────── */ + +/** Santimat (MAD × 100) → a display string like "972.00 MAD". */ +function formatMoney(santimat: number | null | undefined, currency?: string): string { + if (santimat == null) return '—' + return `${(santimat / 100).toFixed(2)} ${currency ?? 'MAD'}` +} + +function formatShort(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '—' + return d.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +function invoiceNotice(inv?: Invoice | null): string { + if (!inv) return 'VAT invoice issued.' + const num = inv.number ?? inv.invoiceNumber ?? inv.id + const total = inv.total != null ? formatMoney(inv.total, inv.currency) : null + if (num && total) return `Invoice ${num} issued for ${total}.` + if (num) return `Invoice ${num} issued.` + if (total) return `VAT invoice issued for ${total}.` + return 'VAT invoice issued.' +} diff --git a/apps/rental/inertia/pages/tenant/customers.tsx b/apps/rental/inertia/pages/tenant/customers.tsx new file mode 100644 index 00000000..2bdbcb77 --- /dev/null +++ b/apps/rental/inertia/pages/tenant/customers.tsx @@ -0,0 +1,458 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { TenantShell, Stat } from '../../components/shells' +import { api, ApiError } from '../../lib/api' + +/* + * Customer PII lives crypto-shredded in the tenant schema: cin / driverLicense / + * passport are encrypted at rest (Law 09-08 / GDPR) and returned decrypted only + * in the authenticated list. `cin` also carries a blind index, so exact lookups + * work without ever decrypting the column. Erasure destroys the per-row key — + * after that a read fails closed with 410 Gone. + */ +type Customer = { + id: string + fullName: string + email: string | null + phone: string | null + cin: string | null + driverLicense: string | null + passport: string | null + address: string | null + nationality: string | null +} + +export default function Customers() { + const [customers, setCustomers] = useState(null) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [busy, setBusy] = useState(null) + const [showAdd, setShowAdd] = useState(false) + const [erased, setErased] = useState>(new Set()) + + const [cinQuery, setCinQuery] = useState('') + const [matches, setMatches] = useState(null) + + const loadCustomers = useCallback(async () => { + try { + const res = await api.get<{ customers: Customer[] }>('/customers') + setCustomers(res.customers) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load customers') + setCustomers([]) + } + }, []) + + useEffect(() => { + loadCustomers() + }, [loadCustomers]) + + const run = useCallback( + async (key: string, fn: () => Promise, msg: string, reload: () => Promise) => { + setBusy(key) + setError(null) + setNotice(null) + try { + await fn() + setNotice(msg) + await reload() + } catch (e) { + setError(e instanceof ApiError ? e.message : 'Action failed') + } finally { + setBusy(null) + } + }, + [] + ) + + const counts = useMemo(() => { + const list = customers ?? [] + return { + total: list.length, + withCin: list.filter((c) => !!c.cin).length, + } + }, [customers]) + + /* Blind-index exact search by CIN — matches encrypted rows without decrypting. */ + const searchByCin = useCallback(async () => { + const cin = cinQuery.trim() + if (!cin) return + setBusy('search') + setError(null) + setNotice(null) + try { + const res = await api.post<{ matches: Customer[] }>('/customers/search', { cin }) + setMatches(res.matches) + setNotice( + `Blind-index lookup: ${res.matches.length} match${res.matches.length === 1 ? '' : 'es'} for that CIN.` + ) + } catch (e) { + setError(e instanceof ApiError ? e.message : 'Search failed') + setMatches([]) + } finally { + setBusy(null) + } + }, [cinQuery]) + + /* + * Crypto-shred: destroy the row key, then prove the read now fails closed by + * asserting GET /customers/:id returns 410 Gone. Governance can refuse (403). + */ + const shred = useCallback(async (c: Customer) => { + if (!confirm(`Permanently erase ${c.fullName}'s PII? This is irreversible (crypto-shred).`)) return + setBusy(`${c.id}:shred`) + setError(null) + setNotice(null) + try { + await api.post(`/customers/${c.id}/shred`) + let failsClosed = false + try { + await api.get(`/customers/${c.id}`) + } catch (e) { + if (e instanceof ApiError && e.status === 410) failsClosed = true + } + setErased((prev) => { + const next = new Set(prev) + next.add(c.id) + return next + }) + setNotice( + failsClosed + ? `${c.fullName}'s PII erased — the ciphertext is unrecoverable and reads now return 410 Gone.` + : `${c.fullName}'s PII erased (crypto-shred).` + ) + } catch (e) { + if (e instanceof ApiError && e.status === 403) { + setError(e.message || 'Erasure refused by governance policy.') + } else { + setError(e instanceof ApiError ? e.message : 'Shred failed') + } + } finally { + setBusy(null) + } + }, []) + + return ( + +
+
+

Customers

+

+ Renter records with encrypted PII — CIN, driver licence and passport are stored + crypto-shredded and stay blind-index searchable. +

+
+
+ +
+
+ + {notice &&
{notice}
} + {error &&
{error}
} + +
+ + + +
+ + {showAdd && ( + { + loadCustomers() + setShowAdd(false) + }} + /> + )} + +
+
+
Search by CIN
+
+
+
+
+ + setCinQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') searchByCin() + }} + placeholder="AB123456" + /> +
+ +
+
+ Exact match against the CIN blind index — the encrypted column is never decrypted to search. +
+ + {matches !== null && ( +
+ {matches.length === 0 ? ( +
No customer matches that CIN.
+ ) : ( + matches.map((m) => ( +
+ match + {m.fullName} + + {m.cin ?? '—'} +
+ )) + )} +
+ )} +
+
+ +
+
+
Customers
+ +
+
+ + + + + + + + + + + + + {customers === null && ( + + + + )} + {customers?.length === 0 && ( + + + + )} + {customers?.map((c) => { + const gone = erased.has(c.id) + return ( + + + + + + + + + ) + })} + +
NameEmailPhoneCINNationalityErasure
+ Loading… +
+ No customers yet. Add one to get started. +
+
{c.fullName}
+ {!gone && (c.driverLicense || c.passport) && ( +
+ {[ + c.driverLicense && `licence ${c.driverLicense}`, + c.passport && `passport ${c.passport}`, + ] + .filter(Boolean) + .join(' · ')} +
+ )} +
{c.email ?? '—'}{c.phone ?? '—'} + {gone ? unrecoverable : c.cin ?? '—'} + {c.nationality ?? '—'} + {gone ? ( + + + Erased — 410 Gone + + ) : ( + + )} +
+
+
+
+ ) +} + +/* ─── Add customer ───────────────────────────────────────────────────────── */ + +function AddCustomer({ + busy, + onDone, + run, +}: { + busy: boolean + onDone: () => void + run: (k: string, fn: () => Promise, m: string, r: () => Promise) => Promise +}) { + const [form, setForm] = useState({ + fullName: '', + email: '', + phone: '', + cin: '', + driverLicense: '', + passport: '', + nationality: '', + address: '', + dateOfBirth: '', + }) + + const set = (key: keyof typeof form) => (value: string) => setForm((f) => ({ ...f, [key]: value })) + const canSubmit = form.fullName.trim().length > 0 + + const submit = () => + run( + 'add-customer', + () => + api.post('/customers', { + fullName: form.fullName.trim(), + ...(form.email ? { email: form.email } : {}), + ...(form.phone ? { phone: form.phone } : {}), + ...(form.cin ? { cin: form.cin } : {}), + ...(form.driverLicense ? { driverLicense: form.driverLicense } : {}), + ...(form.passport ? { passport: form.passport } : {}), + ...(form.nationality ? { nationality: form.nationality } : {}), + ...(form.address ? { address: form.address } : {}), + ...(form.dateOfBirth ? { dateOfBirth: form.dateOfBirth } : {}), + }), + `${form.fullName.trim()} added.`, + async () => onDone() + ) + + return ( +
+
+
Add a customer
+
+
+
+ + set('fullName')(e.target.value)} + placeholder="Yasmine El Amrani" + /> + + + set('email')(e.target.value)} + placeholder="yasmine@example.ma" + /> + + + set('phone')(e.target.value)} + placeholder="+212 6 12 34 56 78" + /> + + + set('cin')(e.target.value)} + placeholder="AB123456" + /> + + + set('driverLicense')(e.target.value)} + placeholder="1234567" + /> + + + set('passport')(e.target.value)} + placeholder="MA0000000" + /> + + + set('nationality')(e.target.value)} + placeholder="Moroccan" + /> + + + set('dateOfBirth')(e.target.value)} + /> + + +